From c91ad82531ef2057c4753b67b57247ab934efb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Sat, 2 May 2026 18:45:40 +0200 Subject: [PATCH 1/4] feat(pg): add asDomain() for PostgreSQL DOMAIN types PgType.asDomain(name) renames the typename for SQL rendering and switches the array codec to text-parsing so domain arrays decode correctly when PG JDBC's ResultSetMetaData resolves domains to their underlying type. It also registers the underlying typename as a query-analyzer alias so the match succeeds without further user opt-in. Two parser bugs surfaced and fixed along the way: - PgType.array() hardcoded ',' as the new type's arrayDelimiter, breaking geometric arrays like box[] (which use ';'). Now propagates the original delimiter end-to-end through readCompositeList. - PgRecordParser.parseUnquotedArrayElement didn't track {} brace depth or "..." quoted-string state, breaking multi-dim arrays whose elements are inline {...} sub-arrays containing commas (jsonb[][], text[][], etc.). Test coverage: - New PgDomainTest with the user-facing Name + .array().to(Bijection) pattern, plus 27 representative scalar+array roundtrips, plus targeted cases for ENUM, COMPOSITE, CHECK violation, NOT NULL violation, Optional, domain-over-domain, and domain-as-composite-field. - PgTypeTest matrix extended: every Element now spawns a parallel domain variant (auto via domainEntry); ENUM and COMPOSITE entries added with their setup DDL carried in the entry; ensureDomain runs all setup inside the rollback-only transaction. Catch sites switched to a causeChain helper so finally-block "transaction aborted" errors don't mask the real root cause. - PgRecordParserTest +21 focused tests for parseArray (scalar, quoted, bare-nested, multi-level, custom delimiter, quoted-JSON leaves) plus roundtrip property tests covering every value class encodeArray quotes. Kotlin/Scala wrappers expose .asDomain(name). Docs updated with a new PostgreSQL DOMAIN section and snippets in all three languages. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../docs/postgresql/PgDomainType.java | 31 + .../docs/postgresql/PgDomainType.kt | 31 + .../docs/postgresql/PgDomainType.scala | 25 + .../kotlin/dev/typr/foundationskt/PgType.kt | 3 + .../scala/dev/typr/foundationssc/PgType.scala | 3 + .../dev/typr/foundations/PgDomainTest.java | 637 ++++++++++++++++++ .../typr/foundations/PgRecordParserTest.java | 265 ++++++++ .../java/dev/typr/foundations/PgTypeTest.java | 243 ++++++- .../src/java/dev/typr/foundations/PgRead.java | 6 +- .../dev/typr/foundations/PgRecordParser.java | 31 +- .../src/java/dev/typr/foundations/PgType.java | 35 +- site/docs/postgresql.md | 29 +- 12 files changed, 1302 insertions(+), 37 deletions(-) create mode 100644 documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainType.java create mode 100644 documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainType.kt create mode 100644 documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainType.scala create mode 100644 foundations-jdbc-test/src/java/dev/typr/foundations/PgDomainTest.java diff --git a/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainType.java b/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainType.java new file mode 100644 index 00000000..20b17cdd --- /dev/null +++ b/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainType.java @@ -0,0 +1,31 @@ +package dev.typr.foundations.docs.postgresql; + +import dev.typr.foundations.Bijection; +import dev.typr.foundations.PgType; +import dev.typr.foundations.PgTypes; +import java.util.List; + +@SuppressWarnings("unused") +public class PgDomainType { + // start:scalar + // PG schema: CREATE DOMAIN person_name AS varchar(100); + public record Name(String value) { + public static final PgType pgType = + PgTypes.text.transform(Name::new, Name::value).asDomain("person_name"); + } + // stop:scalar + + // start:array + // Arrays of domains "just work" — `.array()` composes after `.asDomain(...)`. + // Use `.to(Bijection)` if you want the outer container as a different wrapper type. + public record MiddleName(Name value) {} + + public static final PgType> middleNames = + Name.pgType + .array() + .to( + Bijection.of( + ns -> ns.stream().map(MiddleName::new).toList(), + ms -> ms.stream().map(MiddleName::value).toList())); + // stop:array +} diff --git a/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainType.kt b/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainType.kt new file mode 100644 index 00000000..588103d7 --- /dev/null +++ b/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainType.kt @@ -0,0 +1,31 @@ +package dev.typr.foundationskt.docs.postgresql + +import dev.typr.foundationskt.* + +@Suppress("unused") +class PgDomainType { + //start:scalar + // PG schema: CREATE DOMAIN person_name AS varchar(100); + data class Name(val value: String) { + companion object { + val pgType: PgType = + PgTypes.text.transform(::Name, Name::value).asDomain("person_name") + } + } + //stop:scalar + + //start:array + // Arrays of domains "just work" — `.array()` composes after `.asDomain(...)`. Use + // `.transform(...)` at the list level if you want to map the container to a different + // wrapper type without changing the schema. + data class MiddleName(val value: Name) + + val middleNames: PgType> = + Name.pgType + .array() + .transform( + { ns -> ns.map(::MiddleName) }, + { ms -> ms.map(MiddleName::value) } + ) + //stop:array +} diff --git a/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainType.scala b/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainType.scala new file mode 100644 index 00000000..b7c570d8 --- /dev/null +++ b/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainType.scala @@ -0,0 +1,25 @@ +package dev.typr.foundationssc.docs.postgresql +import dev.typr.foundationssc.* + +@SuppressWarnings(Array("unused")) +object PgDomainType: + // start:scalar + // PG schema: CREATE DOMAIN person_name AS varchar(100); + case class Name(value: String) + object Name: + val pgType: PgType[Name] = + PgTypes.text.transform(Name.apply, _.value).asDomain("person_name") + // stop:scalar + + // start:array + // Arrays of domains "just work" — `.array` composes after `.asDomain(...)`. Use + // `.transform(...)` at the list level to map the container to a different wrapper type + // without changing the schema. + case class MiddleName(value: Name) + + val middleNames: PgType[List[MiddleName]] = + Name.pgType.array.transform( + ns => ns.map(MiddleName.apply), + ms => ms.map(_.value) + ) + // stop:array 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..7cd5cf45 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,9 @@ 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)) + 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..7a83afb6 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,9 @@ 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)) + 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/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/PgRecordParserTest.java b/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserTest.java index dcadf27c..fd84b8fe 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,231 @@ public void testInvalidEmpty() { PgRecordParser.parse(""); } + // ==================== parseArray ==================== + + @Test + public void testParseArrayEmpty() { + assertParseArray("{}", List.of()); + } + + @Test + public void testParseArraySimpleUnquoted() { + assertParseArray("{1,2,3}", List.of("1", "2", "3")); + assertParseArray("{a,b,c}", List.of("a", "b", "c")); + } + + @Test + public void testParseArraySingleElement() { + assertParseArray("{42}", List.of("42")); + assertParseArray("{\"hello\"}", List.of("hello")); + } + + @Test + public void testParseArrayNullElements() { + // PG arrays use the literal NULL (case-insensitive) for null elements. + assertParseArray("{NULL}", Arrays.asList((String) null)); + assertParseArray("{a,NULL,c}", Arrays.asList("a", null, "c")); + assertParseArray("{NULL,NULL}", Arrays.asList(null, null)); + } + + @Test + public void testParseArrayQuotedWithCommas() { + assertParseArray("{\"a,b\",c}", List.of("a,b", "c")); + assertParseArray("{\"hello, world\",\"foo, bar\"}", List.of("hello, world", "foo, bar")); + } + + @Test + public void testParseArrayQuotedWithBraces() { + // Quoted elements containing braces — must not affect depth tracking. + assertParseArray("{\"{not nested}\"}", List.of("{not nested}")); + assertParseArray("{\"{a,b}\",c}", List.of("{a,b}", "c")); + } + + @Test + public void testParseArrayQuotedJson() { + // Real-world: jsonb[] arrives with each JSON value quoted and quotes inside escaped with \. + assertParseArray("{\"{\\\"k\\\": 1}\"}", List.of("{\"k\": 1}")); + assertParseArray( + "{\"{\\\"a\\\": 1}\",\"{\\\"b\\\": 2}\"}", List.of("{\"a\": 1}", "{\"b\": 2}")); + } + + @Test + public void testParseArrayQuotedRangeLiteral() { + // Range types: PG quotes them inside arrays because the literal contains commas/brackets. + assertParseArray("{\"[1,10)\"}", List.of("[1,10)")); + assertParseArray("{\"[1,10)\",\"[20,30)\"}", List.of("[1,10)", "[20,30)")); + } + + @Test + public void testParseArrayCustomDelimiter() { + // Geometric types use ';' as element delimiter. A single box: one element delimited by + // nothing, so the comma inside the box must not split it. + assertParseArrayWith("{(1,2),(3,4)}", ';', List.of("(1,2),(3,4)")); + // Two boxes separated by ';'. + assertParseArrayWith("{(1,2),(3,4);(5,6),(7,8)}", ';', List.of("(1,2),(3,4)", "(5,6),(7,8)")); + } + + @Test + public void testParseArrayBareNested() { + // Multi-dim arrays put sub-arrays inline as raw {...} (no surrounding quotes). Internal + // delimiters must be ignored at the outer level. + assertParseArray("{{1,2},{3,4}}", List.of("{1,2}", "{3,4}")); + assertParseArray("{{a,b,c}}", List.of("{a,b,c}")); + assertParseArray("{{1}}", List.of("{1}")); + } + + @Test + public void testParseArrayBareNestedMixedPosition() { + // Nested element appearing at start, middle, end. + assertParseArray("{{a,b},c,d}", List.of("{a,b}", "c", "d")); + assertParseArray("{a,{b,c},d}", List.of("a", "{b,c}", "d")); + assertParseArray("{a,b,{c,d}}", List.of("a", "b", "{c,d}")); + } + + @Test + public void testParseArrayDeeplyNested() { + assertParseArray("{{{1,2},{3,4}},{{5,6},{7,8}}}", List.of("{{1,2},{3,4}}", "{{5,6},{7,8}}")); + assertParseArray("{{{a}}}", List.of("{{a}}")); + } + + @Test + public void testParseArrayBareNestedWithQuotedInner() { + // Sub-array elements contain quoted strings with internal commas — both brace-depth and + // quoted-state tracking must cooperate. + assertParseArray("{{\"a,b\",c},{d}}", List.of("{\"a,b\",c}", "{d}")); + assertParseArray("{{\"{not array}\",x},{y}}", List.of("{\"{not array}\",x}", "{y}")); + } + + @Test + public void testParseArrayBareNestedWithQuotedJson() { + // jsonb[][] real-world shape: outer is bare-nested, inner element is quoted JSON containing + // braces, commas, and escaped quotes. + assertParseArray( + "{{\"{\\\"k\\\": 1}\"},{\"{\\\"k\\\": 2}\"}}", + List.of("{\"{\\\"k\\\": 1}\"}", "{\"{\\\"k\\\": 2}\"}")); + } + + @Test + public void testParseArrayBareNestedEmpty() { + assertParseArray("{{},{}}", List.of("{}", "{}")); + assertParseArray("{{},{a}}", List.of("{}", "{a}")); + } + + @Test + public void testParseArrayBareNestedWithNull() { + // NULL appears at the outer level — NULL itself is unquoted and contains no braces, so + // standard handling applies. + assertParseArray("{{a,b},NULL,{c,d}}", Arrays.asList("{a,b}", null, "{c,d}")); + } + + @Test + public void testParseArrayBackslashEscapesInsideNested() { + // Backslash-escaped quote inside the quoted element of a sub-array: scanner must not exit + // the quoted region on the escaped quote. + assertParseArray("{{\"a\\\"b\",c}}", List.of("{\"a\\\"b\",c}")); + } + + @Test + public void testParseArrayWhitespacePadding() { + assertParseArray(" {1,2,3} ", List.of("1", "2", "3")); + } + + @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 +561,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..40bc78e9 100644 --- a/foundations-jdbc/src/java/dev/typr/foundations/PgType.java +++ b/foundations-jdbc/src/java/dev/typr/foundations/PgType.java @@ -100,6 +100,37 @@ 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()); + } + public PgType renamed(String value) { return withTypename(typename.renamed(value)); } @@ -248,7 +279,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 +366,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..b07fc2ea 100644 --- a/site/docs/postgresql.md +++ b/site/docs/postgresql.md @@ -255,10 +255,37 @@ 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(name)`. It +renames the typename for SQL rendering and configures the array codec to text-parse, so +arrays of the domain decode correctly even though PG JDBC's `ResultSetMetaData` resolves +domains to their underlying type. + + + +`asDomain` also covers domain over enum, domain over composite, domain over a user-defined +type, etc. — the underlying codec is reused; only the typename and the array decode path +change. + +Arrays of a domain compose with the rest of the type DSL — `.array()` produces +`PgType>`, and you can layer a list-level `to(Bijection)` on top to map the +container to a different wrapper type without changing the schema: + + + +:::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()`: From 605a3a83a1ba34b724b98e3838cabd634ea77a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Sat, 2 May 2026 18:51:22 +0200 Subject: [PATCH 2/4] docs(pg): split PgDomainType snippets into one-per-file (site build) Site validator allows only one //start marker per snippet file. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...{PgDomainType.java => PgDomainTypeArray.java} | 13 +++++-------- .../docs/postgresql/PgDomainTypeScalar.java | 15 +++++++++++++++ .../{PgDomainType.kt => PgDomainTypeArray.kt} | 15 ++++++--------- .../docs/postgresql/PgDomainTypeScalar.kt | 16 ++++++++++++++++ ...gDomainType.scala => PgDomainTypeArray.scala} | 15 ++++++--------- .../docs/postgresql/PgDomainTypeScalar.scala | 12 ++++++++++++ site/docs/postgresql.md | 4 ++-- 7 files changed, 62 insertions(+), 28 deletions(-) rename documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/{PgDomainType.java => PgDomainTypeArray.java} (84%) create mode 100644 documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeScalar.java rename documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/{PgDomainType.kt => PgDomainTypeArray.kt} (69%) create mode 100644 documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeScalar.kt rename documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/{PgDomainType.scala => PgDomainTypeArray.scala} (81%) create mode 100644 documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeScalar.scala diff --git a/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainType.java b/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeArray.java similarity index 84% rename from documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainType.java rename to documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeArray.java index 20b17cdd..a0ef4bce 100644 --- a/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainType.java +++ b/documentation-examples-java/src/java/dev/typr/foundations/docs/postgresql/PgDomainTypeArray.java @@ -6,18 +6,15 @@ import java.util.List; @SuppressWarnings("unused") -public class PgDomainType { - // start:scalar - // PG schema: CREATE DOMAIN person_name AS varchar(100); +public class PgDomainTypeArray { + // start + // Arrays of domains "just work" — `.array()` composes after `.asDomain(...)`. + // Use `.to(Bijection)` if you want the outer container as a different wrapper type. public record Name(String value) { public static final PgType pgType = PgTypes.text.transform(Name::new, Name::value).asDomain("person_name"); } - // stop:scalar - // start:array - // Arrays of domains "just work" — `.array()` composes after `.asDomain(...)`. - // Use `.to(Bijection)` if you want the outer container as a different wrapper type. public record MiddleName(Name value) {} public static final PgType> middleNames = @@ -27,5 +24,5 @@ public record MiddleName(Name value) {} Bijection.of( ns -> ns.stream().map(MiddleName::new).toList(), ms -> ms.stream().map(MiddleName::value).toList())); - // stop: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..6c2cb66d --- /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.transform(Name::new, Name::value).asDomain("person_name"); + } + // stop +} diff --git a/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainType.kt b/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeArray.kt similarity index 69% rename from documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainType.kt rename to documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeArray.kt index 588103d7..c308c327 100644 --- a/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainType.kt +++ b/documentation-examples-kotlin/src/kotlin/dev/typr/foundationskt/docs/postgresql/PgDomainTypeArray.kt @@ -3,21 +3,18 @@ package dev.typr.foundationskt.docs.postgresql import dev.typr.foundationskt.* @Suppress("unused") -class PgDomainType { - //start:scalar - // PG schema: CREATE DOMAIN person_name AS varchar(100); +class PgDomainTypeArray { + //start + // Arrays of domains "just work" — `.array()` composes after `.asDomain(...)`. Use + // `.transform(...)` at the list level to map the container to a different wrapper type + // without changing the schema. data class Name(val value: String) { companion object { val pgType: PgType = PgTypes.text.transform(::Name, Name::value).asDomain("person_name") } } - //stop:scalar - //start:array - // Arrays of domains "just work" — `.array()` composes after `.asDomain(...)`. Use - // `.transform(...)` at the list level if you want to map the container to a different - // wrapper type without changing the schema. data class MiddleName(val value: Name) val middleNames: PgType> = @@ -27,5 +24,5 @@ class PgDomainType { { ns -> ns.map(::MiddleName) }, { ms -> ms.map(MiddleName::value) } ) - //stop: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..af3e6d67 --- /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.transform(::Name, Name::value).asDomain("person_name") + } + } + //stop +} diff --git a/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainType.scala b/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeArray.scala similarity index 81% rename from documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainType.scala rename to documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeArray.scala index b7c570d8..b86568ad 100644 --- a/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainType.scala +++ b/documentation-examples-scala/src/scala/dev/typr/foundationssc/docs/postgresql/PgDomainTypeArray.scala @@ -2,19 +2,16 @@ package dev.typr.foundationssc.docs.postgresql import dev.typr.foundationssc.* @SuppressWarnings(Array("unused")) -object PgDomainType: - // start:scalar - // PG schema: CREATE DOMAIN person_name AS varchar(100); +object PgDomainTypeArray: + // start + // Arrays of domains "just work" — `.array` composes after `.asDomain(...)`. Use + // `.transform(...)` at the list level to map the container to a different wrapper type + // without changing the schema. case class Name(value: String) object Name: val pgType: PgType[Name] = PgTypes.text.transform(Name.apply, _.value).asDomain("person_name") - // stop:scalar - // start:array - // Arrays of domains "just work" — `.array` composes after `.asDomain(...)`. Use - // `.transform(...)` at the list level to map the container to a different wrapper type - // without changing the schema. case class MiddleName(value: Name) val middleNames: PgType[List[MiddleName]] = @@ -22,4 +19,4 @@ object PgDomainType: ns => ns.map(MiddleName.apply), ms => ms.map(_.value) ) - // stop: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..816f75b6 --- /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.transform(Name.apply, _.value).asDomain("person_name") + // stop diff --git a/site/docs/postgresql.md b/site/docs/postgresql.md index b07fc2ea..beb87892 100644 --- a/site/docs/postgresql.md +++ b/site/docs/postgresql.md @@ -267,7 +267,7 @@ renames the typename for SQL rendering and configures the array codec to text-pa arrays of the domain decode correctly even though PG JDBC's `ResultSetMetaData` resolves domains to their underlying type. - + `asDomain` also covers domain over enum, domain over composite, domain over a user-defined type, etc. — the underlying codec is reused; only the typename and the array decode path @@ -277,7 +277,7 @@ Arrays of a domain compose with the rest of the type DSL — `.array()` produces `PgType>`, and you can layer a list-level `to(Bijection)` on top to map the container to a different wrapper type without changing the schema: - + :::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 From f577fdfdc0e10808fdd06d38475dfc721530ee95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Sat, 2 May 2026 19:01:06 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(pg):=20add=20asDomain(name,=20f,=20g)?= =?UTF-8?q?=20=E2=80=94=20combined=20rename=20+=20transform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The point of asDomain was to wrap the type once at the scalar level so .array() and the rest of the type DSL carry the wrapper through. Doing asDomain then transform as separate calls is two operations for what should be one declaration. The new overload takes the constructor / extractor inline: PgTypes.text.asDomain("person_name", Name::new, Name::value) Doc snippets updated: scalar uses the combined form; array example just calls .array() on the wrapped scalar (no list-level bijection needed). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../docs/postgresql/PgDomainTypeArray.java | 19 +++++-------------- .../docs/postgresql/PgDomainTypeScalar.java | 2 +- .../docs/postgresql/PgDomainTypeArray.kt | 18 ++++-------------- .../docs/postgresql/PgDomainTypeScalar.kt | 2 +- .../docs/postgresql/PgDomainTypeArray.scala | 16 ++++------------ .../docs/postgresql/PgDomainTypeScalar.scala | 2 +- .../kotlin/dev/typr/foundationskt/PgType.kt | 4 ++++ .../scala/dev/typr/foundationssc/PgType.scala | 4 ++++ .../src/java/dev/typr/foundations/PgType.java | 13 +++++++++++++ site/docs/postgresql.md | 19 +++++++++---------- 10 files changed, 46 insertions(+), 53 deletions(-) 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 index a0ef4bce..da0db85b 100644 --- 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 @@ -1,6 +1,5 @@ package dev.typr.foundations.docs.postgresql; -import dev.typr.foundations.Bijection; import dev.typr.foundations.PgType; import dev.typr.foundations.PgTypes; import java.util.List; @@ -8,21 +7,13 @@ @SuppressWarnings("unused") public class PgDomainTypeArray { // start - // Arrays of domains "just work" — `.array()` composes after `.asDomain(...)`. - // Use `.to(Bijection)` if you want the outer container as a different wrapper type. + // 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.transform(Name::new, Name::value).asDomain("person_name"); - } - - public record MiddleName(Name value) {} + PgTypes.text.asDomain("person_name", Name::new, Name::value); - public static final PgType> middleNames = - Name.pgType - .array() - .to( - Bijection.of( - ns -> ns.stream().map(MiddleName::new).toList(), - ms -> ms.stream().map(MiddleName::value).toList())); + 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 index 6c2cb66d..a11652b5 100644 --- 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 @@ -9,7 +9,7 @@ public class PgDomainTypeScalar { // PG schema: CREATE DOMAIN person_name AS varchar(100); public record Name(String value) { public static final PgType pgType = - PgTypes.text.transform(Name::new, Name::value).asDomain("person_name"); + 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 index c308c327..60992a27 100644 --- 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 @@ -5,24 +5,14 @@ import dev.typr.foundationskt.* @Suppress("unused") class PgDomainTypeArray { //start - // Arrays of domains "just work" — `.array()` composes after `.asDomain(...)`. Use - // `.transform(...)` at the list level to map the container to a different wrapper type - // without changing the schema. + // 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.transform(::Name, Name::value).asDomain("person_name") + PgTypes.text.asDomain("person_name", ::Name, Name::value) + val pgArrayType: PgType> = pgType.array() } } - - data class MiddleName(val value: Name) - - val middleNames: PgType> = - Name.pgType - .array() - .transform( - { ns -> ns.map(::MiddleName) }, - { ms -> ms.map(MiddleName::value) } - ) //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 index af3e6d67..52897beb 100644 --- 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 @@ -9,7 +9,7 @@ class PgDomainTypeScalar { data class Name(val value: String) { companion object { val pgType: PgType = - PgTypes.text.transform(::Name, Name::value).asDomain("person_name") + 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 index b86568ad..1566ef67 100644 --- 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 @@ -4,19 +4,11 @@ import dev.typr.foundationssc.* @SuppressWarnings(Array("unused")) object PgDomainTypeArray: // start - // Arrays of domains "just work" — `.array` composes after `.asDomain(...)`. Use - // `.transform(...)` at the list level to map the container to a different wrapper type - // without changing the schema. + // 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.transform(Name.apply, _.value).asDomain("person_name") - - case class MiddleName(value: Name) - - val middleNames: PgType[List[MiddleName]] = - Name.pgType.array.transform( - ns => ns.map(MiddleName.apply), - ms => ms.map(_.value) - ) + 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 index 816f75b6..6c9701ff 100644 --- 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 @@ -8,5 +8,5 @@ object PgDomainTypeScalar: case class Name(value: String) object Name: val pgType: PgType[Name] = - PgTypes.text.transform(Name.apply, _.value).asDomain("person_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 7cd5cf45..6ee57128 100644 --- a/foundations-jdbc-kotlin/src/kotlin/dev/typr/foundationskt/PgType.kt +++ b/foundations-jdbc-kotlin/src/kotlin/dev/typr/foundationskt/PgType.kt @@ -35,6 +35,10 @@ class PgType(override val underlying: dev.typr.foundations.PgType) : DbTyp /** 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 7a83afb6..834797de 100644 --- a/foundations-jdbc-scala/src/scala/dev/typr/foundationssc/PgType.scala +++ b/foundations-jdbc-scala/src/scala/dev/typr/foundationssc/PgType.scala @@ -36,6 +36,10 @@ class PgType[T](override val underlying: dev.typr.foundations.PgType[T]) extends /** 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/src/java/dev/typr/foundations/PgType.java b/foundations-jdbc/src/java/dev/typr/foundations/PgType.java index 40bc78e9..44df81ec 100644 --- a/foundations-jdbc/src/java/dev/typr/foundations/PgType.java +++ b/foundations-jdbc/src/java/dev/typr/foundations/PgType.java @@ -131,6 +131,19 @@ public PgType asDomain(String domainName) { : 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)); } diff --git a/site/docs/postgresql.md b/site/docs/postgresql.md index beb87892..8e2cce1f 100644 --- a/site/docs/postgresql.md +++ b/site/docs/postgresql.md @@ -262,20 +262,19 @@ 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(name)`. It -renames the typename for SQL rendering and configures the array codec to text-parse, so -arrays of the domain decode correctly even though PG JDBC's `ResultSetMetaData` resolves -domains to their underlying type. +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` also covers domain over enum, domain over composite, domain over a user-defined -type, etc. — the underlying codec is reused; only the typename and the array decode path -change. +`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 compose with the rest of the type DSL — `.array()` produces -`PgType>`, and you can layer a list-level `to(Bijection)` on top to map the -container to a different wrapper type without changing the schema: +Arrays of a domain "just work" — wrap once at the scalar level and `.array()` carries the +wrapper through. No list-level bijection is needed: From e141bbc8a0e7283353bc7951e45b18b30c37541d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Sat, 2 May 2026 20:47:29 +0200 Subject: [PATCH 4/4] test(pg): cross-check parser against live PG + adversarial roundtrip fuzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new test classes pinned to the same shared corpus (PgArrayParseCases): PgRecordParserAgreementTest — for every parseArray case, ask PG to interpret the same string under the case's named cast (text[] for the 1-D and bare-nested set, box[] for the ';' delim set) and assert PG and our parser agree on the element list / dimensions / top-level count. The shared corpus also drives the existing pure-unit assertions in PgRecordParserTest, so a new case is verified twice (parser self-consistent, parser matches PG) with one corpus entry. PgFuzzRoundtripTest — ~60 adversarial strings paired explicitly with a PgType, threaded through scalar / 1-D array singleton / 1-D array multi-element / asDomain wrapper / single-field composite / two-field composite / array-of-composite / composite-with-array-field. Asserts the read-back value equals the original. Corpus covers empty/whitespace, every PG-significant punctuation, every quote/backslash combo, NULL/null literal lookalikes, control characters (CR LF TAB VT FF BEL DEL), multi-byte UTF-8, emoji (single + ZWJ family + skin-tone), RTL Arabic and Hebrew, CJK, combining marks, ZWJ + ZWSP + BOM mid-string, soft hyphen, NBSP, supplementary plane, 100x-repeated braces and \". Also: PgType.asDomain(name, f, g) overload — combined rename + transform so the value-class wrapping is in place at the scalar level before .array() runs (the original point of asDomain). Kotlin and Scala wrappers expose it. Doc snippets updated to use the combined form and demonstrate that arrays "just work" with no list-level bijection. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../typr/foundations/PgArrayParseCases.java | 101 +++ .../typr/foundations/PgFuzzRoundtripTest.java | 694 ++++++++++++++++++ .../PgRecordParserAgreementTest.java | 168 +++++ .../typr/foundations/PgRecordParserTest.java | 144 +--- 4 files changed, 985 insertions(+), 122 deletions(-) create mode 100644 foundations-jdbc-test/src/java/dev/typr/foundations/PgArrayParseCases.java create mode 100644 foundations-jdbc-test/src/java/dev/typr/foundations/PgFuzzRoundtripTest.java create mode 100644 foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserAgreementTest.java 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/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 fd84b8fe..da14f706 100644 --- a/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserTest.java +++ b/foundations-jdbc-test/src/java/dev/typr/foundations/PgRecordParserTest.java @@ -323,131 +323,31 @@ public void testInvalidEmpty() { // ==================== 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 testParseArrayEmpty() { - assertParseArray("{}", List.of()); - } - - @Test - public void testParseArraySimpleUnquoted() { - assertParseArray("{1,2,3}", List.of("1", "2", "3")); - assertParseArray("{a,b,c}", List.of("a", "b", "c")); - } - - @Test - public void testParseArraySingleElement() { - assertParseArray("{42}", List.of("42")); - assertParseArray("{\"hello\"}", List.of("hello")); - } - - @Test - public void testParseArrayNullElements() { - // PG arrays use the literal NULL (case-insensitive) for null elements. - assertParseArray("{NULL}", Arrays.asList((String) null)); - assertParseArray("{a,NULL,c}", Arrays.asList("a", null, "c")); - assertParseArray("{NULL,NULL}", Arrays.asList(null, null)); - } - - @Test - public void testParseArrayQuotedWithCommas() { - assertParseArray("{\"a,b\",c}", List.of("a,b", "c")); - assertParseArray("{\"hello, world\",\"foo, bar\"}", List.of("hello, world", "foo, bar")); - } - - @Test - public void testParseArrayQuotedWithBraces() { - // Quoted elements containing braces — must not affect depth tracking. - assertParseArray("{\"{not nested}\"}", List.of("{not nested}")); - assertParseArray("{\"{a,b}\",c}", List.of("{a,b}", "c")); - } - - @Test - public void testParseArrayQuotedJson() { - // Real-world: jsonb[] arrives with each JSON value quoted and quotes inside escaped with \. - assertParseArray("{\"{\\\"k\\\": 1}\"}", List.of("{\"k\": 1}")); - assertParseArray( - "{\"{\\\"a\\\": 1}\",\"{\\\"b\\\": 2}\"}", List.of("{\"a\": 1}", "{\"b\": 2}")); - } - - @Test - public void testParseArrayQuotedRangeLiteral() { - // Range types: PG quotes them inside arrays because the literal contains commas/brackets. - assertParseArray("{\"[1,10)\"}", List.of("[1,10)")); - assertParseArray("{\"[1,10)\",\"[20,30)\"}", List.of("[1,10)", "[20,30)")); - } - - @Test - public void testParseArrayCustomDelimiter() { - // Geometric types use ';' as element delimiter. A single box: one element delimited by - // nothing, so the comma inside the box must not split it. - assertParseArrayWith("{(1,2),(3,4)}", ';', List.of("(1,2),(3,4)")); - // Two boxes separated by ';'. - assertParseArrayWith("{(1,2),(3,4);(5,6),(7,8)}", ';', List.of("(1,2),(3,4)", "(5,6),(7,8)")); - } - - @Test - public void testParseArrayBareNested() { - // Multi-dim arrays put sub-arrays inline as raw {...} (no surrounding quotes). Internal - // delimiters must be ignored at the outer level. - assertParseArray("{{1,2},{3,4}}", List.of("{1,2}", "{3,4}")); - assertParseArray("{{a,b,c}}", List.of("{a,b,c}")); - assertParseArray("{{1}}", List.of("{1}")); - } - - @Test - public void testParseArrayBareNestedMixedPosition() { - // Nested element appearing at start, middle, end. - assertParseArray("{{a,b},c,d}", List.of("{a,b}", "c", "d")); - assertParseArray("{a,{b,c},d}", List.of("a", "{b,c}", "d")); - assertParseArray("{a,b,{c,d}}", List.of("a", "b", "{c,d}")); - } - - @Test - public void testParseArrayDeeplyNested() { - assertParseArray("{{{1,2},{3,4}},{{5,6},{7,8}}}", List.of("{{1,2},{3,4}}", "{{5,6},{7,8}}")); - assertParseArray("{{{a}}}", List.of("{{a}}")); - } - - @Test - public void testParseArrayBareNestedWithQuotedInner() { - // Sub-array elements contain quoted strings with internal commas — both brace-depth and - // quoted-state tracking must cooperate. - assertParseArray("{{\"a,b\",c},{d}}", List.of("{\"a,b\",c}", "{d}")); - assertParseArray("{{\"{not array}\",x},{y}}", List.of("{\"{not array}\",x}", "{y}")); - } - - @Test - public void testParseArrayBareNestedWithQuotedJson() { - // jsonb[][] real-world shape: outer is bare-nested, inner element is quoted JSON containing - // braces, commas, and escaped quotes. - assertParseArray( - "{{\"{\\\"k\\\": 1}\"},{\"{\\\"k\\\": 2}\"}}", - List.of("{\"{\\\"k\\\": 1}\"}", "{\"{\\\"k\\\": 2}\"}")); - } - - @Test - public void testParseArrayBareNestedEmpty() { - assertParseArray("{{},{}}", List.of("{}", "{}")); - assertParseArray("{{},{a}}", List.of("{}", "{a}")); - } - - @Test - public void testParseArrayBareNestedWithNull() { - // NULL appears at the outer level — NULL itself is unquoted and contains no braces, so - // standard handling applies. - assertParseArray("{{a,b},NULL,{c,d}}", Arrays.asList("{a,b}", null, "{c,d}")); - } - - @Test - public void testParseArrayBackslashEscapesInsideNested() { - // Backslash-escaped quote inside the quoted element of a sub-array: scanner must not exit - // the quoted region on the escaped quote. - assertParseArray("{{\"a\\\"b\",c}}", List.of("{\"a\\\"b\",c}")); + 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); } - @Test - public void testParseArrayWhitespacePadding() { - assertParseArray(" {1,2,3} ", List.of("1", "2", "3")); + 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)