diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c720c..593a118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, diff --git a/lib/src/generator/cb_delegate_generator.dart b/lib/src/generator/cb_delegate_generator.dart index 2b15c8e..6e6ac4d 100644 --- a/lib/src/generator/cb_delegate_generator.dart +++ b/lib/src/generator/cb_delegate_generator.dart @@ -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), @@ -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>>') - ..optionalParameters.addAll([ - Parameter((p) => p - ..name = 'where' - ..named = true - ..type = refer('Map?')), - 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?')), - Parameter((p) => p - ..name = 'includeRequired' - ..named = true - ..type = refer('Map?')), - Parameter((p) => p - ..name = 'selectFields' - ..named = true - ..type = refer('List?')), - Parameter((p) => p - ..name = 'computed' - ..named = true - ..type = refer('Map?')), - Parameter((p) => p - ..name = 'distinct' - ..named = true - ..type = refer('bool?')), - Parameter((p) => p - ..name = 'distinctFields' - ..named = true - ..type = refer('List?')), - ]) - ..body = Code(''' - final queryBuilder = JsonQueryBuilder() - .model('$t') - .action(QueryAction.findMany); - - if (where != null) queryBuilder.where(where); - if (orderBy is Map) 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?>') - ..optionalParameters.addAll([ - Parameter((p) => p - ..name = 'where' - ..named = true - ..type = refer('Map?')), - Parameter((p) => p - ..name = 'orderBy' - ..named = true - ..type = refer('dynamic')), - Parameter((p) => p - ..name = 'include' - ..named = true - ..type = refer('Map?')), - ]) - ..body = Code(''' - final queryBuilder = JsonQueryBuilder() - .model('$t') - .action(QueryAction.findFirst); - - if (where != null) queryBuilder.where(where); - if (orderBy is Map) 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') @@ -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) @@ -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); diff --git a/lib/src/generator/cb_filter_types_generator.dart b/lib/src/generator/cb_filter_types_generator.dart index 0c4f04a..e9be704 100644 --- a/lib/src/generator/cb_filter_types_generator.dart +++ b/lib/src/generator/cb_filter_types_generator.dart @@ -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, }; ''', ), @@ -170,7 +172,10 @@ class CbFilterTypesGenerator { Class _filter(String name, String doc, List 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') @@ -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' @@ -205,6 +210,12 @@ class CbFilterTypesGenerator { final entries = []; 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( diff --git a/lib/src/generator/cb_model_generator.dart b/lib/src/generator/cb_model_generator.dart index a5b15f4..6ab5aa2 100644 --- a/lib/src/generator/cb_model_generator.dart +++ b/lib/src/generator/cb_model_generator.dart @@ -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"; } @@ -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' diff --git a/lib/src/runtime/query/sql_compiler.dart b/lib/src/runtime/query/sql_compiler.dart index e6a5947..7974e95 100644 --- a/lib/src/runtime/query/sql_compiler.dart +++ b/lib/src/runtime/query/sql_compiler.dart @@ -1353,6 +1353,7 @@ RETURNING * value is Map && (value.containsKey('connect') || value.containsKey('disconnect') || + value.containsKey('set') || value.containsKey('create')); /// Parent PK value known at compile time (from `data` on create or `where` @@ -1404,6 +1405,18 @@ RETURNING * final v = value as Map; 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, + )); + } if (v.containsKey('connect')) { mutations.addAll(_compileConnectOperations( parentId: parentId.toString(), @@ -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).', + ); + } // Nested create: child rows carry the FK back to the parent. if (v.containsKey('create')) { final creates = v['create']; @@ -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], + ); + } + /// Normalize connect/disconnect input to a list of maps. /// /// Handles both single item and array formats: diff --git a/pubspec.yaml b/pubspec.yaml index 481c5ff..b6efee4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/unit/typed_projection_test.dart b/test/unit/typed_projection_test.dart index 64a12ac..21bcbb4 100644 --- a/test/unit/typed_projection_test.dart +++ b/test/unit/typed_projection_test.dart @@ -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'))); }); }); } diff --git a/test/unit/v090_null_semantics_test.dart b/test/unit/v090_null_semantics_test.dart new file mode 100644 index 0000000..038e677 --- /dev/null +++ b/test/unit/v090_null_semantics_test.dart @@ -0,0 +1,182 @@ +import 'package:test/test.dart'; +import 'package:prisma_flutter_connector/src/generator/cb_delegate_generator.dart'; +import 'package:prisma_flutter_connector/src/generator/cb_filter_types_generator.dart'; +import 'package:prisma_flutter_connector/src/generator/cb_model_generator.dart'; +import 'package:prisma_flutter_connector/src/generator/prisma_parser.dart'; +import 'package:prisma_flutter_connector/src/runtime/query/sql_compiler.dart'; +import 'package:prisma_flutter_connector/src/runtime/query/json_protocol.dart'; +import 'package:prisma_flutter_connector/src/runtime/schema/schema_registry.dart'; + +String _flat(String code) => code.replaceAll(RegExp(r'\s+'), ' '); + +void main() { + group('isNull typed filters (#0.9.0)', () { + final parsed = PrismaParser().parse('model Ping { id String @id }'); + + test('every filter class carries isNull with true/false emission', () { + final flat = _flat(CbFilterTypesGenerator(parsed).generate()); + // param present on plain + custom-body filters + expect('bool? isNull'.allMatches(flat).length, greaterThan(5)); + expect(flat, contains("if (isNull == true) 'isNull': true")); + expect(flat, contains("if (isNull == false) 'isNotNull': true")); + }); + + test('compiler emits IS NULL / IS NOT NULL', () { + final c = SqlCompiler(provider: 'postgresql'); + final qNull = JsonQueryBuilder() + .model('Session') + .action(QueryAction.findMany) + .where({ + 'endedAt': {'isNull': true} + }).build(); + expect(c.compile(qNull).sql, contains('"endedAt" IS NULL')); + final qNotNull = JsonQueryBuilder() + .model('Session') + .action(QueryAction.findMany) + .where({ + 'startedAt': {'isNotNull': true} + }).build(); + expect(c.compile(qNotNull).sql, contains('"startedAt" IS NOT NULL')); + }); + }); + + group('setNull on typed updates (#0.9.0)', () { + const schema = ''' +model User { + id String @id + image String? + name String +} +'''; + test('update/updateMany accept setNull and inject explicit nulls', () { + final parsed = PrismaParser().parse(schema); + final flat = _flat(CbDelegateGenerator(parsed, serverMode: true) + .generateDelegate(parsed.models.first)); + expect(flat, contains('List? setNull')); + expect(flat, contains('data0[f.fieldName] = null')); + }); + + test('compiler SET emits NULL assignment for explicit null values', () { + final c = SqlCompiler(provider: 'postgresql'); + final q = JsonQueryBuilder() + .model('User') + .action(QueryAction.update) + .where({'id': 'u1'}).data({'image': null}).build(); + final r = c.compile(q); + expect(r.sql, contains('"image" = \$1')); + expect(r.args.first, isNull); + }); + }); + + group('m2m nested set (#0.9.0)', () { + test('write input for m2m relation carries set', () { + const schema = ''' +model Profile { + id String @id + subDomains SubDomain[] +} +model SubDomain { + id String @id + profiles Profile[] +} +'''; + final parsed = PrismaParser().parse(schema); + final flat = _flat(CbModelGenerator(parsed) + .generateModel(parsed.models.firstWhere((m) => m.name == 'Profile'))); + expect(flat, contains('List? set')); + expect( + flat, + contains( + "if (set != null) 'set': set!.map((e) => e.toJson()).toList()")); + }); + + test('engine compiles set as junction clear + connects', () { + final s = SchemaRegistry(); + s.registerModel( + ModelSchema(name: 'Profile', tableName: 'Profile', fields: { + 'id': const FieldInfo( + name: 'id', columnName: 'id', type: 'String', isId: true), + }, relations: { + 'subDomains': RelationInfo.manyToMany( + name: 'subDomains', + targetModel: 'SubDomain', + joinTable: '_ProfileToSubDomain', + joinColumn: 'A', + inverseJoinColumn: 'B', + ), + })); + s.registerModel( + const ModelSchema(name: 'SubDomain', tableName: 'SubDomain', fields: { + 'id': + FieldInfo(name: 'id', columnName: 'id', type: 'String', isId: true), + })); + final c = SqlCompiler(provider: 'postgresql', schema: s); + final compiled = c.compileWithRelations(JsonQueryBuilder() + .model('Profile') + .action(QueryAction.update) + .where({'id': 'p1'}).data({ + 'subDomains': { + 'set': [ + {'id': 's1'}, + {'id': 's2'} + ] + } + }).build()); + final muts = compiled.relationMutations; + expect(muts.length, 3); // 1 clear + 2 connects + expect(muts.first.sql, + contains('DELETE FROM "_ProfileToSubDomain" WHERE "A" = \$1')); + expect(muts[1].sql, contains('INSERT INTO "_ProfileToSubDomain"')); + expect(muts[1].args, equals(['p1', 's1'])); + expect(muts[2].args, equals(['p1', 's2'])); + }); + + test('set on a 1:N relation throws (no silent data loss)', () { + final s = SchemaRegistry(); + s.registerModel(ModelSchema(name: 'Author', tableName: 'Author', fields: { + 'id': const FieldInfo( + name: 'id', columnName: 'id', type: 'String', isId: true), + }, relations: { + 'posts': RelationInfo.oneToMany( + name: 'posts', targetModel: 'Post', foreignKey: 'authorId'), + })); + s.registerModel( + const ModelSchema(name: 'Post', tableName: 'Post', fields: { + 'id': + FieldInfo(name: 'id', columnName: 'id', type: 'String', isId: true), + 'authorId': + FieldInfo(name: 'authorId', columnName: 'authorId', type: 'String'), + })); + final c = SqlCompiler(provider: 'postgresql', schema: s); + expect( + () => c.compileWithRelations(JsonQueryBuilder() + .model('Author') + .action(QueryAction.update) + .where({'id': 'a1'}).data({ + 'posts': { + 'set': [ + {'id': 'p1'} + ] + } + }).build()), + throwsA(isA()), + ); + }); + }); + + group('null-tolerant defaulted-array decode (#0.9.0)', () { + test('required String[] decodes NULL as const []', () { + const schema = ''' +model Clip { + id String @id + urls String[] @default([]) +} +'''; + final parsed = PrismaParser().parse(schema); + final flat = + _flat(CbModelGenerator(parsed).generateModel(parsed.models.first)); + expect(flat, contains("(json['urls'] as List?)?.cast()")); + expect(flat, isNot(contains("(json['urls'] as List).cast()"))); + }); + }); +}