From 3af9c0ddaa8c82879528e468fac11e0b8eaf8b4c Mon Sep 17 00:00:00 2001
From: Manu <manuelreyesbravo@gmail.com>
Date: Wed, 23 Sep 2026 16:30:11 -0300
Subject: [PATCH] Make citext's split_part() handle field positions like the
 core one

ec0294fb2c8 taught split_part() to count negative positions from the
end and to reject a position of zero.  citext's version, which splits
with a case-insensitive regular expression and subscripts the result,
was not updated, so it returned NULL for negative positions, for a
position of zero and for a position past the last field, where the core
function returns the field, an error and an empty string.  With an empty
delimiter it also split the string into single characters, where the
core function treats the whole string as the only field.

Handle the positions the same way, and leave a zero position and an
empty delimiter, which have no case to ignore, to the core function.
This needs a new extension version, 1.9.

Reported-by: Yuelin Wang <1217816127@qq.com>
Discussion: https://postgr.es/m/19602-5ec4b4e30fa6f5f2@postgresql.org
---
 contrib/citext/Makefile              |  1 +
 contrib/citext/citext--1.8--1.9.sql  | 20 ++++++++++++++
 contrib/citext/citext.control        |  2 +-
 contrib/citext/expected/citext.out   | 40 ++++++++++++++++++++++++++++
 contrib/citext/expected/citext_1.out | 40 ++++++++++++++++++++++++++++
 contrib/citext/meson.build           |  1 +
 contrib/citext/sql/citext.sql        |  8 ++++++
 7 files changed, 111 insertions(+), 1 deletion(-)
 create mode 100644 contrib/citext/citext--1.8--1.9.sql

diff --git a/contrib/citext/Makefile b/contrib/citext/Makefile
index fc990607bf2..f4db865ced5 100644
--- a/contrib/citext/Makefile
+++ b/contrib/citext/Makefile
@@ -4,6 +4,7 @@ MODULES = citext
 
 EXTENSION = citext
 DATA = citext--1.4.sql \
+	citext--1.8--1.9.sql \
 	citext--1.7--1.8.sql \
 	citext--1.6--1.7.sql \
 	citext--1.5--1.6.sql \
diff --git a/contrib/citext/citext--1.8--1.9.sql b/contrib/citext/citext--1.8--1.9.sql
new file mode 100644
index 00000000000..6573f55b5f9
--- /dev/null
+++ b/contrib/citext/citext--1.8--1.9.sql
@@ -0,0 +1,20 @@
+/* contrib/citext/citext--1.8--1.9.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION citext UPDATE TO '1.9'" to load this file. \quit
+
+-- Follow the core split_part(): a negative position counts from the end, a
+-- position past the last field returns an empty string, and a position of
+-- zero is an error.  A zero position and an empty delimiter, where there is no
+-- case to ignore, are left to the core function.
+CREATE OR REPLACE FUNCTION split_part( citext, citext, int ) RETURNS TEXT
+LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE
+RETURN CASE
+  WHEN $3 = 0 OR pg_catalog.length($2::pg_catalog.text) = 0 THEN
+    pg_catalog.split_part($1::pg_catalog.text, $2::pg_catalog.text, $3)
+  ELSE
+    (SELECT COALESCE(fields[CASE WHEN $3 > 0 THEN $3
+                                 ELSE pg_catalog.array_length(fields, 1) + $3 + 1 END],
+                     '')
+     FROM (SELECT pg_catalog.regexp_split_to_array( $1::pg_catalog.text, pg_catalog.regexp_replace($2::pg_catalog.text, '([^a-zA-Z_0-9])', E'\\\\\\1', 'g'), 'i') AS fields) AS s)
+END;
diff --git a/contrib/citext/citext.control b/contrib/citext/citext.control
index 2b0f3fa8407..a76f6bb2292 100644
--- a/contrib/citext/citext.control
+++ b/contrib/citext/citext.control
@@ -1,6 +1,6 @@
 # citext extension
 comment = 'data type for case-insensitive character strings'
-default_version = '1.8'
+default_version = '1.9'
 module_pathname = '$libdir/citext'
 relocatable = true
 trusted = true
diff --git a/contrib/citext/expected/citext.out b/contrib/citext/expected/citext.out
index 8c0bf54f0f3..1e8e7c2a5b1 100644
--- a/contrib/citext/expected/citext.out
+++ b/contrib/citext/expected/citext.out
@@ -2120,6 +2120,46 @@ SELECT split_part('abcTdefTghi', 't'::citext, 2) = 'def' AS t;
  t
 (1 row)
 
+-- positions are handled as in the core split_part()
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -1) = 'ghi' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -3) = 'abc' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 4) = '' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -4) = '' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 1) = 'abcTdefTghi' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 2) = '' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 0);
+ERROR:  field position must not be zero
+CONTEXT:  SQL function "split_part" statement 1
 SELECT strpos('high'::citext, 'gh'        ) = 3 AS t;
  t 
 ---
diff --git a/contrib/citext/expected/citext_1.out b/contrib/citext/expected/citext_1.out
index c5e5f180f2b..e6c64d60a67 100644
--- a/contrib/citext/expected/citext_1.out
+++ b/contrib/citext/expected/citext_1.out
@@ -2120,6 +2120,46 @@ SELECT split_part('abcTdefTghi', 't'::citext, 2) = 'def' AS t;
  t
 (1 row)
 
+-- positions are handled as in the core split_part()
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -1) = 'ghi' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -3) = 'abc' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 4) = '' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -4) = '' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 1) = 'abcTdefTghi' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 2) = '' AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 0);
+ERROR:  field position must not be zero
+CONTEXT:  SQL function "split_part" statement 1
 SELECT strpos('high'::citext, 'gh'        ) = 3 AS t;
  t 
 ---
diff --git a/contrib/citext/meson.build b/contrib/citext/meson.build
index 1cc49fc999f..d8aba86ca44 100644
--- a/contrib/citext/meson.build
+++ b/contrib/citext/meson.build
@@ -27,6 +27,7 @@ install_data(
   'citext--1.5--1.6.sql',
   'citext--1.6--1.7.sql',
   'citext--1.7--1.8.sql',
+  'citext--1.8--1.9.sql',
   kwargs: contrib_data_args,
 )
 
diff --git a/contrib/citext/sql/citext.sql b/contrib/citext/sql/citext.sql
index aa1cf9abd5c..ccbcae8d6bd 100644
--- a/contrib/citext/sql/citext.sql
+++ b/contrib/citext/sql/citext.sql
@@ -653,6 +653,14 @@ SELECT split_part('abc~@~def~@~ghi'::citext, '~@~', 2) = 'def' AS t;
 SELECT split_part('abcTdefTghi'::citext, 't', 2) = 'def' AS t;
 SELECT split_part('abcTdefTghi'::citext, 't'::citext, 2) = 'def' AS t;
 SELECT split_part('abcTdefTghi', 't'::citext, 2) = 'def' AS t;
+-- positions are handled as in the core split_part()
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -1) = 'ghi' AS t;
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -3) = 'abc' AS t;
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 4) = '' AS t;
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, -4) = '' AS t;
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 1) = 'abcTdefTghi' AS t;
+SELECT split_part('abcTdefTghi'::citext, ''::citext, 2) = '' AS t;
+SELECT split_part('abcTdefTghi'::citext, 't'::citext, 0);
 
 SELECT strpos('high'::citext, 'gh'        ) = 3 AS t;
 SELECT strpos('high',         'gh'::citext) = 3 AS t;
-- 
2.55.0

