Skip to content

Commit f869d2c

Browse files
committed
apacheGH-51215: [C++] Preserve strftime behavior with standard chrono
Translate Arrow's strftime syntax to C++20 chrono replacement fields when using the standard backend. Preserve empty formats, literals and braces, locale-sensitive output, duration directives, timezone offsets and abbreviations, and %E/%O modifiers. Keep the vendored formatter available for fallback builds.
1 parent f9f521f commit f869d2c

2 files changed

Lines changed: 184 additions & 10 deletions

File tree

‎cpp/src/arrow/compute/kernels/scalar_temporal_test.cc‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,28 @@ TEST_F(ScalarTemporalTest, TestAssumeTimezoneNonexistent) {
20032003
&options_earliest);
20042004
}
20052005

2006+
TEST_F(ScalarTemporalTest, StrftimeFormatSyntax) {
2007+
const auto type = timestamp(TimeUnit::MILLI, "UTC");
2008+
const char* input = R"(["1970-01-01T00:00:00.123", null])";
2009+
for (const auto& [format, expected] :
2010+
{std::pair{"", R"(["", null])"},
2011+
std::pair{"literal {%Y}", R"(["literal {1970}", null])"},
2012+
std::pair{"unmatched }%Y{", R"(["unmatched }1970{", null])"},
2013+
std::pair{"%Y}", R"(["1970}", null])"},
2014+
std::pair{"%Q %q %J %z %Z", R"(["123 ms %J +0000 UTC", null])"},
2015+
std::pair{"%% %n%t %Ez %Oz %OV %EJ end%",
2016+
R"(["% \n\t +00:00 +00:00 01 %EJ end%", null])"}}) {
2017+
SCOPED_TRACE(format);
2018+
const auto options = StrftimeOptions(format);
2019+
CheckScalarUnary("strftime", type, input, utf8(), expected, &options);
2020+
}
2021+
2022+
const auto options = StrftimeOptions("%Q %q");
2023+
CheckScalarUnary("strftime", timestamp(TimeUnit::MICRO, "UTC"),
2024+
R"(["1970-01-01T00:00:00.000001", null])", utf8(),
2025+
R"(["1 \u00b5s", null])", &options);
2026+
}
2027+
20062028
TEST_F(ScalarTemporalTest, StrftimeOffsetTimezone) {
20072029
auto options_ymdhms = StrftimeOptions("%Y-%m-%dT%H:%M:%S");
20082030

‎cpp/src/arrow/util/chrono_internal.h‎

Lines changed: 162 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@
3838
#if ARROW_USE_STD_CHRONO
3939
// Use C++20 standard library chrono
4040
# include <format>
41-
# include <iterator>
41+
# include <locale>
4242
# include <ostream>
43+
# include <ratio>
44+
# include <type_traits>
4345
#else
4446
// Use vendored Howard Hinnant date library
4547
# include "arrow/vendored/datetime.h"
@@ -125,22 +127,172 @@ inline const time_zone* locate_zone(std::string_view tz_name) {
125127

126128
inline const time_zone* current_zone() { return std::chrono::current_zone(); }
127129

128-
// Formatting support - streams directly using C++20 std::vformat_to
129-
// Provides: direct streaming, stream state preservation, chaining, rich format specifiers
130+
namespace detail {
131+
132+
// Argument positions passed to std::vformat by to_stream below.
133+
enum class FormatArgument : char {
134+
ZonedTime = '0',
135+
TimeOfDay = '1',
136+
TimeOfDayCount = '2',
137+
};
138+
139+
template <typename CharT>
140+
void AppendEscapedLiteral(std::basic_string<CharT>* out, CharT value) {
141+
out->push_back(value);
142+
if (value == CharT{'{'} || value == CharT{'}'}) {
143+
out->push_back(value);
144+
}
145+
}
146+
147+
// These are the directives accepted by Arrow's existing strftime syntax. Treat
148+
// all others as literals to preserve compatibility.
149+
template <typename CharT>
150+
bool IsSupportedStrftimeSpecifier(CharT modifier, CharT specifier) {
151+
const auto contains = [specifier](const char* candidates) {
152+
for (; *candidates != '\0'; ++candidates) {
153+
if (specifier == static_cast<CharT>(*candidates)) return true;
154+
}
155+
return false;
156+
};
157+
if (modifier == CharT{}) {
158+
return contains("aAbBhcCxdeDFgGHIjmMprRSTuUVWwXyYzZ");
159+
}
160+
if (modifier == CharT{'E'}) {
161+
return contains("cCxXyYz");
162+
}
163+
if (modifier == CharT{'O'}) {
164+
return contains("deHImMSuUVwWyz");
165+
}
166+
return false;
167+
}
168+
169+
template <typename CharT>
170+
void AppendChronoField(std::basic_string<CharT>* out, FormatArgument argument,
171+
CharT specifier, CharT modifier = CharT{}) {
172+
*out += {CharT{'{'}, static_cast<CharT>(argument), CharT{':'}, CharT{'L'}, CharT{'%'}};
173+
if (modifier != CharT{}) out->push_back(modifier);
174+
*out += {specifier, CharT{'}'}};
175+
}
176+
177+
template <typename CharT>
178+
void AppendLocalizedField(std::basic_string<CharT>* out, FormatArgument argument) {
179+
*out += {CharT{'{'}, static_cast<CharT>(argument), CharT{':'}, CharT{'L'}, CharT{'}'}};
180+
}
181+
182+
template <typename CharT>
183+
std::basic_string<CharT> ToChronoFormat(const CharT* fmt, bool use_microseconds_suffix) {
184+
std::basic_string<CharT> out;
185+
while (*fmt != CharT{}) {
186+
if (*fmt != CharT{'%'}) {
187+
AppendEscapedLiteral(&out, *fmt++);
188+
continue;
189+
}
190+
191+
++fmt;
192+
if (*fmt == CharT{}) {
193+
AppendEscapedLiteral(&out, CharT{'%'});
194+
break;
195+
}
196+
197+
CharT modifier{};
198+
if (*fmt == CharT{'E'} || *fmt == CharT{'O'}) {
199+
modifier = *fmt++;
200+
if (*fmt == CharT{}) {
201+
AppendEscapedLiteral(&out, CharT{'%'});
202+
AppendEscapedLiteral(&out, modifier);
203+
break;
204+
}
205+
}
206+
const CharT specifier = *fmt++;
207+
208+
if (modifier == CharT{}) {
209+
switch (specifier) {
210+
case CharT{'%'}:
211+
AppendEscapedLiteral(&out, CharT{'%'});
212+
continue;
213+
case CharT{'n'}:
214+
AppendEscapedLiteral(&out, CharT{'\n'});
215+
continue;
216+
case CharT{'t'}:
217+
AppendEscapedLiteral(&out, CharT{'\t'});
218+
continue;
219+
case CharT{'Q'}:
220+
// Formatting a duration's %Q does not consistently apply the numeric locale.
221+
AppendLocalizedField(&out, FormatArgument::TimeOfDayCount);
222+
continue;
223+
case CharT{'q'}:
224+
if (use_microseconds_suffix) {
225+
// Some standard libraries use "us"; Arrow uses the micro sign.
226+
if constexpr (std::is_same_v<CharT, char>) {
227+
AppendEscapedLiteral(&out, CharT{'\xC2'});
228+
AppendEscapedLiteral(&out, CharT{'\xB5'});
229+
} else {
230+
AppendEscapedLiteral(&out, static_cast<CharT>(0xB5));
231+
}
232+
AppendEscapedLiteral(&out, CharT{'s'});
233+
} else {
234+
AppendChronoField(&out, FormatArgument::TimeOfDay, specifier);
235+
}
236+
continue;
237+
default:
238+
break;
239+
}
240+
}
241+
242+
# if defined(__GLIBCXX__)
243+
if (modifier == CharT{'O'} && specifier == CharT{'V'}) {
244+
// libstdc++ does not yet accept %OV; use its equivalent base representation.
245+
AppendChronoField(&out, FormatArgument::ZonedTime, specifier);
246+
continue;
247+
}
248+
# endif
249+
250+
if (IsSupportedStrftimeSpecifier(modifier, specifier)) {
251+
AppendChronoField(&out, FormatArgument::ZonedTime, specifier, modifier);
252+
} else {
253+
AppendEscapedLiteral(&out, CharT{'%'});
254+
if (modifier != CharT{}) AppendEscapedLiteral(&out, modifier);
255+
AppendEscapedLiteral(&out, specifier);
256+
}
257+
}
258+
return out;
259+
}
260+
261+
} // namespace detail
262+
263+
// Convert Arrow's strftime syntax to C++20 replacement fields. Literal braces and
264+
// unsupported directives remain literal, and %Q/%q use local time of day.
130265
template <typename CharT, typename Traits, typename Duration, typename TimeZonePtr>
131266
std::basic_ostream<CharT, Traits>& to_stream(
132267
std::basic_ostream<CharT, Traits>& os, const CharT* fmt,
133268
const std::chrono::zoned_time<Duration, TimeZonePtr>& zt) {
134-
std::vformat_to(std::ostreambuf_iterator<CharT>(os), std::string("{:") + fmt + "}",
135-
std::make_format_args(zt));
269+
static_assert(std::is_same_v<CharT, char> || std::is_same_v<CharT, wchar_t>);
270+
using Precision = typename std::chrono::zoned_time<Duration, TimeZonePtr>::duration;
271+
const auto standard_format = detail::ToChronoFormat(
272+
fmt, std::ratio_equal_v<typename Precision::period, std::micro>);
273+
const auto local_time = zt.get_local_time();
274+
const auto local_day = std::chrono::floor<std::chrono::days>(local_time);
275+
const auto time_of_day = local_time - local_day;
276+
const auto time_of_day_count = time_of_day.count();
277+
278+
std::basic_string<CharT> formatted;
279+
if constexpr (std::is_same_v<CharT, char>) {
280+
formatted = std::vformat(os.getloc(), standard_format,
281+
std::make_format_args(zt, time_of_day, time_of_day_count));
282+
} else {
283+
formatted = std::vformat(os.getloc(), standard_format,
284+
std::make_wformat_args(zt, time_of_day, time_of_day_count));
285+
}
286+
os.write(formatted.data(), static_cast<std::streamsize>(formatted.size()));
136287
return os;
137288
}
138289

139-
// Format a duration using strftime-like format specifiers
140-
// Converts "%H%M" style to C++20's "{:%H%M}" style and uses std::vformat
141-
template <typename Duration>
142-
std::string format(const char* fmt, const Duration& d) {
143-
return std::vformat(std::string("{:") + fmt + "}", std::make_format_args(d));
290+
// Format a duration or time point using strftime-like format specifiers.
291+
// Converts "%H%M" style to C++20's "{:L%H%M}" style and uses std::vformat.
292+
template <typename Temporal>
293+
std::string format(const char* fmt, const Temporal& value) {
294+
return std::vformat(std::locale{}, std::string("{:L") + fmt + "}",
295+
std::make_format_args(value));
144296
}
145297

146298
inline constexpr std::chrono::month jan = std::chrono::January;

0 commit comments

Comments
 (0)