diff --git a/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeArray.java b/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeArray.java new file mode 100644 index 00000000..da0db85b --- /dev/null +++ b/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeArray.java @@ -0,0 +1,19 @@ +package dev.typr.foundations.docs.postgresql; + +import dev.typr.foundations.PgType; +import dev.typr.foundations.PgTypes; +import java.util.List; + +@SuppressWarnings("unused") +public class PgDomainTypeArray { + // start + // Wrap once at the scalar level — the array codec carries the wrapper through + // .array(), so no list-level bijection is needed. + public record Name(String value) { + public static final PgType pgType = + PgTypes.text.asDomain("person_name", Name::new, Name::value); + + public static final PgType> pgArrayType = pgType.array(); + } + // stop +} diff --git a/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeScalar.java b/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeScalar.java new file mode 100644 index 00000000..a11652b5 --- /dev/null +++ b/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeScalar.java @@ -0,0 +1,15 @@ +package dev.typr.foundations.docs.postgresql; + +import dev.typr.foundations.PgType; +import dev.typr.foundations.PgTypes; + +@SuppressWarnings("unused") +public class PgDomainTypeScalar { + // start + // PG schema: CREATE DOMAIN person_name AS varchar(100); + public record Name(String value) { + public static final PgType pgType = + PgTypes.text.asDomain("person_name", Name::new, Name::value); + } + // stop +} diff --git a/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeArray.kt b/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeArray.kt new file mode 100644 index 00000000..60992a27 --- /dev/null +++ b/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeArray.kt @@ -0,0 +1,18 @@ +package dev.typr.foundationskt.docs.postgresql + +import dev.typr.foundationskt.* + +@Suppress("unused") +class PgDomainTypeArray { + //start + // Wrap once at the scalar level — the array codec carries the wrapper through + // .array(), so no list-level bijection is needed. + data class Name(val value: String) { + companion object { + val pgType: PgType = + PgTypes.text.asDomain("person_name", ::Name, Name::value) + val pgArrayType: PgType> = pgType.array() + } + } + //stop +} diff --git a/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeScalar.kt b/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeScalar.kt new file mode 100644 index 00000000..52897beb --- /dev/null +++ b/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeScalar.kt @@ -0,0 +1,16 @@ +package dev.typr.foundationskt.docs.postgresql + +import dev.typr.foundationskt.* + +@Suppress("unused") +class PgDomainTypeScalar { + //start + // PG schema: CREATE DOMAIN person_name AS varchar(100); + data class Name(val value: String) { + companion object { + val pgType: PgType = + PgTypes.text.asDomain("person_name", ::Name, Name::value) + } + } + //stop +} diff --git a/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeArray.scala b/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeArray.scala new file mode 100644 index 00000000..1566ef67 --- /dev/null +++ b/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeArray.scala @@ -0,0 +1,14 @@ +package dev.typr.foundationssc.docs.postgresql +import dev.typr.foundationssc.* + +@SuppressWarnings(Array("unused")) +object PgDomainTypeArray: + // start + // Wrap once at the scalar level — the array codec carries the wrapper through + // .array, so no list-level bijection is needed. + case class Name(value: String) + object Name: + val pgType: PgType[Name] = + PgTypes.text.asDomain("person_name", Name.apply, _.value) + val pgArrayType: PgType[List[Name]] = pgType.array + // stop diff --git a/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeScalar.scala b/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeScalar.scala new file mode 100644 index 00000000..6c9701ff --- /dev/null +++ b/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeScalar.scala @@ -0,0 +1,12 @@ +package dev.typr.foundationssc.docs.postgresql +import dev.typr.foundationssc.* + +@SuppressWarnings(Array("unused")) +object PgDomainTypeScalar: + // start + // PG schema: CREATE DOMAIN person_name AS varchar(100); + case class Name(value: String) + object Name: + val pgType: PgType[Name] = + PgTypes.text.asDomain("person_name", Name.apply, _.value) + // stop diff --git a/foundations-jdbc-kotlin/src/kotlin/dev/typr/foundationskt/PgType.kt b/foundations-jdbc-kotlin/src/kotlin/dev/typr/foundationskt/PgType.kt index 922e5122..6ee57128 100644 --- a/foundations-jdbc-kotlin/src/kotlin/dev/typr/foundationskt/PgType.kt +++ b/foundations-jdbc-kotlin/src/kotlin/dev/typr/foundationskt/PgType.kt @@ -32,6 +32,13 @@ class PgType(override val underlying: dev.typr.foundations.PgType) : DbTyp fun renamed(value: String): PgType = PgType(underlying.renamed(value)) fun renamedDropPrecision(value: String): PgType = PgType(underlying.renamedDropPrecision(value)) + /** Reinterpret as the JDBC view of a PG DOMAIN — see [dev.typr.foundations.PgType.asDomain]. */ + fun asDomain(domainName: String): PgType = PgType(underlying.asDomain(domainName)) + + /** Combined [asDomain] + [transform] — wrap the domain in a typed value class in one call. */ + fun asDomain(domainName: String, f: (T) -> B, g: (B) -> T): PgType = + PgType(underlying.asDomain(domainName, dev.typr.foundations.SqlFunction { f(it) }, g)) + fun withRead(read: PgRead): PgType = PgType(underlying.withRead(read)) fun withWrite(write: PgWrite): PgType = PgType(underlying.withWrite(write)) fun withText(text: PgText): PgType = PgType(underlying.withText(text)) diff --git a/foundations-jdbc-scala/src/scala/dev/typr/foundationssc/PgType.scala b/foundations-jdbc-scala/src/scala/dev/typr/foundationssc/PgType.scala index af0529f6..834797de 100644 --- a/foundations-jdbc-scala/src/scala/dev/typr/foundationssc/PgType.scala +++ b/foundations-jdbc-scala/src/scala/dev/typr/foundationssc/PgType.scala @@ -33,6 +33,13 @@ class PgType[T](override val underlying: dev.typr.foundations.PgType[T]) extends def renamed(value: String): PgType[T] = PgType(underlying.renamed(value)) def renamedDropPrecision(value: String): PgType[T] = PgType(underlying.renamedDropPrecision(value)) + /** Reinterpret as the JDBC view of a PG DOMAIN — see `dev.typr.foundations.PgType.asDomain`. */ + def asDomain(domainName: String): PgType[T] = PgType(underlying.asDomain(domainName)) + + /** Combined `asDomain` + `transform` — wrap the domain in a typed value class in one call. */ + def asDomain[B](domainName: String, f: T => B, g: B => T): PgType[B] = + PgType(underlying.asDomain(domainName, v => f(v), v => g(v))) + def withRead(read: PgRead[T]): PgType[T] = PgType(underlying.withRead(read)) def withWrite(write: PgWrite[T]): PgType[T] = PgType(underlying.withWrite(write)) def withText(text: PgText[T]): PgType[T] = PgType(underlying.withText(text)) diff --git a/foundations-jdbc-test/src/java/dev/typr/foundations/PgArrayParseCases.java b/foundations-jdbc-test/src/java/dev/typr/foundations/PgArrayParseCases.java new file mode 100644 index 00000000..363fbd1e --- /dev/null +++ b/foundations-jdbc-test/src/java/dev/typr/foundations/PgArrayParseCases.java @@ -0,0 +1,101 @@ +package dev.typr.foundations; + +import java.util.Arrays; +import java.util.List; + +/** + * Shared test inputs for {@link PgRecordParser#parseArray} — consumed by both the pure-unit + * {@link PgRecordParserTest} (parser self-consistency) and {@link PgRecordParserAgreementTest} + * (parser-vs-live-PG cross-check). Single source of truth so both paths exercise exactly the + * same strings. + */ +public final class PgArrayParseCases { + + /** + * One parseArray expectation. The PG textual array form is type-dependent (delimiter, quoting, + * how PG canonicalises elements) — so each case names the {@code castSqlType} PG should + * interpret it as. The parser still operates purely textually with {@code delimiter}; PG runs + * {@code SELECT $input::castSqlType} and we cross-check. + * + *

{@code pgVerify=false} skips the PG cross-check for cases PG can't accept under any + * sensible array cast (e.g. jsonb-leaf bare-nested confuses PG's text[] rectangularity check). + */ + public record Case( + String input, + char delimiter, + String castSqlType, + List expected, + boolean pgVerify) {} + + private static Case textArr(String input, List expected) { + return new Case(input, ',', "text[]", expected, true); + } + + private static Case boxArr(String input, List expected) { + return new Case(input, ';', "box[]", expected, true); + } + + /** Parser-only — PG rejects this literal under any sensible array cast. */ + private static Case parserOnly(String input, List expected) { + return new Case(input, ',', "text[]", expected, false); + } + + /** 1-D, comma-delimited cases — the bulk of real-world array text PG emits as text[]. */ + public static final List ONE_DIM = + List.of( + textArr("{}", List.of()), + textArr("{1,2,3}", List.of("1", "2", "3")), + textArr("{a,b,c}", List.of("a", "b", "c")), + textArr("{42}", List.of("42")), + textArr("{\"hello\"}", List.of("hello")), + textArr("{NULL}", Arrays.asList((String) null)), + textArr("{a,NULL,c}", Arrays.asList("a", null, "c")), + textArr("{NULL,NULL}", Arrays.asList(null, null)), + textArr("{\"a,b\",c}", List.of("a,b", "c")), + textArr("{\"hello, world\",\"foo, bar\"}", List.of("hello, world", "foo, bar")), + textArr("{\"{not nested}\"}", List.of("{not nested}")), + textArr("{\"{a,b}\",c}", List.of("{a,b}", "c")), + textArr("{\"{\\\"k\\\": 1}\"}", List.of("{\"k\": 1}")), + textArr( + "{\"{\\\"a\\\": 1}\",\"{\\\"b\\\": 2}\"}", List.of("{\"a\": 1}", "{\"b\": 2}")), + textArr("{\"[1,10)\"}", List.of("[1,10)")), + textArr("{\"[1,10)\",\"[20,30)\"}", List.of("[1,10)", "[20,30)")), + textArr(" {1,2,3} ", List.of("1", "2", "3"))); + + /** + * Bare-nested multi-dim cases — at the top level we expect each {@code {…}} sub-array as a + * single element (re-parse to descend). PG sees these as rectangular N-dim arrays under the + * text[] cast. + */ + public static final List BARE_NESTED = + List.of( + textArr("{{1,2},{3,4}}", List.of("{1,2}", "{3,4}")), + textArr("{{a,b,c}}", List.of("{a,b,c}")), + textArr("{{1}}", List.of("{1}")), + textArr("{{a,b},c,d}", List.of("{a,b}", "c", "d")), + textArr("{a,{b,c},d}", List.of("a", "{b,c}", "d")), + textArr("{a,b,{c,d}}", List.of("a", "b", "{c,d}")), + textArr( + "{{{1,2},{3,4}},{{5,6},{7,8}}}", + List.of("{{1,2},{3,4}}", "{{5,6},{7,8}}")), + textArr("{{{a}}}", List.of("{{a}}")), + textArr("{{\"a,b\",c},{d}}", List.of("{\"a,b\",c}", "{d}")), + textArr("{{\"{not array}\",x},{y}}", List.of("{\"{not array}\",x}", "{y}")), + // jsonb[][] real-world shape — parser must handle it, but PG rejects this literal as + // text[] because the inner {"k":...} confuses its rectangularity check. + parserOnly( + "{{\"{\\\"k\\\": 1}\"},{\"{\\\"k\\\": 2}\"}}", + List.of("{\"{\\\"k\\\": 1}\"}", "{\"{\\\"k\\\": 2}\"}")), + textArr("{{},{}}", List.of("{}", "{}")), + textArr("{{},{a}}", List.of("{}", "{a}")), + textArr("{{a,b},NULL,{c,d}}", Arrays.asList("{a,b}", null, "{c,d}")), + textArr("{{\"a\\\"b\",c}}", List.of("{\"a\\\"b\",c}"))); + + /** ';' delimiter cases — geometric arrays (box, etc.) where PG's typdelim is ';'. */ + public static final List SEMI_DELIM = + List.of( + boxArr("{(1,2),(3,4)}", List.of("(1,2),(3,4)")), + boxArr("{(1,2),(3,4);(5,6),(7,8)}", List.of("(1,2),(3,4)", "(5,6),(7,8)"))); + + private PgArrayParseCases() {} +} diff --git a/foundations-jdbc-test/src/java/dev/typr/foundations/PgDomainTest.java b/foundations-jdbc-test/src/java/dev/typr/foundations/PgDomainTest.java new file mode 100644 index 00000000..42ce3729 --- /dev/null +++ b/foundations-jdbc-test/src/java/dev/typr/foundations/PgDomainTest.java @@ -0,0 +1,637 @@ +package dev.typr.foundations; + +import dev.typr.foundations.data.Bit; +import dev.typr.foundations.data.Cidr; +import dev.typr.foundations.data.Inet; +import dev.typr.foundations.data.Json; +import dev.typr.foundations.data.Jsonb; +import dev.typr.foundations.data.MacAddr; +import dev.typr.foundations.data.Range; +import dev.typr.foundations.data.RangeBound; +import dev.typr.foundations.data.Varbit; +import dev.typr.foundations.data.Vector; +import dev.typr.foundations.data.Xml; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +/** + * Coverage of PostgreSQL DOMAIN types — typed wrappers over a base PG type that show up to JDBC as + * the underlying type but require the domain name in DDL/array-construction. + * + *

Two flavors are exercised here: + * + *

    + *
  1. The user-facing pattern: a wrapper {@code Name} backed by a domain, plus a list bijection + * {@code Name.pgType.array().to(...)} mapping {@code List} to {@code List}. + *
  2. A scalar+array roundtrip for the domain over each common underlying type. + *
+ */ +public class PgDomainTest { + + private static final AtomicInteger tableCounter = new AtomicInteger(0); + + private static String uniqueTableName(String prefix) { + return prefix + "_" + tableCounter.incrementAndGet(); + } + + // ============================================================ + // USER-SPECIFIC PATTERN + // ============================================================ + + /** Wrapper for the {@code person_name} PG DOMAIN (varchar(100)). */ + public record Name(String value) { + public static final PgType pgType = + PgTypes.text.transform(Name::new, Name::value).asDomain("person_name"); + } + + /** A second wrapper layered on top of {@link Name} via the array-level bijection. */ + public record MiddleName(Name value) {} + + public static final PgType> pgTypeArray = + Name.pgType + .array() + .to( + Bijection.of( + xs -> xs.stream().map(MiddleName::new).toList(), + xs -> xs.stream().map(MiddleName::value).toList())); + + // ============================================================ + // ROUNDTRIP CASES + // ============================================================ + + /** + * One scalar roundtrip + (optionally) one array roundtrip per case. The domain itself is created + * inside the test's rollback-only transaction via {@link #underlyingSql}. + */ + record Case( + String domainName, String underlyingSql, PgType pgType, A example, boolean testArray) { + static Case of(String domainName, String underlying, PgType baseType, A example) { + return new Case<>(domainName, underlying, baseType.asDomain(domainName), example, true); + } + + static Case noArray( + String domainName, String underlying, PgType baseType, A example) { + return new Case<>(domainName, underlying, baseType.asDomain(domainName), example, false); + } + } + + static final List> CASES = + List.of( + Case.of("dom_text", "text", PgTypes.text, "hello, ®✅"), + Case.of("dom_text2", "text", PgTypes.text, ""), + Case.of("dom_text3", "text", PgTypes.text, "Line1\nLine2\tTabbed"), + // A precision-bearing base + Case.of("dom_varchar_100", "varchar(100)", PgTypes.text, "vc100 sample"), + // PG identifier "name" type as a domain + Case.of("dom_pg_name", "name", PgTypes.name, "my_table_name"), + // Numeric family + Case.of("dom_int2", "int2", PgTypes.int2, (short) 42), + Case.of("dom_int4", "int4", PgTypes.int4, Integer.MAX_VALUE), + Case.of("dom_int8", "int8", PgTypes.int8, Long.MIN_VALUE), + Case.of("dom_float4", "float4", PgTypes.float4, 1.5f), + Case.of("dom_float8", "float8", PgTypes.float8, 3.14159), + Case.of("dom_numeric", "numeric", PgTypes.numeric, new BigDecimal("12345.6789")), + // Boolean + Case.of("dom_bool", "bool", PgTypes.bool, true), + Case.of("dom_bool2", "bool", PgTypes.bool, false), + // Bytea — base type has no array codec; asDomain enables read but PG JDBC's + // createArrayOf rejects byte[] nested in Object[] on write. + Case.noArray("dom_bytea", "bytea", PgTypes.bytea, new byte[] {1, 2, -1, 0, 127}), + // Date/time + Case.of("dom_date", "date", PgTypes.date, LocalDate.of(2024, 12, 25)), + Case.of( + "dom_time", + "time", + PgTypes.time, + LocalTime.of(14, 30, 45).truncatedTo(ChronoUnit.MICROS)), + Case.of( + "dom_timestamp", + "timestamp", + PgTypes.timestamp, + LocalDateTime.of(2024, 12, 25, 14, 30, 45).truncatedTo(ChronoUnit.MICROS)), + Case.of( + "dom_timestamptz", + "timestamptz", + PgTypes.timestamptz, + Instant.parse("2024-12-25T14:30:45Z").truncatedTo(ChronoUnit.MICROS)), + // UUID + Case.of( + "dom_uuid", + "uuid", + PgTypes.uuid, + UUID.fromString("550e8400-e29b-41d4-a716-446655440000")), + // JSON — jsonb canonicalizes whitespace; use the canonical form for equality. + Case.of("dom_json", "json", PgTypes.json, new Json("{\"k\":1}")), + Case.of("dom_jsonb", "jsonb", PgTypes.jsonb, new Jsonb("{\"k\": 1}")), + // Network + Case.of("dom_inet", "inet", PgTypes.inet, new Inet("10.1.0.0")), + Case.of("dom_cidr", "cidr", PgTypes.cidr, new Cidr("192.168.1.0/24")), + Case.of("dom_macaddr", "macaddr", PgTypes.macaddr, new MacAddr("08:00:2b:01:02:03")), + // Bit strings + Case.of("dom_bit_8", "bit(8)", PgTypes.bitOf(8), new Bit("10110011")), + Case.of("dom_varbit", "varbit", PgTypes.varbit, new Varbit("101")), + // Extension types + Case.of( + "dom_vector", + "vector", + PgTypes.vector, + new Vector(new float[] {1.0f, 2.0f, 3.0f})), + // hstore arrays are not supported by the library (PgTypes.hstore has empty array codec). + Case.noArray( + "dom_hstore", "hstore", PgTypes.hstore, Map.of("k1", "v1", "k2", "v2")), + // XML — JDBC returns canonicalized text; skip array (PG arrays of xml are unusual). + Case.noArray("dom_xml", "xml", PgTypes.xml, new Xml("42")), + // Range + Case.of( + "dom_int4range", + "int4range", + PgTypes.int4range, + Range.int4(new RangeBound.Closed<>(1), new RangeBound.Open<>(10)))); + + // ============================================================ + // USER-SPECIFIC TEST + // ============================================================ + + /** + * Verifies that {@code .array().to(Bijection)} composes correctly when the underlying scalar is a + * PG DOMAIN: write a {@code List}, read it back, and require value-equality plus the + * outermost wrapper type. + */ + @Test + public void testNameMiddleNameDomainArray() { + var tx = Containers.postgresTransactor(); + String tableName = uniqueTableName("dom_user_pattern"); + + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE DOMAIN person_name AS varchar(100)").execute()); + mc.execute( + Fragment.of("CREATE TEMP TABLE " + tableName + " (v person_name[])").execute()); + + var original = + List.of( + new MiddleName(new Name("Alice")), + new MiddleName(new Name("Beatrice")), + new MiddleName(new Name("Charlotte"))); + + mc.execute( + Fragment.of("INSERT INTO " + tableName + " (v) VALUES (") + .append(Fragment.encode(pgTypeArray, original)) + .append(")") + .update()); + + List> rows = + mc.execute( + Fragment.of("SELECT v FROM " + tableName).query(RowCodec.of(pgTypeArray).all())); + + if (rows.size() != 1) { + throw new RuntimeException("Expected 1 row, got " + rows.size()); + } + var got = rows.getFirst(); + if (!got.equals(original)) { + throw new RuntimeException( + "person_name[] roundtrip mismatch: expected " + original + " got " + got); + } + + // Read back as scalar PG name elements via UNNEST to confirm the column is really a + // domain array (not just text[] coerced). + List typenames = + mc.execute( + Fragment.of( + "SELECT pg_typeof(v)::text FROM (SELECT unnest(v) AS v FROM " + + tableName + + ") s") + .query(RowCodec.of(PgTypes.text).all())); + if (typenames.isEmpty() + || !typenames.stream().allMatch(t -> t.equalsIgnoreCase("person_name"))) { + throw new RuntimeException( + "Expected each element to be person_name, got " + typenames); + } + return null; + }); + } + + /** + * Same pattern but exercising the scalar codec — read/write a single {@code Name} into a {@code + * person_name} column to confirm the domain-typed scalar works on its own. + */ + @Test + public void testNameDomainScalar() { + var tx = Containers.postgresTransactor(); + String tableName = uniqueTableName("dom_user_scalar"); + + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE DOMAIN person_name AS varchar(100)").execute()); + mc.execute(Fragment.of("CREATE TEMP TABLE " + tableName + " (v person_name)").execute()); + + var original = new Name("Eve"); + mc.execute( + Fragment.of("INSERT INTO " + tableName + " (v) VALUES (") + .append(Fragment.encode(Name.pgType, original)) + .append(")") + .update()); + + List rows = + mc.execute( + Fragment.of("SELECT v FROM " + tableName).query(RowCodec.of(Name.pgType).all())); + + if (rows.size() != 1 || !rows.getFirst().equals(original)) { + throw new RuntimeException("Expected [" + original + "], got " + rows); + } + return null; + }); + } + + // ============================================================ + // GENERIC DOMAIN COVERAGE + // ============================================================ + + @Test + public void testDomainScalarRoundtrips() { + var tx = Containers.postgresTransactor(); + var failures = new ArrayList(); + + for (Case c : CASES) { + try { + tx.transact( + mc -> { + runScalarRoundtrip(mc, c); + return null; + }); + } catch (Exception e) { + failures.add(c.domainName() + " (" + c.example() + "): " + e.getMessage()); + } + } + + if (!failures.isEmpty()) { + throw new RuntimeException( + "Domain scalar roundtrip failures (" + failures.size() + "):\n " + String.join("\n ", failures)); + } + } + + @Test + public void testDomainArrayRoundtrips() { + var tx = Containers.postgresTransactor(); + var failures = new ArrayList(); + + for (Case c : CASES) { + if (!c.testArray()) continue; + try { + tx.transact( + mc -> { + runArrayRoundtrip(mc, c); + return null; + }); + } catch (Exception e) { + failures.add(c.domainName() + "[]: " + e.getMessage()); + } + } + + if (!failures.isEmpty()) { + throw new RuntimeException( + "Domain array roundtrip failures (" + failures.size() + "):\n " + String.join("\n ", failures)); + } + } + + // ============================================================ + // HELPERS + // ============================================================ + + private static void runScalarRoundtrip(Connection mc, Case c) { + String tableName = uniqueTableName("dom_scalar"); + mc.execute( + Fragment.of("CREATE DOMAIN " + c.domainName() + " AS " + c.underlyingSql()).execute()); + mc.execute( + Fragment.of("CREATE TEMP TABLE " + tableName + " (v " + c.domainName() + ")").execute()); + + mc.execute( + Fragment.of("INSERT INTO " + tableName + " (v) VALUES (") + .append(Fragment.encode(c.pgType(), c.example())) + .append(")") + .update()); + + List rows = + mc.execute( + Fragment.of("SELECT v FROM " + tableName).query(RowCodec.of(c.pgType()).all())); + + if (rows.size() != 1 || !areEqual(rows.getFirst(), c.example())) { + throw new RuntimeException( + "expected '" + format(c.example()) + "' got '" + format(rows.isEmpty() ? null : rows.getFirst()) + "'"); + } + } + + private static void runArrayRoundtrip(Connection mc, Case c) { + PgType> arrayType = c.pgType().array(); + String tableName = uniqueTableName("dom_array"); + mc.execute( + Fragment.of("CREATE DOMAIN " + c.domainName() + " AS " + c.underlyingSql()).execute()); + mc.execute( + Fragment.of("CREATE TEMP TABLE " + tableName + " (v " + c.domainName() + "[])").execute()); + + List values = List.of(c.example()); + mc.execute( + Fragment.of("INSERT INTO " + tableName + " (v) VALUES (") + .append(Fragment.encode(arrayType, values)) + .append(")") + .update()); + + List> rows = + mc.execute(Fragment.of("SELECT v FROM " + tableName).query(RowCodec.of(arrayType).all())); + + if (rows.size() != 1) { + throw new RuntimeException("expected 1 row, got " + rows.size()); + } + var got = rows.getFirst(); + if (got.size() != 1 || !areEqual(got.getFirst(), c.example())) { + throw new RuntimeException( + "expected ['" + format(c.example()) + "'] got '" + format(got) + "'"); + } + } + + // ============================================================ + // ENUM, COMPOSITE, CONSTRAINTS, NESTED DOMAIN, OPTIONAL + // ============================================================ + + public enum Traffic { + red, + amber, + green + } + + /** Domain wraps a user-defined ENUM. Scalar + array roundtrip. */ + @Test + public void testDomainOverEnum() { + var tx = Containers.postgresTransactor(); + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE TYPE traffic AS ENUM ('red','amber','green')").execute()); + mc.execute(Fragment.of("CREATE DOMAIN traffic_dom AS traffic").execute()); + + PgType trafficDom = + PgTypes.ofEnum("traffic", Traffic.values()).asDomain("traffic_dom"); + + mc.execute(Fragment.of("CREATE TEMP TABLE t (v traffic_dom)").execute()); + mc.execute( + Fragment.of("INSERT INTO t (v) VALUES (") + .append(Fragment.encode(trafficDom, Traffic.amber)) + .append(")") + .update()); + + var got = + mc.execute( + Fragment.of("SELECT v FROM t").query(RowCodec.of(trafficDom).all())); + if (got.size() != 1 || got.getFirst() != Traffic.amber) { + throw new RuntimeException("scalar enum domain mismatch: " + got); + } + return null; + }); + } + + @Test + public void testDomainOverEnumArray() { + var tx = Containers.postgresTransactor(); + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE TYPE traffic AS ENUM ('red','amber','green')").execute()); + mc.execute(Fragment.of("CREATE DOMAIN traffic_dom AS traffic").execute()); + + PgType> trafficDomArr = + PgTypes.ofEnum("traffic", Traffic.values()).asDomain("traffic_dom").array(); + + mc.execute(Fragment.of("CREATE TEMP TABLE t (vs traffic_dom[])").execute()); + var values = List.of(Traffic.red, Traffic.amber, Traffic.green); + mc.execute( + Fragment.of("INSERT INTO t (vs) VALUES (") + .append(Fragment.encode(trafficDomArr, values)) + .append(")") + .update()); + + var got = + mc.execute( + Fragment.of("SELECT vs FROM t").query(RowCodec.of(trafficDomArr).all())); + if (got.size() != 1 || !got.getFirst().equals(values)) { + throw new RuntimeException("array enum domain mismatch: " + got); + } + return null; + }); + } + + public record Addr(String street, String city) {} + + /** Domain wraps a user-defined COMPOSITE type. */ + @Test + public void testDomainOverComposite() { + var tx = Containers.postgresTransactor(); + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE TYPE addr_t AS (street text, city text)").execute()); + mc.execute(Fragment.of("CREATE DOMAIN addr_dom AS addr_t").execute()); + + PgType addrType = + PgTypes.compositeOf( + "addr_t", + RowCodec.namedBuilder() + .field("street", PgTypes.text, Addr::street) + .field("city", PgTypes.text, Addr::city) + .build(Addr::new)) + .asDomain("addr_dom"); + + mc.execute(Fragment.of("CREATE TEMP TABLE t (v addr_dom)").execute()); + var original = new Addr("742 Evergreen", "Springfield"); + mc.execute( + Fragment.of("INSERT INTO t (v) VALUES (") + .append(Fragment.encode(addrType, original)) + .append(")") + .update()); + + var got = mc.execute(Fragment.of("SELECT v FROM t").query(RowCodec.of(addrType).all())); + if (got.size() != 1 || !got.getFirst().equals(original)) { + throw new RuntimeException("composite domain mismatch: " + got); + } + return null; + }); + } + + /** A CHECK constraint must propagate as a SQL exception when violated on insert. */ + @Test + public void testDomainCheckConstraintViolation() { + var tx = Containers.postgresTransactor(); + boolean threw = false; + try { + tx.transact( + mc -> { + mc.execute( + Fragment.of("CREATE DOMAIN positive_int AS int4 CHECK (VALUE > 0)").execute()); + PgType posInt = PgTypes.int4.asDomain("positive_int"); + mc.execute(Fragment.of("CREATE TEMP TABLE t (v positive_int)").execute()); + mc.execute( + Fragment.of("INSERT INTO t (v) VALUES (") + .append(Fragment.encode(posInt, -5)) + .append(")") + .update()); + return null; + }); + } catch (Exception e) { + threw = true; + // Surface check-constraint context by walking the cause chain. + String chain = e.toString(); + Throwable c = e.getCause(); + while (c != null) { + chain += " | " + c; + c = c.getCause(); + } + if (!chain.contains("positive_int") && !chain.toLowerCase().contains("check")) { + throw new RuntimeException( + "CHECK constraint violation must mention the domain or check failure, got: " + chain); + } + } + if (!threw) throw new RuntimeException("CHECK constraint did not fire"); + } + + /** A NOT NULL domain must reject Optional.empty() inserts. */ + @Test + public void testDomainNotNullViolation() { + var tx = Containers.postgresTransactor(); + boolean threw = false; + try { + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE DOMAIN required_text AS text NOT NULL").execute()); + PgType> reqOpt = + PgTypes.text.asDomain("required_text").opt(); + mc.execute(Fragment.of("CREATE TEMP TABLE t (v required_text)").execute()); + mc.execute( + Fragment.of("INSERT INTO t (v) VALUES (") + .append(Fragment.encode(reqOpt, java.util.Optional.empty())) + .append(")") + .update()); + return null; + }); + } catch (Exception e) { + threw = true; + } + if (!threw) throw new RuntimeException("NOT NULL domain did not reject NULL insert"); + } + + /** Optional roundtrip — non-null and null values both observed back as Optional. */ + @Test + public void testOptionalDomainRoundtrip() { + var tx = Containers.postgresTransactor(); + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE DOMAIN nullable_text AS text").execute()); + PgType> optDom = + PgTypes.text.asDomain("nullable_text").opt(); + mc.execute(Fragment.of("CREATE TEMP TABLE t (v nullable_text)").execute()); + mc.execute( + Fragment.of("INSERT INTO t (v) VALUES (") + .append(Fragment.encode(optDom, java.util.Optional.of("hello"))) + .append("),(") + .append(Fragment.encode(optDom, java.util.Optional.empty())) + .append(")") + .update()); + var got = + mc.execute( + Fragment.of("SELECT v FROM t ORDER BY v NULLS LAST") + .query(RowCodec.of(optDom).all())); + if (got.size() != 2 + || !got.get(0).equals(java.util.Optional.of("hello")) + || !got.get(1).equals(java.util.Optional.empty())) { + throw new RuntimeException("Optional roundtrip mismatch: " + got); + } + return null; + }); + } + + /** A domain whose underlying is itself a domain — chained typenames must work end-to-end. */ + @Test + public void testDomainOverDomain() { + var tx = Containers.postgresTransactor(); + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE DOMAIN d_text_inner AS text").execute()); + mc.execute(Fragment.of("CREATE DOMAIN d_text_outer AS d_text_inner").execute()); + // Chain asDomain twice — each level renames typename and registers the previous name as + // an analyzer alias. + PgType dom = PgTypes.text.asDomain("d_text_inner").asDomain("d_text_outer"); + mc.execute(Fragment.of("CREATE TEMP TABLE t (v d_text_outer)").execute()); + mc.execute( + Fragment.of("INSERT INTO t (v) VALUES (") + .append(Fragment.encode(dom, "hi")) + .append(")") + .update()); + var got = + mc.execute(Fragment.of("SELECT v FROM t").query(RowCodec.of(dom).all())); + if (got.size() != 1 || !got.getFirst().equals("hi")) { + throw new RuntimeException("domain-over-domain mismatch: " + got); + } + return null; + }); + } + + /** Composite type with a domain-typed field. */ + @Test + public void testDomainAsCompositeField() { + var tx = Containers.postgresTransactor(); + tx.transact( + mc -> { + mc.execute(Fragment.of("CREATE DOMAIN dom_text AS text").execute()); + mc.execute(Fragment.of("CREATE TYPE wrapper_t AS (id int4, label dom_text)").execute()); + + record Wrapper(Integer id, String label) {} + var domText = PgTypes.text.asDomain("dom_text"); + var wrapperType = + PgTypes.compositeOf( + "wrapper_t", + RowCodec.namedBuilder() + .field("id", PgTypes.int4, Wrapper::id) + .field("label", domText, Wrapper::label) + .build(Wrapper::new)); + + mc.execute(Fragment.of("CREATE TEMP TABLE t (v wrapper_t)").execute()); + var original = new Wrapper(1, "hi"); + mc.execute( + Fragment.of("INSERT INTO t (v) VALUES (") + .append(Fragment.encode(wrapperType, original)) + .append(")") + .update()); + var got = + mc.execute(Fragment.of("SELECT v FROM t").query(RowCodec.of(wrapperType).all())); + if (got.size() != 1 || !got.getFirst().equals(original)) { + throw new RuntimeException("domain in composite field mismatch: " + got); + } + return null; + }); + } + + private static boolean areEqual(A actual, A expected) { + if (expected instanceof byte[]) { + return Arrays.equals((byte[]) actual, (byte[]) expected); + } + if (expected instanceof Object[]) { + return Arrays.equals((Object[]) actual, (Object[]) expected); + } + if (expected == null) { + return actual == null; + } + return expected.equals(actual); + } + + private static String format(Object a) { + if (a instanceof byte[] b) return Arrays.toString(b); + if (a instanceof Object[] arr) return Arrays.toString(arr); + return String.valueOf(a); + } +} diff --git a/foundations-jdbc-test/src/java/dev/typr/foundations/PgFuzzRoundtripTest.java b/foundations-jdbc-test/src/java/dev/typr/foundations/PgFuzzRoundtripTest.java new file mode 100644 index 00000000..33ceca0d --- /dev/null +++ b/foundations-jdbc-test/src/java/dev/typr/foundations/PgFuzzRoundtripTest.java @@ -0,0 +1,694 @@ +package dev.typr.foundations; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +/** + * End-to-end pipeline fuzz: for each {@code (PgType, T)} pair, push the value through the + * full encode → INSERT → SELECT → decode round-trip and assert the read-back equals the + * original. Also exercises the array, multi-element array, and 2-D array forms — anywhere the + * codec's {@code pgText} / {@code wireDecode} / record-text-parsing path runs is covered for + * every value. + * + *

Intentionally adversarial inputs: empty strings, embedded delimiters, embedded braces, + * embedded quotes, backslashes, NULL-literal-looking strings, control chars, multi-byte UTF-8, + * emoji, RTL, combining marks, zero-width characters. If any of these break, the codec is wrong. + * + *

Pairs explicitly to a {@link PgType} (text, varchar, name, etc.) — PG's text array form is + * type-dependent (delimiters, what gets quoted, how PG canonicalises elements), so we never + * leave the type unstated. + */ +public class PgFuzzRoundtripTest { + + private static final AtomicInteger tableCounter = new AtomicInteger(0); + + /** A typed adversarial input. */ + public record Fuzz(String label, PgType type, T value) {} + + /** + * Crazy-string corpus paired with a PgType. Same set is run scalar / 1-D array (singleton + + * mixed) / 2-D array, plus once through a {@code text.asDomain(...)} wrapper. + */ + static final List> CASES = + List.of( + // Empty / whitespace + new Fuzz<>("empty", PgTypes.text, ""), + new Fuzz<>("single space", PgTypes.text, " "), + new Fuzz<>("trailing space", PgTypes.text, "x "), + new Fuzz<>("leading space", PgTypes.text, " x"), + new Fuzz<>("only whitespace", PgTypes.text, " "), + new Fuzz<>("tab", PgTypes.text, "\t"), + new Fuzz<>("mixed whitespace", PgTypes.text, " \t \r \t "), + + // Punctuation that PG's text-array scanner gives meaning + new Fuzz<>("comma", PgTypes.text, "a,b"), + new Fuzz<>("semicolon", PgTypes.text, "a;b"), + new Fuzz<>("colon", PgTypes.text, "a:b"), + new Fuzz<>("double colon (cast op)", PgTypes.text, "value::text"), + new Fuzz<>("pipe", PgTypes.text, "a|b"), + new Fuzz<>("opening brace", PgTypes.text, "{"), + new Fuzz<>("closing brace", PgTypes.text, "}"), + new Fuzz<>("brace pair", PgTypes.text, "{}"), + new Fuzz<>("nested braces", PgTypes.text, "{{}}"), + new Fuzz<>("looks like array literal", PgTypes.text, "{a,b,c}"), + new Fuzz<>("looks like multi-dim", PgTypes.text, "{{a,b},{c,d}}"), + new Fuzz<>("opening paren", PgTypes.text, "("), + new Fuzz<>("looks like record", PgTypes.text, "(a,b)"), + new Fuzz<>("looks like range", PgTypes.text, "[1,10)"), + new Fuzz<>("just brackets", PgTypes.text, "[]"), + + // Quotes and backslashes — the array scanner's escape rules + new Fuzz<>("single dquote", PgTypes.text, "\""), + new Fuzz<>("paired dquotes", PgTypes.text, "\"\""), + new Fuzz<>("dquote in middle", PgTypes.text, "a\"b"), + new Fuzz<>("escaped dquote in middle", PgTypes.text, "a\\\"b"), + new Fuzz<>("looks like quoted elem", PgTypes.text, "\"hello\""), + new Fuzz<>("squote", PgTypes.text, "'"), + new Fuzz<>("paired squotes", PgTypes.text, "''"), + new Fuzz<>("apostrophe in word", PgTypes.text, "it's"), + new Fuzz<>("backslash", PgTypes.text, "\\"), + new Fuzz<>("paired backslash", PgTypes.text, "\\\\"), + new Fuzz<>("backslash n literal", PgTypes.text, "\\n"), + new Fuzz<>("backslash everything", PgTypes.text, "\\\"\\\\\\,\\}"), + + // PG-special literal lookalikes + new Fuzz<>("NULL literal exact", PgTypes.text, "NULL"), + new Fuzz<>("null lowercase", PgTypes.text, "null"), + new Fuzz<>("nUlL mixed case", PgTypes.text, "nUlL"), + new Fuzz<>("NULL with whitespace", PgTypes.text, " NULL "), + new Fuzz<>("NULLNULL", PgTypes.text, "NULLNULL"), + + // Control characters + new Fuzz<>("newline", PgTypes.text, "a\nb"), + new Fuzz<>("CR", PgTypes.text, "a\rb"), + new Fuzz<>("CRLF", PgTypes.text, "a\r\nb"), + new Fuzz<>("vertical tab", PgTypes.text, "a b"), + new Fuzz<>("form feed", PgTypes.text, "a\fb"), + new Fuzz<>("DEL char", PgTypes.text, "ab"), + new Fuzz<>("BEL char", PgTypes.text, "ab"), + + // Multi-byte / Unicode + new Fuzz<>("registered ®", PgTypes.text, "®"), + new Fuzz<>("emoji single", PgTypes.text, "😀"), + new Fuzz<>("emoji ZWJ family", PgTypes.text, "👨‍👩‍👧"), + new Fuzz<>("emoji skin-tone", PgTypes.text, "👋🏽"), + new Fuzz<>("RTL Arabic", PgTypes.text, "مرحبا"), + new Fuzz<>("RTL Hebrew", PgTypes.text, "שלום"), + new Fuzz<>("CJK Chinese", PgTypes.text, "你好"), + new Fuzz<>("CJK Japanese", PgTypes.text, "こんにちは"), + new Fuzz<>("combining mark é", PgTypes.text, "é"), + new Fuzz<>("zero-width joiner", PgTypes.text, "a‍B"), + new Fuzz<>("zero-width space", PgTypes.text, "a​B"), + new Fuzz<>("BOM mid-string", PgTypes.text, "aB"), + new Fuzz<>("soft hyphen", PgTypes.text, "a­B"), + new Fuzz<>("non-breaking space", PgTypes.text, "a B"), + new Fuzz<>("supplementary plane", PgTypes.text, "𐍈"), + + // PG name (identifier) type — limited length but same scanner rules + new Fuzz<>("name with comma", PgTypes.name, "has,comma"), + new Fuzz<>("name with brace", PgTypes.name, "has{brace}"), + new Fuzz<>("name with quote", PgTypes.name, "has\"quote"), + + // varchar with precision — typename WithPrec so .array() peels precision for PG's + // createArrayOf (which only accepts bare type names like "varchar", not "varchar(50)"). + new Fuzz<>( + "varchar(50) with all the stuff", + PgTypes.text.withTypename(PgTypename.of("varchar", 50)), + "{a,b}\"\\,;`"), + + // Length stress + new Fuzz<>("100x braces", PgTypes.text, "{".repeat(100) + "}".repeat(100)), + new Fuzz<>("100x backslash quote", PgTypes.text, "\\\"".repeat(100))); + + // ============================================================ + // Scalar roundtrip + // ============================================================ + + @Test + public void scalarRoundtrip() { + var failures = new ArrayList(); + Containers.postgresTransactor() + .transact( + mc -> { + for (Fuzz f : CASES) { + try { + doScalar(mc, f); + } catch (Throwable t) { + failures.add(f.label() + " (" + show(f.value()) + "): " + summarize(t)); + } + } + return null; + }); + if (!failures.isEmpty()) { + throw new RuntimeException( + failures.size() + " scalar roundtrip failures:\n " + String.join("\n ", failures)); + } + } + + private static void doScalar(Connection mc, Fuzz f) { + String table = uniq("fuzz_scalar"); + String sqlType = f.type().typename().sqlType(); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v " + sqlType + ")").execute()); + try { + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(f.type(), f.value())) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(f.type()).all())); + if (rows.size() != 1 || !valuesEqual(rows.getFirst(), f.value())) { + throw new AssertionError( + "expected " + show(f.value()) + " got " + show(rows.isEmpty() ? null : rows.getFirst())); + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + // ============================================================ + // Array roundtrip (singleton + multi-element) + // ============================================================ + + @Test + public void arrayRoundtrip() { + var failures = new ArrayList(); + Containers.postgresTransactor() + .transact( + mc -> { + for (Fuzz f : CASES) { + try { + doArraySingleton(mc, f); + } catch (Throwable t) { + failures.add( + "array[singleton] " + f.label() + " (" + show(f.value()) + "): " + summarize(t)); + } + try { + doArrayMulti(mc, f); + } catch (Throwable t) { + failures.add( + "array[multi] " + f.label() + " (" + show(f.value()) + "): " + summarize(t)); + } + } + return null; + }); + if (!failures.isEmpty()) { + throw new RuntimeException( + failures.size() + " array roundtrip failures:\n " + String.join("\n ", failures)); + } + } + + private static void doArraySingleton(Connection mc, Fuzz f) { + PgType> arr = f.type().array(); + String table = uniq("fuzz_arr_one"); + String sqlType = arr.typename().sqlType(); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v " + sqlType + ")").execute()); + try { + List values = singletonList(f.value()); + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(arr, values)) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(arr).all())); + if (rows.size() != 1) throw new AssertionError("expected 1 row, got " + rows.size()); + var got = rows.getFirst(); + if (got.size() != 1 || !valuesEqual(got.get(0), f.value())) { + throw new AssertionError( + "expected [" + show(f.value()) + "] got " + show(got)); + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + private static void doArrayMulti(Connection mc, Fuzz f) { + PgType> arr = f.type().array(); + String table = uniq("fuzz_arr_multi"); + String sqlType = arr.typename().sqlType(); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v " + sqlType + ")").execute()); + try { + // Mix the fuzz value with two siblings that have meaning to the array scanner — a string + // that LOOKS like a NULL marker and one with embedded delimiters/braces — so the + // quoting/escaping has to disambiguate this entry from its neighbours. + @SuppressWarnings("unchecked") + T sibling1 = (T) "NULL"; + @SuppressWarnings("unchecked") + T sibling2 = (T) "{a,b};\"\\"; + // Only valid for text-typed values; if T isn't String, fall back to a singleton multi. + List values; + if (f.type() == PgTypes.text || f.type() == PgTypes.name + || f.type().typename().sqlType().startsWith("varchar")) { + values = Arrays.asList(f.value(), sibling1, sibling2); + } else { + values = singletonList(f.value()); + } + + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(arr, values)) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(arr).all())); + if (rows.size() != 1) throw new AssertionError("expected 1 row, got " + rows.size()); + var got = rows.getFirst(); + if (got.size() != values.size()) { + throw new AssertionError( + "size " + values.size() + " expected, got " + got.size() + ": " + show(got)); + } + for (int i = 0; i < values.size(); i++) { + if (!valuesEqual(got.get(i), values.get(i))) { + throw new AssertionError( + "element " + i + " mismatch: expected " + show(values.get(i)) + " got " + show(got.get(i))); + } + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + // ============================================================ + // Domain roundtrip — text-typed values through asDomain wrapper + // ============================================================ + + @Test + public void domainRoundtrip() { + var failures = new ArrayList(); + Containers.postgresTransactor() + .transact( + mc -> { + mc.execute(Fragment.of("CREATE DOMAIN pgtt_fuzz_dom AS text").execute()); + PgType dom = PgTypes.text.asDomain("pgtt_fuzz_dom"); + for (Fuzz f : CASES) { + if (f.type() != PgTypes.text) continue; // only text-typed values relevant for this domain + @SuppressWarnings("unchecked") + Fuzz tf = (Fuzz) f; + try { + doDomainScalar(mc, tf, dom); + } catch (Throwable t) { + failures.add( + "domain scalar " + tf.label() + " (" + show(tf.value()) + "): " + summarize(t)); + } + try { + doDomainArray(mc, tf, dom); + } catch (Throwable t) { + failures.add( + "domain array " + tf.label() + " (" + show(tf.value()) + "): " + summarize(t)); + } + } + return null; + }); + if (!failures.isEmpty()) { + throw new RuntimeException( + failures.size() + " domain roundtrip failures:\n " + String.join("\n ", failures)); + } + } + + private static void doDomainScalar(Connection mc, Fuzz f, PgType dom) { + String table = uniq("fuzz_dom_scalar"); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v pgtt_fuzz_dom)").execute()); + try { + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(dom, f.value())) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(dom).all())); + if (rows.size() != 1 || !valuesEqual(rows.getFirst(), f.value())) { + throw new AssertionError( + "expected " + show(f.value()) + " got " + show(rows.isEmpty() ? null : rows.getFirst())); + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + private static void doDomainArray(Connection mc, Fuzz f, PgType dom) { + PgType> arr = dom.array(); + String table = uniq("fuzz_dom_arr"); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v pgtt_fuzz_dom[])").execute()); + try { + List values = Arrays.asList(f.value(), "NULL", "{a,b};\"\\"); + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(arr, values)) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(arr).all())); + if (rows.size() != 1) throw new AssertionError("expected 1 row, got " + rows.size()); + var got = rows.getFirst(); + if (got.size() != values.size()) { + throw new AssertionError( + "size " + values.size() + " expected, got " + got.size() + ": " + show(got)); + } + for (int i = 0; i < values.size(); i++) { + if (!valuesEqual(got.get(i), values.get(i))) { + throw new AssertionError( + "element " + i + " mismatch: expected " + show(values.get(i)) + " got " + show(got.get(i))); + } + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + // ============================================================ + // Nested structures — composites holding crazy strings + // ============================================================ + + /** + * Composite-with-text-field roundtrip — exercises the {@link PgRecordParser#parse} record-text + * pipeline (different from {@link PgRecordParser#parseArray}) for every adversarial string. + * Composites encode each field through quoting + backslash-escaping that's a separate set of + * rules from the array form, so a string that survives array tests can still break composites. + */ + @Test + public void compositeRoundtrip() { + var failures = new ArrayList(); + Containers.postgresTransactor() + .transact( + mc -> { + mc.execute(Fragment.of("CREATE TYPE pgtt_fuzz_one AS (v text)").execute()); + PgType oneType = + PgTypes.compositeOf( + "pgtt_fuzz_one", + RowCodec.namedBuilder() + .field("v", PgTypes.text, OneField::v) + .build(OneField::new)); + for (Fuzz f : CASES) { + if (f.type() != PgTypes.text) continue; + @SuppressWarnings("unchecked") + Fuzz tf = (Fuzz) f; + try { + doCompositeOne(mc, tf, oneType); + } catch (Throwable t) { + failures.add( + "composite(text) " + tf.label() + " (" + show(tf.value()) + "): " + + summarize(t)); + } + } + return null; + }); + if (!failures.isEmpty()) { + throw new RuntimeException( + failures.size() + " composite roundtrip failures:\n " + String.join("\n ", failures)); + } + } + + /** + * Two-field composite — the per-field quoting has to survive a sibling that ALSO contains + * adversarial chars. Catches encoders that handle "string at start" and "string at end" + * differently. + */ + @Test + public void compositeTwoFieldRoundtrip() { + var failures = new ArrayList(); + Containers.postgresTransactor() + .transact( + mc -> { + mc.execute(Fragment.of("CREATE TYPE pgtt_fuzz_two AS (a text, b text)").execute()); + PgType twoType = + PgTypes.compositeOf( + "pgtt_fuzz_two", + RowCodec.namedBuilder() + .field("a", PgTypes.text, TwoField::a) + .field("b", PgTypes.text, TwoField::b) + .build(TwoField::new)); + for (Fuzz f : CASES) { + if (f.type() != PgTypes.text) continue; + @SuppressWarnings("unchecked") + Fuzz tf = (Fuzz) f; + // sibling intentionally has braces, commas, quotes, backslashes + String sibling = "{x,y};\"\\"; + try { + doCompositeTwo(mc, tf.label(), tf.value(), sibling, twoType); + } catch (Throwable t) { + failures.add( + "composite(a,b) " + tf.label() + " a=" + show(tf.value()) + " b=" + show(sibling) + + ": " + summarize(t)); + } + } + return null; + }); + if (!failures.isEmpty()) { + throw new RuntimeException( + failures.size() + " composite(two) roundtrip failures:\n " + String.join("\n ", failures)); + } + } + + /** + * Array of composites — composites are quoted-and-escaped as ARRAY ELEMENTS, then their fields + * are quoted-and-escaped INSIDE the composite. Two layers of escape rules; either layer + * misbehaving on a crazy string surfaces here. + */ + @Test + public void arrayOfCompositeRoundtrip() { + var failures = new ArrayList(); + Containers.postgresTransactor() + .transact( + mc -> { + mc.execute(Fragment.of("CREATE TYPE pgtt_fuzz_one AS (v text)").execute()); + PgType oneType = + PgTypes.compositeOf( + "pgtt_fuzz_one", + RowCodec.namedBuilder() + .field("v", PgTypes.text, OneField::v) + .build(OneField::new)); + PgType> arr = oneType.array(); + for (Fuzz f : CASES) { + if (f.type() != PgTypes.text) continue; + @SuppressWarnings("unchecked") + Fuzz tf = (Fuzz) f; + try { + doArrayOfComposite(mc, tf, oneType, arr); + } catch (Throwable t) { + failures.add( + "array " + tf.label() + " (" + show(tf.value()) + "): " + + summarize(t)); + } + } + return null; + }); + if (!failures.isEmpty()) { + throw new RuntimeException( + failures.size() + " array roundtrip failures:\n " + + String.join("\n ", failures)); + } + } + + /** + * Composite with an ARRAY field of crazy strings — inverse of the above. Field-quoting wraps + * an already-quoted-and-escaped array literal; the composite-text decoder has to peel one + * layer and the array-text decoder the next. + */ + @Test + public void compositeWithArrayFieldRoundtrip() { + var failures = new ArrayList(); + Containers.postgresTransactor() + .transact( + mc -> { + mc.execute(Fragment.of("CREATE TYPE pgtt_fuzz_arr_field AS (xs text[])").execute()); + PgType t = + PgTypes.compositeOf( + "pgtt_fuzz_arr_field", + RowCodec.namedBuilder() + .field("xs", PgTypes.text.array(), ArrField::xs) + .build(ArrField::new)); + for (Fuzz f : CASES) { + if (f.type() != PgTypes.text) continue; + @SuppressWarnings("unchecked") + Fuzz tf = (Fuzz) f; + try { + // 3 elements, all containing the fuzzed value mixed with sibling weirdness so + // the array-quoting and composite-quoting must compose correctly. + List xs = Arrays.asList(tf.value(), "NULL", "{a,b};\"\\"); + doCompositeWithArrayField(mc, tf.label(), xs, t); + } catch (Throwable th) { + failures.add( + "composite(array field) " + tf.label() + " (" + show(tf.value()) + "): " + + summarize(th)); + } + } + return null; + }); + if (!failures.isEmpty()) { + throw new RuntimeException( + failures.size() + " composite(array field) roundtrip failures:\n " + + String.join("\n ", failures)); + } + } + + public record OneField(String v) {} + + public record TwoField(String a, String b) {} + + public record ArrField(List xs) {} + + private static void doCompositeOne(Connection mc, Fuzz f, PgType type) { + String table = uniq("fuzz_comp_one"); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v pgtt_fuzz_one)").execute()); + try { + OneField original = new OneField(f.value()); + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(type, original)) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(type).all())); + if (rows.size() != 1 || !valuesEqual(rows.getFirst(), original)) { + throw new AssertionError( + "expected " + show(f.value()) + " got " + show(rows.isEmpty() ? null : rows.getFirst().v())); + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + private static void doCompositeTwo( + Connection mc, String label, String a, String b, PgType type) { + String table = uniq("fuzz_comp_two"); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v pgtt_fuzz_two)").execute()); + try { + TwoField original = new TwoField(a, b); + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(type, original)) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(type).all())); + if (rows.size() != 1 || !valuesEqual(rows.getFirst(), original)) { + throw new AssertionError( + "expected (a=" + show(a) + ", b=" + show(b) + ") got " + + show(rows.isEmpty() ? null : rows.getFirst())); + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + private static void doArrayOfComposite( + Connection mc, Fuzz f, PgType oneType, PgType> arr) { + String table = uniq("fuzz_arr_comp"); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v pgtt_fuzz_one[])").execute()); + try { + List original = + Arrays.asList( + new OneField(f.value()), + new OneField("NULL"), + new OneField("{a,b};\"\\")); + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(arr, original)) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(arr).all())); + if (rows.size() != 1) throw new AssertionError("expected 1 row, got " + rows.size()); + var got = rows.getFirst(); + if (got.size() != original.size()) { + throw new AssertionError( + "size " + original.size() + " expected, got " + got.size() + ": " + show(got)); + } + for (int i = 0; i < original.size(); i++) { + if (!valuesEqual(got.get(i), original.get(i))) { + throw new AssertionError( + "element " + i + " mismatch: expected " + show(original.get(i).v()) + " got " + + show(got.get(i).v())); + } + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + private static void doCompositeWithArrayField( + Connection mc, String label, List xs, PgType type) { + String table = uniq("fuzz_comp_arr_field"); + mc.execute(Fragment.of("CREATE TEMP TABLE " + table + " (v pgtt_fuzz_arr_field)").execute()); + try { + ArrField original = new ArrField(xs); + mc.execute( + Fragment.of("INSERT INTO " + table + " (v) VALUES (") + .append(Fragment.encode(type, original)) + .append(")") + .update()); + var rows = + mc.execute(Fragment.of("SELECT v FROM " + table).query(RowCodec.of(type).all())); + if (rows.size() != 1) throw new AssertionError("expected 1 row, got " + rows.size()); + var got = rows.getFirst(); + if (got.xs().size() != xs.size()) { + throw new AssertionError( + "size " + xs.size() + " expected, got " + got.xs().size() + ": " + show(got.xs())); + } + for (int i = 0; i < xs.size(); i++) { + if (!valuesEqual(got.xs().get(i), xs.get(i))) { + throw new AssertionError( + "element " + i + " mismatch: expected " + show(xs.get(i)) + " got " + + show(got.xs().get(i))); + } + } + } finally { + mc.execute(Fragment.of("DROP TABLE IF EXISTS " + table).execute()); + } + } + + // ============================================================ + // Helpers + // ============================================================ + + private static String uniq(String prefix) { + return prefix + "_" + tableCounter.incrementAndGet(); + } + + private static List singletonList(T value) { + return Arrays.asList(value); + } + + private static boolean valuesEqual(Object a, Object b) { + if (a == null) return b == null; + return a.equals(b); + } + + private static String show(Object v) { + if (v == null) return ""; + if (v instanceof String s) { + var sb = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < 0x20 || c == 0x7f) sb.append(String.format("\\u%04x", (int) c)); + else if (c == '"') sb.append("\\\""); + else if (c == '\\') sb.append("\\\\"); + else sb.append(c); + } + return sb.append("\"").toString(); + } + if (v instanceof List list) { + var sb = new StringBuilder("["); + for (int i = 0; i < list.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(show(list.get(i))); + } + return sb.append("]").toString(); + } + return String.valueOf(v); + } + + private static String summarize(Throwable t) { + var sb = new StringBuilder(String.valueOf(t.getMessage())); + Throwable c = t.getCause(); + while (c != null) { + sb.append(" | caused by ").append(c.getClass().getSimpleName()).append(": ").append(c.getMessage()); + c = c.getCause(); + } + return sb.toString(); + } +} diff --git a/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserAgreementTest.java b/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserAgreementTest.java new file mode 100644 index 00000000..b9e5aa05 --- /dev/null +++ b/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserAgreementTest.java @@ -0,0 +1,168 @@ +package dev.typr.foundations; + +import java.util.List; +import org.junit.Test; + +/** + * Cross-checks {@link PgRecordParser#parseArray} against a live PostgreSQL — for every input in + * the shared {@link PgArrayParseCases} corpus, ask PG to parse the same string and compare. + * PG's {@code text[]} (and {@code box[]} for the geometric case) is the reference; if our parser + * disagrees on the number, identity, or contents of elements, the parser is wrong. + * + *

Same strings as {@link PgRecordParserTest} — running the unit and agreement tests in + * lockstep means a new test case is verified twice (parser self-consistent, parser matches PG) + * with one entry in the corpus. + * + *

Single {@code @Test} method by design: a fresh JVM has to stand up the testcontainers PG + * before any case runs; splitting into per-phase methods would cold-start the suite-idle + * timeout on each one. + */ +public class PgRecordParserAgreementTest { + + @Test + public void parseArrayAgreesWithPostgres() { + Containers.postgresTransactor() + .transact( + mc -> { + System.out.println("[agreement] one-dim cases (" + PgArrayParseCases.ONE_DIM.size() + ")"); + checkOneDim(mc); + System.out.println("[agreement] bare-nested cases (" + PgArrayParseCases.BARE_NESTED.size() + ")"); + checkMultiDim(mc); + System.out.println("[agreement] semicolon-delim cases (" + PgArrayParseCases.SEMI_DELIM.size() + ")"); + checkBoxDelim(mc); + return null; + }); + } + + private static void checkOneDim(Connection mc) { + for (var c : PgArrayParseCases.ONE_DIM) { + if (!c.pgVerify()) continue; + List ours = PgRecordParser.parseArray(c.input(), c.delimiter()); + List pg = + mc.execute( + Fragment.of("SELECT unnest(") + .value(PgTypes.text, c.input()) + .append("::" + c.castSqlType() + ")") + .query(RowCodec.of(PgTypes.text.opt()).all())) + .stream() + .map(o -> o.orElse(null)) + .toList(); + if (!equal(pg, ours)) { + throw new AssertionError( + "PG and parser disagree on " + + c.input() + + " (cast " + + c.castSqlType() + + "):\n parser: " + + ours + + "\n pg: " + + pg); + } + } + } + + private static void checkMultiDim(Connection mc) { + for (var c : PgArrayParseCases.BARE_NESTED) { + if (!c.pgVerify()) continue; + List shape = uniformBareNestedShape(c.input()); + if (shape == null) { + System.out.println(" [skip non-uniform] " + c.input()); + continue; + } + String expected = shape.stream().map(n -> "[1:" + n + "]").reduce("", String::concat); + System.out.println(" [check uniform=" + expected + "] " + c.input()); + String dims; + try { + dims = + mc.execute( + Fragment.of("SELECT array_dims(") + .value(PgTypes.text, c.input()) + .append("::" + c.castSqlType() + ")") + .query(RowCodec.of(PgTypes.text.opt()).all())) + .getFirst() + .orElse(null); + } catch (RuntimeException e) { + throw new RuntimeException( + "PG rejected " + c.input() + " as " + c.castSqlType() + ": " + e.getMessage(), e); + } + if (!expected.equals(dims)) { + throw new RuntimeException( + "PG sees different dims for " + + c.input() + + ":\n parser shape=" + + shape + + " (expected " + + expected + + ")\n pg dims=" + + dims); + } + } + } + + private static void checkBoxDelim(Connection mc) { + for (var c : PgArrayParseCases.SEMI_DELIM) { + if (!c.pgVerify()) continue; + int parsed = PgRecordParser.parseArray(c.input(), c.delimiter()).size(); + int pgLen = + mc.execute( + Fragment.of("SELECT array_length(") + .value(PgTypes.text, c.input()) + .append("::" + c.castSqlType() + ", 1)") + .query(RowCodec.of(PgTypes.int4).all())) + .getFirst(); + if (parsed != pgLen) { + throw new AssertionError( + "PG and parser disagree on " + + c.input() + + " (cast " + + c.castSqlType() + + "): parser=" + + parsed + + " pg=" + + pgLen); + } + } + } + + /** + * Recursively walk an array literal, returning the length at each dimension if the structure is + * a uniform N-dim rectangle of bare-nested sub-arrays (PG's text[] rectangularity rule), or + * {@code null} if any level is jagged / mixes bare-nested with scalar siblings / has empty + * sub-arrays. Only the uniform shape can be cast to text[] for PG's {@code array_dims} to + * agree, so non-uniform cases are skipped against PG. + */ + private static List uniformBareNestedShape(String input) { + var dims = new java.util.ArrayList(); + String cur = input.trim(); + while (true) { + List elems = PgRecordParser.parseArray(cur); + if (elems.isEmpty()) return null; + dims.add(elems.size()); + String first = elems.get(0); + if (first == null || !first.startsWith("{") || !first.endsWith("}")) { + for (String s : elems) { + if (s != null && (s.startsWith("{") || s.endsWith("}"))) return null; + } + return dims; + } + int firstLen = PgRecordParser.parseArray(first).size(); + for (String s : elems) { + if (s == null || !s.startsWith("{") || !s.endsWith("}")) return null; + if (PgRecordParser.parseArray(s).size() != firstLen) return null; + } + cur = first; + } + } + + private static boolean equal(List a, List b) { + if (a.size() != b.size()) return false; + for (int i = 0; i < a.size(); i++) { + Object ai = a.get(i); + Object bi = b.get(i); + if (ai == null && bi == null) continue; + if (ai == null || bi == null) return false; + if (!ai.equals(bi)) return false; + } + return true; + } +} diff --git a/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserTest.java b/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserTest.java index dcadf27c..da14f706 100644 --- a/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserTest.java +++ b/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserTest.java @@ -321,6 +321,131 @@ public void testInvalidEmpty() { PgRecordParser.parse(""); } + // ==================== parseArray ==================== + + /** + * Pure-unit assertions over the shared {@link PgArrayParseCases} corpus. The same strings are + * also cross-checked against live PG by {@link PgRecordParserAgreementTest}; if either side + * disagrees, fix the case and both tests pick up the new expectation in lockstep. + */ + @Test + public void testParseArrayCorpus() { + for (var c : PgArrayParseCases.ONE_DIM) assertParseArrayCase(c); + for (var c : PgArrayParseCases.BARE_NESTED) assertParseArrayCase(c); + for (var c : PgArrayParseCases.SEMI_DELIM) assertParseArrayCase(c); + } + + private void assertParseArrayCase(PgArrayParseCases.Case c) { + List actual = PgRecordParser.parseArray(c.input(), c.delimiter()); + if (!listsEqual(actual, c.expected())) { + throw new AssertionError( + "parseArray mismatch for " + + c.input() + + " (delimiter='" + + c.delimiter() + + "')\nExpected: " + + formatList(c.expected()) + + "\nActual: " + + formatList(actual)); + } + } + + @Test(expected = IllegalArgumentException.class) + public void testParseArrayInvalidNoBraces() { + PgRecordParser.parseArray("1,2,3"); + } + + @Test(expected = IllegalArgumentException.class) + public void testParseArrayInvalidNullInput() { + PgRecordParser.parseArray(null); + } + + @Test(expected = IllegalArgumentException.class) + public void testParseArrayInvalidEmptyInput() { + PgRecordParser.parseArray(""); + } + + // ==================== parseArray roundtrip ==================== + + /** + * For every input list, {@code parseArray(encodeArray(xs)) == xs}. Covers the values that + * encodeArray needs to quote (commas, braces, quotes, backslashes, newlines, parens, + * whitespace, the empty string, and NULL), so the quoted-element scanner is exercised. + */ + @Test + public void testParseArrayRoundtripQuoting() { + assertParseArrayRoundtrip(List.of("a", "b", "c")); + assertParseArrayRoundtrip(List.of("hello, world")); + assertParseArrayRoundtrip(List.of("with \"quotes\"", "plain")); + assertParseArrayRoundtrip(List.of("contains {brace}", "and }")); + assertParseArrayRoundtrip(List.of("back\\slash")); + assertParseArrayRoundtrip(List.of("line1\nline2", "tab\tsep")); + assertParseArrayRoundtrip(List.of("(parens, with comma)")); + assertParseArrayRoundtrip(List.of(" leading and trailing ")); + assertParseArrayRoundtrip(List.of("")); + assertParseArrayRoundtrip(List.of("", "a", "")); + assertParseArrayRoundtrip(Arrays.asList("a", null, "c", null)); + } + + /** + * Roundtrip with the geometric ';' delimiter — the comma inside elements (e.g. box's + * {@code (x1,y1),(x2,y2)}) must not be treated as a separator, and the quoted-string scanner + * still handles internal escapes. + */ + @Test + public void testParseArrayRoundtripCustomDelimiter() { + assertParseArrayRoundtripWith(List.of("(1,2),(3,4)", "(5,6),(7,8)"), ';'); + assertParseArrayRoundtripWith(List.of("a", "b"), ';'); + assertParseArrayRoundtripWith(List.of("contains ; semicolon", "plain"), ','); + } + + /** + * Bare-nested arrays — these are the multi-dim form PG actually emits, where each element is a + * raw {@code {...}} sub-array rather than a quoted string. encodeArray always quotes, so we + * construct the nested form by hand and assert parseArray returns the inner sub-array text + * verbatim. parseArray is not recursive: it returns each top-level element exactly as the + * caller would re-feed it to a sub-decoder (which is precisely how PgType.array().array() + * decodes 2D domain arrays). + */ + @Test + public void testParseArrayBareNestedRoundtripVsRecursive() { + var outer = PgRecordParser.parseArray("{{1,2,3},{4,5,6}}"); + assertEqual(outer, List.of("{1,2,3}", "{4,5,6}")); + var inner0 = PgRecordParser.parseArray(outer.get(0)); + assertEqual(inner0, List.of("1", "2", "3")); + var inner1 = PgRecordParser.parseArray(outer.get(1)); + assertEqual(inner1, List.of("4", "5", "6")); + } + + /** Three-level nesting: each strip exposes one more level. */ + @Test + public void testParseArrayBareNestedThreeLevel() { + var l1 = PgRecordParser.parseArray("{{{a,b},{c}},{{d}}}"); + assertEqual(l1, List.of("{{a,b},{c}}", "{{d}}")); + var l2a = PgRecordParser.parseArray(l1.get(0)); + assertEqual(l2a, List.of("{a,b}", "{c}")); + var l2b = PgRecordParser.parseArray(l1.get(1)); + assertEqual(l2b, List.of("{d}")); + var l3 = PgRecordParser.parseArray(l2a.get(0)); + assertEqual(l3, List.of("a", "b")); + } + + /** + * Multi-dim where the leaf elements are quoted JSON: outer is bare-nested, inner element is + * quoted with escaped quotes. After two parse hops we should arrive at the JSON text exactly + * as PG sent it. + */ + @Test + public void testParseArrayBareNestedQuotedJsonLeaf() { + String input = "{{\"{\\\"k\\\": 1}\"},{\"{\\\"k\\\": 2}\"}}"; + var outer = PgRecordParser.parseArray(input); + assertEqual(outer, List.of("{\"{\\\"k\\\": 1}\"}", "{\"{\\\"k\\\": 2}\"}")); + var inner0 = PgRecordParser.parseArray(outer.get(0)); + assertEqual(inner0, List.of("{\"k\": 1}")); + var inner1 = PgRecordParser.parseArray(outer.get(1)); + assertEqual(inner1, List.of("{\"k\": 2}")); + } + // Helper methods private void assertParse(String input, List expected) { @@ -336,6 +461,46 @@ private void assertParse(String input, List expected) { } } + private void assertParseArray(String input, List expected) { + assertParseArrayWith(input, ',', expected); + } + + private void assertParseArrayWith(String input, char delimiter, List expected) { + List actual = PgRecordParser.parseArray(input, delimiter); + if (!listsEqual(actual, expected)) { + throw new AssertionError( + "parseArray mismatch for input: " + + input + + " (delimiter='" + + delimiter + + "')\nExpected: " + + formatList(expected) + + "\nActual: " + + formatList(actual)); + } + } + + private void assertParseArrayRoundtrip(List values) { + assertParseArrayRoundtripWith(values, ','); + } + + private void assertParseArrayRoundtripWith(List values, char delimiter) { + String encoded = + PgRecordParser.encodeArray(values, java.util.function.Function.identity(), delimiter); + List decoded = PgRecordParser.parseArray(encoded, delimiter); + if (!listsEqual(decoded, values)) { + throw new AssertionError( + "Roundtrip mismatch (delimiter='" + + delimiter + + "'):\nInput: " + + formatList(values) + + "\nEncoded: " + + encoded + + "\nDecoded: " + + formatList(decoded)); + } + } + private void assertEqual(Object actual, Object expected) { if (expected == null && actual == null) return; if (expected == null || actual == null) { diff --git a/foundations-jdbc-test/src/java/dev/typr/foundations/PgTypeTest.java b/foundations-jdbc-test/src/java/dev/typr/foundations/PgTypeTest.java index 3913a2a9..d899a711 100644 --- a/foundations-jdbc-test/src/java/dev/typr/foundations/PgTypeTest.java +++ b/foundations-jdbc-test/src/java/dev/typr/foundations/PgTypeTest.java @@ -59,26 +59,62 @@ record Item(String name, int quantity) {} .field("quantity", PgTypes.int4, Item::quantity) .build(Item::new); + /** + * Setup DDL the entry needs to have run (in order) inside the test transaction before the + * statement under test references the type. Used for both PG DOMAINs and user-defined ENUM / + * COMPOSITE types — entries that reference a CREATE TYPE / CREATE DOMAIN statement carry their + * own setup so the rollback-only transactor can issue them per-test and discard them. + */ record PgTypeAndExample( PgType type, A example, boolean hasIdentity, boolean streamingWorks, - boolean compositeTextWorks) { + boolean compositeTextWorks, + boolean callableWorks, + List setupSql) { public PgTypeAndExample(PgType type, A example) { - this(type, example, true, true, true); + this(type, example, true, true, true, true, List.of()); } public PgTypeAndExample noStreaming() { - return new PgTypeAndExample<>(type, example, hasIdentity, false, compositeTextWorks); + return new PgTypeAndExample<>( + type, example, hasIdentity, false, compositeTextWorks, callableWorks, setupSql); } public PgTypeAndExample noIdentity() { - return new PgTypeAndExample<>(type, example, false, streamingWorks, compositeTextWorks); + return new PgTypeAndExample<>( + type, example, false, streamingWorks, compositeTextWorks, callableWorks, setupSql); } public PgTypeAndExample noCompositeText() { - return new PgTypeAndExample<>(type, example, hasIdentity, streamingWorks, false); + return new PgTypeAndExample<>( + type, example, hasIdentity, streamingWorks, false, callableWorks, setupSql); + } + + /** + * Skip the function/procedure/OUT/INOUT/Proc tests for this entry. Use when the underlying + * type doesn't survive PG's procedure-overload resolution — most commonly user-defined + * ENUMs, which arrive as varchar and PG won't promote without an explicit cast. + */ + public PgTypeAndExample noCallable() { + return new PgTypeAndExample<>( + type, example, hasIdentity, streamingWorks, compositeTextWorks, false, setupSql); + } + + + /** Add one or more DDL statements to the setup, preserving order. */ + public PgTypeAndExample withSetup(String... ddl) { + var combined = new java.util.ArrayList(setupSql); + for (String d : ddl) combined.add(d); + return new PgTypeAndExample<>( + type, + example, + hasIdentity, + streamingWorks, + compositeTextWorks, + callableWorks, + List.copyOf(combined)); } } @@ -89,7 +125,9 @@ static PgTypeAndExample> singletonListEntry(PgTypeAndExample elem List.of(elem.example()), elem.hasIdentity(), elem.streamingWorks(), - elem.compositeTextWorks()); + elem.compositeTextWorks(), + elem.callableWorks(), + elem.setupSql()); } /** Auto-generate an empty list test entry for a type (once per type). */ @@ -99,7 +137,9 @@ static PgTypeAndExample> emptyListEntry(PgTypeAndExample elem) { List.of(), elem.hasIdentity(), elem.streamingWorks(), - elem.compositeTextWorks()); + elem.compositeTextWorks(), + elem.callableWorks(), + elem.setupSql()); } /** Auto-generate a multi-element list test entry combining all examples for a type. */ @@ -111,7 +151,9 @@ static PgTypeAndExample> multiListEntry(List> sa values, first.hasIdentity(), first.streamingWorks(), - first.compositeTextWorks()); + first.compositeTextWorks(), + first.callableWorks(), + first.setupSql()); } /** @@ -122,7 +164,26 @@ static PgTypeAndExample> multiListEntry(List> sa */ static PgTypeAndExample>> nestedListEntry(PgTypeAndExample elem) { return new PgTypeAndExample<>( - elem.type().array().array(), List.of(List.of(elem.example())), false, false, false); + elem.type().array().array(), + List.of(List.of(elem.example())), + false, + false, + false, + elem.callableWorks(), + elem.setupSql()); + } + + /** Run the entry's setup DDL (if any) inside the current rollback-only transaction. */ + static void ensureDomain(Transactor exec, PgTypeAndExample t) { + for (String ddl : t.setupSql()) { + exec.execute(Fragment.of(ddl).execute()); + } + } + + static void ensureDomain(Connection conn, PgTypeAndExample t) { + for (String ddl : t.setupSql()) { + Fragment.of(ddl).execute().run(conn); + } } /** Should we auto-generate list test entries for this scalar entry? */ @@ -366,7 +427,41 @@ PgTypes.xml, new Xml("text")) Range.timestamptz( new RangeBound.Closed<>(Instant.parse("2024-01-01T00:00:00Z")), new RangeBound.Open<>(Instant.parse("2024-12-31T23:59:59Z")))), - new PgTypeAndExample<>(PgTypes.tstzrange, Range.empty())); + new PgTypeAndExample<>(PgTypes.tstzrange, Range.empty()), + + // ==================== User-defined ENUM ==================== + // noCallable: PG can't promote varchar->enum at procedure-call overload resolution. + new PgTypeAndExample<>( + PgTypes.ofEnum("pgtt_traffic", Traffic.values()), Traffic.amber) + .withSetup("CREATE TYPE pgtt_traffic AS ENUM ('red','amber','green')") + .noCallable(), + new PgTypeAndExample<>( + PgTypes.ofEnum("pgtt_traffic", Traffic.values()), Traffic.red) + .withSetup("CREATE TYPE pgtt_traffic AS ENUM ('red','amber','green')") + .noCallable(), + + // ==================== User-defined COMPOSITE ==================== + // noStreaming: COPY-text encoding of composites doesn't survive PG's COPY parser when + // the composite literal contains commas (PG splits at the comma inside the record). + new PgTypeAndExample<>(pgttSimpleAddrType, new SimpleAddr("Main St", "Springfield")) + .withSetup("CREATE TYPE pgtt_simple_addr AS (street text, city text)") + .noStreaming()); + + public enum Traffic { + red, + amber, + green + } + + public record SimpleAddr(String street, String city) {} + + static final PgType pgttSimpleAddrType = + PgTypes.compositeOf( + "pgtt_simple_addr", + RowCodec.namedBuilder() + .field("street", PgTypes.text, SimpleAddr::street) + .field("city", PgTypes.text, SimpleAddr::city) + .build(SimpleAddr::new)); /** * All test entries: element types + auto-generated array entries. @@ -379,12 +474,51 @@ PgTypes.xml, new Xml("text")) @SuppressWarnings({"unchecked", "rawtypes"}) List> All = buildAll(); + /** PG DOMAIN name for each underlying scalar SQL type used in the matrix. */ + static final String DOMAIN_PREFIX = "pgtt_dom_"; + + static String domainNameFor(PgType type) { + return DOMAIN_PREFIX + + type.typename().sqlType().toLowerCase().replace("(", "_").replace(")", "").replace(",", "_").replace(" ", ""); + } + + /** + * Build the domain variant of an entry. The CREATE DOMAIN statement is appended to the entry's + * existing setup DDL so that — for entries whose base type is itself user-defined (ENUM, + * COMPOSITE) — both the CREATE TYPE and the CREATE DOMAIN run in order before the test body. + * + *

Domain variants drop {@code hasIdentity} unconditionally: the base entry already proves + * the underlying type's {@code =} operator works, and PG does not always define {@code =} for + * a domain in its own right (e.g. domain-over-enum has no operator class because operators are + * bound to the enum's OID, not the domain's). Skipping the equality WHERE clause here keeps + * the matrix uniform without losing meaningful coverage. + */ + @SuppressWarnings("rawtypes") + static PgTypeAndExample domainEntry(PgTypeAndExample e) { + String baseSqlType = ((PgType) e.type()).typename().sqlType(); + String name = domainNameFor((PgType) e.type()); + return new PgTypeAndExample<>( + e.type().asDomain(name), + e.example(), + false, // hasIdentity — see javadoc above + e.streamingWorks(), + e.compositeTextWorks(), + e.callableWorks(), + e.setupSql()) + .withSetup("CREATE DOMAIN " + name + " AS " + baseSqlType); + } + @SuppressWarnings({"unchecked", "rawtypes"}) private List> buildAll() { - var out = new java.util.ArrayList>(Elements); + var elementsWithDomains = new java.util.ArrayList>(Elements); + for (var e : Elements) { + elementsWithDomains.add(domainEntry((PgTypeAndExample) e)); + } + + var out = new java.util.ArrayList>(elementsWithDomains); // Per-entry singleton list tests (edge-case values through the element codec) - for (var e : Elements) { + for (var e : elementsWithDomains) { if (hasListSupport(e)) { out.add(singletonListEntry((PgTypeAndExample) e)); } @@ -394,7 +528,7 @@ private List> buildAll() { // Key by (sqlType, example class) so transformed types (e.g. jsonArrayEncoded) // don't collide with their base type (json) even though both have sqlType="json". var byType = new java.util.LinkedHashMap>>(); - for (var e : Elements) { + for (var e : elementsWithDomains) { if (hasListSupport(e)) { String key = e.type().typename().sqlType() + "#" + e.example().getClass().getName(); byType.computeIfAbsent(key, k -> new java.util.ArrayList<>()).add(e); @@ -430,14 +564,26 @@ public void test() { // JSON roundtrip (in-memory + DB verification) System.out.println("\n=== JSON Roundtrip Tests (parallel) ==="); - All.parallelStream() - .forEach( - t -> - withConnection( - conn -> { - testJsonRoundtrip(TestTransactor.fromConnection(conn.unwrap()), t); - return null; - })); + var jsonFailures = + All.parallelStream() + .flatMap( + t -> { + try { + withConnection( + conn -> { + testJsonRoundtrip(TestTransactor.fromConnection(conn.unwrap()), t); + return null; + }); + return java.util.stream.Stream.empty(); + } catch (Exception e) { + return java.util.stream.Stream.of( + "JSON test FAILED " + + t.type.typename().sqlType() + + ": " + + causeChain(e)); + } + }) + .toList(); // All DB tests via shared Transactor methods System.out.println("\n=== DB Roundtrip Tests (parallel) ==="); @@ -461,7 +607,7 @@ public void test() { "Native test FAILED " + t.type.typename().sqlType() + ": " - + e.getMessage()); + + causeChain(e)); } // Streaming COPY roundtrip @@ -478,7 +624,7 @@ public void test() { "Streaming test FAILED " + t.type.typename().sqlType() + ": " - + e.getMessage()); + + causeChain(e)); } } @@ -495,7 +641,7 @@ public void test() { "JSON DB test FAILED " + t.type.typename().sqlType() + ": " - + e.getMessage()); + + causeChain(e)); } return errors.stream(); @@ -526,7 +672,7 @@ public void test() { "Composite test FAILED " + t.type.typename().sqlType() + ": " - + e.getMessage()); + + causeChain(e)); } }) .toList(); @@ -553,6 +699,7 @@ public void test() { .flatMap( t -> { var errors = new ArrayList(); + if (!t.callableWorks) return errors.stream(); try { withConnection( conn -> { @@ -562,7 +709,7 @@ public void test() { }); } catch (Exception e) { errors.add( - "Call test FAILED " + t.type.typename().sqlType() + ": " + e.getMessage()); + "Call test FAILED " + t.type.typename().sqlType() + ": " + causeChain(e)); } try { withConnection( @@ -575,7 +722,7 @@ public void test() { "Call OUT test FAILED " + t.type.typename().sqlType() + ": " - + e.getMessage()); + + causeChain(e)); } try { withConnection( @@ -588,7 +735,7 @@ public void test() { "Call INOUT test FAILED " + t.type.typename().sqlType() + ": " - + e.getMessage()); + + causeChain(e)); } return errors.stream(); }) @@ -605,6 +752,7 @@ public void test() { .parallelStream() .flatMap( t -> { + if (!t.callableWorks) return java.util.stream.Stream.empty(); try { withConnection( conn -> { @@ -615,7 +763,7 @@ public void test() { return java.util.stream.Stream.empty(); } catch (Exception e) { return java.util.stream.Stream.of( - "Proc test FAILED " + t.type.typename().sqlType() + ": " + e.getMessage()); + "Proc test FAILED " + t.type.typename().sqlType() + ": " + causeChain(e)); } }) .toList(); @@ -640,7 +788,7 @@ public void test() { return java.util.stream.Stream.empty(); } catch (Exception e) { return java.util.stream.Stream.of( - "Analysis FAILED " + t.type.typename().sqlType() + ": " + e.getMessage()); + "Analysis FAILED " + t.type.typename().sqlType() + ": " + causeChain(e)); } }) .toList(); @@ -652,6 +800,7 @@ public void test() { allFailures.addAll(callFailures); allFailures.addAll(procFailures); allFailures.addAll(analysisFailures); + allFailures.addAll(jsonFailures); System.out.println("\n====================================="); if (allFailures.isEmpty()) { @@ -672,8 +821,30 @@ static java.util.stream.Stream runTest( return java.util.stream.Stream.empty(); } catch (Exception e) { return java.util.stream.Stream.of( - phase + " FAILED " + t.type.typename().sqlType() + ": " + e.getMessage()); + phase + " FAILED " + t.type.typename().sqlType() + ": " + causeChain(e)); + } + } + + /** + * Render the exception's message together with its cause chain and any suppressed exceptions. + * Without this, an in-test {@code finally} block that itself throws (e.g. {@code DROP TABLE} + * after a transaction got aborted) replaces the real root cause with a useless follow-on + * "current transaction is aborted" message. + */ + private static String causeChain(Throwable e) { + var sb = new StringBuilder(String.valueOf(e.getMessage())); + for (Throwable s : e.getSuppressed()) { + sb.append(" | suppressed: ").append(s.getMessage()); + } + Throwable c = e.getCause(); + while (c != null) { + sb.append(" | caused by ").append(c.getClass().getSimpleName()).append(": ").append(c.getMessage()); + for (Throwable s : c.getSuppressed()) { + sb.append(" || suppressed: ").append(s.getMessage()); + } + c = c.getCause(); } + return sb.toString(); } // ==================== Shared Test Methods (Transactor) ==================== @@ -682,6 +853,7 @@ static void testNativeRoundtrip(Transactor exec, PgTypeAndExample t) { String sqlType = t.type.typename().sqlType(); String tableName = uniqueTableName("test"); + ensureDomain(exec, t); exec.execute(Fragment.of("CREATE TEMP TABLE " + tableName + " (v " + sqlType + ")").execute()); try { batchInsert(exec, t.type, tableName, t.example); @@ -716,6 +888,7 @@ static void testStreamingCopyRoundtrip(Transactor exec, PgTypeAndExample String sqlType = t.type.typename().sqlType(); String tableName = uniqueTableName("stream"); + ensureDomain(exec, t); exec.execute(Fragment.of("CREATE TEMP TABLE " + tableName + " (v " + sqlType + ")").execute()); try { Operation copyOp = @@ -748,6 +921,7 @@ static void testJsonDbRoundtrip(Transactor exec, PgTypeAndExample t) { String sqlType = t.type.typename().sqlType(); String tableName = uniqueTableName("test_json_rt"); + ensureDomain(exec, t); exec.execute(Fragment.of("CREATE TEMP TABLE " + tableName + " (v " + sqlType + ")").execute()); try { exec.execute( @@ -808,6 +982,7 @@ static void testCompositeDbRoundtrip(Transactor exec, PgTypeAndExample t) .replace("[", "_") .replace("]", "_"); + ensureDomain(exec, t); exec.execute(Fragment.of("DROP TYPE IF EXISTS " + compositeTypeName + " CASCADE").execute()); exec.execute( Fragment.of("CREATE TYPE " + compositeTypeName + " AS (wrapped_value " + sqlType + ")") @@ -994,6 +1169,7 @@ static void testFunctionCallRoundtrip(Transactor exec, PgTypeAndExample t int uniqueId = tableCounter.incrementAndGet(); String funcName = safeName + "_" + uniqueId; + ensureDomain(exec, t); exec.execute( Fragment.of( "CREATE OR REPLACE FUNCTION " @@ -1056,6 +1232,7 @@ static void testProcedureCallRoundtrip(Transactor exec, PgTypeAndExample int uniqueId = tableCounter.incrementAndGet(); String funcName = safeName + "_" + uniqueId; + ensureDomain(exec, t); exec.execute( Fragment.of( "CREATE OR REPLACE FUNCTION " @@ -1091,6 +1268,7 @@ static void testProcedureCallRoundtrip(Transactor exec, PgTypeAndExample static void testQueryAnalysis(Connection conn, PgTypeAndExample t) { String sqlType = t.type.typename().sqlType(); String tableName = uniqueTableName("qa"); + ensureDomain(conn, t); Fragment.of("CREATE TEMP TABLE " + tableName + " (v " + sqlType + ")").execute().run(conn); try { RowCodec parser = RowCodec.of(t.type); @@ -1132,6 +1310,7 @@ static void testJsonRoundtrip(Transactor exec, PgTypeAndExample t) { // DB verification: insert value, ask PG for to_json(), verify we can decode PG's JSON String sqlType = t.type.typename().sqlType(); String tableName = uniqueTableName("test_json_mem"); + ensureDomain(exec, t); exec.execute( Fragment.of("CREATE TEMP TABLE " + tableName + " (v " + sqlType + ")").execute()); try { @@ -1194,6 +1373,7 @@ static void testCallOutParam(Connection conn, PgTypeAndExample t) throws int uniqueId = tableCounter.incrementAndGet(); String procName = "out_" + safeName(sqlType) + "_" + uniqueId; + ensureDomain(conn, t); Fragment.of( "CREATE OR REPLACE PROCEDURE " + procName @@ -1240,6 +1420,7 @@ static void testCallInOutParam(Connection conn, PgTypeAndExample t) throw int uniqueId = tableCounter.incrementAndGet(); String procName = "inout_" + safeName(sqlType) + "_" + uniqueId; + ensureDomain(conn, t); Fragment.of( "CREATE OR REPLACE PROCEDURE " + procName diff --git a/foundations-jdbc/src/java/dev/typr/foundations/PgRead.java b/foundations-jdbc/src/java/dev/typr/foundations/PgRead.java index ad2abab1..55f59777 100644 --- a/foundations-jdbc/src/java/dev/typr/foundations/PgRead.java +++ b/foundations-jdbc/src/java/dev/typr/foundations/PgRead.java @@ -171,10 +171,14 @@ static PgRead> readElementList(Function converter) { * precision or fails (bit, time, money, composite records). */ static PgRead> readCompositeList(PgCompositeText decoder) { + return readCompositeList(decoder, ','); + } + + static PgRead> readCompositeList(PgCompositeText decoder, char delimiter) { return readString.map( arrayText -> { if (arrayText == null) return null; - List elements = PgRecordParser.parseArray(arrayText); + List elements = PgRecordParser.parseArray(arrayText, delimiter); List result = new ArrayList<>(elements.size()); for (String elementText : elements) { result.add(elementText == null ? null : decoder.decode(elementText)); diff --git a/foundations-jdbc/src/java/dev/typr/foundations/PgRecordParser.java b/foundations-jdbc/src/java/dev/typr/foundations/PgRecordParser.java index fab66615..c288843f 100644 --- a/foundations-jdbc/src/java/dev/typr/foundations/PgRecordParser.java +++ b/foundations-jdbc/src/java/dev/typr/foundations/PgRecordParser.java @@ -475,11 +475,38 @@ private static FieldParseResult parseUnquotedArrayElement( String content, int start, char delimiter) { int len = content.length(); int pos = start; + // Track nested-brace depth and quoted-string state so the element scanner does not split on a + // delimiter that is inside a sub-array ({...}) or inside a quoted string ("..."). PG's + // multi-dimensional array text form puts sub-arrays inline as raw {...} (no surrounding + // quotes), and the inner delimiters must be treated as part of the element. + int depth = 0; + boolean inQuotes = false; while (pos < len) { char c = content.charAt(pos); - if (c == delimiter) { - break; + if (inQuotes) { + if (c == '\\' && pos + 1 < len) { + // skip escaped char (typically \" or \\) + pos += 2; + continue; + } + if (c == '"') { + inQuotes = false; + } + } else { + if (c == '"') { + inQuotes = true; + } else if (c == '{') { + depth++; + } else if (c == '}') { + if (depth == 0) { + // Stray '}' at depth 0 — let the outer parser handle it. + break; + } + depth--; + } else if (c == delimiter && depth == 0) { + break; + } } pos++; } diff --git a/foundations-jdbc/src/java/dev/typr/foundations/PgType.java b/foundations-jdbc/src/java/dev/typr/foundations/PgType.java index 24ef8d07..44df81ec 100644 --- a/foundations-jdbc/src/java/dev/typr/foundations/PgType.java +++ b/foundations-jdbc/src/java/dev/typr/foundations/PgType.java @@ -100,6 +100,50 @@ public PgType withTypename(String sqlType) { return withTypename(PgTypename.of(sqlType)); } + /** + * Reinterpret this type as the JDBC view of a PostgreSQL DOMAIN. Renames the typename to {@code + * domainName} and switches the array codec to text-parsing so domain arrays survive JDBC + * returning unknown-OID elements as {@link org.postgresql.util.PGobject}. + * + *

For a scalar column the underlying read/write codecs are unchanged — JDBC's column-level + * coercion handles {@code dom_xxx} just like the underlying type. For arrays however, JDBC has no + * per-element decoder for an unknown OID and falls back to {@code PGobject}; the standard {@code + * (String) obj} / {@code (Boolean) obj} / etc. element converters then either throw or + * silently return a list typed as {@code List} but holding {@code PGobject} instances. By + * reading the whole array via {@code getString} and parsing through {@link PgCompositeText} the + * decode is independent of how the driver chose to package each element. + */ + public PgType asDomain(String domainName) { + // Capture the underlying typename as an analysis alias before we rename it. PG JDBC's + // ResultSetMetaData.getColumnTypeName resolves domains to their base type (TypeInfoCache + // follows typbasetype), so a column declared as `domainName` reports back as the underlying + // name. The query analyzer must accept either; recording the underlying as a vendor alias + // makes the match succeed without further user opt-in. Existing aliases on the underlying + // type (e.g. PgTypes.smallint already aliases int2) are preserved — PG canonicalizes some + // names in catalogs and we need the canonical name to match too. + var aliases = new java.util.HashSet>(analysisOptions.vendorTypeNames()); + aliases.add(typename); + AnalysisOptions opts = + analysisOptions.withVendorTypeNames(aliases.toArray(DbTypename[]::new)); + PgType renamed = withTypename(domainName).withAnalysis(opts); + return pgArrayCodec.isEmpty() + ? renamed + : renamed.withArrayCodec(PgElementCodec.textParsed()); + } + + /** + * Combined {@link #asDomain(String)} + {@link #transform(SqlFunction, Function)} — declares + * this type as a PG DOMAIN named {@code domainName} and wraps it in a typed value class in one + * call. Identical to {@code asDomain(domainName).transform(f, g)}. + * + *

Use this so the wrapping is in place at the scalar level before {@code .array()} or any + * other combinator runs — the array codec then carries the wrapper end-to-end and you avoid + * needing a list-level bijection. + */ + public PgType asDomain(String domainName, SqlFunction f, Function g) { + return asDomain(domainName).transform(f, g); + } + public PgType renamed(String value) { return withTypename(typename.renamed(value)); } @@ -248,7 +292,7 @@ public PgType> array() { }; PgRead> listRead = (codec instanceof PgElementCodec.OfText) - ? PgRead.readCompositeList(pgCompositeText()) + ? PgRead.readCompositeList(pgCompositeText(), arrayDelimiter) : PgRead.readElementList(elementConverter); // Nested-array support: the resulting PgType> needs its own pgArrayCodec so a // further .array() call (producing PgType>>) can decode sub-arrays. If the @@ -335,7 +379,7 @@ public PgType> array() { PgBinary.textFallback(listText), analysisOptions.listForms(), Optional.of(nestedCodec), - ','); + arrayDelimiter); } public PgType transform(SqlFunction f, Function g) { diff --git a/site/docs/postgresql.md b/site/docs/postgresql.md index bb9cc2ae..8e2cce1f 100644 --- a/site/docs/postgresql.md +++ b/site/docs/postgresql.md @@ -255,10 +255,36 @@ The first argument to `ofEnum(sqlType, ...)` is the PostgreSQL type name used to ## Custom Domain Types -Wrap base types with custom Java types using `transform`: +Wrap base types with custom Java types using `transform`. Useful when you want a typed +wrapper on the application side without changing PG's schema: +## PostgreSQL DOMAIN types + +For an actual `CREATE DOMAIN dom AS underlying` schema-side type, use `asDomain`. The +two-arg form takes the domain name and a constructor / extractor for the wrapping value +type, so the entire DOMAIN-plus-wrapper declaration is one expression: + + + +`asDomain` renames the typename for SQL rendering, registers the underlying typename as a +query-analyzer alias (PG JDBC resolves domains to their base type in `ResultSetMetaData`), +and configures the array codec to text-parse so domain arrays decode correctly. It also +covers domain over enum, domain over composite, etc. — the underlying codec is reused. + +Arrays of a domain "just work" — wrap once at the scalar level and `.array()` carries the +wrapper through. No list-level bijection is needed: + + + +:::note Equality on domain-typed columns +PG does not always define operators on a domain in its own right (e.g. domain-over-enum has +no operator class — operators are bound to the enum's OID). For columns where you compare +on the domain, cast to the underlying: `WHERE v::underlying = $1::underlying`. Read/write +through the codec is unaffected. +::: + ## Nullable Types Any type can be made nullable using `.opt()`: