Skip to content

feat(pg-functions): implement PostgreSQL string functions - #407

Merged
sunng87 merged 5 commits into
datafusion-contrib:masterfrom
sunng87:feat/pg-string-functions
Sep 14, 2026
Merged

sunng87 merged 5 commits into
datafusion-contrib:masterfrom
sunng87:feat/pg-string-functions

Conversation

@sunng87

@sunng87 sunng87 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

Implements the String Functions category (the catalog's 2nd part after the math functions) as DataFusion ScalarUDFs, covering 14 PostgreSQL built-ins listed as 🚧 P2 in `datafusion-pg-functions/functions.md`.

Organized into six modules under `src/string/`:

Module Functions
`convert` `to_bin(int)`, `to_oct(int)`
`quote` `quote_literal(text)`, `quote_nullable(text)`
`unicode` `normalize(text[,form])`, `casefold(text)`, `unicode_assigned(text)`, `unistr(text)`
`format` `format(fmt,...)`, `sprintf(fmt,...)`
`regexp` `regexp_substr(text,pattern,...)`, `regexp_split_to_array(text,pattern)`
`encoding` `pg_client_encoding()`, `to_ascii(text)`

The `string` Cargo feature now pulls in `regex`, `unicode-normalization`, and `icu_properties` (optional). `functions.md` is updated to flip these rows to 🔧.

Postgres compatibility notes

Each UDF matches documented Postgres semantics:

  • `to_bin`/`to_oct` — two's-complement for negatives (matching the `to_hex` family), supporting `int4` and `int8`.
  • `casefold` — full Unicode case folding (CaseFolding.txt C+F): `ß`→`ss`, `ſ`→`s`.
  • `unicode_assigned` — ICU4X general-category lookup; Private-Use-Area (Co) → `true`, reserved (Cn) → `false`.
  • `quote_literal`/`quote_nullable` — single quotes doubled; backslashes left as-is (`standard_conforming_strings = on`).
  • `regexp_substr` — `start` is a 1-based character position (char-based, no multibyte panic).
  • `format`/`sprintf` — strict Postgres grammar (`%[position]s|I|L`, `%%`); width/flags rejected.
  • `to_ascii` — transliterates Latin accents to ASCII base (`café`→`cafe`).

Testing

  • 42 unit tests pass (including NULL, empty, boundary cases, and row-wise vectorized batches per `functions.md` convention chore(deps): bump chrono from 0.4.37 to 0.4.38 #4).
  • The `string.slt` sqllogictest integration suite passes through the full SQL → plan → execute path.
  • `cargo check --workspace` clean; 0 clippy warnings.

Follows the conventions in `functions.md`: `*UDF` structs, `ScalarUDFImpl` impls, PG manual links + compatibility docs at each file header, constructors wired into `register()`.

Implement 14 string functions from the PostgreSQL built-in catalog as
DataFusion ScalarUDFs, organized into six modules under src/string/:

- convert:    to_bin(int), to_oct(int) — integer-to-text base conversion
- quote:      quote_literal(text), quote_nullable(text) — SQL quoting
- unicode:    normalize(text[,form]), casefold(text), unicode_assigned(text),
              unistr(text) — Unicode normalization and escape decoding
- format:     format(fmt,...), sprintf(fmt,...) — PG %s/%I/%L text formatting
- regexp:     regexp_substr(text,pattern,...), regexp_split_to_array(text,pat)
              — regex extraction and splitting
- encoding:   pg_client_encoding(), to_ascii(text) — encoding utilities

Each UDF follows the conventions from functions.md: ScalarUDFImpl with
PartialEq/Eq/Hash derives, NULL propagation, and unit tests covering
boundary cases. A string.slt integration test file exercises all functions
through the SQL → plan → execute path.

The 'string' Cargo feature now pulls in 'regex' and 'unicode-normalization'
as optional dependencies. functions.md is updated to mark 14 entries as 🔧.
Address every finding from the two-axis review of the string functions.

Spec (Postgres semantics):
- regexp_substr: 'start' is a 1-based CHARACTER position; resolve it via
  char_indices so a start landing inside a multibyte UTF-8 char no longer
  panics (was a crash on valid input).
- regexp_split_to_array: was implemented but never registered; now wired into
  the string register() and given a working array/column path.
- to_bin/to_oct: negatives now render two's-complement (matching the to_hex
  family) instead of sign-magnitude ('-1101').
- casefold: full Unicode case folding (CaseFolding.txt C+F), so casefold('ß')
  -> 'ss' and long-s 'ſ' -> 's' (previously Rust to_lowercase, which kept 'ß').
- unicode_assigned: use ICU4X general-category tables via icu_properties;
  Private-Use-Area chars (category Co) now correctly return true, and reserved
  codepoints (Cn) correctly return false (was inverted on PUA, blind to Cn).
- quote_literal/quote_nullable: stop doubling backslashes to match
  standard_conforming_strings = on (backslash is an ordinary char).
- to_ascii: transliterate Latin accented chars to their ASCII base (cafe,
  Munchen) instead of replacing with '?'.
- format/sprintf: implement the exact Postgres grammar (%[position]s|I|L and
  %%); reject width/flag specifiers, which the spec does not support.

Standards:
- Rename all UDF structs to the documented *UDF suffix (was *Udf).
- Add the previously-unregistered regexp_split_to_array to register().
- Document Postgres compatibility + link the manual at the top of each file.
- Add row-wise vectorized-batch unit tests for each function (convention datafusion-contrib#4).
- Replace 'Arc::new(...finish()) as _' with explicit 'as ArrayRef'.
- Clear all clippy warnings (borrowed-expression, map_or, match->?).

Add icu_properties behind the 'string' feature for the general-category
lookup. All 42 unit tests, the sqllogictest suite, and clippy pass.
Address the follow-up review findings:

- regexp: the 'c' flag had been mapped to (?i), making case-SENSITIVE
  matching case-insensitive — the opposite of Postgres, where 'c' is
  case-sensitive. Flags are now applied in order with last-one-wins
  semantics ('i' enables, 'c' disables), matching PG ('ic' → sensitive,
  'ci' → insensitive). Regression test covers all four combinations.
- casefold: replace the hand-enumerated fold set (which missed final
  sigma ς→σ, micro µ→μ, capital sharp-s ẞ→ss, Greek symbols, Cyrillic
  historical letters) with the ICU4X CaseMapper via icu_casemap — the
  same tables Postgres uses. Tests added for ς, µ, ẞ.
- regexp_split_to_array: NULL input now returns a NULL of the declared
  List type instead of untyped ScalarValue::Null.
- format: correct the %I documentation — reserved words are not checked
  (documented deviation rather than an inaccurate claim).
- functions.md: flip the 12 string rows left as 🚧 by an earlier atomic
  edit failure (casefold, format, normalize, pg_client_encoding,
  quote_literal, quote_nullable, sprintf, to_ascii, to_bin, to_oct,
  unicode_assigned, unistr) to 🔧, and fix the Pattern Matching summary
  (2 implemented, 2 P2 remaining). The String summary (16 🔧 / 3 P2) and
  TOTAL now match the section contents.

43 unit tests, the sqllogictest suite, clippy, and fmt all pass.
… behavior

Spin up a real PostgreSQL 18.4 and verify every string UDF against it;
correct four functions whose behavior differed from upstream (three of
which were over-corrected in an earlier review pass).

- to_ascii: replace the hand-written Latin-1/Ext-A table with Postgres'
  actual LATIN1 -> ASCII table, extracted verbatim from the live server
  (all 128 entries for 0x80-0xFF). Postgres' mapping is idiosyncratic -
  ss -> 'B' (not 'ss'), AE -> 'A' (not 'AE'), (c) -> 'C', (pounds) -> 'L',
  x -> 'x' - because it maps every byte to a single ASCII char and
  replaces unmapped chars with a space. General libraries (any_ascii,
  deunicode) produce different mappings (e.g. ss -> ss) and cannot be
  substituted; the old hand table also mapped lowercase Ext-A letters to
  uppercase ('a' with macron -> 'A') and dropped symbols Postgres maps.
- casefold: Postgres' casefold is SIMPLE per-character lowercase
  (verified: casefold('ss')='ss' not 'ss', 'I-dotted' -> 'i', final
  sigma not context-folded), so switch from CaseMapper::fold_string
  (full folding) to CaseMapper::simple_lowercase, which matches exactly.
- quote_literal/quote_nullable: Postgres emits the E'...' escape-string
  form with quotes AND backslashes doubled when the value contains a
  backslash, and plain '...' otherwise (verified with
  standard_conforming_strings=on). Restore backslash handling with the
  E-prefix.
- format/sprintf: PG 18 DOES support width specifiers (verified
  format('%10s','x') right-aligns; '%-10s' left-aligns; '%*s' takes the
  width from the argument list; a leading 0 is part of the width). Restore
  width/flag/'*' support that an earlier pass had removed as 'scope
  creep'. sprintf is not in PG 18.4 - catalog note corrected to PG 19+.
- unistr: add Postgres' bare \XXXX escape form, require exactly 6 hex
  digits after \+, and error on unrecognized escapes (\n, \\)
  instead of passing them through - all verified against the live server.

All expectations in unit tests and string.slt updated to the verified
values. 44 unit tests, sqllogictest, clippy, and fmt pass. The any_ascii
evaluation dependency is removed (it does not match Postgres).
@sunng87
sunng87 merged commit 5ebb180 into datafusion-contrib:master Sep 14, 2026
8 of 12 checks passed
@sunng87
sunng87 deleted the feat/pg-string-functions branch September 14, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant