diff --git a/doc/src/sgml/datatype.sgml b/doc/src/sgml/datatype.sgml
index d8d91678e86d4..c51c5ab754be4 100644
--- a/doc/src/sgml/datatype.sgml
+++ b/doc/src/sgml/datatype.sgml
@@ -243,6 +243,12 @@
autoincrementing four-byte integer
+
+ sql_query
+
+ pre-parsed, analyzed SQL query
+
+
text
@@ -5148,6 +5154,73 @@ WHERE ...
+
+ sql_query Type
+
+
+ sql_query
+
+
+
+ The sql_query data type stores a fully analyzed SQL query.
+ Its input function parses and semantically analyzes the input SQL text,
+ resolving all object names to object identifiers (OIDs) at that time.
+ Its output function deparsed the stored query back to canonical SQL text
+ using PostgreSQL's deparser, which is sensitive
+ to the current search_path: when the search path is
+ empty, all relation and type names are emitted in schema-qualified form.
+
+
+
+ This type is used as the argument type for ts_stat
+ when the SQL query argument should be pre-validated and schema-qualified
+ at assignment time rather than at execution time. The canonical use case
+ is a materialized view body that calls ts_stat: when
+ PostgreSQL refreshes the view (e.g., via
+ pg_restore) with an empty search_path
+ for security, a text argument containing unqualified table
+ names would fail to resolve. A sql_query value instead
+ emits public.articles from its output function and the
+ query executes correctly.
+
+
+
+ Internally, the value is stored in the same
+ nodeToString serialization format used by
+ pg_node_tree catalog columns. Unlike pg_node_tree,
+ sql_query accepts SQL text as input. The serialized format is
+ not intended to be stable across major PostgreSQL
+ versions; values are correctly round-tripped by pg_dump
+ because the dump writes the canonical SQL text produced by the output
+ function.
+
+
+
+ The sql_query type supports the standard comparison operators
+ (=, <>,
+ <, <=,
+ >, >=)
+ and a B-tree operator class, which makes it usable in
+ ORDER BY clauses and indexes. Comparison is performed
+ on the canonical SQL text.
+
+
+
+ A cast from text to sql_query is available but
+ requires an explicit cast (CAST(... AS sql_query) or
+ the ::sql_query notation). No implicit cast is
+ defined, to prevent accidental substitution. The reverse cast from
+ sql_query to text is implicit.
+
+
+
+ ts_stat accepts a sql_query value
+ directly; no column definition list is needed because the output columns
+ are declared as named OUT parameters.
+ See for details.
+
+
+
Pseudo-Types
diff --git a/doc/src/sgml/func/func-textsearch.sgml b/doc/src/sgml/func/func-textsearch.sgml
index 290ad81d6979b..99c16d295c08f 100644
--- a/doc/src/sgml/func/func-textsearch.sgml
+++ b/doc/src/sgml/func/func-textsearch.sgml
@@ -1039,6 +1039,29 @@
(foo,10,15) ...
+
+
+
+ ts_stat ( sqlquery sql_query
+ , weights text )
+ setof record
+ ( word text,
+ ndoc integer,
+ nentry integer )
+
+
+ Like the text form, but accepts a pre-parsed
+ sql_query value. The query is deparsed at execution
+ time using the current search_path, which ensures
+ that schema-qualified names are emitted when the search path is
+ empty (as during pg_restore's
+ REFRESH MATERIALIZED VIEW step).
+
+
+ ts_stat($$ SELECT vector FROM apod $$::sql_query)
+ (foo,10,15) ...
+
+
diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c
index c54774b327590..11f96024ed3e5 100644
--- a/src/backend/catalog/dependency.c
+++ b/src/backend/catalog/dependency.c
@@ -84,9 +84,12 @@
#include "funcapi.h"
#include "miscadmin.h"
#include "nodes/nodeFuncs.h"
+#include "nodes/nodes.h"
+#include "nodes/parsenodes.h"
#include "parser/parsetree.h"
#include "rewrite/rewriteRemove.h"
#include "storage/lmgr.h"
+#include "utils/builtins.h"
#include "utils/fmgroids.h"
#include "utils/lsyscache.h"
#include "utils/syscache.h"
@@ -1943,6 +1946,31 @@ find_expr_references_walker(Node *node,
context->addrs);
break;
+ /*
+ * A sql_query constant holds a nodeToString-serialized
+ * analyzed Query. Recursively extract all objects
+ * referenced by that inner query so they become
+ * dependencies of the outer expression (e.g. a matview
+ * body). This is what lets pg_restore order REFRESH
+ * MATERIALIZED VIEW after the tables the query references.
+ */
+ case SQL_QUERYOID:
+ {
+ char *nodestr;
+ Query *innerq;
+
+ nodestr = TextDatumGetCString(con->constvalue);
+ innerq = castNode(Query, stringToNode(nodestr));
+ pfree(nodestr);
+
+ context->rtables = lcons(innerq->rtable,
+ context->rtables);
+ find_expr_references_walker((Node *) innerq, context);
+ context->rtables =
+ list_delete_first(context->rtables);
+ break;
+ }
+
/*
* Dependencies for regrole should be shared among all
* databases, so explicitly inhibit to have dependencies.
diff --git a/src/backend/utils/adt/meson.build b/src/backend/utils/adt/meson.build
index d793f8145f6c2..e1bb125ee4379 100644
--- a/src/backend/utils/adt/meson.build
+++ b/src/backend/utils/adt/meson.build
@@ -102,6 +102,7 @@ backend_sources += files(
'ruleutils.c',
'selfuncs.c',
'skipsupport.c',
+ 'sqlquery.c',
'tid.c',
'timestamp.c',
'trigfuncs.c',
diff --git a/src/backend/utils/adt/sqlquery.c b/src/backend/utils/adt/sqlquery.c
new file mode 100644
index 0000000000000..4263084bd7bb3
--- /dev/null
+++ b/src/backend/utils/adt/sqlquery.c
@@ -0,0 +1,285 @@
+/*-------------------------------------------------------------------------
+ *
+ * sqlquery.c
+ * I/O and comparison functions for the sql_query data type.
+ *
+ * sql_query stores a fully analyzed SQL query tree in nodeToString() format
+ * (same wire representation as pg_node_tree columns such as pg_rewrite.ev_action).
+ * The external text representation is canonical SQL produced by pg_get_querydef().
+ *
+ * Key property: because the stored datum holds an analyzed Query node with
+ * relation OIDs rather than unqualified names, deparsing in a context with an
+ * empty search_path (e.g. pg_restore's REFRESH MATERIALIZED VIEW security
+ * context) automatically emits schema-qualified names. This fixes the failure
+ * of ts_stat() inside materialized views during pg_restore.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/utils/adt/sqlquery.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/hash.h"
+#include "catalog/pg_collation.h"
+#include "libpq/pqformat.h"
+#include "nodes/nodes.h"
+#include "nodes/parsenodes.h"
+#include "nodes/pg_list.h"
+#include "parser/parser.h"
+#include "tcop/tcopprot.h"
+#include "utils/builtins.h"
+#include "utils/ruleutils.h"
+#include "utils/varlena.h"
+
+PG_FUNCTION_INFO_V1(sql_query_in);
+PG_FUNCTION_INFO_V1(sql_query_out);
+PG_FUNCTION_INFO_V1(sql_query_recv);
+PG_FUNCTION_INFO_V1(sql_query_send);
+PG_FUNCTION_INFO_V1(sql_query_eq);
+PG_FUNCTION_INFO_V1(sql_query_ne);
+PG_FUNCTION_INFO_V1(sql_query_lt);
+PG_FUNCTION_INFO_V1(sql_query_le);
+PG_FUNCTION_INFO_V1(sql_query_gt);
+PG_FUNCTION_INFO_V1(sql_query_ge);
+PG_FUNCTION_INFO_V1(sql_query_cmp);
+PG_FUNCTION_INFO_V1(sql_query_hash);
+PG_FUNCTION_INFO_V1(sql_query_hash_extended);
+
+
+/*
+ * sql_query_parse_and_analyze
+ *
+ * Parse and semantically analyze a SQL string, returning a palloc'd
+ * nodeToString() serialization of the resulting Query node. Errors on
+ * invalid SQL or on anything other than exactly one statement.
+ */
+static char *
+sql_query_parse_and_analyze(const char *str)
+{
+ List *raw_list;
+ RawStmt *rawstmt;
+ List *query_list;
+ Query *query;
+
+ raw_list = raw_parser(str, RAW_PARSE_DEFAULT);
+
+ if (list_length(raw_list) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("sql_query value must contain exactly one SQL statement")));
+
+ rawstmt = linitial_node(RawStmt, raw_list);
+
+ /*
+ * Analyze with no parameter types. Parameterized queries ($1, $2, …)
+ * are unsupported because the sql_query type carries no parameter type
+ * information.
+ */
+ query_list = pg_analyze_and_rewrite_fixedparams(rawstmt, str, NULL, 0, NULL);
+
+ if (list_length(query_list) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("unexpected result from query analysis")));
+
+ query = linitial_node(Query, query_list);
+
+ return nodeToString(query);
+}
+
+
+/*
+ * sql_query_in - input function
+ *
+ * Parse and analyze the SQL string; store the analyzed Query as nodeToString.
+ */
+Datum
+sql_query_in(PG_FUNCTION_ARGS)
+{
+ char *str = PG_GETARG_CSTRING(0);
+ char *nodestr;
+
+ nodestr = sql_query_parse_and_analyze(str);
+ PG_RETURN_TEXT_P(cstring_to_text(nodestr));
+}
+
+
+/*
+ * sql_query_out - output function
+ *
+ * Deparse the stored Query node back to canonical SQL text.
+ * generate_relation_name() is search_path-sensitive: when called in a context
+ * with an empty search_path (e.g. pg_restore's REFRESH), it emits
+ * schema-qualified names, making the deparsed SQL safe to re-execute.
+ */
+Datum
+sql_query_out(PG_FUNCTION_ARGS)
+{
+ text *val = PG_GETARG_TEXT_PP(0);
+ char *nodestr = text_to_cstring(val);
+ Query *query;
+ char *result;
+
+ query = castNode(Query, stringToNode(nodestr));
+ result = pg_get_querydef(query, false);
+
+ PG_RETURN_CSTRING(result);
+}
+
+
+/*
+ * sql_query_recv - binary input function
+ *
+ * Read a SQL text string from the wire; parse and analyze it.
+ */
+Datum
+sql_query_recv(PG_FUNCTION_ARGS)
+{
+ StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
+ char *str;
+ int nbytes;
+ char *nodestr;
+
+ str = pq_getmsgtext(buf, buf->len - buf->cursor, &nbytes);
+ nodestr = sql_query_parse_and_analyze(str);
+ pfree(str);
+
+ PG_RETURN_TEXT_P(cstring_to_text(nodestr));
+}
+
+
+/*
+ * sql_query_send - binary output function
+ *
+ * Send the canonical SQL text representation (search_path-aware deparsing).
+ */
+Datum
+sql_query_send(PG_FUNCTION_ARGS)
+{
+ text *val = PG_GETARG_TEXT_PP(0);
+ char *nodestr = text_to_cstring(val);
+ Query *query;
+ char *sql;
+ StringInfoData buf;
+
+ query = castNode(Query, stringToNode(nodestr));
+ sql = pg_get_querydef(query, false);
+
+ pq_begintypsend(&buf);
+ pq_sendtext(&buf, sql, strlen(sql));
+ PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
+}
+
+
+/*
+ * sql_query_cmp - comparison function
+ *
+ * Compares the nodeToString representations of two sql_query values.
+ * Two queries that parse and analyze to the same Query node are equal.
+ * The ordering is consistent but not guaranteed to be human-meaningful.
+ */
+Datum
+sql_query_cmp(PG_FUNCTION_ARGS)
+{
+ text *a = PG_GETARG_TEXT_PP(0);
+ text *b = PG_GETARG_TEXT_PP(1);
+ int result;
+
+ /*
+ * Compare the nodeToString representations byte-for-byte using C
+ * collation. The stored format is always ASCII, and equality means
+ * identical serialized Query trees, so locale-sensitive ordering would be
+ * both meaningless and potentially inconsistent with the hash functions.
+ */
+ result = varstr_cmp(VARDATA_ANY(a), VARSIZE_ANY_EXHDR(a),
+ VARDATA_ANY(b), VARSIZE_ANY_EXHDR(b),
+ C_COLLATION_OID);
+
+ PG_FREE_IF_COPY(a, 0);
+ PG_FREE_IF_COPY(b, 1);
+
+ PG_RETURN_INT32(result);
+}
+
+Datum
+sql_query_eq(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_BOOL(DatumGetInt32(DirectFunctionCall2(sql_query_cmp,
+ PG_GETARG_DATUM(0),
+ PG_GETARG_DATUM(1))) == 0);
+}
+
+Datum
+sql_query_ne(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_BOOL(DatumGetInt32(DirectFunctionCall2(sql_query_cmp,
+ PG_GETARG_DATUM(0),
+ PG_GETARG_DATUM(1))) != 0);
+}
+
+Datum
+sql_query_lt(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_BOOL(DatumGetInt32(DirectFunctionCall2(sql_query_cmp,
+ PG_GETARG_DATUM(0),
+ PG_GETARG_DATUM(1))) < 0);
+}
+
+Datum
+sql_query_le(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_BOOL(DatumGetInt32(DirectFunctionCall2(sql_query_cmp,
+ PG_GETARG_DATUM(0),
+ PG_GETARG_DATUM(1))) <= 0);
+}
+
+Datum
+sql_query_gt(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_BOOL(DatumGetInt32(DirectFunctionCall2(sql_query_cmp,
+ PG_GETARG_DATUM(0),
+ PG_GETARG_DATUM(1))) > 0);
+}
+
+Datum
+sql_query_ge(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_BOOL(DatumGetInt32(DirectFunctionCall2(sql_query_cmp,
+ PG_GETARG_DATUM(0),
+ PG_GETARG_DATUM(1))) >= 0);
+}
+
+/*
+ * sql_query_hash / sql_query_hash_extended
+ *
+ * Hash support functions for the hash opclass. We hash the raw bytes of the
+ * nodeToString representation, consistent with the C-collation comparison
+ * used by sql_query_cmp.
+ */
+Datum
+sql_query_hash(PG_FUNCTION_ARGS)
+{
+ text *val = PG_GETARG_TEXT_PP(0);
+ Datum result;
+
+ result = hash_any((unsigned char *) VARDATA_ANY(val),
+ VARSIZE_ANY_EXHDR(val));
+ PG_FREE_IF_COPY(val, 0);
+ PG_RETURN_DATUM(result);
+}
+
+Datum
+sql_query_hash_extended(PG_FUNCTION_ARGS)
+{
+ text *val = PG_GETARG_TEXT_PP(0);
+ uint64 seed = PG_GETARG_INT64(1);
+ Datum result;
+
+ result = hash_any_extended((unsigned char *) VARDATA_ANY(val),
+ VARSIZE_ANY_EXHDR(val), seed);
+ PG_FREE_IF_COPY(val, 0);
+ PG_RETURN_DATUM(result);
+}
diff --git a/src/backend/utils/adt/tsvector_op.c b/src/backend/utils/adt/tsvector_op.c
index 53a9541e89f6a..15e07510952e4 100644
--- a/src/backend/utils/adt/tsvector_op.c
+++ b/src/backend/utils/adt/tsvector_op.c
@@ -31,6 +31,7 @@
#include "utils/builtins.h"
#include "utils/regproc.h"
#include "utils/rel.h"
+#include "utils/ruleutils.h"
typedef struct
@@ -2638,6 +2639,18 @@ ts_stat_sql(MemoryContext persistentContext, text *txt, text *ws)
return stat;
}
+/*
+ * ts_stat1 / ts_stat2
+ *
+ * ts_stat accepts a sql_query value. The datum holds an analyzed Query node
+ * in nodeToString() format. We deparse it to canonical SQL via
+ * pg_get_querydef(), which is search_path-sensitive: in a context with a
+ * restricted search_path (e.g. pg_restore's REFRESH MATERIALIZED VIEW), it
+ * emits schema-qualified names, making the re-executed query safe.
+ */
+PG_FUNCTION_INFO_V1(ts_stat1);
+PG_FUNCTION_INFO_V1(ts_stat2);
+
Datum
ts_stat1(PG_FUNCTION_ARGS)
{
@@ -2647,12 +2660,19 @@ ts_stat1(PG_FUNCTION_ARGS)
if (SRF_IS_FIRSTCALL())
{
TSVectorStat *stat;
- text *txt = PG_GETARG_TEXT_PP(0);
+ text *sqldatum = PG_GETARG_TEXT_PP(0);
+ Query *query;
+ char *sql;
+ text *sqltxt;
+
+ query = castNode(Query, stringToNode(text_to_cstring(sqldatum)));
+ sql = pg_get_querydef(query, false);
+ sqltxt = cstring_to_text(sql);
funcctx = SRF_FIRSTCALL_INIT();
SPI_connect();
- stat = ts_stat_sql(funcctx->multi_call_memory_ctx, txt, NULL);
- PG_FREE_IF_COPY(txt, 0);
+ stat = ts_stat_sql(funcctx->multi_call_memory_ctx, sqltxt, NULL);
+ pfree(sqltxt);
ts_setup_firstcall(fcinfo, funcctx, stat);
SPI_finish();
}
@@ -2672,13 +2692,20 @@ ts_stat2(PG_FUNCTION_ARGS)
if (SRF_IS_FIRSTCALL())
{
TSVectorStat *stat;
- text *txt = PG_GETARG_TEXT_PP(0);
+ text *sqldatum = PG_GETARG_TEXT_PP(0);
text *ws = PG_GETARG_TEXT_PP(1);
+ Query *query;
+ char *sql;
+ text *sqltxt;
+
+ query = castNode(Query, stringToNode(text_to_cstring(sqldatum)));
+ sql = pg_get_querydef(query, false);
+ sqltxt = cstring_to_text(sql);
funcctx = SRF_FIRSTCALL_INIT();
SPI_connect();
- stat = ts_stat_sql(funcctx->multi_call_memory_ctx, txt, ws);
- PG_FREE_IF_COPY(txt, 0);
+ stat = ts_stat_sql(funcctx->multi_call_memory_ctx, sqltxt, ws);
+ pfree(sqltxt);
PG_FREE_IF_COPY(ws, 1);
ts_setup_firstcall(fcinfo, funcctx, stat);
SPI_finish();
diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h
index 04eb2af2ac870..2891f63d345cc 100644
--- a/src/include/catalog/catversion.h
+++ b/src/include/catalog/catversion.h
@@ -57,6 +57,6 @@
*/
/* yyyymmddN */
-#define CATALOG_VERSION_NO 202606232
+#define CATALOG_VERSION_NO 202606253
#endif
diff --git a/src/include/catalog/pg_amop.dat b/src/include/catalog/pg_amop.dat
index 8d5a0004a478a..9d15a67e5d674 100644
--- a/src/include/catalog/pg_amop.dat
+++ b/src/include/catalog/pg_amop.dat
@@ -3250,4 +3250,26 @@
amoprighttype => 'point', amopstrategy => '7', amopopr => '@>(box,point)',
amopmethod => 'brin' },
+# btree sql_query_ops
+{ amopfamily => 'btree/sql_query_ops', amoplefttype => 'sql_query',
+ amoprighttype => 'sql_query', amopstrategy => '1',
+ amopopr => '<(sql_query,sql_query)', amopmethod => 'btree' },
+{ amopfamily => 'btree/sql_query_ops', amoplefttype => 'sql_query',
+ amoprighttype => 'sql_query', amopstrategy => '2',
+ amopopr => '<=(sql_query,sql_query)', amopmethod => 'btree' },
+{ amopfamily => 'btree/sql_query_ops', amoplefttype => 'sql_query',
+ amoprighttype => 'sql_query', amopstrategy => '3',
+ amopopr => '=(sql_query,sql_query)', amopmethod => 'btree' },
+{ amopfamily => 'btree/sql_query_ops', amoplefttype => 'sql_query',
+ amoprighttype => 'sql_query', amopstrategy => '4',
+ amopopr => '>=(sql_query,sql_query)', amopmethod => 'btree' },
+{ amopfamily => 'btree/sql_query_ops', amoplefttype => 'sql_query',
+ amoprighttype => 'sql_query', amopstrategy => '5',
+ amopopr => '>(sql_query,sql_query)', amopmethod => 'btree' },
+
+# hash sql_query_ops
+{ amopfamily => 'hash/sql_query_ops', amoplefttype => 'sql_query',
+ amoprighttype => 'sql_query', amopstrategy => '1',
+ amopopr => '=(sql_query,sql_query)', amopmethod => 'hash' },
+
]
diff --git a/src/include/catalog/pg_amproc.dat b/src/include/catalog/pg_amproc.dat
index 4a1efdbc89986..2becdaf88c694 100644
--- a/src/include/catalog/pg_amproc.dat
+++ b/src/include/catalog/pg_amproc.dat
@@ -2036,4 +2036,16 @@
{ amprocfamily => 'brin/box_inclusion_ops', amproclefttype => 'box',
amprocrighttype => 'box', amprocnum => '13', amproc => 'box_contain' },
+# btree sql_query_ops support functions
+{ amprocfamily => 'btree/sql_query_ops', amproclefttype => 'sql_query',
+ amprocrighttype => 'sql_query', amprocnum => '1', amproc => 'sql_query_cmp' },
+{ amprocfamily => 'btree/sql_query_ops', amproclefttype => 'sql_query',
+ amprocrighttype => 'sql_query', amprocnum => '4', amproc => 'btequalimage' },
+
+# hash sql_query_ops support functions
+{ amprocfamily => 'hash/sql_query_ops', amproclefttype => 'sql_query',
+ amprocrighttype => 'sql_query', amprocnum => '1', amproc => 'sql_query_hash' },
+{ amprocfamily => 'hash/sql_query_ops', amproclefttype => 'sql_query',
+ amprocrighttype => 'sql_query', amprocnum => '2', amproc => 'sql_query_hash_extended' },
+
]
diff --git a/src/include/catalog/pg_cast.dat b/src/include/catalog/pg_cast.dat
index a7b6d812c5ac9..5e01f3d9edd0c 100644
--- a/src/include/catalog/pg_cast.dat
+++ b/src/include/catalog/pg_cast.dat
@@ -594,4 +594,10 @@
{ castsource => 'tstzrange', casttarget => 'tstzmultirange',
castfunc => 'tstzmultirange(tstzrange)', castcontext => 'e',
castmethod => 'f' },
+
+# sql_query casts: implicit from text (parses and analyzes); implicit to text (deparsed SQL)
+{ castsource => 'text', casttarget => 'sql_query', castfunc => '0',
+ castcontext => 'i', castmethod => 'i' },
+{ castsource => 'sql_query', casttarget => 'text', castfunc => '0',
+ castcontext => 'i', castmethod => 'i' },
]
diff --git a/src/include/catalog/pg_opclass.dat b/src/include/catalog/pg_opclass.dat
index df170b80840bb..c9215bc196c48 100644
--- a/src/include/catalog/pg_opclass.dat
+++ b/src/include/catalog/pg_opclass.dat
@@ -484,6 +484,11 @@
opcfamily => 'brin/pg_lsn_bloom_ops', opcintype => 'pg_lsn',
opcdefault => 'f', opckeytype => 'pg_lsn' },
+{ opcmethod => 'btree', opcname => 'sql_query_ops',
+ opcfamily => 'btree/sql_query_ops', opcintype => 'sql_query' },
+{ opcmethod => 'hash', opcname => 'sql_query_ops',
+ opcfamily => 'hash/sql_query_ops', opcintype => 'sql_query' },
+
# no brin opclass for enum, tsvector, tsquery, jsonb
{ opcmethod => 'brin', opcname => 'box_inclusion_ops',
diff --git a/src/include/catalog/pg_operator.dat b/src/include/catalog/pg_operator.dat
index c7f860c442b3d..06b743a57df7e 100644
--- a/src/include/catalog/pg_operator.dat
+++ b/src/include/catalog/pg_operator.dat
@@ -830,6 +830,38 @@
oprcom => '<=(text,text)', oprnegate => '<(text,text)', oprcode => 'text_ge',
oprrest => 'scalargesel', oprjoin => 'scalargejoinsel' },
+# sql_query comparison operators
+{ oid => '8809', descr => 'equal',
+ oprname => '=', oprcanmerge => 't', oprcanhash => 't',
+ oprleft => 'sql_query', oprright => 'sql_query', oprresult => 'bool',
+ oprcom => '=(sql_query,sql_query)', oprnegate => '<>(sql_query,sql_query)',
+ oprcode => 'sql_query_eq', oprrest => 'eqsel', oprjoin => 'eqjoinsel' },
+{ oid => '8810', descr => 'not equal',
+ oprname => '<>', oprleft => 'sql_query', oprright => 'sql_query',
+ oprresult => 'bool', oprcom => '<>(sql_query,sql_query)',
+ oprnegate => '=(sql_query,sql_query)', oprcode => 'sql_query_ne',
+ oprrest => 'neqsel', oprjoin => 'neqjoinsel' },
+{ oid => '8811', descr => 'less than',
+ oprname => '<', oprleft => 'sql_query', oprright => 'sql_query',
+ oprresult => 'bool', oprcom => '>(sql_query,sql_query)',
+ oprnegate => '>=(sql_query,sql_query)', oprcode => 'sql_query_lt',
+ oprrest => 'scalarltsel', oprjoin => 'scalarltjoinsel' },
+{ oid => '8812', descr => 'less than or equal',
+ oprname => '<=', oprleft => 'sql_query', oprright => 'sql_query',
+ oprresult => 'bool', oprcom => '>=(sql_query,sql_query)',
+ oprnegate => '>(sql_query,sql_query)', oprcode => 'sql_query_le',
+ oprrest => 'scalarlesel', oprjoin => 'scalarlejoinsel' },
+{ oid => '8813', descr => 'greater than',
+ oprname => '>', oprleft => 'sql_query', oprright => 'sql_query',
+ oprresult => 'bool', oprcom => '<(sql_query,sql_query)',
+ oprnegate => '<=(sql_query,sql_query)', oprcode => 'sql_query_gt',
+ oprrest => 'scalargtsel', oprjoin => 'scalargtjoinsel' },
+{ oid => '8814', descr => 'greater than or equal',
+ oprname => '>=', oprleft => 'sql_query', oprright => 'sql_query',
+ oprresult => 'bool', oprcom => '<=(sql_query,sql_query)',
+ oprnegate => '<(sql_query,sql_query)', oprcode => 'sql_query_ge',
+ oprrest => 'scalargesel', oprjoin => 'scalargejoinsel' },
+
{ oid => '670', descr => 'equal',
oprname => '=', oprcanmerge => 't', oprcanhash => 't', oprleft => 'float8',
oprright => 'float8', oprresult => 'bool', oprcom => '=(float8,float8)',
diff --git a/src/include/catalog/pg_opfamily.dat b/src/include/catalog/pg_opfamily.dat
index c6110183103aa..72655738a12f7 100644
--- a/src/include/catalog/pg_opfamily.dat
+++ b/src/include/catalog/pg_opfamily.dat
@@ -308,5 +308,9 @@
opfmethod => 'hash', opfname => 'multirange_ops' },
{ oid => '6158',
opfmethod => 'gist', opfname => 'multirange_ops' },
+{ oid => '8815',
+ opfmethod => 'btree', opfname => 'sql_query_ops' },
+{ oid => '8818',
+ opfmethod => 'hash', opfname => 'sql_query_ops' },
]
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index fa76c7923f0c3..8cd8153c7f6de 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -581,6 +581,47 @@
proname => 'pg_node_tree_send', provolatile => 's', prorettype => 'bytea',
proargtypes => 'pg_node_tree', prosrc => 'pg_node_tree_send' },
+# sql_query type I/O and comparison functions
+{ oid => '8796', descr => 'I/O',
+ proname => 'sql_query_in', provolatile => 's', prorettype => 'sql_query',
+ proargtypes => 'cstring', prosrc => 'sql_query_in' },
+{ oid => '8797', descr => 'I/O',
+ proname => 'sql_query_out', provolatile => 's', prorettype => 'cstring',
+ proargtypes => 'sql_query', prosrc => 'sql_query_out' },
+{ oid => '8798', descr => 'I/O',
+ proname => 'sql_query_recv', provolatile => 's', prorettype => 'sql_query',
+ proargtypes => 'internal', prosrc => 'sql_query_recv' },
+{ oid => '8799', descr => 'I/O',
+ proname => 'sql_query_send', provolatile => 's', prorettype => 'bytea',
+ proargtypes => 'sql_query', prosrc => 'sql_query_send' },
+{ oid => '8800', descr => 'equal',
+ proname => 'sql_query_eq', prorettype => 'bool',
+ proargtypes => 'sql_query sql_query', prosrc => 'sql_query_eq' },
+{ oid => '8801', descr => 'not equal',
+ proname => 'sql_query_ne', prorettype => 'bool',
+ proargtypes => 'sql_query sql_query', prosrc => 'sql_query_ne' },
+{ oid => '8802', descr => 'less than',
+ proname => 'sql_query_lt', prorettype => 'bool',
+ proargtypes => 'sql_query sql_query', prosrc => 'sql_query_lt' },
+{ oid => '8803', descr => 'less than or equal',
+ proname => 'sql_query_le', prorettype => 'bool',
+ proargtypes => 'sql_query sql_query', prosrc => 'sql_query_le' },
+{ oid => '8804', descr => 'greater than',
+ proname => 'sql_query_gt', prorettype => 'bool',
+ proargtypes => 'sql_query sql_query', prosrc => 'sql_query_gt' },
+{ oid => '8805', descr => 'greater than or equal',
+ proname => 'sql_query_ge', prorettype => 'bool',
+ proargtypes => 'sql_query sql_query', prosrc => 'sql_query_ge' },
+{ oid => '8806', descr => 'less-equal-greater',
+ proname => 'sql_query_cmp', prorettype => 'int4',
+ proargtypes => 'sql_query sql_query', prosrc => 'sql_query_cmp' },
+{ oid => '8816', descr => 'hash',
+ proname => 'sql_query_hash', prorettype => 'int4',
+ proargtypes => 'sql_query', prosrc => 'sql_query_hash' },
+{ oid => '8817', descr => 'hash',
+ proname => 'sql_query_hash_extended', prorettype => 'int8',
+ proargtypes => 'sql_query int8', prosrc => 'sql_query_hash_extended' },
+
# OIDS 200 - 299
{ oid => '200', descr => 'I/O',
@@ -10070,16 +10111,16 @@
proname => 'ts_typanalyze', provolatile => 's', prorettype => 'bool',
proargtypes => 'internal', prosrc => 'ts_typanalyze' },
-{ oid => '3689', descr => 'statistics of tsvector column',
+{ oid => '8807', descr => 'statistics of tsvector column from pre-parsed query',
proname => 'ts_stat', procost => '10', prorows => '10000', proretset => 't',
provolatile => 'v', proparallel => 'u', prorettype => 'record',
- proargtypes => 'text', proallargtypes => '{text,text,int4,int4}',
+ proargtypes => 'sql_query', proallargtypes => '{sql_query,text,int4,int4}',
proargmodes => '{i,o,o,o}', proargnames => '{query,word,ndoc,nentry}',
prosrc => 'ts_stat1' },
-{ oid => '3690', descr => 'statistics of tsvector column',
+{ oid => '8808', descr => 'statistics of tsvector column from pre-parsed query',
proname => 'ts_stat', procost => '10', prorows => '10000', proretset => 't',
provolatile => 'v', proparallel => 'u', prorettype => 'record',
- proargtypes => 'text text', proallargtypes => '{text,text,text,int4,int4}',
+ proargtypes => 'sql_query text', proallargtypes => '{sql_query,text,text,int4,int4}',
proargmodes => '{i,i,o,o,o}',
proargnames => '{query,weights,word,ndoc,nentry}', prosrc => 'ts_stat2' },
diff --git a/src/include/catalog/pg_type.dat b/src/include/catalog/pg_type.dat
index 42ee494601ba1..71ec300d5394b 100644
--- a/src/include/catalog/pg_type.dat
+++ b/src/include/catalog/pg_type.dat
@@ -149,6 +149,12 @@
typoutput => 'pg_node_tree_out', typreceive => 'pg_node_tree_recv',
typsend => 'pg_node_tree_send', typalign => 'i', typstorage => 'x',
typcollation => 'default' },
+{ oid => '8794', array_type_oid => '8795',
+ descr => 'pre-parsed, analyzed SQL query',
+ typname => 'sql_query', typlen => '-1', typbyval => 'f',
+ typcategory => 'Z', typinput => 'sql_query_in',
+ typoutput => 'sql_query_out', typreceive => 'sql_query_recv',
+ typsend => 'sql_query_send', typalign => 'i', typstorage => 'x' },
{ oid => '3361', descr => 'multivariate ndistinct coefficients',
typname => 'pg_ndistinct', typlen => '-1', typbyval => 'f',
typcategory => 'Z', typinput => 'pg_ndistinct_in',
diff --git a/src/test/regress/expected/opr_sanity.out b/src/test/regress/expected/opr_sanity.out
index 80400a3373473..3ca8bd9764a98 100644
--- a/src/test/regress/expected/opr_sanity.out
+++ b/src/test/regress/expected/opr_sanity.out
@@ -1428,7 +1428,13 @@ ORDER BY 1;
3940 | jsonb_extract_path_text | get value from jsonb as text with path elements
3951 | json_extract_path | get value from json with path elements
3953 | json_extract_path_text | get value from json as text with path elements
-(9 rows)
+ 8800 | sql_query_eq | equal
+ 8801 | sql_query_ne | not equal
+ 8802 | sql_query_lt | less than
+ 8803 | sql_query_le | less than or equal
+ 8804 | sql_query_gt | greater than
+ 8805 | sql_query_ge | greater than or equal
+(15 rows)
-- Operators that are commutator pairs should have identical volatility
-- and leakproofness markings on their implementation functions.
diff --git a/src/test/regress/expected/sqlquery.out b/src/test/regress/expected/sqlquery.out
new file mode 100644
index 0000000000000..d8e1afe32e143
--- /dev/null
+++ b/src/test/regress/expected/sqlquery.out
@@ -0,0 +1,244 @@
+--
+-- Tests for the sql_query data type
+--
+-- sql_query stores a fully analyzed SQL query in nodeToString() format.
+-- Its key property is that deparsing under an empty search_path emits
+-- schema-qualified names, fixing pg_restore's REFRESH MATERIALIZED VIEW
+-- failure when ts_stat() references unqualified table names.
+--
+-- Verify type and operator catalog entries
+SELECT typname, typcategory, typinput::regproc, typoutput::regproc
+FROM pg_type WHERE typname = 'sql_query';
+ typname | typcategory | typinput | typoutput
+-----------+-------------+--------------+---------------
+ sql_query | Z | sql_query_in | sql_query_out
+(1 row)
+
+SELECT oprname, oprleft::regtype, oprright::regtype, oprresult::regtype
+FROM pg_operator WHERE oprleft = 'sql_query'::regtype
+ORDER BY oprname;
+ oprname | oprleft | oprright | oprresult
+---------+-----------+-----------+-----------
+ < | sql_query | sql_query | boolean
+ <= | sql_query | sql_query | boolean
+ <> | sql_query | sql_query | boolean
+ = | sql_query | sql_query | boolean
+ > | sql_query | sql_query | boolean
+ >= | sql_query | sql_query | boolean
+(6 rows)
+
+SELECT amopstrategy, amoplefttype::regtype, amoprighttype::regtype
+FROM pg_amop
+WHERE amopfamily = (
+ SELECT oid FROM pg_opfamily
+ WHERE opfmethod = (SELECT oid FROM pg_am WHERE amname = 'btree')
+ AND opfname = 'sql_query_ops'
+)
+ORDER BY amopstrategy;
+ amopstrategy | amoplefttype | amoprighttype
+--------------+--------------+---------------
+ 1 | sql_query | sql_query
+ 2 | sql_query | sql_query
+ 3 | sql_query | sql_query
+ 4 | sql_query | sql_query
+ 5 | sql_query | sql_query
+(5 rows)
+
+-- I/O: canonical SQL produced by pg_get_querydef()
+SELECT 'SELECT 1 + 1'::sql_query;
+ sql_query
+-------------------------------
+ SELECT (1 + 1) AS "?column?"
+(1 row)
+
+SELECT 'SELECT 1+1'::sql_query; -- normalised to same form
+ sql_query
+-------------------------------
+ SELECT (1 + 1) AS "?column?"
+(1 row)
+
+SELECT 'select 1+1'::sql_query; -- case-insensitive SQL keyword
+ sql_query
+-------------------------------
+ SELECT (1 + 1) AS "?column?"
+(1 row)
+
+-- Multiple-statement input is rejected
+SELECT 'SELECT 1; SELECT 2'::sql_query;
+ERROR: sql_query value must contain exactly one SQL statement
+LINE 1: SELECT 'SELECT 1; SELECT 2'::sql_query;
+ ^
+-- Invalid SQL is rejected
+SELECT 'NOT VALID SQL'::sql_query;
+ERROR: syntax error at or near "NOT"
+LINE 1: SELECT 'NOT VALID SQL'::sql_query;
+ ^
+-- Non-SELECT statements are accepted (any fully-parseable statement)
+SELECT 'UPDATE pg_class SET relname = relname WHERE false'::sql_query;
+ sql_query
+----------------------------------------
+ UPDATE pg_class SET relname = relname+
+ WHERE false
+(1 row)
+
+-- Arrays of sql_query
+SELECT ARRAY['SELECT 1'::sql_query, 'SELECT 2'::sql_query];
+ array
+-----------------------------------------------------------
+ {" SELECT 1 AS \"?column?\""," SELECT 2 AS \"?column?\""}
+(1 row)
+
+-- Equality and comparison operators
+SELECT 'SELECT 1 + 1'::sql_query = 'SELECT 1+1'::sql_query AS equal;
+ equal
+-------
+ t
+(1 row)
+
+SELECT 'SELECT 1'::sql_query = 'SELECT 2'::sql_query AS not_equal;
+ not_equal
+-----------
+ f
+(1 row)
+
+SELECT 'SELECT 1'::sql_query < 'SELECT 2'::sql_query AS less_than;
+ less_than
+-----------
+ t
+(1 row)
+
+SELECT 'SELECT 2'::sql_query > 'SELECT 1'::sql_query AS greater_than;
+ greater_than
+--------------
+ t
+(1 row)
+
+-- ORDER BY uses the B-tree opclass
+SELECT q FROM
+ (VALUES ('SELECT 3'::sql_query), ('SELECT 1'::sql_query), ('SELECT 2'::sql_query)) v(q)
+ORDER BY q;
+ q
+-------------------------
+ SELECT 1 AS "?column?"
+ SELECT 2 AS "?column?"
+ SELECT 3 AS "?column?"
+(3 rows)
+
+-- DISTINCT: synonymous queries collapse to one row
+SELECT DISTINCT q FROM
+ (VALUES ('SELECT 1+1'::sql_query), ('SELECT 1 + 1'::sql_query), ('SELECT 2'::sql_query)) v(q)
+ORDER BY q;
+ q
+-------------------------------
+ SELECT 2 AS "?column?"
+ SELECT (1 + 1) AS "?column?"
+(2 rows)
+
+-- text ↔ sql_query casts: both directions are implicit
+SELECT 'SELECT 1'::sql_query::text; -- sql_query → text (implicit)
+ text
+-------------------------
+ SELECT 1 AS "?column?"
+(1 row)
+
+SELECT CAST('SELECT 1' AS sql_query); -- text → sql_query (implicit)
+ sql_query
+-------------------------
+ SELECT 1 AS "?column?"
+(1 row)
+
+-- ts_stat with sql_query
+--
+-- String literals are untyped; with only ts_stat(sql_query) available,
+-- function type resolution coerces them to sql_query automatically via
+-- sql_query_in(). No explicit cast is needed, preserving backward
+-- compatibility with existing ts_stat('...') call sites.
+DROP TABLE IF EXISTS sqlquery_articles;
+NOTICE: table "sqlquery_articles" does not exist, skipping
+CREATE TABLE sqlquery_articles (body text);
+INSERT INTO sqlquery_articles VALUES
+ ('the cat sat on the mat'),
+ ('cats are great');
+SELECT word, ndoc, nentry
+FROM ts_stat(
+ $$ SELECT to_tsvector('english', body) FROM sqlquery_articles $$
+)
+ORDER BY ndoc DESC, word;
+ word | ndoc | nentry
+-------+------+--------
+ cat | 2 | 2
+ great | 1 | 1
+ mat | 1 | 1
+ sat | 1 | 1
+(4 rows)
+
+-- Weights filter: 'abcd' includes all weights including the default 'D'
+SELECT word, ndoc, nentry
+FROM ts_stat(
+ $$ SELECT to_tsvector('english', body) FROM sqlquery_articles $$,
+ 'abcd'
+)
+ORDER BY ndoc DESC, word;
+ word | ndoc | nentry
+-------+------+--------
+ cat | 2 | 2
+ great | 1 | 1
+ mat | 1 | 1
+ sat | 1 | 1
+(4 rows)
+
+-- Materialized view using ts_stat(sql_query) and its dependencies
+--
+-- After the matview is created, the dependency tracker must have recorded
+-- sqlquery_articles as a dependency of the rewrite rule (via the sql_query
+-- constant in the ts_stat() argument).
+CREATE MATERIALIZED VIEW sqlquery_word_stats AS
+ SELECT word, ndoc, nentry
+ FROM ts_stat(
+ $$ SELECT to_tsvector('english', body) FROM sqlquery_articles $$
+ )
+ ORDER BY ndoc DESC, word;
+SELECT * FROM sqlquery_word_stats;
+ word | ndoc | nentry
+-------+------+--------
+ cat | 2 | 2
+ great | 1 | 1
+ mat | 1 | 1
+ sat | 1 | 1
+(4 rows)
+
+-- Dependency must include sqlquery_articles
+SELECT d.refobjid::regclass AS dep
+FROM pg_rewrite r
+JOIN pg_depend d ON d.classid = 'pg_rewrite'::regclass AND d.objid = r.oid
+WHERE r.ev_class = 'sqlquery_word_stats'::regclass
+ AND d.refclassid = 'pg_class'::regclass
+ AND d.refobjid <> 'sqlquery_word_stats'::regclass
+ORDER BY dep;
+ dep
+-------------------
+ sqlquery_articles
+(1 row)
+
+-- Primary fix: REFRESH under empty search_path must succeed.
+--
+-- pg_restore uses an empty search_path when it runs REFRESH MATERIALIZED VIEW
+-- for security. With the old text-based ts_stat, the unqualified table name
+-- "sqlquery_articles" cannot be resolved in that context. With sql_query,
+-- pg_get_querydef() emits the schema-qualified name "public.sqlquery_articles"
+-- so SPI_prepare() succeeds.
+SET search_path = '';
+REFRESH MATERIALIZED VIEW public.sqlquery_word_stats;
+RESET search_path;
+SELECT * FROM sqlquery_word_stats;
+ word | ndoc | nentry
+-------+------+--------
+ cat | 2 | 2
+ great | 1 | 1
+ mat | 1 | 1
+ sat | 1 | 1
+(4 rows)
+
+-- Cleanup
+DROP MATERIALIZED VIEW sqlquery_word_stats;
+DROP TABLE sqlquery_articles;
diff --git a/src/test/regress/expected/type_sanity.out b/src/test/regress/expected/type_sanity.out
index 1d21d3eb44678..fcc4d5029e65f 100644
--- a/src/test/regress/expected/type_sanity.out
+++ b/src/test/regress/expected/type_sanity.out
@@ -787,7 +787,8 @@ CREATE TABLE tab_core_types AS SELECT
'(2020-01-02 03:04:05, 2021-02-03 06:07:08)'::tsrange,
'{(2020-01-02 03:04:05, 2021-02-03 06:07:08)}'::tsmultirange,
'(2020-01-02 03:04:05, 2021-02-03 06:07:08)'::tstzrange,
- '{(2020-01-02 03:04:05, 2021-02-03 06:07:08)}'::tstzmultirange;
+ '{(2020-01-02 03:04:05, 2021-02-03 06:07:08)}'::tstzmultirange,
+ 'SELECT 1'::sql_query;
-- Sanity check on the previous table, checking that all core types are
-- included in this table.
SELECT oid, typname, typtype, typelem, typarray
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47fb30..0016aaf30b79c 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -102,7 +102,7 @@ test: publication subscription
# Another group of parallel tests
# select_views depends on create_view
# ----------
-test: select_views portals_p2 foreign_key dependency guc bitmapops combocid tsearch tsdicts foreign_data window xmlmap functional_deps advisory_lock indirect_toast equivclass stats_rewrite graph_table
+test: select_views portals_p2 foreign_key dependency guc bitmapops combocid tsearch tsdicts foreign_data window xmlmap functional_deps advisory_lock indirect_toast equivclass stats_rewrite graph_table sqlquery
# ----------
# Another group of parallel tests (JSON related)
diff --git a/src/test/regress/sql/sqlquery.sql b/src/test/regress/sql/sqlquery.sql
new file mode 100644
index 0000000000000..a0487b19cb324
--- /dev/null
+++ b/src/test/regress/sql/sqlquery.sql
@@ -0,0 +1,144 @@
+--
+-- Tests for the sql_query data type
+--
+-- sql_query stores a fully analyzed SQL query in nodeToString() format.
+-- Its key property is that deparsing under an empty search_path emits
+-- schema-qualified names, fixing pg_restore's REFRESH MATERIALIZED VIEW
+-- failure when ts_stat() references unqualified table names.
+--
+
+-- Verify type and operator catalog entries
+
+SELECT typname, typcategory, typinput::regproc, typoutput::regproc
+FROM pg_type WHERE typname = 'sql_query';
+
+SELECT oprname, oprleft::regtype, oprright::regtype, oprresult::regtype
+FROM pg_operator WHERE oprleft = 'sql_query'::regtype
+ORDER BY oprname;
+
+SELECT amopstrategy, amoplefttype::regtype, amoprighttype::regtype
+FROM pg_amop
+WHERE amopfamily = (
+ SELECT oid FROM pg_opfamily
+ WHERE opfmethod = (SELECT oid FROM pg_am WHERE amname = 'btree')
+ AND opfname = 'sql_query_ops'
+)
+ORDER BY amopstrategy;
+
+-- I/O: canonical SQL produced by pg_get_querydef()
+
+SELECT 'SELECT 1 + 1'::sql_query;
+SELECT 'SELECT 1+1'::sql_query; -- normalised to same form
+SELECT 'select 1+1'::sql_query; -- case-insensitive SQL keyword
+
+-- Multiple-statement input is rejected
+
+SELECT 'SELECT 1; SELECT 2'::sql_query;
+
+-- Invalid SQL is rejected
+
+SELECT 'NOT VALID SQL'::sql_query;
+
+-- Non-SELECT statements are accepted (any fully-parseable statement)
+
+SELECT 'UPDATE pg_class SET relname = relname WHERE false'::sql_query;
+
+-- Arrays of sql_query
+
+SELECT ARRAY['SELECT 1'::sql_query, 'SELECT 2'::sql_query];
+
+-- Equality and comparison operators
+
+SELECT 'SELECT 1 + 1'::sql_query = 'SELECT 1+1'::sql_query AS equal;
+SELECT 'SELECT 1'::sql_query = 'SELECT 2'::sql_query AS not_equal;
+SELECT 'SELECT 1'::sql_query < 'SELECT 2'::sql_query AS less_than;
+SELECT 'SELECT 2'::sql_query > 'SELECT 1'::sql_query AS greater_than;
+
+-- ORDER BY uses the B-tree opclass
+
+SELECT q FROM
+ (VALUES ('SELECT 3'::sql_query), ('SELECT 1'::sql_query), ('SELECT 2'::sql_query)) v(q)
+ORDER BY q;
+
+-- DISTINCT: synonymous queries collapse to one row
+
+SELECT DISTINCT q FROM
+ (VALUES ('SELECT 1+1'::sql_query), ('SELECT 1 + 1'::sql_query), ('SELECT 2'::sql_query)) v(q)
+ORDER BY q;
+
+-- text ↔ sql_query casts: both directions are implicit
+
+SELECT 'SELECT 1'::sql_query::text; -- sql_query → text (implicit)
+SELECT CAST('SELECT 1' AS sql_query); -- text → sql_query (implicit)
+
+-- ts_stat with sql_query
+--
+-- String literals are untyped; with only ts_stat(sql_query) available,
+-- function type resolution coerces them to sql_query automatically via
+-- sql_query_in(). No explicit cast is needed, preserving backward
+-- compatibility with existing ts_stat('...') call sites.
+
+DROP TABLE IF EXISTS sqlquery_articles;
+CREATE TABLE sqlquery_articles (body text);
+INSERT INTO sqlquery_articles VALUES
+ ('the cat sat on the mat'),
+ ('cats are great');
+
+SELECT word, ndoc, nentry
+FROM ts_stat(
+ $$ SELECT to_tsvector('english', body) FROM sqlquery_articles $$
+)
+ORDER BY ndoc DESC, word;
+
+-- Weights filter: 'abcd' includes all weights including the default 'D'
+
+SELECT word, ndoc, nentry
+FROM ts_stat(
+ $$ SELECT to_tsvector('english', body) FROM sqlquery_articles $$,
+ 'abcd'
+)
+ORDER BY ndoc DESC, word;
+
+-- Materialized view using ts_stat(sql_query) and its dependencies
+--
+-- After the matview is created, the dependency tracker must have recorded
+-- sqlquery_articles as a dependency of the rewrite rule (via the sql_query
+-- constant in the ts_stat() argument).
+
+CREATE MATERIALIZED VIEW sqlquery_word_stats AS
+ SELECT word, ndoc, nentry
+ FROM ts_stat(
+ $$ SELECT to_tsvector('english', body) FROM sqlquery_articles $$
+ )
+ ORDER BY ndoc DESC, word;
+
+SELECT * FROM sqlquery_word_stats;
+
+-- Dependency must include sqlquery_articles
+
+SELECT d.refobjid::regclass AS dep
+FROM pg_rewrite r
+JOIN pg_depend d ON d.classid = 'pg_rewrite'::regclass AND d.objid = r.oid
+WHERE r.ev_class = 'sqlquery_word_stats'::regclass
+ AND d.refclassid = 'pg_class'::regclass
+ AND d.refobjid <> 'sqlquery_word_stats'::regclass
+ORDER BY dep;
+
+-- Primary fix: REFRESH under empty search_path must succeed.
+--
+-- pg_restore uses an empty search_path when it runs REFRESH MATERIALIZED VIEW
+-- for security. With the old text-based ts_stat, the unqualified table name
+-- "sqlquery_articles" cannot be resolved in that context. With sql_query,
+-- pg_get_querydef() emits the schema-qualified name "public.sqlquery_articles"
+-- so SPI_prepare() succeeds.
+
+SET search_path = '';
+REFRESH MATERIALIZED VIEW public.sqlquery_word_stats;
+RESET search_path;
+
+SELECT * FROM sqlquery_word_stats;
+
+-- Cleanup
+
+DROP MATERIALIZED VIEW sqlquery_word_stats;
+DROP TABLE sqlquery_articles;
diff --git a/src/test/regress/sql/type_sanity.sql b/src/test/regress/sql/type_sanity.sql
index 95d5b6e09151a..0774592da7c14 100644
--- a/src/test/regress/sql/type_sanity.sql
+++ b/src/test/regress/sql/type_sanity.sql
@@ -603,7 +603,8 @@ CREATE TABLE tab_core_types AS SELECT
'(2020-01-02 03:04:05, 2021-02-03 06:07:08)'::tsrange,
'{(2020-01-02 03:04:05, 2021-02-03 06:07:08)}'::tsmultirange,
'(2020-01-02 03:04:05, 2021-02-03 06:07:08)'::tstzrange,
- '{(2020-01-02 03:04:05, 2021-02-03 06:07:08)}'::tstzmultirange;
+ '{(2020-01-02 03:04:05, 2021-02-03 06:07:08)}'::tstzmultirange,
+ 'SELECT 1'::sql_query;
-- Sanity check on the previous table, checking that all core types are
-- included in this table.