Skip to content

v0.6.0: Prisma 7 schema compatibility — @map/@@map, @updatedAt, BigInt defaults, enum[] decode, FK resolution - #72

Closed
teetangh wants to merge 6 commits into
mainfrom
feat/prisma7-schema-compat
Closed

v0.6.0: Prisma 7 schema compatibility — @map/@@map, @updatedAt, BigInt defaults, enum[] decode, FK resolution#72
teetangh wants to merge 6 commits into
mainfrom
feat/prisma7-schema-compat

Conversation

@teetangh

Copy link
Copy Markdown
Owner

Why

The familiarise web app's Prisma 7 schema (5,028 lines, 120 models, 100 enums — BetterAuth @@map-ed tables, BigInt paise money columns, composite-@@id counters) is being synced into the mobile Dart Frog backend. Generating a client from it surfaced six generator/runtime gaps, all fixed here with tests. This is the connector release the mobile schema-sync PR (Practitionist/familiarise_mobile) depends on.

What

Parser & generators

  • Model-level @@map("table")PrismaModel.dbName; generated SQL targets the mapped table (model User { @@map("users") }FROM "users"). Explicit @@map wins over reserved-keyword renames.
  • Field-level @map("column")PrismaField.dbName; flows into @JsonKey, fromJson/toJson keys, and registry columnName (e.g. status AppointmentStatus @map("requestStatus")).
  • Enum bodies: @@map(...) block attributes and value-level attributes are no longer emitted as enum values (previously generated invalid Dart).
  • Composite @@id models (no unique scalar field): delegates omit findUnique/findUniqueOrThrow/update/delete instead of referencing a nonexistent WhereUniqueInput.
  • BigInt @default(0): freezed @Default is impossible (BigInt isn't const) — model params become required with a BigInt.from() fromJson fallback; CreateInput leaves them nullable so the DB default applies.
  • One-to-one FK on the target model (Program.licensedSeatConfig where LicensedSeatConfig.programId owns the @relation): registry now emits isOwner: false with the target's real FK instead of fabricating <fieldName>Id — fixes column tN.id does not exist on nested includes.

Runtime

  • SqlCompiler field→column translation: WHERE / INSERT columns / UPDATE SET / ORDER BY resolve Dart field names through the registry, so typed-delegate CRUD is correct on @map-ed columns end-to-end. Unregistered keys pass through unchanged — legacy JsonQueryBuilder callers using literal column names are unaffected (verified inside AND/OR/NOT recursion too).
  • @updatedAt auto-fill: create/createMany fill it (NOW() on pg/supabase, ISO-8601 param elsewhere); update/updateMany refresh it unless explicitly supplied. Previously every typed create on a table with @updatedAt failed its NOT NULL constraint.
  • PostgresAdapter enum[]/custom array decoding: parses the binary ARRAY wire format and text array literals into List<String?> (NULL elements preserved) instead of returning raw bytes.

Validation

  • 380 unit tests pass (66 → 380; 40 new tests across 7 files cover every change), flutter analyze clean, dart format clean.
  • Live end-to-end against the real 5,028-line production schema on local PostgreSQL 16: full API sweep through the consuming Dart Frog backend — auth on @@map-ed tables, plan CRUD with BigInt paise, @map-ed Consultation.status transitions, enum[] profile reads, nested include through Program → LicensedSeatConfig/CreditPoolConfig. Zero open failures.

Follow-ups (complete-ORM roadmap)

Tracked in #71: interactive transactions #68, nested writes #64, typed include/relation hydration #67, upsert #66, groupBy/aggregate #65, cursor pagination/Json filters #69, connection pooling #70.

Release

Merging does NOT publish. After merge: git tag v0.6.0 && git push origin v0.6.0 → publish.yml OIDC-publishes to pub.dev.

🤖 Generated with Claude Code

Kaustav Ghosh and others added 6 commits June 13, 2026 02:07
…legates

- Parse model-level @@Map("table") into PrismaModel.dbName (explicit
  @@Map wins over reserved-keyword renames)
- Parse field-level @Map("column") into PrismaField.dbName (priority:
  explicit @Map > reserved-keyword rename > PascalCase normalization)
- Stop emitting enum block attributes (@@Map) and value-level attributes
  as enum values — previously generated invalid Dart identifiers
- Delegate generator omits findUnique/findUniqueOrThrow/update/delete
  for models whose only identifier is a composite @@id (no
  WhereUniqueInput exists for them)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BigInt has no const constructor, so freezed @default is impossible:
- Model class: BigInt-with-literal-default fields become required params;
  fromJson supplies the schema default via BigInt.from()
- CreateInput: such fields stay nullable WITHOUT @default (the database
  applies the default)
- Tests cover required/optional/defaulted BigInt fields and @jsonkey
  emission + mapped fromJson/toJson keys for @map-ed fields

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- FieldInfo.isUpdatedAt, emitted by the registry generator from
  @updatedat fields (consumed by the SQL compiler)
- One-to-one relations whose FK lives on the TARGET model (e.g.
  Program.licensedSeatConfig with LicensedSeatConfig.programId owning the
  @relation) now emit isOwner:false with the target's real FK instead of
  fabricating a nonexistent <fieldName>Id column on the parent — fixes
  'column tN.id does not exist' on nested includes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- WHERE keys, INSERT columns, UPDATE SET keys, and ORDER BY keys resolve
  Dart field names to database column names via the registry, making
  typed-delegate CRUD correct on @map-ed columns end-to-end; keys that
  are not registered field names pass through unchanged (legacy
  JsonQueryBuilder callers keep working), including in AND/OR/NOT
- create/createMany fill @updatedat columns (NOW() on pg/supabase,
  ISO-8601 param elsewhere); update/updateMany refresh @updatedat unless
  the caller supplied a value — previously every typed create on a table
  with @updatedat failed its NOT NULL constraint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UndecodedBytes was blindly UTF-8 decoded, so custom enum array columns
(e.g. SessionType[]) surfaced as raw wire-format bytes. Now parses the
binary ARRAY wire format (1-D, NULL elements supported) and text array
literals ({A,"c d",NULL}) into List<String?>, falling back to UTF-8 for
scalar enum labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for @map and @@map attributes, enabling transparent translation of Dart field and model names to database column and table names. It also adds auto-fill and refresh capabilities for @updatedAt fields, omits unique-keyed delegate methods for models with only composite IDs, decodes custom PostgreSQL enum arrays, and implements special handling for BigInt default values. The review feedback highlights a potential precision loss issue on the web when using BigInt.from for default values, recommending BigInt.parse instead, and warns against mutating the data map directly in SqlCompiler to prevent runtime crashes on unmodifiable maps.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +143 to +149
if (dartType == 'BigInt') {
final parse = "BigInt.parse(json['$key'].toString())";
if (hasDefault) {
return "json['$key'] != null ? $parse : BigInt.from(${f.defaultValue})";
}
return f.isRequired ? parse : "json['$key'] != null ? $parse : null";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using BigInt.from(${f.defaultValue}) can lead to precision loss on the web (Dart compiled to JavaScript) if the default value exceeds double.maxSafeInteger (9007199254740991), because BigInt.from takes a num which is represented as a double-precision float in JS.\n\nUsing BigInt.parse('${f.defaultValue}') with a string literal avoids this issue and preserves full precision across all platforms.

    if (dartType == 'BigInt') {\n      final parse = "BigInt.parse(json['$key'].toString())";\n      if (hasDefault) {\n        return "json['$key'] != null ? $parse : BigInt.parse('${f.defaultValue}')";\n      }\n      return f.isRequired ? parse : "json['$key'] != null ? $parse : null";\n    }

Comment on lines +456 to 478
// Auto-generate @default(uuid()), @default(cuid()), @default(now()) and
// @updatedAt values. @updatedAt columns are NOT NULL with no database
// default — Prisma clients supply the timestamp on every create.
final effectiveSchema = schema ?? schemaRegistry;
final model = effectiveSchema.getModel(query.modelName);
if (model != null) {
for (final field in model.fields.values) {
if (data.containsKey(field.name)) continue;
if (data.containsKey(field.name) ||
data.containsKey(field.columnName)) {
continue;
}
if (field.defaultValue == 'uuid()' || field.defaultValue == 'cuid()') {
if (provider == 'postgresql' || provider == 'supabase') {
data[field.name] = const _RawSql('gen_random_uuid()');
}
} else if (field.defaultValue == 'now()') {
} else if (field.defaultValue == 'now()' || field.isUpdatedAt) {
if (provider == 'postgresql' || provider == 'supabase') {
data[field.name] = const _RawSql('NOW()');
} else {
data[field.name] = DateTime.now().toUtc().toIso8601String();
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Mutating the data map directly can cause runtime crashes (e.g., UnsupportedError: Cannot modify unmodifiable map) if the caller passes an unmodifiable map (such as a const map or a map wrapped in UnmodifiableMapView). It also introduces side effects by mutating the query arguments map owned by the caller.\n\nCreating a modifiable copy of the map via Map<String, dynamic>.from at the start of the method avoids these issues and ensures defensive programming.

    final data = Map<String, dynamic>.from(args['data'] as Map<String, dynamic>);\n    // Auto-generate @default(uuid()), @default(cuid()), @default(now()) and\n    // @updatedAt values. @updatedAt columns are NOT NULL with no database\n    // default — Prisma clients supply the timestamp on every create.\n    final effectiveSchema = schema ?? schemaRegistry;\n    final model = effectiveSchema.getModel(query.modelName);\n    if (model != null) {\n      for (final field in model.fields.values) {\n        if (data.containsKey(field.name) ||\n            data.containsKey(field.columnName)) {\n          continue;\n        }\n        if (field.defaultValue == 'uuid()' || field.defaultValue == 'cuid()') {\n          if (provider == 'postgresql' || provider == 'supabase') {\n            data[field.name] = const _RawSql('gen_random_uuid()');\n          }\n        } else if (field.defaultValue == 'now()' || field.isUpdatedAt) {\n          if (provider == 'postgresql' || provider == 'supabase') {\n            data[field.name] = const _RawSql('NOW()');\n          } else {\n            data[field.name] = DateTime.now().toUtc().toIso8601String();\n          }\n        }\n      }\n    }

Comment on lines +584 to +595
// Prisma semantics: @updatedAt columns refresh on every update unless
// the caller supplied them explicitly.
final model = (schema ?? schemaRegistry).getModel(query.modelName);
if (model != null) {
for (final field in model.fields.values) {
if (field.isUpdatedAt &&
!data.containsKey(field.name) &&
!data.containsKey(field.columnName)) {
data[field.name] = DateTime.now().toUtc().toIso8601String();
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Mutating the data map directly can cause runtime crashes if the caller passes an unmodifiable map, and has side effects on the caller's query arguments.\n\nCreating a modifiable copy of the map via Map<String, dynamic>.from at the start of the method avoids these issues.

    final data = Map<String, dynamic>.from(args['data'] as Map<String, dynamic>);\n    // Prisma semantics: @updatedAt columns refresh on every update unless\n    // the caller supplied them explicitly.\n    final model = (schema ?? schemaRegistry).getModel(query.modelName);\n    if (model != null) {\n      for (final field in model.fields.values) {\n        if (field.isUpdatedAt &&\n            !data.containsKey(field.name) &&\n            !data.containsKey(field.columnName)) {\n          data[field.name] = DateTime.now().toUtc().toIso8601String();\n        }\n      }\n    }

Comment on lines +58 to +66
test('BigInt with default falls back to BigInt.from(<default>)', () {
final flat = flatten(generateWallet());

expect(
flat,
contains("balancePaise: json['balancePaise'] != null "
"? BigInt.parse(json['balancePaise'].toString()) "
": BigInt.from(0)"));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test expectation to match the suggested change from BigInt.from to BigInt.parse to avoid precision loss on the web.

      test('BigInt with default falls back to BigInt.parse(\'<default>\')', () {\n        final flat = flatten(generateWallet());\n\n        expect(\n            flat,\n            contains("balancePaise: json['balancePaise'] != null "\n                "? BigInt.parse(json['balancePaise'].toString()) "\n                ": BigInt.parse('0')"));\n      });

@teetangh

Copy link
Copy Markdown
Owner Author

Recreating this PR to trigger CodeRabbit (installed after this PR was opened) — same branch, same commits. Continued in the follow-up PR.

@teetangh teetangh closed this Jun 12, 2026
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