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

## [Unreleased]

## [0.9.0] - 2026-07-24

The **null-semantics** release — closes the gaps found while migrating the
familiarise backend to a fully typed data layer, and completes the raw-helper
deprecation cycle.

### Added
- **`setNull` on typed updates** — `update`/`updateMany` gain
`setNull: List<{Model}ScalarField>?`; listed fields are injected as explicit
`NULL` assignments (typed inputs otherwise drop null fields, making
null-clears inexpressible).
- **`isNull` on every filter class** — `isNull: true` compiles to `IS NULL`,
`isNull: false` to `IS NOT NULL` (all scalar/enum/BigInt/Bytes/Json/list
filters).
- **Nested `set` for many-to-many relations** — to-many relation write inputs
gain `set: List<{Related}WhereUniqueInput>?`; the engine clears the junction
rows for the parent and connects exactly the given targets (replace
semantics). `set` on 1:N/1:1 throws `UnsupportedError` (re-parenting is not
implemented) instead of silently dropping data.

### Changed
- **Null-tolerant array decode** — required `String[]`-style columns now
hydrate SQL `NULL` as `const []` instead of crashing `fromJson` (dirty data
tolerated in favour of the column default).

### Removed
- **`findManyRaw` / `findFirstRaw`** — deprecated in 0.8.0, removed as
scheduled. Use `findManyProjected` / `findFirstProjected` (typed inputs,
Map rows) or typed `findMany` + `toJson()`.

## [0.8.0] - 2026-07-03

The **typed-projection** release: the last raw-map surfaces (`select`,
Expand Down
132 changes: 22 additions & 110 deletions lib/src/generator/cb_delegate_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ class CbDelegateGenerator {
_findMany(modelName, tableName, hasUniqueFields),
_findManyProjected(modelName, tableName, hasUniqueFields),
_findFirstProjected(modelName, tableName),
_findManyRaw(modelName, tableName),
_findFirstRaw(modelName, tableName),
_create(modelName, tableName, relLiteral),
_createMany(modelName, tableName),
_createManyAndReturn(modelName, tableName),
Expand Down Expand Up @@ -401,113 +399,6 @@ class CbDelegateGenerator {
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
..returns = refer('Future<List<Map<String, dynamic>>>')
..optionalParameters.addAll([
Parameter((p) => p
..name = 'where'
..named = true
..type = refer('Map<String, dynamic>?')),
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?')),
Parameter((p) => p
..name = 'include'
..named = true
..type = refer('Map<String, dynamic>?')),
Parameter((p) => p
..name = 'includeRequired'
..named = true
..type = refer('Map<String, dynamic>?')),
Parameter((p) => p
..name = 'selectFields'
..named = true
..type = refer('List<String>?')),
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 = 'distinctFields'
..named = true
..type = refer('List<String>?')),
])
..body = Code('''
final queryBuilder = JsonQueryBuilder()
.model('$t')
.action(QueryAction.findMany);

if (where != null) queryBuilder.where(where);
if (orderBy is Map<String, dynamic>) queryBuilder.orderBy(orderBy);
if (orderBy is List) queryBuilder.orderBy(orderBy);
if (take != null) queryBuilder.take(take);
if (skip != null) queryBuilder.skip(skip);
if (include != null) queryBuilder.include(include);
if (includeRequired != null) queryBuilder.includeRequired(includeRequired);
if (selectFields != null) queryBuilder.selectFields(selectFields);
if (computed != null) queryBuilder.computed(computed);
if (distinct == true) queryBuilder.distinct(distinctFields);

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

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>?>')
..optionalParameters.addAll([
Parameter((p) => p
..name = 'where'
..named = true
..type = refer('Map<String, dynamic>?')),
Parameter((p) => p
..name = 'orderBy'
..named = true
..type = refer('dynamic')),
Parameter((p) => p
..name = 'include'
..named = true
..type = refer('Map<String, dynamic>?')),
])
..body = Code('''
final queryBuilder = JsonQueryBuilder()
.model('$t')
.action(QueryAction.findFirst);

if (where != null) queryBuilder.where(where);
if (orderBy is Map<String, dynamic>) queryBuilder.orderBy(orderBy);
if (orderBy is List) queryBuilder.orderBy(orderBy);
if (include != null) queryBuilder.include(include);

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

Method _create(String m, String t, String relLiteral) => Method((b) => b
..name = 'create'
..docs.add('/// Create a new $m')
Expand Down Expand Up @@ -611,9 +502,20 @@ class CbDelegateGenerator {
..named = true
..required = true
..type = refer('Update${m}Input')),
Parameter((p) => p
..name = 'setNull'
..named = true
..type = refer('List<${m}ScalarField>?')),
])
..body = Code('''
final data0 = data.toJson();
// Explicit null-clears: typed inputs drop null fields, so fields to be
// set to NULL are listed here and injected as explicit nulls.
if (setNull != null) {
for (final f in setNull) {
data0[f.fieldName] = null;
}
}
final query = JsonQueryBuilder()
.model('$t')
.action(QueryAction.update)
Expand Down Expand Up @@ -685,13 +587,23 @@ class CbDelegateGenerator {
..named = true
..required = true
..type = refer('Update${m}Input')),
Parameter((p) => p
..name = 'setNull'
..named = true
..type = refer('List<${m}ScalarField>?')),
])
..body = Code('''
final data0 = data.toJson();
if (setNull != null) {
for (final f in setNull) {
data0[f.fieldName] = null;
}
}
final query = JsonQueryBuilder()
.model('$t')
.action(QueryAction.updateMany)
.where(_whereToJson(where))
.data(data.toJson())
.data(data0)
.build();

return await _executor.executeMutation(query);
Expand Down
15 changes: 13 additions & 2 deletions lib/src/generator/cb_filter_types_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class CbFilterTypesGenerator {
if (lte != null) 'lte': lte,
if (gt != null) 'gt': gt,
if (gte != null) 'gte': gte,
if (isNull == true) 'isNull': true,
if (isNull == false) 'isNotNull': true,
};
''',
),
Expand Down Expand Up @@ -170,7 +172,10 @@ class CbFilterTypesGenerator {

Class _filter(String name, String doc, List<Parameter> params,
{String? toJsonBodyOverride}) {
final toJsonBody = toJsonBodyOverride ?? _filterToJsonBody(params);
// Every filter gets a null-check operator: isNull:true -> IS NULL,
// isNull:false -> IS NOT NULL (compiler operators isNull/isNotNull).
final allParams = [...params, _p('bool?', 'isNull')];
final toJsonBody = toJsonBodyOverride ?? _filterToJsonBody(allParams);
return Class((b) => b
..name = name
..docs.add('/// Filter for $doc fields')
Expand All @@ -184,7 +189,7 @@ class CbFilterTypesGenerator {
..factory = true
..constant = true
..redirect = refer('_$name')
..optionalParameters.addAll(params)),
..optionalParameters.addAll(allParams)),
Constructor((c) => c
..factory = true
..name = 'fromJson'
Expand All @@ -205,6 +210,12 @@ class CbFilterTypesGenerator {
final entries = <String>[];
for (final p in params) {
final name = p.name;
if (name == 'isNull') {
// true -> IS NULL, false -> IS NOT NULL (distinct compiler operators).
entries.add("if (isNull == true) 'isNull': true");
entries.add("if (isNull == false) 'isNotNull': true");
continue;
}
final jsonKey = name == 'in_' ? 'in' : name;
final typeStr = p.type?.symbol ?? '';
entries.add(
Expand Down
13 changes: 11 additions & 2 deletions lib/src/generator/cb_model_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,17 @@ class CbModelGenerator {
!_isEnumType(f.type);
final effectiveRequired = f.isRequired && !hasDefault;
if (f.isList) {
// List columns decode null-tolerantly even when required: a NULL in a
// non-null array column is dirty data, but falling back to [] beats
// crashing hydration of the whole row (matches the DB default).
if (_isEnumType(f.type)) {
return effectiveRequired
? "(json['$key'] as List).map((e) => _\$${f.type}FromJson(e as String)).toList()"
? "(json['$key'] as List?)?.map((e) => _\$${f.type}FromJson(e as String)).toList() ?? const []"
: "(json['$key'] as List?)?.map((e) => _\$${f.type}FromJson(e as String)).toList()";
}
final defaultSuffix = hasDefault ? ' ?? ${f.defaultValue}' : '';
return effectiveRequired
? "(json['$key'] as List).cast<$dartType>()"
? "(json['$key'] as List?)?.cast<$dartType>() ?? const []"
: "(json['$key'] as List?)?.cast<$dartType>()$defaultSuffix";
}

Expand Down Expand Up @@ -726,6 +729,12 @@ class CbModelGenerator {
"if (connect != null) 'connect': connect!.map((e) => e.toJson()).toList()");
entries.add(
"if (disconnect != null) 'disconnect': disconnect!.map((e) => e.toJson()).toList()");
params.add(Parameter((p) => p
..name = 'set'
..named = true
..type = refer('List<${related}WhereUniqueInput>?')));
entries.add(
"if (set != null) 'set': set!.map((e) => e.toJson()).toList()");
}
params.add(Parameter((p) => p
..name = 'create'
Expand Down
33 changes: 33 additions & 0 deletions lib/src/runtime/query/sql_compiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,7 @@ RETURNING *
value is Map<String, dynamic> &&
(value.containsKey('connect') ||
value.containsKey('disconnect') ||
value.containsKey('set') ||
value.containsKey('create'));
Comment on lines 1353 to 1357

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep scalar {set: ...} out of relation-op detection.

_atomicUpdateOp treats {set: 5} as a scalar assignment, but this change classifies it as a relation operation. _compileCleanMainMutation then drops scalar fields without relation metadata from cleanData, silently omitting the update. Make relation detection context-aware before adding set.

🤖 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/sql_compiler.dart` around lines 1353 - 1357, Update the
relation-operation detection used by _atomicUpdateOp so a set key is recognized
as a relation operation only when relation metadata/context confirms it is
relational. Preserve scalar {set: ...} values as scalar assignments, ensuring
_compileCleanMainMutation retains those fields in cleanData when no relation
metadata is present.


/// Parent PK value known at compile time (from `data` on create or `where`
Expand Down Expand Up @@ -1404,6 +1405,18 @@ RETURNING *
final v = value as Map<String, dynamic>;

if (relation.type == RelationType.manyToMany) {
// `set`: replace the full relation — clear all junction rows for the
// parent, then connect exactly the given targets.
if (v.containsKey('set')) {
final clear = _compileJunctionClear(relation, parentId.toString());
if (clear != null) mutations.add(clear);
mutations.addAll(_compileConnectOperations(
parentId: parentId.toString(),
relation: relation,
connectItems: _normalizeConnectDisconnect(v['set']),
effectiveSchema: effectiveSchema,
Comment on lines +1408 to +1417

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve primary-key types in relation mutations.

parentId is dynamic but this path converts it to String, while the clear and reconnect queries bind ArgType.string. Many-to-many relations with non-string primary keys can fail or use incorrect parameter types. Preserve raw IDs and bind their actual types.

🤖 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/sql_compiler.dart` around lines 1408 - 1417, Update the
set-handling path in the relation mutation compiler to pass the raw parentId
through _compileJunctionClear and _compileConnectOperations instead of
converting it with toString(). Ensure those helpers bind the parent identifier
using its actual primary-key type rather than forcing ArgType.string, while
preserving the existing clear-then-reconnect behavior.

));
Comment on lines +1410 to +1418

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate every set selector before clearing the junction.

_compileConnectOperations skips targets missing their primary key, so {set: [{}]} deletes all existing links and reconnects none. Validate all targets before appending the destructive DELETE and reject malformed selectors.

🤖 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/sql_compiler.dart` around lines 1410 - 1418, Validate
every normalized selector in the `set` branch before invoking or appending
`_compileJunctionClear`, rejecting any target missing its required primary key.
Only perform the destructive clear and subsequent `_compileConnectOperations`
call after all selectors pass validation, while preserving valid set replacement
behavior.

}
if (v.containsKey('connect')) {
mutations.addAll(_compileConnectOperations(
parentId: parentId.toString(),
Expand All @@ -1422,6 +1435,13 @@ RETURNING *
}
} else if (relation.type == RelationType.oneToMany ||
relation.type == RelationType.oneToOne) {
if (v.containsKey('set')) {
throw UnsupportedError(
'Nested `set` on the ${relation.type.name} relation '
'"${entry.key}" is only supported for many-to-many relations '
'(1:N re-parenting is not implemented).',
);
}
Comment on lines 1436 to +1444

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

Reject set on manyToOne relations too.

The guard covers only oneToMany and oneToOne. A manyToOne {set: ...} is recognized, stripped from the main mutation, and never handled by _buildRelationMutations, so the update succeeds without applying the relation change. Include RelationType.manyToOne in the rejection path.

🤖 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/sql_compiler.dart` around lines 1436 - 1444, Extend the
relation-type guard in the nested mutation handling branch to include
RelationType.manyToOne alongside oneToMany and oneToOne. Preserve the existing
UnsupportedError message and behavior for unsupported nested set operations.

// Nested create: child rows carry the FK back to the parent.
if (v.containsKey('create')) {
final creates = v['create'];
Expand Down Expand Up @@ -1472,6 +1492,19 @@ RETURNING *
return mutations;
}

/// DELETE all junction rows for [parentId] on an m2m relation (the clear
/// half of a nested `set`). Returns null when the relation lacks junction
/// metadata.
SqlQuery? _compileJunctionClear(RelationInfo relation, String parentId) {
if (relation.joinTable == null || relation.joinColumn == null) return null;
return SqlQuery(
sql: 'DELETE FROM ${_quoteIdentifier(relation.joinTable!)} '
'WHERE ${_quoteIdentifier(relation.joinColumn!)} = ${_placeholder(1)}',
args: [parentId],
argTypes: const [ArgType.string],
);
}

Comment on lines +1495 to +1507

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail loudly when many-to-many metadata is incomplete.

This helper permits missing inverseJoinColumn; the subsequent connect compiler then returns no queries, leaving the DELETE to clear all links permanently. Missing joinTable or joinColumn instead makes the entire set a silent no-op. Validate all junction metadata before clearing and throw an error for invalid relation configuration.

🤖 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/sql_compiler.dart` around lines 1495 - 1507, The
_compileJunctionClear method must validate all many-to-many junction metadata,
including inverseJoinColumn, before generating a DELETE query. Throw an error
when joinTable, joinColumn, or inverseJoinColumn is missing; only return the
clear query for fully configured relations, preventing incomplete configurations
from silently clearing links or becoming no-ops.

/// Normalize connect/disconnect input to a list of maps.
///
/// Handles both single item and array formats:
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.8.0
version: 0.9.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
7 changes: 3 additions & 4 deletions test/unit/typed_projection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,10 @@ model User { id String @id }
expect(flat, contains('executeQueryAsSingleMap'));
});

test('raw helpers are @Deprecated pointing at projected finders', () {
test('raw helpers are removed in 0.9.0', () {
final flat = _flat(delegate());
expect(flat, contains('@Deprecated('));
expect(flat, contains("'Use findManyProjected (typed inputs) instead"));
expect(flat, contains("'Use findFirstProjected (typed inputs) instead"));
expect(flat, isNot(contains('findManyRaw')));
expect(flat, isNot(contains('findFirstRaw')));
});
});
}
Loading
Loading