Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@ All notable changes to the Prisma Flutter Connector.

## [Unreleased]

## [0.6.0] - 2026-06-12

### Added

#### Parser: `@map` / `@@map` support
- **Model-level `@@map("table_name")`** is now parsed into `PrismaModel.dbName`, so generated delegates and the schema registry target the mapped database table (e.g., `model User { ... @@map("users") }` → `FROM "users"`). Explicit `@@map` takes precedence over reserved-keyword renames.
- **Field-level `@map("column_name")`** is now parsed into `PrismaField.dbName`, flowing into generated `@JsonKey` annotations, JSON serialization keys, and schema-registry column names (e.g., `status AppointmentStatus @map("requestStatus")`). Priority: explicit `@map` > reserved-keyword rename > PascalCase normalization.

#### SqlCompiler: field → column translation for `@map`-ed fields
- **WHERE keys, INSERT columns, UPDATE SET keys, and ORDER BY keys now resolve Dart field names to database column names** via the schema registry (`where: {'status': ...}` compiles to `"requestStatus" = $1` when the field carries `@map("requestStatus")`). This makes typed-delegate CRUD correct on mapped columns end-to-end — generated Create/Update/Where inputs emit Dart field names, which the compiler now maps.
- **Pass-through fallback preserved**: keys that are not registered field names (legacy JsonQueryBuilder callers using literal column names) compile unchanged, including inside `AND`/`OR`/`NOT` recursion.

#### SqlCompiler: `@updatedAt` auto-fill
- **`create`/`createMany` now fill `@updatedAt` columns** (NOW() on PostgreSQL/Supabase, ISO-8601 parameter elsewhere) — previously every typed-delegate create on a table with `updatedAt DateTime @updatedAt` failed with a NOT NULL violation.
- **`update`/`updateMany` refresh `@updatedAt`** unless the caller supplied a value (Prisma semantics). New `FieldInfo.isUpdatedAt` flag, emitted by the registry generator.

#### PostgresAdapter: enum[] / custom array decoding
- **Custom enum array columns (e.g. `SessionType[]`) now decode to `List<String>`** instead of raw PostgreSQL wire-format bytes. Handles both the binary ARRAY wire format and text array literals (`{A,B,"c d",NULL}`), with NULL elements preserved.

#### Registry generator: one-to-one FK on the target model
- **Relations whose foreign key lives on the TARGET model** (e.g. `Program.licensedSeatConfig` where `LicensedSeatConfig.programId` owns the `@relation`) are now emitted as `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.

### Fixed

#### Parser: enum block attributes treated as values
- **`@@map("...")` inside an enum body is no longer emitted as an enum value** (previously generated invalid Dart identifiers and broke compilation for schemas using mapped enums, e.g. BetterAuth/Prisma 7 schemas).
- **Value-level attributes on enum values are stripped** — `ACTIVE @map("active")` now parses as `ACTIVE`.

#### Delegate generator: models without unique scalar fields
- **Models whose only identifier is a composite `@@id([a, b])`** (no field-level `@id`/`@unique`) no longer generate delegates referencing a nonexistent `WhereUniqueInput` class. `findUnique`, `findUniqueOrThrow`, `update`, and `delete` are omitted for such models; `findFirst`, `findMany`, `updateMany`, `deleteMany`, `create`, and `count` remain available.

## [0.5.5] - 2026-04-04

### Fixed
Expand Down
19 changes: 12 additions & 7 deletions lib/src/generator/cb_delegate_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@ class CbDelegateGenerator {
Directive.import('../models/${toSnakeCase(modelName)}.dart'),
Directive.import('../filters.dart'),
])
..body.add(_buildDelegateClass(modelName, tableName)));
..body.add(_buildDelegateClass(modelName, tableName,
hasUniqueFields: model.fields
.any((f) => (f.isId || f.isUnique) && !f.isRelation))));

final emitter = DartEmitter(useNullSafetySyntax: true);
return _formatter.format('${library.accept(emitter)}');
}

Class _buildDelegateClass(String modelName, String tableName) {
Class _buildDelegateClass(String modelName, String tableName,
{required bool hasUniqueFields}) {
return Class((b) => b
..name = '${modelName}Delegate'
..docs.addAll([
Expand All @@ -53,22 +56,24 @@ class CbDelegateGenerator {
..name = '_executor'
..toThis = true))))
..methods.addAll([
_findUnique(modelName, tableName),
_findUniqueOrThrow(modelName),
// Models without any unique scalar field (e.g. composite @@id only)
// have no WhereUniqueInput, so unique-keyed methods are omitted
if (hasUniqueFields) _findUnique(modelName, tableName),
if (hasUniqueFields) _findUniqueOrThrow(modelName),
_findFirst(modelName, tableName),
_findMany(modelName, tableName),
_findManyRaw(modelName, tableName),
_findFirstRaw(modelName, tableName),
_create(modelName, tableName),
_createMany(modelName, tableName),
_update(modelName, tableName),
if (hasUniqueFields) _update(modelName, tableName),
_updateMany(modelName, tableName),
_delete(modelName, tableName),
if (hasUniqueFields) _delete(modelName, tableName),
_deleteMany(modelName, tableName),
_count(modelName, tableName),
_groupBy(modelName, tableName),
_normalizeForJson(),
_whereUniqueToJson(modelName),
if (hasUniqueFields) _whereUniqueToJson(modelName),
_whereToJson(modelName),
_orderByToJson(modelName),
]));
Expand Down
27 changes: 22 additions & 5 deletions lib/src/generator/cb_model_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ class CbModelGenerator {
: "json['$key'] != null ? _\$${enumName}FromJson(json['$key'] as String) : null";
}

// BigInt cannot use @Default (no const constructor), so fields with a
// literal default are required in the Dart class and fromJson supplies
// the fallback via BigInt.from()
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";
}
Comment on lines +143 to +149

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    }


final defaultSuffix = hasDefault ? ' ?? ${f.defaultValue}' : '';
return switch (dartType) {
'String' => effectiveRequired
Expand All @@ -154,9 +165,6 @@ class CbModelGenerator {
'DateTime' => effectiveRequired
? "json['$key'] is DateTime ? json['$key'] as DateTime : DateTime.parse(json['$key'] as String)"
: "json['$key'] != null ? (json['$key'] is DateTime ? json['$key'] as DateTime : DateTime.parse(json['$key'] as String)) : null",
'BigInt' => effectiveRequired
? "BigInt.parse(json['$key'].toString())"
: "json['$key'] != null ? BigInt.parse(json['$key'].toString()) : null",
'Map<String, dynamic>' => f.isRequired
? "json['$key'] as Map<String, dynamic>"
: "json['$key'] as Map<String, dynamic>?",
Expand Down Expand Up @@ -341,7 +349,12 @@ class CbModelGenerator {
final hasScalarDefault = f.defaultValue != null &&
!f.isRelation &&
!_isPrismaRuntimeDefault(f.defaultValue!);
if (hasScalarDefault) {
if (hasScalarDefault && dartType == 'BigInt') {
// BigInt has no const constructor → @Default is impossible; the
// fromJson fallback (BigInt.from) supplies the schema default
type = dartType;
isRequired = true;
} else if (hasScalarDefault) {
annotations.add(CodeExpression(Code('Default(${f.defaultValue})')));
type = f.isList ? 'List<$dartType>' : dartType;
} else if (f.isRequired && !f.isList) {
Expand Down Expand Up @@ -402,7 +415,11 @@ class CbModelGenerator {
isRequired = true;
} else if (f.defaultValue != null &&
!_isPrismaRuntimeDefault(f.defaultValue!)) {
annotations.add(CodeExpression(Code('Default(${f.defaultValue})')));
if (dartType != 'BigInt') {
// BigInt has no const constructor → no @Default; leave the field
// nullable and let the database apply the schema default
annotations.add(CodeExpression(Code('Default(${f.defaultValue})')));
}
type = f.isList ? 'List<$dartType>?' : '$dartType?';
} else {
type = f.isList ? 'List<$dartType>?' : '$dartType?';
Expand Down
45 changes: 34 additions & 11 deletions lib/src/generator/cb_schema_registry_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class CbSchemaRegistryGenerator {
if (field.isId) parts.add('isId: true');
if (field.isUnique) parts.add('isUnique: true');
if (!field.isRequired) parts.add('isNullable: true');
if (field.isUpdatedAt) parts.add('isUpdatedAt: true');
if (field.defaultValue != null) {
parts.add("defaultValue: '${field.defaultValue}'");
}
Expand Down Expand Up @@ -128,10 +129,39 @@ class CbSchemaRegistryGenerator {
));
}
} else {
// Use this field's own @relation(fields: [...]) first, then fall back to convention
final fk = _findForeignKeyFromField(field) ??
_findForeignKeyOnModel(model, target);
if (fk == null) continue;
// FK on THIS model: the field's own @relation(fields: [...]) or a
// sibling scalar declared by another relation field to the same
// target.
final ownFk = (field.relationFromFields?.isNotEmpty ?? false)
? field.relationFromFields!.first
: _findForeignKeyOnModel(model, target);

if (ownFk == null) {
// FK on the TARGET model (e.g. Program.licensedSeatConfig where
// LicensedSeatConfig.programId owns the relation): non-owner
// one-to-one joined via the target's FK back to our PK.
final targetBack = targetModel.fields
.where((f) =>
f.isRelation &&
f.type == model.name &&
!f.isList &&
(f.relationFromFields?.isNotEmpty ?? false))
.firstOrNull;
if (targetBack != null) {
entries.add(_RelEntry(
fieldName: field.name,
code: "RelationInfo.oneToOne("
"name: '${field.name}', "
"targetModel: '$target', "
"foreignKey: '${targetBack.relationFromFields!.first}', "
"isOwner: false)",
));
continue;
}
}

// Last resort keeps the historical convention-based guess.
final fk = ownFk ?? '${toLowerCamelCase(field.type)}Id';

final backRef = targetModel.fields
.where((f) => f.isRelation && f.type == model.name && !f.isList);
Expand Down Expand Up @@ -195,13 +225,6 @@ class CbSchemaRegistryGenerator {
return null;
}

String? _findForeignKeyFromField(PrismaField field) {
if (field.relationFromFields?.isNotEmpty == true) {
return field.relationFromFields!.first;
}
return '${toLowerCamelCase(field.type)}Id';
}

String _prismaTypeToDartType(String t) => switch (t) {
'String' => 'String',
'Int' => 'int',
Expand Down
25 changes: 21 additions & 4 deletions lib/src/generator/prisma_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,11 @@ class PrismaParser {
}
return line.trim();
})
.where((line) => line.isNotEmpty)
// Skip block attributes like @@map("DbEnumName") — they are not values
.where((line) => line.isNotEmpty && !line.startsWith('@@'))
// Keep only the value identifier, dropping value-level attributes
// such as `ACTIVE @map("active")`
.map((line) => line.split(RegExp(r'\s+')).first)
.toList();

enums.add(PrismaEnum(name: name, values: values));
Expand All @@ -461,7 +465,12 @@ class PrismaParser {
// Handle reserved keywords - auto-rename if needed
final modelResult = _handleReservedKeyword(originalModelName, 'model');
final modelName = modelResult.dartName;
final modelDbName = modelResult.dbName;

// Explicit @@map("table_name") takes precedence over reserved-keyword
// renames for the database table name
final modelMapMatch =
RegExp(r'@@map\(\s*"([^"]+)"\s*\)').firstMatch(modelBody);
final modelDbName = modelMapMatch?.group(1) ?? modelResult.dbName;

if (modelResult.warning != null) {
warnings.add(modelResult.warning!);
Expand Down Expand Up @@ -592,9 +601,17 @@ class PrismaParser {
// Normalize field name (PascalCase → camelCase)
final normalizedName = _normalizeFieldName(dartFieldName);

// Determine dbName: prioritize reserved keyword rename, then PascalCase normalization
// Explicit @map("column_name") on the field (block-level @@ lines
// are skipped above, so this cannot match @@map)
final fieldMapMatch =
RegExp(r'@map\(\s*"([^"]+)"\s*\)').firstMatch(attributes);

// Determine dbName: explicit @map wins, then reserved keyword
// rename, then PascalCase normalization
String? dbName;
if (fieldDbName != null) {
if (fieldMapMatch != null) {
dbName = fieldMapMatch.group(1);
} else if (fieldDbName != null) {
// Field was renamed due to reserved keyword
dbName = fieldDbName;
} else if (normalizedName != dartFieldName) {
Expand Down
97 changes: 94 additions & 3 deletions lib/src/runtime/adapters/postgres_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
library;

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';

import 'package:postgres/postgres.dart' as pg;
import 'package:prisma_flutter_connector/src/runtime/adapters/types.dart';

Expand Down Expand Up @@ -245,19 +248,107 @@ class PostgresAdapter implements SqlDriverAdapter {
}

/// Convert PostgreSQL values to Dart types.
/// Handles special types like UndecodedBytes (enums, custom types).
/// Handles special types like UndecodedBytes (enums, enum arrays, and
/// other custom types the driver has no codec for).
dynamic _convertValue(dynamic value) {
if (value == null) return null;

// Handle UndecodedBytes (PostgreSQL enums and custom types)
if (value is pg.UndecodedBytes) {
// UndecodedBytes contains raw bytes - decode as UTF-8 string
return String.fromCharCodes(value.bytes);
final bytes = value.bytes;
if (value.isBinary) {
// Custom ARRAY types (e.g. enum[]) arrive in the binary array wire
// format; scalar enums arrive as plain label bytes.
final parsed = parsePgBinaryArray(bytes);
if (parsed != null) return parsed;
return utf8.decode(bytes, allowMalformed: true);
}
final text = utf8.decode(bytes, allowMalformed: true);
// Text-format array literal for an unknown element type: {A,B}
if (text.length >= 2 && text.startsWith('{') && text.endsWith('}')) {
return parsePgTextArray(text);
}
return text;
}

return value;
}

/// Parse the PostgreSQL binary ARRAY wire format (one-dimensional) into a
/// List of UTF-8 element strings (enum labels, text, …).
///
/// Layout: int32 ndim, int32 hasNull, int32 elemOid, then per dimension
/// {int32 size, int32 lowerBound}, then per element {int32 byteLength
/// (-1 = NULL), payload bytes}. Returns null when the bytes do not parse
/// cleanly as such an array (callers fall back to plain UTF-8 decode).
static List<String?>? parsePgBinaryArray(List<int> bytes) {
if (bytes.length < 12) return null;
final data = ByteData.sublistView(Uint8List.fromList(bytes));
final ndim = data.getInt32(0);
final hasNull = data.getInt32(4);
if (hasNull != 0 && hasNull != 1) return null;
if (ndim == 0) return bytes.length == 12 ? <String?>[] : null;
if (ndim != 1 || bytes.length < 20) return null;

final size = data.getInt32(12);
if (size < 0 || size > 100000) return null;

final elements = <String?>[];
var offset = 20;
for (var i = 0; i < size; i++) {
if (offset + 4 > bytes.length) return null;
final len = data.getInt32(offset);
offset += 4;
if (len == -1) {
elements.add(null);
continue;
}
if (len < 0 || offset + len > bytes.length) return null;
elements.add(utf8.decode(bytes.sublist(offset, offset + len),
allowMalformed: true));
offset += len;
}
return offset == bytes.length ? elements : null;
}

/// Parse a PostgreSQL text-format array literal ({A,B,"c d",NULL}) into a
/// List of element strings.
static List<String?> parsePgTextArray(String text) {
final inner = text.substring(1, text.length - 1);
if (inner.isEmpty) return <String?>[];

final elements = <String?>[];
final current = StringBuffer();
var inQuotes = false;
var wasQuoted = false;
for (var i = 0; i < inner.length; i++) {
final ch = inner[i];
if (inQuotes) {
if (ch == r'\') {
i++;
if (i < inner.length) current.write(inner[i]);
} else if (ch == '"') {
inQuotes = false;
} else {
current.write(ch);
}
} else if (ch == '"') {
inQuotes = true;
wasQuoted = true;
} else if (ch == ',') {
final raw = current.toString();
elements.add(!wasQuoted && raw == 'NULL' ? null : raw);
current.clear();
wasQuoted = false;
} else {
current.write(ch);
}
}
final raw = current.toString();
elements.add(!wasQuoted && raw == 'NULL' ? null : raw);
return elements;
}

/// Infer column type from value.
ColumnType _inferColumnType(dynamic value) {
if (value == null) return ColumnType.unknown;
Expand Down
Loading
Loading