#3057·crystal

Computed columns returning a domain over a composite type are not exposed

Author: hannesjCreated May 26, 2026Updated May 26, 2026
Labels🐛 bug

Summary

PostgreSQL computed columns are dropped from the GraphQL schema when its return type is a domain over a composite type. No error, the field just doesn't appear. The same function returning the underlying composite directly works. Domain columns of the same domain type work fine.

Steps to reproduce

Schema:

sql
create type money_raw as (amount numeric(10,2), currency_code text);
create domain money as money_raw check (length((value).currency_code) = 3);

create table thing (
  id serial primary key,
  net_amount money,
  tax_amount money
);

create function thing_total_amount(t thing) returns money as $$
  select case
    when t.net_amount is null or t.tax_amount is null then null::money
    else row((t.net_amount).amount + (t.tax_amount).amount,
             (t.net_amount).currency_code)::money
  end
$$ language sql stable;

create function thing_total_amount_raw(t thing) returns money_raw as $$
  select row((t.net_amount).amount + (t.tax_amount).amount,
             (t.net_amount).currency_code)::money_raw
$$ language sql stable;

Build the schema and inspect the Thing type.

Expected results

Thing.totalAmount: Money — the computed column should be exposed using the same GraphQL output type (Money) that the domain columns already use.

Actual results

  • thing_total_amount (returns the domain money) → no totalAmount field at all.
  • thing_total_amount_raw (returns the composite money_raw) → field appears, typed as a separate MoneyRaw object type.
  • Scalars and domain-over-scalar return types work as expected.

Additional context

Root cause (traced in [email protected]/dist/plugins/PgProceduresPlugin.js):

@dataplan/pg's domainOfCodec builds the domain codec by spreading the inner composite codec (...innerCodec), so the domain codec inherits .attributes from money_raw (and additionally carries domainOfCodec). In PgProceduresPlugin, the resource-building branch is gated only on returnCodec.attributes || returnCodec.arrayOfCodec?.attributes, so the domain-over-composite return is routed into the composite/table-return branch. That branch resolves the backing pg_class from the return type's typrelid:

javascript
const pgClass = await info.helpers.pgIntrospection.getClass(serviceName, pgType.typrelid);
if (!pgClass) return null; // resource silently dropped

A domain's pg_type.typrelid is 0, so getClass returns null and the entire function resource is dropped. (For comparison: a bare composite has a real typrelid, so its resource builds — but it then registers/uses a separate MoneyRaw object type rather than the domain's Money.)

Domain columns are unaffected because PgTablesPlugin registers the Money output type for the domain codec (keyed by codec identity) and PgAttributesPlugin resolves columns through that registration.

Possible Solution

The domain-over-composite return should not be treated as a table/composite return; it should keep its own (domain) codec, which already has a registered output type. Excluding domain codecs from the composite branch so they fall through to the scalar/else branch (which builds the resource with codec: returnCodec) fixes it, the field is then exposed using the domain's already-registered Money type, and no duplicate MoneyRaw type is created:

diff
                     if (!returnCodec.isAnonymous &&
+                        !returnCodec.domainOfCodec &&
                         (returnCodec.attributes || returnCodec.arrayOfCodec?.attributes)) {

Verified against [email protected]: with this one-line change, a computed column returning a domain-over-composite is exposed using the same GraphQL type as the domain columns (Money), and the stray MoneyRaw type disappears.