Hi

st 12. 8. 2026 v 23:51 odesílatel Bernd Reiß <[email protected]> napsal:

> Hi Pavel,
>
> I've looked into your patch and provide the review below.
>
> Threads I found touching TMMONTH + glibc:
>
> https://www.postgresql.org/message-id/CALSKcLS3Zi4o0Ak5pNOheqmAiKWcez%2B%2BqwKvyPccvOz2v62QZQ%40mail.gmail.com
> (Russian)
> https://www.postgresql.org/message-id/14717914.JAiOoc7IO7%40utklippan
>
> I did not come across a prior thread proposing an implementation of what
> this patch does. Worth noting that in the utklippan thread above, Tom Lane
> suggested that a new format code or modifier would be the palatable way to
> address a similar problem.
>
> Contents & Purpose
> ==================
> This patch gives users control over the case (genitive vs. nominative)
> to use when pulling localized month names from  glibc via the function
> to_char(). The author proposes to add a TAMMONTH option for formatting
> dates.
>
> Intended behaviour: TMMONTH uses the genitive form (if applicable to the
> language; equivalent to %B) while TAMMONTH uses the nominative
> (equivalent to %OB).
>
> The patch contains regression test cases. It also adds a corresponding
> entry in the documentation.
>
> Initial Run
> ===========
> The patch applies cleanly to HEAD. The regression tests all pass
> successfully against the new patch, but fail against pre-patched HEAD,
> so the test cases are sane and do cover the new behavior.
>
> Manual Testing
> ==============
> The provided examples all work fine. However, I realized that the
> shortened form TAMMON falls back to English instead of honouring
> lc_time:
>
> postgres=# set lc_time to 'de_DE.UTF-8';
> SET
> postgres=# SELECT to_char(date '2026-03-01'::date, 'DD TMMON');
> SELECT to_char(date '2026-03-01'::date, 'DD TAMMON');
>   to_char
> ---------
>   01 MÄR
> (1 row)
>
>   to_char
> ---------
>   01 MAR
> (1 row)
>
> It looks like only the full-month cases (DCH_MONTH/Month/month) were
> wired up for
> TAM; the abbreviated cases (DCH_MON/Mon/mon) still test IS_SUFFIX_TM
> only, so TAM
> silently drops to the default English abbreviation. I'd expect TAMMON to
> stay
> localized.
>

TAMMON is not implemented, because glibc doesn't provide an alternative
form for abbreviated month names.
It is a question if it is better to raise an error, return a non
alternative name or just ignore this flag. I have not strong
opinion about this. Inside DCH_to_char the prefix TM is ignored when it is
not used. So I did the same.

But I can imagine using localized abbreviation. It is possible for the
Czech language - but I have not idea if it is true for other languages.


>
> Code Review
> ===========
> I am not sure if this condition can be ever true:
>
> + if (IS_SUFFIX_TM(n->suffix) && IS_SUFFIX_TAM(n->suffix))
>
> In case this is not possible the subsequent error message will never be
> thrown and the whole block is dead code.
>
> However, if there is a reason for this check, it would be nice to
> have a comment mention it.
>

Yes, I badly expected that prefixes could be mixed. I checked the code, and
this is not possible, so I removed this check



>
> One thing I am not entirely sure about is this line:
>
> + if (strftime_l(bufptr, MAX_L10N_DATA, "%OB", timeinfo, locale) <= 0)
> +     strftimefail = true;
>
> As far as I checked %OB is not supported on Windows:
>
>
> https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/strftime-wcsftime-strftime-l-wcsftime-l?view=msvc-170
>
> I was, however, not able to confirm this on a Windows machine right now.
> I might be able to do so on the weekend.
>
> However, the same problem exists with older glibc versions not supporting
> %OB. As far as I understand strftime_l this will lead to it returning 0 and
> therefore setting strftimefail to true always. Since this if statement
> is evaluated
> unconditionally I expect this to also influence the behaviour of TM.
>

I am afraid about this case too, and I expect maybe some fallback mode
there. But because
I have not a Window machine or some machine with older glibc I decided to
don't touch it
for this moment.

glibc older than 2.28 doesn't support %OB. On the second hand the result of
%B is probably
equal to expected result %B.


> Nitpicking & Conclusion
> =======================
> I feel like the documentation could explicitly mention the difference
> between TMMONTH and TAMMONTH (i.e., mentioning genitive and nominative
> like the Locale docs you mentioned do). I also think it would be
> beneficial to
> mention that this linguistic detail is specific to certain languages.
>

please, if you can write this part of the doc. My English is not good
enough to write well about these linguistic details.
It is a problem primarily for slavic languages - like Czech or Russian -
but maybe it can be a wide problem.


> Furthermore, in general I feel like the code could have more comments.
> The single comment /* TAM suffix - localized alternative month name */
> I would rather put outside the if-else block and set TAM directly in
> relation
> to TM there.
>
> All in all, this topic seems to have been a pain point for many people
> as the discussion threads mentioned in the beginning attest. The patch
> offers a solution to make glibc behaviour more predictable for users.
> I see a very real use case here.
>

Thank you very much for this immediate review

Regards

Pavel


> Best
> Bernd
>
> On 12/08/2026 09:13, Pavel Stehule wrote:
> > Hi
> >
> > út 11. 8. 2026 v 17:14 odesílatel Pavel Stehule
> > <[email protected]> napsal:
> >
> >
> >
> >     út 11. 8. 2026 v 17:06 odesílatel Pavel Stehule
> >     <[email protected]> napsal:
> >
> >         Hi
> >
> >         My customer reported an interesting issue. He needs translated
> >         month name, and then he use
> >         to_char(current_date, 'tmmonth');
> >
> >         Unfortunately, glibc returns nouns in the genitive case
> >         instead of the nominative case.
> >
> >         This is a glibc feature from the 2.28 release. Genitive case
> >         makes sense, when the result holds a day, but without it, it
> >         is messy.
> >
> >         glibc has alternative month names, that can be taken by usage
> >         placeholder '%OB'  of function strftime.
> >
> https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap07.html
> >         - see alt_mon.
> >
> >         Can we enhance prefixes (maybe tmo) to be possible to use
> >         alternative names?
> >
> >
> >     example:
> >
> >     pavel@nemesis:~/src/orafce$ date +'%B'
> >     srpna
> >     pavel@nemesis:~/src/orafce$ date +'%OB'
> >     srpen
> >     pavel@nemesis:~/src/orafce$ LANG=C date +'%OB'
> >     August
> >
> >
> > attached patch that implements 'TAM' modifier for data/timestamp
> > formatting
> >
> > Regards
> >
> > Pavel
> >
> >
> >         Regards
> >
> >         Pavel
> >
> >
> >
> >
> >
>
>
From c1511bb9165e4b0685a0990632a8ff4ae9d8c1ec Mon Sep 17 00:00:00 2001
From: "[email protected]" <[email protected]>
Date: Wed, 12 Aug 2026 07:03:28 +0200
Subject: [PATCH] introduce 'tam' modifier for date/timestamp formatting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

glibc returns localized month names in the genitive case, but it also
provides alternative month names in the nominative case. The TAM prefix
allows you to retrieve this alternative name.

example:

SELECT to_char(date '2026-08-01', 'TMMONTH');
┌─────────┐
│ to_char │
╞═════════╡
│ SRPNA   │
└─────────┘
(1 row)

SELECT to_char(date '2026-08-01', 'TAMMONTH');
┌─────────┐
│ to_char │
╞═════════╡
│ SRPEN   │
└─────────┘
(1 row)
---
 doc/src/sgml/func/func-formatting.sgml        |   6 +
 src/backend/utils/adt/formatting.c            | 103 +++++++++++++++---
 src/backend/utils/adt/pg_locale.c             |   9 +-
 src/include/utils/pg_locale.h                 |   1 +
 .../regress/expected/collate.linux.utf8.out   |  28 +++++
 src/test/regress/sql/collate.linux.utf8.sql   |  12 ++
 6 files changed, 142 insertions(+), 17 deletions(-)

diff --git a/doc/src/sgml/func/func-formatting.sgml b/doc/src/sgml/func/func-formatting.sgml
index e4edaf4f42c..85444015caa 100644
--- a/doc/src/sgml/func/func-formatting.sgml
+++ b/doc/src/sgml/func/func-formatting.sgml
@@ -477,6 +477,12 @@
          <xref linkend="guc-lc-time"/>)</entry>
         <entry><literal>TMMonth</literal></entry>
        </row>
+       <row>
+        <entry><literal>TAM</literal> prefix</entry>
+        <entry>translation alternative mode (use localized alternative month names based on
+         <xref linkend="guc-lc-time"/>)</entry>
+        <entry><literal>TAMMonth</literal></entry>
+       </row>
        <row>
         <entry><literal>SP</literal> suffix</entry>
         <entry>spell mode (not implemented)</entry>
diff --git a/src/backend/utils/adt/formatting.c b/src/backend/utils/adt/formatting.c
index effad4c37dd..42f7935ac7c 100644
--- a/src/backend/utils/adt/formatting.c
+++ b/src/backend/utils/adt/formatting.c
@@ -553,6 +553,7 @@ do { \
 #define DCH_SUFFIX_th	0x04
 #define DCH_SUFFIX_SP	0x08
 #define DCH_SUFFIX_TM	0x10
+#define DCH_SUFFIX_TAM	0x20
 
 /*
  * Suffix tests
@@ -594,16 +595,25 @@ IS_SUFFIX_TM(uint8 _s)
 	return (_s & DCH_SUFFIX_TM);
 }
 
+static inline bool
+IS_SUFFIX_TAM(uint8 _s)
+{
+	return (_s & DCH_SUFFIX_TAM);
+}
+
 /*
  * Suffixes definition for DATE-TIME TO/FROM CHAR
  */
 #define TM_SUFFIX_LEN	2
+#define TAM_SUFFIX_LEN	3
 
 static const KeySuffix DCH_suff[] = {
 	{"FM", 2, DCH_SUFFIX_FM, SUFFTYPE_PREFIX},
 	{"fm", 2, DCH_SUFFIX_FM, SUFFTYPE_PREFIX},
 	{"TM", TM_SUFFIX_LEN, DCH_SUFFIX_TM, SUFFTYPE_PREFIX},
+	{"TAM", TAM_SUFFIX_LEN, DCH_SUFFIX_TAM, SUFFTYPE_PREFIX},
 	{"tm", 2, DCH_SUFFIX_TM, SUFFTYPE_PREFIX},
+	{"tam", 3, DCH_SUFFIX_TAM, SUFFTYPE_PREFIX},
 	{"TH", 2, DCH_SUFFIX_TH, SUFFTYPE_POSTFIX},
 	{"th", 2, DCH_SUFFIX_th, SUFFTYPE_POSTFIX},
 	{"SP", 2, DCH_SUFFIX_SP, SUFFTYPE_POSTFIX},
@@ -2786,11 +2796,26 @@ DCH_to_char(FormatNode *node, bool is_interval, TmToChar *in, char *out, Oid col
 				INVALID_FOR_INTERVAL;
 				if (!tm->tm_mon)
 					break;
-				if (IS_SUFFIX_TM(n->suffix))
+				if (IS_SUFFIX_TM(n->suffix) || IS_SUFFIX_TAM(n->suffix))
 				{
-					char	   *str = str_toupper_z(localized_full_months[tm->tm_mon - 1], collid);
+					char	   *str;
+					char	   **localized_months;
+					int			suffix_len;
 
-					if (strlen(str) <= (n->key->len + TM_SUFFIX_LEN) * DCH_MAX_ITEM_SIZ)
+					if (IS_SUFFIX_TM(n->suffix))
+					{
+						localized_months = localized_full_months;
+						suffix_len = TM_SUFFIX_LEN;
+					}
+					else
+					{
+						localized_months = localized_alt_full_months;
+						suffix_len = TAM_SUFFIX_LEN;
+					}
+
+					str = str_toupper_z(localized_months[tm->tm_mon - 1], collid);
+
+					if (strlen(str) <= (n->key->len + suffix_len) * DCH_MAX_ITEM_SIZ)
 						strcpy(s, str);
 					else
 						ereport(ERROR,
@@ -2806,11 +2831,26 @@ DCH_to_char(FormatNode *node, bool is_interval, TmToChar *in, char *out, Oid col
 				INVALID_FOR_INTERVAL;
 				if (!tm->tm_mon)
 					break;
-				if (IS_SUFFIX_TM(n->suffix))
+				if (IS_SUFFIX_TM(n->suffix) || IS_SUFFIX_TAM(n->suffix))
 				{
-					char	   *str = str_initcap_z(localized_full_months[tm->tm_mon - 1], collid);
+					char	   *str;
+					char	   **localized_months;
+					int			suffix_len;
 
-					if (strlen(str) <= (n->key->len + TM_SUFFIX_LEN) * DCH_MAX_ITEM_SIZ)
+					if (IS_SUFFIX_TM(n->suffix))
+					{
+						localized_months = localized_full_months;
+						suffix_len = TM_SUFFIX_LEN;
+					}
+					else
+					{
+						localized_months = localized_alt_full_months;
+						suffix_len = TAM_SUFFIX_LEN;
+					}
+
+					str = str_initcap_z(localized_months[tm->tm_mon - 1], collid);
+
+					if (strlen(str) <= (n->key->len + suffix_len) * DCH_MAX_ITEM_SIZ)
 						strcpy(s, str);
 					else
 						ereport(ERROR,
@@ -2826,11 +2866,26 @@ DCH_to_char(FormatNode *node, bool is_interval, TmToChar *in, char *out, Oid col
 				INVALID_FOR_INTERVAL;
 				if (!tm->tm_mon)
 					break;
-				if (IS_SUFFIX_TM(n->suffix))
+				if (IS_SUFFIX_TM(n->suffix) || IS_SUFFIX_TAM(n->suffix))
 				{
-					char	   *str = str_tolower_z(localized_full_months[tm->tm_mon - 1], collid);
+					char	   *str;
+					char	   **localized_months;
+					int			suffix_len;
 
-					if (strlen(str) <= (n->key->len + TM_SUFFIX_LEN) * DCH_MAX_ITEM_SIZ)
+					if (IS_SUFFIX_TM(n->suffix))
+					{
+						localized_months = localized_full_months;
+						suffix_len = TM_SUFFIX_LEN;
+					}
+					else
+					{
+						localized_months = localized_alt_full_months;
+						suffix_len = TAM_SUFFIX_LEN;
+					}
+
+					str = str_tolower_z(localized_months[tm->tm_mon - 1], collid);
+
+					if (strlen(str) <= (n->key->len + suffix_len) * DCH_MAX_ITEM_SIZ)
 						strcpy(s, str);
 					else
 						ereport(ERROR,
@@ -3565,13 +3620,29 @@ DCH_from_char(FormatNode *node, const char *in, TmFromChar *out,
 			case DCH_MONTH:
 			case DCH_Month:
 			case DCH_month:
-				if (!from_char_seq_search(&value, &s, months_full,
-										  IS_SUFFIX_TM(n->suffix) ? localized_full_months : NULL,
-										  collid,
-										  n, escontext))
-					return;
-				if (!from_char_set_int(&out->mm, value + 1, n, escontext))
-					return;
+				{
+					char	  **months;
+
+					if (IS_SUFFIX_TM(n->suffix) && IS_SUFFIX_TAM(n->suffix))
+						ereturn(escontext,,
+								(errcode(ERRCODE_INVALID_DATETIME_FORMAT),
+								 errmsg("TM and TAM prefixes cannot be used together")));
+
+					if (IS_SUFFIX_TM(n->suffix))
+						months = localized_full_months;
+					else if (IS_SUFFIX_TAM(n->suffix))
+						months = localized_alt_full_months;
+					else
+						months = NULL;
+
+					if (!from_char_seq_search(&value, &s, months_full,
+											  months,
+											  collid,
+											  n, escontext))
+						return;
+					if (!from_char_set_int(&out->mm, value + 1, n, escontext))
+						return;
+				}
 				break;
 			case DCH_MON:
 			case DCH_Mon:
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 9eb99487e57..34d758486b5 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -102,6 +102,7 @@ char	   *localized_abbrev_days[7 + 1];
 char	   *localized_full_days[7 + 1];
 char	   *localized_abbrev_months[12 + 1];
 char	   *localized_full_months[12 + 1];
+char	   *localized_alt_full_months[12 + 1];
 
 static pg_locale_t default_locale = NULL;
 
@@ -701,7 +702,7 @@ cache_single_string(char **dst, const char *src, int encoding)
 void
 cache_locale_time(void)
 {
-	char		buf[(2 * 7 + 2 * 12) * MAX_L10N_DATA];
+	char		buf[(2 * 7 + 3 * 12) * MAX_L10N_DATA];
 	char	   *bufptr;
 	time_t		timenow;
 	struct tm  *timeinfo;
@@ -768,6 +769,9 @@ cache_locale_time(void)
 		if (strftime_l(bufptr, MAX_L10N_DATA, "%B", timeinfo, locale) <= 0)
 			strftimefail = true;
 		bufptr += MAX_L10N_DATA;
+		if (strftime_l(bufptr, MAX_L10N_DATA, "%OB", timeinfo, locale) <= 0)
+			strftimefail = true;
+		bufptr += MAX_L10N_DATA;
 	}
 
 #ifdef WIN32
@@ -824,9 +828,12 @@ cache_locale_time(void)
 		bufptr += MAX_L10N_DATA;
 		cache_single_string(&localized_full_months[i], bufptr, encoding);
 		bufptr += MAX_L10N_DATA;
+		cache_single_string(&localized_alt_full_months[i], bufptr, encoding);
+		bufptr += MAX_L10N_DATA;
 	}
 	localized_abbrev_months[12] = NULL;
 	localized_full_months[12] = NULL;
+	localized_alt_full_months[12] = NULL;
 
 	CurrentLCTimeValid = true;
 }
diff --git a/src/include/utils/pg_locale.h b/src/include/utils/pg_locale.h
index b74821fdfa9..3f79d4f0c1c 100644
--- a/src/include/utils/pg_locale.h
+++ b/src/include/utils/pg_locale.h
@@ -43,6 +43,7 @@ extern PGDLLIMPORT char *localized_abbrev_days[];
 extern PGDLLIMPORT char *localized_full_days[];
 extern PGDLLIMPORT char *localized_abbrev_months[];
 extern PGDLLIMPORT char *localized_full_months[];
+extern PGDLLIMPORT char *localized_alt_full_months[];
 
 extern bool check_locale(int category, const char *locale, char **canonname);
 extern char *pg_perm_setlocale(int category, const char *locale);
diff --git a/src/test/regress/expected/collate.linux.utf8.out b/src/test/regress/expected/collate.linux.utf8.out
index e0a39e4c300..26a03ac4530 100644
--- a/src/test/regress/expected/collate.linux.utf8.out
+++ b/src/test/regress/expected/collate.linux.utf8.out
@@ -463,7 +463,22 @@ SELECT to_char(date '2010-04-01', 'DD TMMON YYYY' COLLATE "tr_TR");
  01 NİS 2010
 (1 row)
 
+-- to_char
+SET lc_time TO 'cs_CZ';
+SELECT to_char(date '2010-02-01', 'DD TMMONTH');
+ to_char  
+----------
+ 01 ÚNORA
+(1 row)
+
+SELECT to_char(date '2010-02-01', 'TAMMONTH');
+ to_char 
+---------
+ ÚNOR
+(1 row)
+
 -- to_date
+SET lc_time TO 'tr_TR';
 SELECT to_date('01 ŞUB 2010', 'DD TMMON YYYY');
   to_date   
 ------------
@@ -497,6 +512,19 @@ SELECT to_date('2010 01 araLık', 'YYYY DD TMMONTH');
  12-01-2010
 (1 row)
 
+SET lc_time TO 'cs_CZ';
+SELECT to_date('1 srpna 2010', 'DD TMMONTH YYYY');
+  to_date   
+------------
+ 08-01-2010
+(1 row)
+
+SELECT to_date('1 srpen 2010', 'DD TAMMONTH YYYY');
+  to_date   
+------------
+ 08-01-2010
+(1 row)
+
 -- backwards parsing
 CREATE VIEW collview1 AS SELECT * FROM collate_test1 WHERE b COLLATE "C" >= 'bbc';
 CREATE VIEW collview2 AS SELECT a, b FROM collate_test1 ORDER BY b COLLATE "C";
diff --git a/src/test/regress/sql/collate.linux.utf8.sql b/src/test/regress/sql/collate.linux.utf8.sql
index 6d726ee9c99..3e7c356adb7 100644
--- a/src/test/regress/sql/collate.linux.utf8.sql
+++ b/src/test/regress/sql/collate.linux.utf8.sql
@@ -182,7 +182,14 @@ SELECT to_char(date '2010-02-01', 'DD TMMON YYYY' COLLATE "tr_TR");
 SELECT to_char(date '2010-04-01', 'DD TMMON YYYY');
 SELECT to_char(date '2010-04-01', 'DD TMMON YYYY' COLLATE "tr_TR");
 
+-- to_char
+SET lc_time TO 'cs_CZ';
+
+SELECT to_char(date '2010-02-01', 'DD TMMONTH');
+SELECT to_char(date '2010-02-01', 'TAMMONTH');
+
 -- to_date
+SET lc_time TO 'tr_TR';
 
 SELECT to_date('01 ŞUB 2010', 'DD TMMON YYYY');
 SELECT to_date('01 Şub 2010', 'DD TMMON YYYY');
@@ -192,6 +199,11 @@ SELECT to_date('01 Aralık 2010', 'DD TMMONTH YYYY');
 SELECT to_date('01 aralık 2010', 'DD TMMONTH YYYY');
 SELECT to_date('2010 01 araLık', 'YYYY DD TMMONTH');
 
+SET lc_time TO 'cs_CZ';
+
+SELECT to_date('1 srpna 2010', 'DD TMMONTH YYYY');
+SELECT to_date('1 srpen 2010', 'DD TAMMONTH YYYY');
+
 -- backwards parsing
 
 CREATE VIEW collview1 AS SELECT * FROM collate_test1 WHERE b COLLATE "C" >= 'bbc';
-- 
2.55.0

Reply via email to