Skip to content
Merged
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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,55 @@ All notable changes to the Prisma Flutter Connector.

## [Unreleased]

## [0.8.0] - 2026-07-03

The **typed-projection** release: the last raw-map surfaces (`select`,
`selectFields`, `distinct`, computed fields, map-based include) now have typed
equivalents, completing the surface needed to retire hand-built
`JsonQueryBuilder` usage entirely.

### Added

#### `{Model}ScalarField` enums
- One plain enum per model (a case per scalar field, carrying the Dart field
name; the compiler resolves `@map` columns via the registry). Near-zero
codegen cost — no freezed/part files.

#### Typed per-relation include `select`
- `XInclude` gains `select: List<{Model}ScalarField>?`, applied when the
include is nested under a parent include:
`AuthorInclude(posts: PostInclude(select: [PostScalarField.title]))`.
`toJson` emits `true` | `{'include': ..., 'select': ...}` — the shape the
relation compiler already consumes. `select` on the root include object is
ignored (root projection goes through the projected finders).

#### `findManyProjected` / `findFirstProjected`
- Fully-typed projection finders on every delegate: `XWhereInput`,
`orderBy` (Map | List | `XOrderByInput`), `take`/`skip`/`cursor`,
`XInclude` (with per-relation select), `select: List<XScalarField>`,
`computed: Map<String, ComputedField>`, `distinct`/`distinctOn:
List<XScalarField>` — with `Map<String, dynamic>` rows out (projected or
computed rows never hydrate typed models). This single surface replaces
every `.select()`/`.selectFields()`/computed/raw-helper call site.

### Deprecated
- **`findManyRaw` / `findFirstRaw`** — use the projected finders; removal
planned for 0.9.0.

### Fixed
- **Include-with-select dropped relation rows when the child primary key was
not selected** — the relation deserializer groups child rows by PK, so a
narrow `select` silently emptied the relation. PK columns are now always
carried in the aliased selection.

### Notes
- Nested typed relation filters (`XRelationFilter(is_:)` chains) compile to
correctly-correlated nested `EXISTS` — semantically equivalent to (and
better-correlated than) the legacy `FilterOperators.relationPath`, which is
now redundant. Limitation: repeating the SAME relation name along one chain
would collide on the `sub_<relation>` alias (not expressible with distinct
relation names).

## [0.7.1] - 2026-07-03

### Fixed
Expand Down
144 changes: 144 additions & 0 deletions lib/src/generator/cb_delegate_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class CbDelegateGenerator {
_findFirst(modelName, tableName),
_findFirstOrThrow(modelName),
_findMany(modelName, tableName, hasUniqueFields),
_findManyProjected(modelName, tableName, hasUniqueFields),
_findFirstProjected(modelName, tableName),
_findManyRaw(modelName, tableName),
_findFirstRaw(modelName, tableName),
_create(modelName, tableName, relLiteral),
Expand Down Expand Up @@ -265,8 +267,146 @@ class CbDelegateGenerator {
return results.map((json) => $m.fromJson(_normalizeForJson(json))).toList();
'''));

/// Fully-typed projection finder: typed where/include/select/distinct with
/// Map rows out (projected/computed rows never hydrate typed models).
Method _findManyProjected(String m, String t, bool hasUniqueFields) =>
Method((b) => b
..name = 'findManyProjected'
..docs.addAll([
'/// Find multiple ${m}s as projected rows (maps).',
'///',
'/// Typed inputs; `Map` rows out — use for scalar projection',
'/// (`select:`/`distinctOn:`), computed correlated subqueries, and',
'/// include-with-select. Rows may be partial, so they are not',
'/// hydrated into typed models.',
])
..modifier = MethodModifier.async
..returns = refer('Future<List<Map<String, dynamic>>>')
..optionalParameters.addAll([
Parameter((p) => p
..name = 'where'
..named = true
..type = refer('${m}WhereInput?')),
Parameter((p) => p
..name = 'orderBy'
..named = true
..type = refer('dynamic')),
Parameter((p) => p
..name = 'take'
..named = true
..type = refer('int?')),
Parameter((p) => p
..name = 'skip'
..named = true
..type = refer('int?')),
if (hasUniqueFields)
Parameter((p) => p
..name = 'cursor'
..named = true
..type = refer('${m}WhereUniqueInput?')),
Parameter((p) => p
..name = 'include'
..named = true
..type = refer('${m}Include?')),
Parameter((p) => p
..name = 'select'
..named = true
..type = refer('List<${m}ScalarField>?')),
Parameter((p) => p
..name = 'computed'
..named = true
..type = refer('Map<String, ComputedField>?')),
Parameter((p) => p
..name = 'distinct'
..named = true
..type = refer('bool?')),
Parameter((p) => p
..name = 'distinctOn'
..named = true
..type = refer('List<${m}ScalarField>?')),
])
..body = Code('''
final queryBuilder = JsonQueryBuilder()
.model('$t')
.action(QueryAction.findMany);

if (where != null) queryBuilder.where(_whereToJson(where));
if (orderBy is Map<String, dynamic>) queryBuilder.orderBy(orderBy);
if (orderBy is List) queryBuilder.orderBy(orderBy);
if (orderBy is ${m}OrderByInput) queryBuilder.orderBy(_orderByToJson(orderBy));
if (take != null) queryBuilder.take(take);
if (skip != null) queryBuilder.skip(skip);
${hasUniqueFields ? 'if (cursor != null) queryBuilder.cursor(_whereUniqueToJson(cursor));' : ''}
if (include != null) queryBuilder.include(include.toJson());
if (select != null && select.isNotEmpty) {
queryBuilder.selectFields([for (final f in select) f.fieldName]);
}
if (computed != null) queryBuilder.computed(computed);
if (distinct == true || (distinctOn != null && distinctOn.isNotEmpty)) {
queryBuilder.distinct(
distinctOn == null || distinctOn.isEmpty
? null
: [for (final f in distinctOn) f.fieldName],
);
}

return await _executor.executeQueryAsMaps(queryBuilder.build());
'''));

/// findFirst variant of [_findManyProjected].
Method _findFirstProjected(String m, String t) => Method((b) => b
..name = 'findFirstProjected'
..docs.addAll([
'/// Find the first $m as a projected row (map). See findManyProjected.',
])
..modifier = MethodModifier.async
..returns = refer('Future<Map<String, dynamic>?>')
..optionalParameters.addAll([
Parameter((p) => p
..name = 'where'
..named = true
..type = refer('${m}WhereInput?')),
Parameter((p) => p
..name = 'orderBy'
..named = true
..type = refer('dynamic')),
Parameter((p) => p
..name = 'include'
..named = true
..type = refer('${m}Include?')),
Parameter((p) => p
..name = 'select'
..named = true
..type = refer('List<${m}ScalarField>?')),
Parameter((p) => p
..name = 'computed'
..named = true
..type = refer('Map<String, ComputedField>?')),
])
..body = Code('''
final queryBuilder = JsonQueryBuilder()
.model('$t')
.action(QueryAction.findFirst);

if (where != null) queryBuilder.where(_whereToJson(where));
if (orderBy is Map<String, dynamic>) queryBuilder.orderBy(orderBy);
if (orderBy is List) queryBuilder.orderBy(orderBy);
if (orderBy is ${m}OrderByInput) queryBuilder.orderBy(_orderByToJson(orderBy));
if (include != null) queryBuilder.include(include.toJson());
if (select != null && select.isNotEmpty) {
queryBuilder.selectFields([for (final f in select) f.fieldName]);
}
if (computed != null) queryBuilder.computed(computed);

return await _executor.executeQueryAsSingleMap(queryBuilder.build());
'''));

Method _findManyRaw(String m, String t) => Method((b) => b
..name = 'findManyRaw'
..annotations.add(CodeExpression(
Code("Deprecated('Use findManyProjected (typed inputs) instead; "
'findManyRaw will be removed in 0.9.0'
"')")))
..docs
.add('/// Find multiple ${m}s as raw maps (use with include/computed)')
..modifier = MethodModifier.async
Expand Down Expand Up @@ -334,6 +474,10 @@ class CbDelegateGenerator {

Method _findFirstRaw(String m, String t) => Method((b) => b
..name = 'findFirstRaw'
..annotations.add(CodeExpression(
Code("Deprecated('Use findFirstProjected (typed inputs) instead; "
'findFirstRaw will be removed in 0.9.0'
"')")))
..docs.add('/// Find the first $m as a raw map (use with include/computed)')
..modifier = MethodModifier.async
..returns = refer('Future<Map<String, dynamic>?>')
Expand Down
96 changes: 82 additions & 14 deletions lib/src/generator/cb_model_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class CbModelGenerator {
_buildListRelationFilter(model),
_buildRelationFilter(model),
_buildOrderByInput(model),
_buildScalarFieldEnum(model),
_buildInclude(model),
..._buildRelationWriteInputs(model),
..._buildEnumConverters(model),
Expand Down Expand Up @@ -754,17 +755,63 @@ class CbModelGenerator {
return specs;
}

// === Scalar-field enum (typed projection) ===

/// Enum-case name for a scalar field, avoiding the identifiers every Dart
/// enum already declares (`values`, `index`, …).
String _scalarEnumCase(String name) {
const reserved = {
'values',
'index',
'hashCode',
'runtimeType',
'toString',
'noSuchMethod',
};
return reserved.contains(name) ? '${name}Field' : name;
}

/// Plain enum `{Model}ScalarField` — one case per scalar (non-relation)
/// field, carrying the Dart field name for the compiler to resolve via the
/// registry (@map-aware). Used by typed projection (`select:`/`distinctOn:`)
/// and per-relation include `select`.
Spec _buildScalarFieldEnum(PrismaModel model) {
final scalars = model.fields.where((f) => !f.isRelation).toList();
final cases = scalars
.map((f) => " ${_scalarEnumCase(f.name)}('${f.name}')")
.join(',\n');
return Code('''
/// Scalar fields of ${model.name} for typed projection.
enum ${model.name}ScalarField {
$cases;

const ${model.name}ScalarField(this.fieldName);

/// The Dart field name (the compiler resolves @map columns via the registry).
final String fieldName;
}
''');
}
Comment on lines +758 to +794

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard against zero-scalar-field models.

If model.fields.where((f) => !f.isRelation) is empty (a model with only relation fields), cases is '' and the generated source is enum ${model.name}ScalarField {\n;\n ... } — an enum with no declared instances, which is invalid Dart (an enum must declare at least one instance). This would break codegen for that model's entire file.

🛡️ Proposed guard
   Spec _buildScalarFieldEnum(PrismaModel model) {
     final scalars = model.fields.where((f) => !f.isRelation).toList();
+    if (scalars.isEmpty) {
+      return Code('''
+/// Scalar fields of ${model.name} for typed projection.
+/// (${model.name} has no scalar fields.)
+enum ${model.name}ScalarField { none(''); const ${model.name}ScalarField(this.fieldName); final String fieldName; }
+''');
+    }
     final cases = scalars
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// === Scalar-field enum (typed projection) ===
/// Enum-case name for a scalar field, avoiding the identifiers every Dart
/// enum already declares (`values`, `index`, …).
String _scalarEnumCase(String name) {
const reserved = {
'values',
'index',
'hashCode',
'runtimeType',
'toString',
'noSuchMethod',
};
return reserved.contains(name) ? '${name}Field' : name;
}
/// Plain enum `{Model}ScalarField` — one case per scalar (non-relation)
/// field, carrying the Dart field name for the compiler to resolve via the
/// registry (@map-aware). Used by typed projection (`select:`/`distinctOn:`)
/// and per-relation include `select`.
Spec _buildScalarFieldEnum(PrismaModel model) {
final scalars = model.fields.where((f) => !f.isRelation).toList();
final cases = scalars
.map((f) => " ${_scalarEnumCase(f.name)}('${f.name}')")
.join(',\n');
return Code('''
/// Scalar fields of ${model.name} for typed projection.
enum ${model.name}ScalarField {
$cases;
const ${model.name}ScalarField(this.fieldName);
/// The Dart field name (the compiler resolves @map columns via the registry).
final String fieldName;
}
''');
}
// === Scalar-field enum (typed projection) ===
/// Enum-case name for a scalar field, avoiding the identifiers every Dart
/// enum already declares (`values`, `index`, …).
String _scalarEnumCase(String name) {
const reserved = {
'values',
'index',
'hashCode',
'runtimeType',
'toString',
'noSuchMethod',
};
return reserved.contains(name) ? '${name}Field' : name;
}
/// Plain enum `{Model}ScalarField` — one case per scalar (non-relation)
/// field, carrying the Dart field name for the compiler to resolve via the
/// registry (`@map-aware`). Used by typed projection (`select:`/`distinctOn:`)
/// and per-relation include `select`.
Spec _buildScalarFieldEnum(PrismaModel model) {
final scalars = model.fields.where((f) => !f.isRelation).toList();
if (scalars.isEmpty) {
return Code('''
/// Scalar fields of ${model.name} for typed projection.
/// (${model.name} has no scalar fields.)
enum ${model.name}ScalarField { none(''); const ${model.name}ScalarField(this.fieldName); final String fieldName; }
''');
}
final cases = scalars
.map((f) => " ${_scalarEnumCase(f.name)}('${f.name}')")
.join(',\n');
return Code('''
/// Scalar fields of ${model.name} for typed projection.
enum ${model.name}ScalarField {
$cases;
const ${model.name}ScalarField(this.fieldName);
/// The Dart field name (the compiler resolves `@map` columns via the registry).
final String fieldName;
}
''');
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/src/generator/cb_model_generator.dart` around lines 758 - 794, Guard
_buildScalarFieldEnum for models with no scalar fields so it does not generate
an invalid enum with only a semicolon. Skip generating the scalar-field enum, or
apply the generator’s established handling for empty declarations, while
preserving the existing enum output for models containing scalar fields and
updating any callers that require a generated type.


// === Include (typed relation selection) ===

/// Typed include class: one `${RelatedModel}Include?` field per relation.
/// A non-null nested include includes that relation (empty = include with no
/// deeper relations); toJson yields the compiler's include-map shape.
/// Typed include class: one `${RelatedModel}Include?` field per relation,
/// plus `select` (that model's own scalar fields) applied when this include
/// is NESTED under a parent include. A non-null nested include includes that
/// relation; toJson yields the compiler's include-map shape:
/// `true` | `{'include': ..., 'select': ...}`.
///
/// NOTE: `select` on the ROOT include object of a query is ignored — root
/// projection goes through the finder's own `select` parameter
/// (findManyProjected).
Class _buildInclude(PrismaModel model) {
final relations = model.fields.where((f) => f.isRelation).toList();
final params = <Parameter>[];
// Statement-style toJson (no runtime helper): an empty nested include
// serializes to `true` (include, no deeper relations); a non-empty one
// nests via {'include': ...}, matching the relation compiler's shape.
final params = <Parameter>[
Parameter((p) => p
..name = 'select'
..named = true
..type = refer('List<${model.name}ScalarField>?')),
];
final stmts = <String>['final map = <String, dynamic>{};'];
for (final f in relations) {
params.add(Parameter((p) => p
Expand All @@ -773,13 +820,29 @@ class CbModelGenerator {
..type = refer('${f.type}Include?')));
stmts.add("if (${f.name} != null) {"
" final n = ${f.name}!.toJson();"
" map['${f.name}'] = n.isEmpty ? true : <String, dynamic>{'include': n};"
" final s = ${f.name}!.selectMap();"
" map['${f.name}'] = (n.isEmpty && s == null)"
" ? true"
" : <String, dynamic>{"
" if (n.isNotEmpty) 'include': n,"
" if (s != null) 'select': s,"
" };"
" }");
}
stmts.add('return map;');
return _freezedClass('${model.name}Include', params,
doc: '/// Typed include for ${model.name} relations',
toJsonBody: stmts.join('\n'));
toJsonBody: stmts.join('\n'),
extraMethods: [
Method((m) => m
..docs.add('/// Scalar projection for this include when nested '
'under a parent include; null = all fields.')
..name = 'selectMap'
..returns = refer('Map<String, dynamic>?')
..body = Code('if (select == null || select!.isEmpty) return null;'
' return <String, dynamic>{'
'for (final f in select!) f.fieldName: true};')),
]);
}

// === OrderByInput ===
Expand Down Expand Up @@ -809,7 +872,9 @@ class CbModelGenerator {
// === Shared: build a @freezed class ===

Class _freezedClass(String name, List<Parameter> params,
{String? doc, required String toJsonBody}) {
{String? doc,
required String toJsonBody,
List<Method> extraMethods = const []}) {
return Class((b) {
if (doc != null) b.docs.add(doc);
b
Expand All @@ -836,10 +901,13 @@ class CbModelGenerator {
..body =
Code("throw UnimplementedError('$name.fromJson not needed');")),
])
..methods.add(Method((m) => m
..name = 'toJson'
..returns = refer('Map<String, dynamic>')
..body = Code(toJsonBody)));
..methods.addAll([
Method((m) => m
..name = 'toJson'
..returns = refer('Map<String, dynamic>')
..body = Code(toJsonBody)),
...extraMethods,
]);
});
}

Expand Down
11 changes: 10 additions & 1 deletion lib/src/runtime/query/relation_compiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,16 @@ class RelationCompiler {
fieldToColumn[field.name] = field.columnName;
}

for (final fieldName in selectedFields) {
// Always carry the primary key: the relation deserializer groups/dedupes
// child rows by PK, so a select that omits it would silently drop the
// relation's rows from the hydrated result.
final effectiveFields = [
...selectedFields,
for (final pk in model.primaryKeys)
if (!selectedFields.contains(pk.name)) pk.name,
];

for (final fieldName in effectiveFields) {
Comment on lines +466 to +475

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

PK-preservation fix leaks into base-model select, not just relation includes.

_addSelectedColumns is shared by the base-model select path (compile(), relationPath: null) and the relation-nested select path (_compileRelation, relationPath always set). The new PK-force logic applies unconditionally to both, so findManyProjected(select: [...]) at the root will always silently include the primary key even when the caller's typed select: explicitly omits it — defeating the precise-projection contract this release is built around. The documented fix is specifically about relation-row dedup/hydration (CHANGELOG "Fixed" section), not the base row.

Scope the PK-force to relation calls only, using the already-available relationPath != null signal:

🛡️ Proposed fix
-    // Always carry the primary key: the relation deserializer groups/dedupes
-    // child rows by PK, so a select that omits it would silently drop the
-    // relation's rows from the hydrated result.
-    final effectiveFields = [
-      ...selectedFields,
-      for (final pk in model.primaryKeys)
-        if (!selectedFields.contains(pk.name)) pk.name,
-    ];
+    // Relation rows are grouped/deduped by PK during hydration, so a
+    // relation-nested select that omits it would silently drop rows. This
+    // only applies to relation-nested selects (relationPath != null); the
+    // base model's own select is returned as-is with no such dedup step.
+    final effectiveFields = relationPath != null
+        ? [
+            ...selectedFields,
+            for (final pk in model.primaryKeys)
+              if (!selectedFields.contains(pk.name)) pk.name,
+          ]
+        : selectedFields;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Always carry the primary key: the relation deserializer groups/dedupes
// child rows by PK, so a select that omits it would silently drop the
// relation's rows from the hydrated result.
final effectiveFields = [
...selectedFields,
for (final pk in model.primaryKeys)
if (!selectedFields.contains(pk.name)) pk.name,
];
for (final fieldName in effectiveFields) {
// Relation rows are grouped/deduped by PK during hydration, so a
// relation-nested select that omits it would silently drop rows. This
// only applies to relation-nested selects (relationPath != null); the
// base model's own select is returned as-is with no such dedup step.
final effectiveFields = relationPath != null
? [
...selectedFields,
for (final pk in model.primaryKeys)
if (!selectedFields.contains(pk.name)) pk.name,
]
: selectedFields;
for (final fieldName in effectiveFields) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/src/runtime/query/relation_compiler.dart` around lines 466 - 475, Scope
the primary-key augmentation in _addSelectedColumns to relation-nested
selections only by applying it when relationPath != null. Preserve
selectedFields unchanged for the base-model compile() path, while retaining PK
inclusion for _compileRelation hydration and deduplication.

// Get actual column name (may differ from field name)
final columnName = fieldToColumn[fieldName] ?? fieldName;

Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: >-
A type-safe Flutter connector for Prisma backends. Generate Dart models
and type-safe APIs from your Prisma schema with support for PostgreSQL,
MySQL, SQLite, and Supabase.
version: 0.7.1
version: 0.8.0
homepage: https://github.com/teetangh/prisma-flutter-connector
repository: https://github.com/teetangh/prisma-flutter-connector
issue_tracker: https://github.com/teetangh/prisma-flutter-connector/issues
Expand Down
Loading
Loading