Skip to content

Add PgCompositeText typeclass and PostgreSQL composite type code generation - #177

Merged
oyvindberg merged 3 commits into
mainfrom
site-documentation
Jan 2, 2026
Merged

Add PgCompositeText typeclass and PostgreSQL composite type code generation#177
oyvindberg merged 3 commits into
mainfrom
site-documentation

Conversation

@oyvindberg

@oyvindberg oyvindberg commented Jan 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds support for PostgreSQL composite types - a powerful feature that enables type-safe handling of complex nested data structures directly in your database schema.

What are PostgreSQL Composite Types?

PostgreSQL allows you to define custom types that group multiple fields together. Here's the actual schema from sql-init/postgres/composite-types.sql:

-- Simple composite type
CREATE TYPE address AS (
    street VARCHAR(100),
    city VARCHAR(50),
    zip VARCHAR(20),
    country VARCHAR(50)
);

-- Nested composite type (contains another composite)
CREATE TYPE contact_info AS (
    email VARCHAR(100),
    phone VARCHAR(20),
    address address  -- nested composite!
);

-- Deeply nested composite type
CREATE TYPE employee_record AS (
    name person_name,          -- nested composite
    contact contact_info,      -- nested composite (which contains nested)
    employee_id INTEGER,
    salary NUMERIC(12,2),
    hire_date DATE
);

-- Array of composite types
CREATE TYPE point_2d AS (
    x DOUBLE PRECISION,
    y DOUBLE PRECISION
);

CREATE TYPE polygon_custom AS (
    name VARCHAR(50),
    vertices point_2d[]  -- array of composite types!
);

What This Enables

With this PR, Typr now generates fully type-safe Java/Kotlin/Scala classes for composite types:

  • Nested structures: Composite types containing other composite types
  • Arrays of composites: point_2d[] - arrays of structured data
  • Complex nesting: Arrays of structures containing arrays of structures
  • All PostgreSQL types: Composites can contain any PostgreSQL type (UUID, JSONB, bytea, intervals, etc.)

Generated Code Examples

Nested Composite - ContactInfo (contains Address)

Scala:

case class ContactInfo(
  email: Option[String],
  phone: Option[String],
  address: Option[Address]  // nested composite type!
)

object ContactInfo {
  given pgStruct: PgStruct[ContactInfo] = PgStruct.builder[ContactInfo]("public.contact_info")
    .optField("email", PgTypes.text, (v: ContactInfo) => v.email.asJava)
    .optField("phone", PgTypes.text, (v: ContactInfo) => v.phone.asJava)
    .optField("address", Address.pgType, (v: ContactInfo) => v.address.asJava)
    .build(arr => ContactInfo(
      email = Option(arr(0).asInstanceOf[String]),
      phone = Option(arr(1).asInstanceOf[String]),
      address = Option(arr(2).asInstanceOf[Address])))

  given pgType: PgType[ContactInfo] = pgStruct.asType()
  given pgTypeArray: PgType[Array[ContactInfo]] = pgType.array(
    PgRead.readCompositeArray(pgType.pgCompositeText(), n => new Array[ContactInfo](n)),
    n => new Array[ContactInfo](n))
}

Deeply Nested - EmployeeRecord (PersonName → ContactInfo → Address)

Java:

public record EmployeeRecord(
  Optional<PersonName> name,       // nested composite
  Optional<ContactInfo> contact,   // nested composite (contains Address)
  Optional<Integer> employeeId,
  Optional<BigDecimal> salary,
  Optional<LocalDate> hireDate
) {
  static public PgStruct<EmployeeRecord> pgStruct =
    PgStruct.<EmployeeRecord>builder("public.employee_record")
      .optField("name", PersonName.pgType, v -> v.name())
      .optField("contact", ContactInfo.pgType, v -> v.contact())
      .optField("employeeId", PgTypes.int4, v -> v.employeeId())
      .optField("salary", PgTypes.numeric, v -> v.salary())
      .optField("hireDate", PgTypes.date, v -> v.hireDate())
      .build(arr -> new EmployeeRecord(
        Optional.ofNullable((PersonName) arr[0]),
        Optional.ofNullable((ContactInfo) arr[1]),
        Optional.ofNullable((Integer) arr[2]),
        Optional.ofNullable((BigDecimal) arr[3]),
        Optional.ofNullable((LocalDate) arr[4])));

  static public PgType<EmployeeRecord> pgType = pgStruct.asType();
  static public PgType<EmployeeRecord[]> pgTypeArray = pgType.array(
    PgRead.readCompositeArray(pgType.pgCompositeText(), EmployeeRecord[]::new),
    EmployeeRecord[]::new);
}

Array of Composites - PolygonCustom (contains Point2d[])

Kotlin:

data class PolygonCustom(
  val name: String?,
  val vertices: Array<Point2d>?  // array of composite types!
) {
  companion object {
    val pgStruct: PgStruct<PolygonCustom> =
      PgStruct.builder<PolygonCustom>("public.polygon_custom")
        .optField("name", PgTypes.text, { v -> Optional.ofNullable(v.name) })
        .optField("vertices", Point2d.pgTypeArray, { v -> Optional.ofNullable(v.vertices) })
        .build { arr -> PolygonCustom(arr[0] as? String, arr[1] as? Array<Point2d>) }

    val pgType: PgType<PolygonCustom> = pgStruct.asType()
    val pgTypeArray: PgType<Array<PolygonCustom>> = pgType.array(
      PgRead.readCompositeArray(pgType.pgCompositeText(), { n -> arrayOfNulls<PolygonCustom>(n) }),
      { n -> arrayOfNulls<PolygonCustom>(n) })
  }
}

The PostgreSQL Composite Text Format

PostgreSQL uses a specific text format for composite values:

(field1,field2,"field with spaces",)

Special rules:

  • Fields are comma-separated within parentheses
  • Quoted fields can contain special characters: "hello, world"
  • Backslash escaping for quotes: "say \"hello\""
  • Empty string vs NULL: "" vs empty (no value between commas)
  • Nested composites use nested parentheses: ("email","phone",("street","city","zip"))
  • Arrays within composites: {value1,value2,value3}

New APIs

PgCompositeText<A> - Bidirectional typeclass for encoding/decoding composite text fields:

public interface PgCompositeText<A> {
    Optional<String> encode(A value);
    A decode(String text);
}

PgStruct.Builder.optField() - Clean handling of nullable fields with Optional:

public <F> Builder<A> optField(String name, PgType<F> type, Function<A, Optional<F>> getter)

PgRead.readCompositeArray() - Parse arrays of composite types from text format:

static <T> PgRead<T[]> readCompositeArray(PgCompositeText<T> decoder, IntFunction<T[]> arrayFactory)

Changes

  • Add PgCompositeText typeclass with implementations for all PostgreSQL types
  • Add PgRecordParser for parsing PostgreSQL composite text format
  • Add PgStruct builder for constructing composite type values with optField for Optional types
  • Add PgRead.readCompositeArray for parsing composite array text format
  • Add PgType.pgCompositeText field for composite text encoding/decoding
  • Generate type-safe composite type classes for Java/Kotlin/Scala with idiomatic Optional handling

Test plan

  • Unit tests for PgRecordParser parsing edge cases
  • Unit tests for PgStruct building composite values
  • Integration tests with generated composite types
  • All PostgreSQL testers pass (scalatypes, javatypes, java)

🤖 Generated with Claude Code

oyvindberg and others added 3 commits January 2, 2026 10:34
…ration

Implement complete support for PostgreSQL composite types (CREATE TYPE ... AS)
with type-safe code generation for Java, Kotlin, and Scala.

A bidirectional typeclass for encoding/decoding values within PostgreSQL
composite type (record) text format. Unlike PgText (COPY format, encode-only),
PgCompositeText handles the specific escaping rules for composite fields:
- Quote doubling ("" for embedded quotes)
- Proper NULL vs empty string distinction
- Nested composite support

Codecs implemented for all PostgreSQL types:
- Primitives: boolean, int2/4/8, float4/8, numeric, text, bytea, uuid
- Date/time: date, time, timetz, timestamp, timestamptz, interval
- Geometric: point, box, circle, line, lseg, path, polygon
- Other: json/jsonb, hstore, xml, arrays, PGobject

Geometric array types use semicolon delimiter (PostgreSQL convention for
types whose text representation contains commas).

New files generated for composite types:
- Java: records with PgStruct builder and PgType instance
- Kotlin: data classes with companion PgStruct/PgType
- Scala: case classes with PgStruct/PgType in companion

PgStruct provides a builder pattern for defining composite type fields
with proper nullability handling and type-safe accessors.

Added comprehensive test composite types in composite-types.sql:
- Simple: address, person_name
- Nested: contact_info (contains address), employee_record (deeply nested)
- With arrays/JSON: inventory_item, metadata_record
- Edge cases: special characters, NULL vs empty string

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
…ypes

- Add PgStruct.Builder.optField() method that accepts getters returning Optional<F>
  and internally unwraps to nullable F for encoding
- Add PgRead.readCompositeArray() for parsing composite array text format
- Update FilePgCompositeType to use optField for nullable fields with proper
  Optional conversions per language:
  - Scala: Uses .asJava via scala.jdk.OptionConverters.RichOption
  - Java: Uses Optional directly
  - Kotlin: Uses Optional.ofNullable() to wrap nullable types
- Removes ugly getOrElse(null.asInstanceOf[...]) hacks from generated code
- Generated composite types now have cleaner, idiomatic code for each language

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@oyvindberg
oyvindberg merged commit 84f7016 into main Jan 2, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant