Skip to content

fix(codegen-ts): projection read type mirrors view-column nullability#81

Merged
dmealing merged 2 commits into
mainfrom
feat/projection-nullability-compile-guards
Jun 27, 2026
Merged

fix(codegen-ts): projection read type mirrors view-column nullability#81
dmealing merged 2 commits into
mainfrom
feat/projection-nullability-compile-guards

Conversation

@dmealing

Copy link
Copy Markdown
Member

Intent

Close the cross-port projection-codegen test gap and fix a projection nullability bug it surfaced.

THE FIX (codegen-ts, the only releasable/behavioral change): a non-required (nullable) projection field generated a nullable Drizzle view column but a non-null Zod read type, so the generated projection query returned T|null into a non-null field and failed to compile under strict TS (likely the source of 'null is not assignable to number' friction seen building real apps). projection-decl.ts now appends .nullable() to the Zod read field whenever the view column is not .notNull(), so the read type matches the view's SELECT type and is more correct. Verified: codegen-ts 826 pass, typecheck clean, no golden broke, cli/sdk unaffected.

THE TEST GAP (test-only, no behavior change): PR #80's original projection bug shipped because only the C# port compiled generated projection code. This adds a real compile/structure guard to every other port — TS (tsc via ts.createProgram), Python (exec of the generated model), Java (in-process javac of the generated DTO, reusing the existing compiler-harness pattern), Kotlin (kotlin-compile-testing of the generated data class; the Exposed table is asserted structurally since Exposed isn't on that module's test classpath — full table compile already lives in integration-tests-kotlin). Each fixture is a projection with a REQUIRED id + a NON-required derived aggregate field (the exact shape that exposed the nullability mismatch). Java/Kotlin/Python were confirmed CORRECT by a prior audit — their tests are preventive guards, not fixes. Production generators in those ports are untouched (test files only).

A prior cross-port audit confirmed TS was the only port with the original PR #80 bug; the others gate projections correctly (Java via KIND_TABLE, Kotlin/Python via subtype/source-kind, C# via IsReadOnlyProjection).

What Changed

  • projection-decl.ts now appends .nullable() to a projection field's Zod read type whenever its generated Drizzle view column is not .notNull(), so a non-required derived field's read type matches the view's SELECT type instead of emitting T | null into a non-null field (previously failed tsc strict compilation, e.g. "null is not assignable to number").
  • Added compile/structure guard tests for the projection codegen path in the four ports that lacked one — TypeScript (tsc via ts.createProgram), Python (exec of the generated model), Java (in-process javac of the generated DTO), and Kotlin (kotlin-compile-testing of the generated data class) — each fixture being a projection with a required id plus a non-required derived aggregate field. These ports' generators are unchanged (test files only); the cross-port audit confirmed only TS carried the bug.
  • Updated CHANGELOG.md and docs/features/source-kinds.md to document that a projection's read-type nullability mirrors its view column.

Risk Assessment

✅ Low: The single behavioral change is a small, well-bounded correctness fix whose nullability invariant is guaranteed consistent with the emitted view column by construction (both derive from the same mapColumnType modifiers), and the remaining changes are preventive test-only guards.

Testing

Exercised the fix at the product level by compiling actual generated projection code with each port's real compiler. The TS compile guard passes with the fix and, when I reverted the one-line change, failed with the exact null is not assignable to number error the fix targets — confirming both the bug and the fix. Captured the generated projection code showing the nullable view column now matched by a .nullable() Zod read type. The Python/Java/Kotlin preventive compile guards all pass, and the codegen-ts suite is green (826). No visual/UI surface is involved — this is codegen output verified via compilation and rendered generated source.

Evidence: TS compile guard FAILS without the fix (proves it catches the bug)

Type '{ id: number; weekCount: number | null; }' is not assignable to type '{ id: number; weekCount: number; }'. Types of property 'weekCount' are incompatible. Type 'number | null' is not assignable to type 'number'. Type 'null' is not assignable to type 'number'. (fail) projection codegen compiles (TS2724 regression guard)

bun test v1.3.8 (b64edcb4)

packages/codegen-ts/test/projection/compile.test.ts:
126 |         ...(await queriesFile().generate(ctx)),
127 |       ];
128 |       for (const f of files) writeFileSync(join(dir, f.path), f.content);
129 | 
130 |       const diagnostics = compile(dir, files.map((f) => f.path));
131 |       expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))).toEqual([]);
                                                                                                 ^
error: expect(received).toEqual(expected)

- []
+ [
+   
+ "Type '{ id: number; weekCount: number | null; }' is not assignable to type '{ id: number; weekCount: number; }'.
+   Types of property 'weekCount' are incompatible.
+     Type 'number | null' is not assignable to type 'number'.
+       Type 'null' is not assignable to type 'number'."
+ ,
+   
+ "Type '{ id: number; weekCount: number | null; }[]' is not assignable to type '{ id: number; weekCount: number; }[]'.
+   Type '{ id: number; weekCount: number | null; }' is not assignable to type '{ id: number; weekCount: number; }'.
+     Types of property 'weekCount' are incompatible.
+       Type 'number | null' is not assignable to type 'number'.
+         Type 'null' is not assignable to type 'number'."
+ ,
+ ]

- Expected  - 1
+ Received  + 15

      at <anonymous> (/home/doug/.no-mistakes/worktrees/4a36a911fd68/01KW4SFZFG5YW6CP5BA3D00ZNH/server/typescript/packages/codegen-ts/test/projection/compile.test.ts:131:92)
(fail) projection codegen compiles (TS2724 regression guard) > generated projection entity + queries type-check with ZERO diagnostics [467.01ms]

 0 pass
 1 fail
 1 expect() calls
Ran 1 test across 1 file. [892.00ms]
Evidence: TS compile guard PASSES with the fix

1 pass 0 fail Ran 1 test across 1 file.

bun test v1.3.8 (b64edcb4)

 1 pass
 0 fail
 1 expect() calls
Ran 1 test across 1 file. [912.00ms]
Evidence: Generated projection code: nullable view column now matched by .nullable() Zod read type

id: integer("id").notNull(), weekCount: integer("week_count"), }).existing(); export const ProgramSummarySchema = z.object({ id: z.number().int(), weekCount: z.number().int().nullable(), });

======================================================================
FILE: ProgramSummary.ts
======================================================================
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
// Source metadata: ProgramSummary (ProgramSummary)
import { integer, sqliteView } from "drizzle-orm/sqlite-core";
import { z } from "zod";

// View declaration — Drizzle uses this for typed SELECT queries.
// The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
// not to attempt DDL for this declaration.
export const programSummaryView = sqliteView("v_program_summary", {
  id: integer("id").notNull(),
  weekCount: integer("week_count"),
}).existing();
export const ProgramSummarySchema = z.object({
  id: z.number().int(),
  weekCount: z.number().int().nullable(),
});
export type ProgramSummary = z.infer<typeof ProgramSummarySchema>;
export const ProgramSummary = {
  $entity: "ProgramSummary",
  $view: "v_program_summary",
  $path: "/program-summaries",
  $apiPrefix: "",
  id: { name: "id", label: "Id", view: "number", dbCol: "id" },
  weekCount: {
    name: "weekCount",
    label: "Week Count",
    view: "number",
    dbCol: "week_count",
  },
} as const;
export type ProgramSummaryFilter = {
  limit?: number;
  offset?: number;
  sort?: string;
  or?: ProgramSummaryFilter[];
  and?: ProgramSummaryFilter[];
};

======================================================================
FILE: ProgramSummary.queries.ts
======================================================================
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
// Source metadata: ProgramSummary (ProgramSummary) — projection (read-only)
// Customize via ProgramSummary.extra.ts in this directory (additional queries, custom logic).
import { eq } from "drizzle-orm";

import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;

import { type ProgramSummary, programSummaryView } from "./ProgramSummary";
export async function findProgramSummaryById(
  db: Db,
  id: number,
): Promise<ProgramSummary | null> {
  const [row] = await db
    .select()
    .from(programSummaryView)
    .where(eq(programSummaryView.id, id))
    .limit(1);
  return row ?? null;
}

export async function listProgramSummaries(
  db: Db,
  opts?: { limit?: number; offset?: number },
): Promise<ProgramSummary[]> {
  let q = db.select().from(programSummaryView).$dynamic();
  if (opts?.limit !== undefined) {
    q = q.limit(opts.limit);
  }
  if (opts?.offset !== undefined) {
    q = q.offset(opts.offset);
  }
  return q;
}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ server/typescript/packages/codegen-ts/src/templates/projection-decl.ts:145 - mapColumnType(f, dialect, columnNamingStrategy, timestampMode) is now invoked twice per field: once in the zodLines map (line 145) purely to read .modifiers for nullability, and again in the viewColumnLines map (line 166) for the full spec. The two passes iterate the same allFields. Computing the spec once per field (e.g. a single pass building both, or a precomputed specs array) would remove the duplicate work and make the nullability/notNull coupling explicit rather than two independent calls that happen to agree. Non-functional; current behavior is correct because both calls are deterministic on the same inputs.
  • ℹ️ server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringProjectionDtoCompileRunTest.java:174 - The StandardFileManager obtained via javac.getStandardFileManager(...) is never closed in compile(), a minor resource leak in the test harness. Wrapping it in try-with-resources (it is Closeable) would mirror the Files.walk stream that is already closed properly. Test-only, no production impact.
✅ **Test** - passed

✅ No issues found.

  • cd server/typescript &amp;&amp; bun test packages/codegen-ts/test/projection/compile.test.ts (with fix → 1 pass)
  • Reverted projection-decl.ts to base commit and re-ran the compile test → fails with Type &#39;null&#39; is not assignable to type &#39;number&#39; on weekCount, then restored the fix (worktree clean)
  • Generated the ProgramSummary projection entity+queries to inspect output: nullable view column weekCount: integer(&#34;week_count&#34;) now paired with weekCount: z.number().int().nullable() read type
  • bun test packages/codegen-ts/test/projection/ (54 pass) and full bun test packages/codegen-ts/ (826 pass)
  • cd server/python &amp;&amp; uv run python -m pytest tests/codegen/test_projection_compile.py -v (2 pass)
  • cd server/java &amp;&amp; mvn -pl codegen-spring -am test -Dtest=SpringProjectionDtoCompileRunTest (1 pass, in-process javac of DTO)
  • cd server/java &amp;&amp; mvn -pl codegen-kotlin -am test -Dtest=KotlinProjectionCompileTest (2 pass, kotlin-compile-testing of generated data class)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dmealing and others added 2 commits June 27, 2026 10:59
… + cross-port compile guards

A non-required (nullable) projection field generated a nullable Drizzle view column
but a NON-null Zod read type, so the generated projection query returned `T | null`
into a non-null `<Name>` field and failed to compile under strict TS (the likely
source of the "null is not assignable to number" friction seen building real apps).
Fix: the projection Zod read schema now appends `.nullable()` whenever the view
column is not `.notNull()`, so the read type matches what `db.select().from(view)`
actually yields (and is more correct — a nullable derived field is typed nullable).

Closes the cross-port test gap that let the original PR #80 projection bug ship:
only C# compiled generated projection code. Adds a real compile/structure guard to
every port that lacked one — TS (tsc), Python (exec), Java (in-process javac of the
DTO), Kotlin (kotlin-compile-testing of the data class) — each exercising a
projection with a required id + a non-required derived field. Test-only for
Java/Kotlin/Python (those ports were already correct); the TS change is the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
@dmealing
dmealing merged commit b0eace3 into main Jun 27, 2026
29 checks passed
@dmealing
dmealing deleted the feat/projection-nullability-compile-guards branch June 27, 2026 15:29
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