From c887250f8891c4d0c225d910ea584bd16b8bbd18 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:16:42 +0800 Subject: [PATCH 001/154] Rename Map annotation to MapTo The `Map` class name conflicts with the built-in `dart:core` Map type. Renaming to `MapTo` avoids the conflict while preserving the annotation's purpose of mapping to a database name. --- pub/orm/lib/schema.dart | 2 +- pub/orm/lib/src/schema/{map.dart => map_to.dart} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename pub/orm/lib/src/schema/{map.dart => map_to.dart} (80%) diff --git a/pub/orm/lib/schema.dart b/pub/orm/lib/schema.dart index e9a589eb..b7919c54 100644 --- a/pub/orm/lib/schema.dart +++ b/pub/orm/lib/schema.dart @@ -1,5 +1,5 @@ export 'src/schema/schema.dart'; export 'src/schema/model.dart'; export 'src/schema/relation.dart'; -export 'src/schema/map.dart'; +export 'src/schema/map_to.dart'; export 'src/schema/id.dart'; diff --git a/pub/orm/lib/src/schema/map.dart b/pub/orm/lib/src/schema/map_to.dart similarity index 80% rename from pub/orm/lib/src/schema/map.dart rename to pub/orm/lib/src/schema/map_to.dart index 752db217..d7a501c8 100644 --- a/pub/orm/lib/src/schema/map.dart +++ b/pub/orm/lib/src/schema/map_to.dart @@ -2,9 +2,9 @@ import 'package:meta/meta.dart'; /// Annotation to map a model or field to a database name. @immutable -final class Map { +final class MapTo { final String name; /// Creates a mapping annotation with the given database name. - const Map(this.name); + const MapTo(this.name); } From f34ad8178fc82ade41b3adced6cf88feec3da936 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:19:03 +0800 Subject: [PATCH 002/154] Implement basic analysis server plugin structure --- pub/orm/lib/main.dart | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/pub/orm/lib/main.dart b/pub/orm/lib/main.dart index 4079f807..a75463eb 100644 --- a/pub/orm/lib/main.dart +++ b/pub/orm/lib/main.dart @@ -1,22 +1,16 @@ -// import 'dart:async'; +import 'dart:async'; -// import 'package:analysis_server_plugin/registry.dart'; +import 'package:analysis_server_plugin/plugin.dart'; +import 'package:analysis_server_plugin/registry.dart'; -// // import 'src/analyzer/plugin.dart'; +class AnalysisPlugin extends Plugin { + @override + String get name => 'orm'; -// // mixin A on Plugin { -// // @override -// // Future register(PluginRegistry registry) async { -// // await super.register(registry); -// // } -// // } + @override + Future register(PluginRegistry registry) async { + // TODO: implement register + } +} -// // class AnalysisPlugin extends Plugin with SchemaAnalyzerPlugin, A { -// // @override -// // Future register(PluginRegistry registry) async { -// // await super.register(registry); -// // // TODO: implement register -// // } -// // } - -// // final plugin = AnalysisPlugin(); +final plugin = AnalysisPlugin(); From e2014ae5c60866a07cb980ccc86480ecd1101f6f Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:26:19 +0800 Subject: [PATCH 003/154] Add ORM playground with SQLite configuration - Add playground directory with pubspec and analysis options - Configure ORM plugin and SQLite provider - Remove unused SQLite annotations file - Update config to only support SQLite provider --- playground/analysis_options.yaml | 4 + playground/orm.config.dart | 3 + playground/pubspec.lock | 204 ++++++++++++++++++++++++ playground/pubspec.yaml | 12 ++ pub/orm/lib/config.dart | 2 +- pub/orm/lib/src/sqlite/annotations.dart | 1 - 6 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 playground/analysis_options.yaml create mode 100644 playground/orm.config.dart create mode 100644 playground/pubspec.lock create mode 100644 playground/pubspec.yaml delete mode 100644 pub/orm/lib/src/sqlite/annotations.dart diff --git a/playground/analysis_options.yaml b/playground/analysis_options.yaml new file mode 100644 index 00000000..2ba868f1 --- /dev/null +++ b/playground/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:lints/recommended.yaml +plugins: + orm: + path: ../pub/orm diff --git a/playground/orm.config.dart b/playground/orm.config.dart new file mode 100644 index 00000000..28fa6dea --- /dev/null +++ b/playground/orm.config.dart @@ -0,0 +1,3 @@ +import 'package:orm/config.dart'; + +const config = Config(provider: .sqlite, output: 'lib/generated'); diff --git a/playground/pubspec.lock b/playground/pubspec.lock new file mode 100644 index 00000000..44523775 --- /dev/null +++ b/playground/pubspec.lock @@ -0,0 +1,204 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + url: "https://pub.dev" + source: hosted + version: "92.0.0" + analysis_server_plugin: + dependency: transitive + description: + name: analysis_server_plugin + sha256: "44adba4d74a2541173bad4c11531d2a4d22810c29c5ddb458a38e9f4d0e5eac7" + url: "https://pub.dev" + source: hosted + version: "0.3.4" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "6645a029da947ffd823d98118f385d4bd26b54eb069c006b22e0b94e451814b5" + url: "https://pub.dev" + source: hosted + version: "0.13.11" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + lints: + dependency: "direct dev" + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + orm: + dependency: "direct main" + description: + path: "../pub/orm" + relative: true + source: path + version: "6.0.0-dev.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: ec709065bb2c911b336853b67f3732dd13e0336bd065cc2f1061d7610ddf45e3 + url: "https://pub.dev" + source: hosted + version: "2.2.3" +sdks: + dart: ">=3.10.1 <4.0.0" diff --git a/playground/pubspec.yaml b/playground/pubspec.yaml new file mode 100644 index 00000000..9be7fd39 --- /dev/null +++ b/playground/pubspec.yaml @@ -0,0 +1,12 @@ +name: playground +publish_to: none + +environment: + sdk: ^3.10.1 + +dependencies: + orm: + path: ../pub/orm + +dev_dependencies: + lints: ^6.0.0 diff --git a/pub/orm/lib/config.dart b/pub/orm/lib/config.dart index 43a68799..23680ef0 100644 --- a/pub/orm/lib/config.dart +++ b/pub/orm/lib/config.dart @@ -1,6 +1,6 @@ import 'package:meta/meta.dart'; -enum DatabaseProvider { sqlite, mysql, postgresql, sqlserver } +enum DatabaseProvider { sqlite } @immutable final class Config { diff --git a/pub/orm/lib/src/sqlite/annotations.dart b/pub/orm/lib/src/sqlite/annotations.dart deleted file mode 100644 index 8b137891..00000000 --- a/pub/orm/lib/src/sqlite/annotations.dart +++ /dev/null @@ -1 +0,0 @@ - From 634eecf121aeb93f2f8ee969e8f2107f7683e2bf Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:09:28 +0800 Subject: [PATCH 004/154] Add ORM analyzer plugin with config validation and fix The plugin now provides a lint rule that checks for the required `const config = Config(...);` in `orm.config.dart` files and offers a quick fix to add it when missing. --- playground/orm.config.dart | 2 - pub/orm/lib/main.dart | 13 +- pub/orm/lib/src/analyzer/assists/.gitkeep | 0 .../analyzer/fixes/config_required_fix.dart | 111 ++++++++++++++++++ .../analyzer/rules/config_required_rule.dart | 84 +++++++++++++ pub/orm/pubspec.yaml | 1 + 6 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 pub/orm/lib/src/analyzer/assists/.gitkeep create mode 100644 pub/orm/lib/src/analyzer/fixes/config_required_fix.dart create mode 100644 pub/orm/lib/src/analyzer/rules/config_required_rule.dart diff --git a/playground/orm.config.dart b/playground/orm.config.dart index 28fa6dea..da59cb86 100644 --- a/playground/orm.config.dart +++ b/playground/orm.config.dart @@ -1,3 +1 @@ import 'package:orm/config.dart'; - -const config = Config(provider: .sqlite, output: 'lib/generated'); diff --git a/pub/orm/lib/main.dart b/pub/orm/lib/main.dart index a75463eb..2b017f51 100644 --- a/pub/orm/lib/main.dart +++ b/pub/orm/lib/main.dart @@ -1,15 +1,20 @@ -import 'dart:async'; - import 'package:analysis_server_plugin/plugin.dart'; import 'package:analysis_server_plugin/registry.dart'; +import 'src/analyzer/fixes/config_required_fix.dart'; +import 'src/analyzer/rules/config_required_rule.dart'; + class AnalysisPlugin extends Plugin { @override String get name => 'orm'; @override - Future register(PluginRegistry registry) async { - // TODO: implement register + void register(PluginRegistry registry) { + registry.registerWarningRule(ConfigRequiredRule()); + registry.registerFixForRule( + ConfigRequiredRule.code, + AddConfigConstantFix.new, + ); } } diff --git a/pub/orm/lib/src/analyzer/assists/.gitkeep b/pub/orm/lib/src/analyzer/assists/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart new file mode 100644 index 00000000..653783ff --- /dev/null +++ b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart @@ -0,0 +1,111 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; + +class AddConfigConstantFix extends ResolvedCorrectionProducer { + static const FixKind _kind = FixKind( + 'orm.fix.addConfigConstant', + DartFixKindPriority.standard, + "Define ORM config: const config = Config(...)", + ); + + AddConfigConstantFix({required super.context}); + + @override + CorrectionApplicability get applicability => + CorrectionApplicability.singleLocation; + + @override + FixKind get fixKind => _kind; + + @override + Future compute(ChangeBuilder builder) async { + final eol = utils.endOfLine; + final configImport = _findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final configInsertOffset = _configInsertOffset(unit); + final importInsertOffset = _importInsertOffset(unit); + final configText = _buildConfigText(prefix, eol); + final importText = _buildImportText(eol); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importInsertOffset == configInsertOffset) { + builder.addInsertion(importInsertOffset, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importInsertOffset, (builder) { + builder.write(importText); + }); + } + + builder.addInsertion(configInsertOffset, (builder) { + if (configInsertOffset != 0 && needsImport == false) { + builder.write(eol); + } + builder.write(configText); + }); + }); + } + + ImportDirective? _findConfigImport(CompilationUnit unit) { + for (final directive in unit.directives) { + if (directive is! ImportDirective) { + continue; + } + final uri = directive.uri.stringValue; + if (uri == 'package:orm/config.dart') { + return directive; + } + } + return null; + } + + int _configInsertOffset(CompilationUnit unit) { + if (unit.directives.isEmpty) { + return 0; + } + return utils.getLineNext(unit.directives.last.end); + } + + int _importInsertOffset(CompilationUnit unit) { + ImportDirective? lastImport; + LibraryDirective? libraryDirective; + for (final directive in unit.directives) { + if (directive is LibraryDirective) { + libraryDirective = directive; + } else if (directive is ImportDirective) { + lastImport = directive; + } + } + + final anchor = lastImport ?? libraryDirective; + if (anchor == null) { + return 0; + } + return utils.getLineNext(anchor.end); + } + + String _buildImportText(String eol) => + "import 'package:orm/config.dart';$eol"; + + String _buildConfigText(String? prefix, String eol) { + final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; + return [ + 'const config = ${qualifier}Config(', + '${utils.oneIndent}provider: $qualifier${qualifier.isNotEmpty ? 'DatabaseProvider' : ''}.sqlite,', + "${utils.oneIndent}output: '', // TODO: update output path", + ');', + '', + ].join(eol); + } +} diff --git a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart new file mode 100644 index 00000000..3448a282 --- /dev/null +++ b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart @@ -0,0 +1,84 @@ +import 'package:analyzer/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/analysis_rule/rule_context.dart'; +import 'package:analyzer/analysis_rule/rule_visitor_registry.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/error/error.dart'; + +class ConfigRequiredRule extends AnalysisRule { + static const LintCode code = LintCode( + 'orm_config_required', + "Missing required 'const config = Config(...);' in orm.config.dart.", + correctionMessage: + "Add a top-level 'const config = Config(...);' to orm.config.dart.", + ); + + ConfigRequiredRule() + : super( + name: 'orm_config_required', + description: + 'Ensures orm.config.dart defines a top-level const Config.', + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + registry.addCompilationUnit(this, _Visitor(this, context)); + } +} + +class _Visitor extends SimpleAstVisitor { + final AnalysisRule rule; + final RuleContext context; + + _Visitor(this.rule, this.context); + + @override + void visitCompilationUnit(CompilationUnit node) { + final currentUnit = context.currentUnit; + final packageRoot = context.package?.root; + if (currentUnit == null || packageRoot == null) { + return; + } + + final configFile = packageRoot.getChildAssumingFile('orm.config.dart'); + if (currentUnit.file.path != configFile.path) { + return; + } + + if (!_hasRequiredConfig(node)) { + rule.reportAtNode(node); + } + } + + bool _hasRequiredConfig(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + + final variables = declaration.variables; + if (!variables.isConst) { + continue; + } + + for (final variable in variables.variables) { + if (variable.name.lexeme != 'config') { + continue; + } + + final initializer = variable.initializer; + if (initializer is InstanceCreationExpression && + initializer.constructorName.type.name.lexeme == 'Config') { + return true; + } + } + } + return false; + } +} diff --git a/pub/orm/pubspec.yaml b/pub/orm/pubspec.yaml index ab09b508..d71ae321 100644 --- a/pub/orm/pubspec.yaml +++ b/pub/orm/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: analyzer: ^9.0.0 analysis_server_plugin: ^0.3.4 meta: ^1.17.0 + analyzer_plugin: ^0.13.11 dev_dependencies: lints: ^6.0.0 From 714d07bb18d97376db124782a3fdccc7bbba2c04 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:26:13 +0800 Subject: [PATCH 005/154] Rename fix and improve config detection - Rename AddConfigConstantFix to ConfigRequiredFix - Add check to skip fix if config variable already exists - Improve rule detection to handle imported Config types - Set rule severity to ERROR --- pub/orm/lib/main.dart | 5 +- .../analyzer/fixes/config_required_fix.dart | 24 ++++++- .../analyzer/rules/config_required_rule.dart | 72 ++++++++++++++++++- 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/pub/orm/lib/main.dart b/pub/orm/lib/main.dart index 2b017f51..c63aed11 100644 --- a/pub/orm/lib/main.dart +++ b/pub/orm/lib/main.dart @@ -11,10 +11,7 @@ class AnalysisPlugin extends Plugin { @override void register(PluginRegistry registry) { registry.registerWarningRule(ConfigRequiredRule()); - registry.registerFixForRule( - ConfigRequiredRule.code, - AddConfigConstantFix.new, - ); + registry.registerFixForRule(ConfigRequiredRule.code, ConfigRequiredFix.new); } } diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart index 653783ff..4a6a2c57 100644 --- a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart @@ -4,14 +4,14 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; -class AddConfigConstantFix extends ResolvedCorrectionProducer { +class ConfigRequiredFix extends ResolvedCorrectionProducer { static const FixKind _kind = FixKind( - 'orm.fix.addConfigConstant', + 'orm.fix.config_required', DartFixKindPriority.standard, "Define ORM config: const config = Config(...)", ); - AddConfigConstantFix({required super.context}); + ConfigRequiredFix({required super.context}); @override CorrectionApplicability get applicability => @@ -22,6 +22,10 @@ class AddConfigConstantFix extends ResolvedCorrectionProducer { @override Future compute(ChangeBuilder builder) async { + if (_hasConfigVariable(unit)) { + return; + } + final eol = utils.endOfLine; final configImport = _findConfigImport(unit); final prefix = configImport?.prefix?.name; @@ -70,6 +74,20 @@ class AddConfigConstantFix extends ResolvedCorrectionProducer { return null; } + bool _hasConfigVariable(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + for (final variable in declaration.variables.variables) { + if (variable.name.lexeme == 'config') { + return true; + } + } + } + return false; + } + int _configInsertOffset(CompilationUnit unit) { if (unit.directives.isEmpty) { return 0; diff --git a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart index 3448a282..42b92ecb 100644 --- a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart +++ b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart @@ -11,6 +11,7 @@ class ConfigRequiredRule extends AnalysisRule { "Missing required 'const config = Config(...);' in orm.config.dart.", correctionMessage: "Add a top-level 'const config = Config(...);' to orm.config.dart.", + severity: DiagnosticSeverity.ERROR, ); ConfigRequiredRule() @@ -57,6 +58,8 @@ class _Visitor extends SimpleAstVisitor { } bool _hasRequiredConfig(CompilationUnit unit) { + final configImport = _findConfigImport(unit); + final hasLocalConfig = _hasLocalConfigDeclaration(unit); for (final declaration in unit.declarations) { if (declaration is! TopLevelVariableDeclaration) { continue; @@ -74,11 +77,78 @@ class _Visitor extends SimpleAstVisitor { final initializer = variable.initializer; if (initializer is InstanceCreationExpression && - initializer.constructorName.type.name.lexeme == 'Config') { + _isOrmConfigInitializer( + initializer, + configImport, + hasLocalConfig, + )) { return true; } } } return false; } + + ImportDirective? _findConfigImport(CompilationUnit unit) { + for (final directive in unit.directives) { + if (directive is! ImportDirective) { + continue; + } + final uri = directive.uri.stringValue; + if (uri == 'package:orm/config.dart') { + return directive; + } + } + return null; + } + + bool _hasLocalConfigDeclaration(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is FunctionDeclaration) { + continue; + } + if (declaration is NamedCompilationUnitMember && + declaration.name.lexeme == 'Config') { + return true; + } + } + return false; + } + + bool _isOrmConfigInitializer( + InstanceCreationExpression initializer, + ImportDirective? configImport, + bool hasLocalConfig, + ) { + if (initializer.constructorName.type.name.lexeme != 'Config') { + return false; + } + + final ctorElement = initializer.constructorName.element; + final classElement = ctorElement?.enclosingElement; + final library = classElement?.library; + final libraryUri = library?.firstFragment.source.uri; + if (libraryUri != null) { + return libraryUri.scheme == 'package' && + libraryUri.path == 'orm/config.dart'; + } + + if (configImport == null) { + return false; + } + + final importPrefix = configImport.prefix?.name; + final usagePrefix = + initializer.constructorName.type.importPrefix?.name.lexeme; + + if (importPrefix != null) { + return usagePrefix == importPrefix; + } + + if (usagePrefix != null) { + return false; + } + + return !hasLocalConfig; + } } From d231b02a52649de1b7eb23cc8d700fcba25426d7 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:56:01 +0800 Subject: [PATCH 006/154] Add analyzer tests and refactor config fixes - Add test_reflective_loader and analyzer_testing dependencies - Create README for analyzer plugin layout - Extract common config fix logic into base class - Add config replacement fix for existing config variables - Add unit tests for config required rule and fix kinds --- playground/orm.config.dart | 5 + pub/orm/lib/main.dart | 5 + pub/orm/lib/src/analyzer/README.md | 25 +++ .../lib/src/analyzer/fixes/_config_fix.dart | 157 ++++++++++++++++++ .../analyzer/fixes/config_required_fix.dart | 107 +----------- .../fixes/config_required_replace_fix.dart | 37 +++++ pub/orm/pubspec.yaml | 2 + .../analyzer/config_required_fix_test.dart | 15 ++ .../analyzer/config_required_rule_test.dart | 96 +++++++++++ pubspec.lock | 16 ++ 10 files changed, 363 insertions(+), 102 deletions(-) create mode 100644 pub/orm/lib/src/analyzer/README.md create mode 100644 pub/orm/lib/src/analyzer/fixes/_config_fix.dart create mode 100644 pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart create mode 100644 pub/orm/test/analyzer/config_required_fix_test.dart create mode 100644 pub/orm/test/analyzer/config_required_rule_test.dart diff --git a/playground/orm.config.dart b/playground/orm.config.dart index da59cb86..585b8451 100644 --- a/playground/orm.config.dart +++ b/playground/orm.config.dart @@ -1 +1,6 @@ import 'package:orm/config.dart'; + +const config = Config( + provider: DatabaseProvider.sqlite, + output: '', // TODO: update output path +); diff --git a/pub/orm/lib/main.dart b/pub/orm/lib/main.dart index c63aed11..415eb44e 100644 --- a/pub/orm/lib/main.dart +++ b/pub/orm/lib/main.dart @@ -2,6 +2,7 @@ import 'package:analysis_server_plugin/plugin.dart'; import 'package:analysis_server_plugin/registry.dart'; import 'src/analyzer/fixes/config_required_fix.dart'; +import 'src/analyzer/fixes/config_required_replace_fix.dart'; import 'src/analyzer/rules/config_required_rule.dart'; class AnalysisPlugin extends Plugin { @@ -12,6 +13,10 @@ class AnalysisPlugin extends Plugin { void register(PluginRegistry registry) { registry.registerWarningRule(ConfigRequiredRule()); registry.registerFixForRule(ConfigRequiredRule.code, ConfigRequiredFix.new); + registry.registerFixForRule( + ConfigRequiredRule.code, + ConfigRequiredReplaceFix.new, + ); } } diff --git a/pub/orm/lib/src/analyzer/README.md b/pub/orm/lib/src/analyzer/README.md new file mode 100644 index 00000000..e3ef1149 --- /dev/null +++ b/pub/orm/lib/src/analyzer/README.md @@ -0,0 +1,25 @@ +# Analyzer plugin layout + +This directory holds analyzer-plugin logic for the ORM package. + +Structure +- `rules/`: analysis rules (diagnostics). +- `fixes/`: quick fixes for rule diagnostics. +- `assists/`: assists not tied to diagnostics. + +Naming +- Rule file: `*_rule.dart` (class `*Rule`). +- Fix file: `*_fix.dart` (class `*Fix`). +- Assist file: `*_assist.dart` (class `*Assist`). + +Identifiers +- Rule name: `orm_`. +- Fix id: `orm.fix.`. +- Assist id: `orm.assist.`. + +Registration +- Register rules and fixes in `lib/main.dart` via `PluginRegistry`. + +Tests +- Place tests under `test/analyzer/`. +- Use `analyzer_testing` + `test_reflective_loader` for rule tests. diff --git a/pub/orm/lib/src/analyzer/fixes/_config_fix.dart b/pub/orm/lib/src/analyzer/fixes/_config_fix.dart new file mode 100644 index 00000000..15def782 --- /dev/null +++ b/pub/orm/lib/src/analyzer/fixes/_config_fix.dart @@ -0,0 +1,157 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/source/source_range.dart'; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; + +abstract class ConfigFix extends ResolvedCorrectionProducer { + ConfigFix({required super.context}); + + ImportDirective? findConfigImport(CompilationUnit unit) { + for (final directive in unit.directives) { + if (directive is! ImportDirective) { + continue; + } + final uri = directive.uri.stringValue; + if (uri == 'package:orm/config.dart') { + return directive; + } + } + return null; + } + + ConfigVariableInfo? findConfigVariable(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + for (final variable in declaration.variables.variables) { + if (variable.name.lexeme == 'config') { + return ConfigVariableInfo(declaration, variable); + } + } + } + return null; + } + + int configInsertOffset(CompilationUnit unit) { + if (unit.directives.isEmpty) { + return 0; + } + return utils.getLineNext(unit.directives.last.end); + } + + int importInsertOffset(CompilationUnit unit) { + ImportDirective? lastImport; + LibraryDirective? libraryDirective; + for (final directive in unit.directives) { + if (directive is LibraryDirective) { + libraryDirective = directive; + } else if (directive is ImportDirective) { + lastImport = directive; + } + } + + final anchor = lastImport ?? libraryDirective; + if (anchor == null) { + return 0; + } + return utils.getLineNext(anchor.end); + } + + String buildImportText(String eol) => "import 'package:orm/config.dart';$eol"; + + String buildConfigText(String? prefix, String eol) { + final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; + final provider = qualifier.isNotEmpty + ? '${qualifier}DatabaseProvider.sqlite' + : '.sqlite'; + return [ + 'const config = ${qualifier}Config(', + '${utils.oneIndent}$provider,', + "${utils.oneIndent}output: '', // TODO: update output path", + ');', + ].join(eol); + } + + Future insertConfig( + ChangeBuilder builder, { + required int configDeclOffset, + }) async { + final eol = utils.endOfLine; + final configImport = findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final importOffset = importInsertOffset(unit); + final configText = buildConfigText(prefix, eol); + final importText = buildImportText(eol); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importOffset == configDeclOffset) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + }); + } + + builder.addInsertion(configDeclOffset, (builder) { + builder.write(configText); + }); + }); + } + + Future replaceConfig( + ChangeBuilder builder, { + required ConfigVariableInfo info, + }) async { + final eol = utils.endOfLine; + final configImport = findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final importOffset = importInsertOffset(unit); + final configText = buildConfigText(prefix, eol); + final importText = buildImportText(eol); + + final declaration = info.declaration; + final replaceRange = SourceRange(declaration.offset, declaration.length); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importOffset == replaceRange.offset) { + builder.addReplacement(replaceRange, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + }); + } + + builder.addReplacement(replaceRange, (builder) { + builder.write(configText); + }); + }); + } +} + +class ConfigVariableInfo { + final TopLevelVariableDeclaration declaration; + final VariableDeclaration variable; + + ConfigVariableInfo(this.declaration, this.variable); + + bool get isSingle => declaration.variables.variables.length == 1; +} diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart index 4a6a2c57..c72c2f68 100644 --- a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart @@ -1,10 +1,11 @@ import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; -import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; -class ConfigRequiredFix extends ResolvedCorrectionProducer { +import '_config_fix.dart'; + +class ConfigRequiredFix extends ConfigFix { static const FixKind _kind = FixKind( 'orm.fix.config_required', DartFixKindPriority.standard, @@ -22,108 +23,10 @@ class ConfigRequiredFix extends ResolvedCorrectionProducer { @override Future compute(ChangeBuilder builder) async { - if (_hasConfigVariable(unit)) { + if (findConfigVariable(unit) != null) { return; } - final eol = utils.endOfLine; - final configImport = _findConfigImport(unit); - final prefix = configImport?.prefix?.name; - final needsImport = configImport == null; - - final configInsertOffset = _configInsertOffset(unit); - final importInsertOffset = _importInsertOffset(unit); - final configText = _buildConfigText(prefix, eol); - final importText = _buildImportText(eol); - - await builder.addDartFileEdit(file, (builder) { - if (needsImport && importInsertOffset == configInsertOffset) { - builder.addInsertion(importInsertOffset, (builder) { - builder.write(importText); - builder.write(eol); - builder.write(configText); - }); - return; - } - - if (needsImport) { - builder.addInsertion(importInsertOffset, (builder) { - builder.write(importText); - }); - } - - builder.addInsertion(configInsertOffset, (builder) { - if (configInsertOffset != 0 && needsImport == false) { - builder.write(eol); - } - builder.write(configText); - }); - }); - } - - ImportDirective? _findConfigImport(CompilationUnit unit) { - for (final directive in unit.directives) { - if (directive is! ImportDirective) { - continue; - } - final uri = directive.uri.stringValue; - if (uri == 'package:orm/config.dart') { - return directive; - } - } - return null; - } - - bool _hasConfigVariable(CompilationUnit unit) { - for (final declaration in unit.declarations) { - if (declaration is! TopLevelVariableDeclaration) { - continue; - } - for (final variable in declaration.variables.variables) { - if (variable.name.lexeme == 'config') { - return true; - } - } - } - return false; - } - - int _configInsertOffset(CompilationUnit unit) { - if (unit.directives.isEmpty) { - return 0; - } - return utils.getLineNext(unit.directives.last.end); - } - - int _importInsertOffset(CompilationUnit unit) { - ImportDirective? lastImport; - LibraryDirective? libraryDirective; - for (final directive in unit.directives) { - if (directive is LibraryDirective) { - libraryDirective = directive; - } else if (directive is ImportDirective) { - lastImport = directive; - } - } - - final anchor = lastImport ?? libraryDirective; - if (anchor == null) { - return 0; - } - return utils.getLineNext(anchor.end); - } - - String _buildImportText(String eol) => - "import 'package:orm/config.dart';$eol"; - - String _buildConfigText(String? prefix, String eol) { - final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; - return [ - 'const config = ${qualifier}Config(', - '${utils.oneIndent}provider: $qualifier${qualifier.isNotEmpty ? 'DatabaseProvider' : ''}.sqlite,', - "${utils.oneIndent}output: '', // TODO: update output path", - ');', - '', - ].join(eol); + await insertConfig(builder, configDeclOffset: configInsertOffset(unit)); } } diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart new file mode 100644 index 00000000..8b45134c --- /dev/null +++ b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart @@ -0,0 +1,37 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; + +import '_config_fix.dart'; + +class ConfigRequiredReplaceFix extends ConfigFix { + static const FixKind _kind = FixKind( + 'orm.fix.config_required_replace', + DartFixKindPriority.standard, + "Replace ORM config: const config = Config(...)", + ); + + ConfigRequiredReplaceFix({required super.context}); + + @override + CorrectionApplicability get applicability => + CorrectionApplicability.singleLocation; + + @override + FixKind get fixKind => _kind; + + @override + Future compute(ChangeBuilder builder) async { + final info = findConfigVariable(unit); + if (info == null) { + return; + } + + if (!info.isSingle || info.declaration.metadata.isNotEmpty) { + return; + } + + await replaceConfig(builder, info: info); + } +} diff --git a/pub/orm/pubspec.yaml b/pub/orm/pubspec.yaml index d71ae321..59cfa70c 100644 --- a/pub/orm/pubspec.yaml +++ b/pub/orm/pubspec.yaml @@ -14,5 +14,7 @@ dependencies: analyzer_plugin: ^0.13.11 dev_dependencies: + analyzer_testing: ^0.1.7 lints: ^6.0.0 test: ^1.25.6 + test_reflective_loader: ^0.4.0 diff --git a/pub/orm/test/analyzer/config_required_fix_test.dart b/pub/orm/test/analyzer/config_required_fix_test.dart new file mode 100644 index 00000000..b604675e --- /dev/null +++ b/pub/orm/test/analyzer/config_required_fix_test.dart @@ -0,0 +1,15 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:orm/src/analyzer/fixes/config_required_fix.dart'; +import 'package:orm/src/analyzer/fixes/config_required_replace_fix.dart'; +import 'package:test/test.dart'; + +void main() { + test('fix kind ids', () { + final context = StubCorrectionProducerContext.instance; + final fix = ConfigRequiredFix(context: context); + final replaceFix = ConfigRequiredReplaceFix(context: context); + + expect(fix.fixKind.id, 'orm.fix.config_required'); + expect(replaceFix.fixKind.id, 'orm.fix.config_required_replace'); + }); +} diff --git a/pub/orm/test/analyzer/config_required_rule_test.dart b/pub/orm/test/analyzer/config_required_rule_test.dart new file mode 100644 index 00000000..2b5331c4 --- /dev/null +++ b/pub/orm/test/analyzer/config_required_rule_test.dart @@ -0,0 +1,96 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:analyzer/src/lint/registry.dart'; +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:orm/src/analyzer/rules/config_required_rule.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +@reflectiveTest +class ConfigRequiredRuleTest extends AnalysisRuleTest { + @override + String get analysisRule => 'orm_config_required'; + + @override + void setUp() { + Registry.ruleRegistry.registerWarningRule(ConfigRequiredRule()); + super.setUp(); + newPubspecYamlFile(testPackageRootPath, 'name: orm\n'); + newFile(join(testPackageRootPath, 'lib', 'config.dart'), r''' +enum DatabaseProvider { sqlite } + +class Config { + final DatabaseProvider provider; + final String output; + + const Config({required this.provider, required this.output}); +} +'''); + } + + Future _assertMissingConfig(String content) async { + final path = join(testPackageRootPath, 'orm.config.dart'); + newFile(path, content); + await assertDiagnosticsInFile(path, [lint(0, content.length)]); + } + + Future _assertValidConfig(String content) async { + final path = join(testPackageRootPath, 'orm.config.dart'); + newFile(path, content); + await assertNoDiagnosticsInFile(path); + } + + void test_missingConfig() async { + await _assertMissingConfig(r''' +import 'package:orm/config.dart'; +'''); + } + + void test_validConfig() async { + await _assertValidConfig(r''' +import 'package:orm/config.dart'; + +const config = Config( + provider: DatabaseProvider.sqlite, + output: '', +); +'''); + } + + void test_prefixedImport() async { + await _assertValidConfig(r''' +import 'package:orm/config.dart' as orm; + +const config = orm.Config( + provider: orm.DatabaseProvider.sqlite, + output: '', +); +'''); + } + + void test_localConfigClass() async { + await _assertMissingConfig(r''' +class Config { + const Config(); +} + +const config = Config(); +'''); + } + + void test_notConst() async { + await _assertMissingConfig(r''' +import 'package:orm/config.dart'; + +final config = Config( + provider: DatabaseProvider.sqlite, + output: '', +); +'''); + } +} + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(ConfigRequiredRuleTest); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 3c375cd5..79307b81 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.11" + analyzer_testing: + dependency: transitive + description: + name: analyzer_testing + sha256: "900f868c2391080f4877ee2eef69e80c47202f0f9321618a04acdbad5c93c69b" + url: "https://pub.dev" + source: hosted + version: "0.1.7" args: dependency: transitive description: @@ -345,6 +353,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.14" + test_reflective_loader: + dependency: transitive + description: + name: test_reflective_loader + sha256: d828d5ca15179aaac2aaf8f510cf0a52ec28e0031681b044ec5e581a4b8002e7 + url: "https://pub.dev" + source: hosted + version: "0.4.0" typed_data: dependency: transitive description: From 89f1e849c3b3f384910dd8ff275b2bad0d590126 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 23:01:20 +0800 Subject: [PATCH 007/154] Update config provider syntax to use named parameter --- playground/orm.config.dart | 2 +- pub/orm/lib/src/analyzer/fixes/_config_fix.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/orm.config.dart b/playground/orm.config.dart index 585b8451..6ffb183a 100644 --- a/playground/orm.config.dart +++ b/playground/orm.config.dart @@ -1,6 +1,6 @@ import 'package:orm/config.dart'; const config = Config( - provider: DatabaseProvider.sqlite, + provider: .sqlite, output: '', // TODO: update output path ); diff --git a/pub/orm/lib/src/analyzer/fixes/_config_fix.dart b/pub/orm/lib/src/analyzer/fixes/_config_fix.dart index 15def782..eecf01b5 100644 --- a/pub/orm/lib/src/analyzer/fixes/_config_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/_config_fix.dart @@ -67,7 +67,7 @@ abstract class ConfigFix extends ResolvedCorrectionProducer { : '.sqlite'; return [ 'const config = ${qualifier}Config(', - '${utils.oneIndent}$provider,', + '${utils.oneIndent}provider: $provider,', "${utils.oneIndent}output: '', // TODO: update output path", ');', ].join(eol); From d82590f11161278eb414644d9d4b45a5eddc0f4c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 23:14:29 +0800 Subject: [PATCH 008/154] Refactor config fix utilities into a mixin Extract common config helper methods from ConfigFix base class into ConfigUtils mixin Update ConfigRequiredFix and ConfigRequiredReplaceFix to use the mixin instead of inheritance Remove ConfigFix base class as it's no longer needed --- .../lib/src/analyzer/fixes/_config_fix.dart | 157 ------------------ .../lib/src/analyzer/fixes/_config_utils.dart | 81 +++++++++ .../analyzer/fixes/config_required_fix.dart | 44 ++++- .../fixes/config_required_replace_fix.dart | 51 +++++- .../analyzer/rules/config_required_rule.dart | 4 +- 5 files changed, 166 insertions(+), 171 deletions(-) delete mode 100644 pub/orm/lib/src/analyzer/fixes/_config_fix.dart create mode 100644 pub/orm/lib/src/analyzer/fixes/_config_utils.dart diff --git a/pub/orm/lib/src/analyzer/fixes/_config_fix.dart b/pub/orm/lib/src/analyzer/fixes/_config_fix.dart deleted file mode 100644 index eecf01b5..00000000 --- a/pub/orm/lib/src/analyzer/fixes/_config_fix.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/source_range.dart'; -import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; - -abstract class ConfigFix extends ResolvedCorrectionProducer { - ConfigFix({required super.context}); - - ImportDirective? findConfigImport(CompilationUnit unit) { - for (final directive in unit.directives) { - if (directive is! ImportDirective) { - continue; - } - final uri = directive.uri.stringValue; - if (uri == 'package:orm/config.dart') { - return directive; - } - } - return null; - } - - ConfigVariableInfo? findConfigVariable(CompilationUnit unit) { - for (final declaration in unit.declarations) { - if (declaration is! TopLevelVariableDeclaration) { - continue; - } - for (final variable in declaration.variables.variables) { - if (variable.name.lexeme == 'config') { - return ConfigVariableInfo(declaration, variable); - } - } - } - return null; - } - - int configInsertOffset(CompilationUnit unit) { - if (unit.directives.isEmpty) { - return 0; - } - return utils.getLineNext(unit.directives.last.end); - } - - int importInsertOffset(CompilationUnit unit) { - ImportDirective? lastImport; - LibraryDirective? libraryDirective; - for (final directive in unit.directives) { - if (directive is LibraryDirective) { - libraryDirective = directive; - } else if (directive is ImportDirective) { - lastImport = directive; - } - } - - final anchor = lastImport ?? libraryDirective; - if (anchor == null) { - return 0; - } - return utils.getLineNext(anchor.end); - } - - String buildImportText(String eol) => "import 'package:orm/config.dart';$eol"; - - String buildConfigText(String? prefix, String eol) { - final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; - final provider = qualifier.isNotEmpty - ? '${qualifier}DatabaseProvider.sqlite' - : '.sqlite'; - return [ - 'const config = ${qualifier}Config(', - '${utils.oneIndent}provider: $provider,', - "${utils.oneIndent}output: '', // TODO: update output path", - ');', - ].join(eol); - } - - Future insertConfig( - ChangeBuilder builder, { - required int configDeclOffset, - }) async { - final eol = utils.endOfLine; - final configImport = findConfigImport(unit); - final prefix = configImport?.prefix?.name; - final needsImport = configImport == null; - - final importOffset = importInsertOffset(unit); - final configText = buildConfigText(prefix, eol); - final importText = buildImportText(eol); - - await builder.addDartFileEdit(file, (builder) { - if (needsImport && importOffset == configDeclOffset) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - builder.write(eol); - builder.write(configText); - }); - return; - } - - if (needsImport) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - }); - } - - builder.addInsertion(configDeclOffset, (builder) { - builder.write(configText); - }); - }); - } - - Future replaceConfig( - ChangeBuilder builder, { - required ConfigVariableInfo info, - }) async { - final eol = utils.endOfLine; - final configImport = findConfigImport(unit); - final prefix = configImport?.prefix?.name; - final needsImport = configImport == null; - - final importOffset = importInsertOffset(unit); - final configText = buildConfigText(prefix, eol); - final importText = buildImportText(eol); - - final declaration = info.declaration; - final replaceRange = SourceRange(declaration.offset, declaration.length); - - await builder.addDartFileEdit(file, (builder) { - if (needsImport && importOffset == replaceRange.offset) { - builder.addReplacement(replaceRange, (builder) { - builder.write(importText); - builder.write(eol); - builder.write(configText); - }); - return; - } - - if (needsImport) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - }); - } - - builder.addReplacement(replaceRange, (builder) { - builder.write(configText); - }); - }); - } -} - -class ConfigVariableInfo { - final TopLevelVariableDeclaration declaration; - final VariableDeclaration variable; - - ConfigVariableInfo(this.declaration, this.variable); - - bool get isSingle => declaration.variables.variables.length == 1; -} diff --git a/pub/orm/lib/src/analyzer/fixes/_config_utils.dart b/pub/orm/lib/src/analyzer/fixes/_config_utils.dart new file mode 100644 index 00000000..f6b4a252 --- /dev/null +++ b/pub/orm/lib/src/analyzer/fixes/_config_utils.dart @@ -0,0 +1,81 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analyzer/dart/ast/ast.dart'; + +mixin ConfigUtils on ResolvedCorrectionProducer { + ImportDirective? findConfigImport(CompilationUnit unit) { + for (final directive in unit.directives) { + if (directive is! ImportDirective) { + continue; + } + final uri = directive.uri.stringValue; + if (uri == 'package:orm/config.dart') { + return directive; + } + } + return null; + } + + ConfigVariableInfo? findConfigVariable(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + for (final variable in declaration.variables.variables) { + if (variable.name.lexeme == 'config') { + return .new(declaration, variable); + } + } + } + return null; + } + + int configInsertOffset(CompilationUnit unit) { + if (unit.directives.isEmpty) { + return 0; + } + return utils.getLineNext(unit.directives.last.end); + } + + int importInsertOffset(CompilationUnit unit) { + ImportDirective? lastImport; + LibraryDirective? libraryDirective; + for (final directive in unit.directives) { + if (directive is LibraryDirective) { + libraryDirective = directive; + } else if (directive is ImportDirective) { + lastImport = directive; + } + } + + final anchor = lastImport ?? libraryDirective; + if (anchor == null) { + return 0; + } + return utils.getLineNext(anchor.end); + } + + String buildImportText(String eol) => "import 'package:orm/config.dart';$eol"; + + String buildConfigText(String? prefix, String eol) { + final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; + final provider = qualifier.isEmpty + ? '.sqlite' + : '${qualifier}DatabaseProvider.sqlite'; + return [ + 'const config = ${qualifier}Config(', + '${utils.oneIndent}provider: $provider,', + "${utils.oneIndent}output: '', // TODO: update output path", + ');', + '', + ].join(eol); + } +} + +class ConfigVariableInfo { + final TopLevelVariableDeclaration declaration; + final VariableDeclaration variable; + + ConfigVariableInfo(this.declaration, this.variable); + + bool get isSingle => declaration.variables.variables.length == 1; +} diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart index c72c2f68..0dfc71c5 100644 --- a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart @@ -3,10 +3,10 @@ import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; -import '_config_fix.dart'; +import '_config_utils.dart'; -class ConfigRequiredFix extends ConfigFix { - static const FixKind _kind = FixKind( +class ConfigRequiredFix extends ResolvedCorrectionProducer with ConfigUtils { + static const _kind = FixKind( 'orm.fix.config_required', DartFixKindPriority.standard, "Define ORM config: const config = Config(...)", @@ -15,8 +15,7 @@ class ConfigRequiredFix extends ConfigFix { ConfigRequiredFix({required super.context}); @override - CorrectionApplicability get applicability => - CorrectionApplicability.singleLocation; + CorrectionApplicability get applicability => .singleLocation; @override FixKind get fixKind => _kind; @@ -27,6 +26,39 @@ class ConfigRequiredFix extends ConfigFix { return; } - await insertConfig(builder, configDeclOffset: configInsertOffset(unit)); + await _insertConfig(builder); + } + + Future _insertConfig(ChangeBuilder builder) async { + final eol = utils.endOfLine; + final configImport = findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final importOffset = importInsertOffset(unit); + final configDeclOffset = configInsertOffset(unit); + final configText = buildConfigText(prefix, eol); + final importText = buildImportText(eol); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importOffset == configDeclOffset) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + }); + } + + builder.addInsertion(configDeclOffset, (builder) { + builder.write(configText); + }); + }); } } diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart index 8b45134c..59465d41 100644 --- a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart @@ -1,12 +1,14 @@ import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; +import 'package:analyzer/source/source_range.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; -import '_config_fix.dart'; +import '_config_utils.dart'; -class ConfigRequiredReplaceFix extends ConfigFix { - static const FixKind _kind = FixKind( +class ConfigRequiredReplaceFix extends ResolvedCorrectionProducer + with ConfigUtils { + static const _kind = FixKind( 'orm.fix.config_required_replace', DartFixKindPriority.standard, "Replace ORM config: const config = Config(...)", @@ -15,8 +17,7 @@ class ConfigRequiredReplaceFix extends ConfigFix { ConfigRequiredReplaceFix({required super.context}); @override - CorrectionApplicability get applicability => - CorrectionApplicability.singleLocation; + CorrectionApplicability get applicability => .singleLocation; @override FixKind get fixKind => _kind; @@ -32,6 +33,44 @@ class ConfigRequiredReplaceFix extends ConfigFix { return; } - await replaceConfig(builder, info: info); + await _replaceConfig(builder, info: info); + } + + Future _replaceConfig( + ChangeBuilder builder, { + required ConfigVariableInfo info, + }) async { + final eol = utils.endOfLine; + final configImport = findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final importOffset = importInsertOffset(unit); + final configText = buildConfigText(prefix, eol); + final importText = buildImportText(eol); + + final declaration = info.declaration; + final replaceRange = SourceRange(declaration.offset, declaration.length); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importOffset == replaceRange.offset) { + builder.addReplacement(replaceRange, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + }); + } + + builder.addReplacement(replaceRange, (builder) { + builder.write(configText); + }); + }); } } diff --git a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart index 42b92ecb..605eefb5 100644 --- a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart +++ b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart @@ -6,12 +6,12 @@ import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; class ConfigRequiredRule extends AnalysisRule { - static const LintCode code = LintCode( + static const code = LintCode( 'orm_config_required', "Missing required 'const config = Config(...);' in orm.config.dart.", correctionMessage: "Add a top-level 'const config = Config(...);' to orm.config.dart.", - severity: DiagnosticSeverity.ERROR, + severity: .ERROR, ); ConfigRequiredRule() From 1834bdac6e627f7d9b7bdf8509526c4e1909dcb1 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sun, 4 Jan 2026 23:35:21 +0800 Subject: [PATCH 009/154] Refactor analyzer config utilities into shared modules --- pub/orm/lib/src/analyzer/README.md | 1 + .../lib/src/analyzer/fixes/_config_utils.dart | 81 ---------- .../analyzer/fixes/config_required_fix.dart | 47 +----- .../fixes/config_required_replace_fix.dart | 53 +------ .../analyzer/rules/config_required_rule.dart | 54 ++----- .../analyzer/utils/analyzer_constants.dart | 15 ++ .../src/analyzer/utils/config_ast_utils.dart | 33 ++++ .../lib/src/analyzer/utils/config_utils.dart | 150 ++++++++++++++++++ .../analyzer/config_required_fix_test.dart | 15 -- .../analyzer/config_required_rule_test.dart | 96 ----------- 10 files changed, 230 insertions(+), 315 deletions(-) delete mode 100644 pub/orm/lib/src/analyzer/fixes/_config_utils.dart create mode 100644 pub/orm/lib/src/analyzer/utils/analyzer_constants.dart create mode 100644 pub/orm/lib/src/analyzer/utils/config_ast_utils.dart create mode 100644 pub/orm/lib/src/analyzer/utils/config_utils.dart delete mode 100644 pub/orm/test/analyzer/config_required_fix_test.dart delete mode 100644 pub/orm/test/analyzer/config_required_rule_test.dart diff --git a/pub/orm/lib/src/analyzer/README.md b/pub/orm/lib/src/analyzer/README.md index e3ef1149..d920c28b 100644 --- a/pub/orm/lib/src/analyzer/README.md +++ b/pub/orm/lib/src/analyzer/README.md @@ -6,6 +6,7 @@ Structure - `rules/`: analysis rules (diagnostics). - `fixes/`: quick fixes for rule diagnostics. - `assists/`: assists not tied to diagnostics. +- `utils/`: shared helpers and constants. Naming - Rule file: `*_rule.dart` (class `*Rule`). diff --git a/pub/orm/lib/src/analyzer/fixes/_config_utils.dart b/pub/orm/lib/src/analyzer/fixes/_config_utils.dart deleted file mode 100644 index f6b4a252..00000000 --- a/pub/orm/lib/src/analyzer/fixes/_config_utils.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; -import 'package:analyzer/dart/ast/ast.dart'; - -mixin ConfigUtils on ResolvedCorrectionProducer { - ImportDirective? findConfigImport(CompilationUnit unit) { - for (final directive in unit.directives) { - if (directive is! ImportDirective) { - continue; - } - final uri = directive.uri.stringValue; - if (uri == 'package:orm/config.dart') { - return directive; - } - } - return null; - } - - ConfigVariableInfo? findConfigVariable(CompilationUnit unit) { - for (final declaration in unit.declarations) { - if (declaration is! TopLevelVariableDeclaration) { - continue; - } - for (final variable in declaration.variables.variables) { - if (variable.name.lexeme == 'config') { - return .new(declaration, variable); - } - } - } - return null; - } - - int configInsertOffset(CompilationUnit unit) { - if (unit.directives.isEmpty) { - return 0; - } - return utils.getLineNext(unit.directives.last.end); - } - - int importInsertOffset(CompilationUnit unit) { - ImportDirective? lastImport; - LibraryDirective? libraryDirective; - for (final directive in unit.directives) { - if (directive is LibraryDirective) { - libraryDirective = directive; - } else if (directive is ImportDirective) { - lastImport = directive; - } - } - - final anchor = lastImport ?? libraryDirective; - if (anchor == null) { - return 0; - } - return utils.getLineNext(anchor.end); - } - - String buildImportText(String eol) => "import 'package:orm/config.dart';$eol"; - - String buildConfigText(String? prefix, String eol) { - final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; - final provider = qualifier.isEmpty - ? '.sqlite' - : '${qualifier}DatabaseProvider.sqlite'; - return [ - 'const config = ${qualifier}Config(', - '${utils.oneIndent}provider: $provider,', - "${utils.oneIndent}output: '', // TODO: update output path", - ');', - '', - ].join(eol); - } -} - -class ConfigVariableInfo { - final TopLevelVariableDeclaration declaration; - final VariableDeclaration variable; - - ConfigVariableInfo(this.declaration, this.variable); - - bool get isSingle => declaration.variables.variables.length == 1; -} diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart index 0dfc71c5..f233d372 100644 --- a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart @@ -3,19 +3,21 @@ import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; -import '_config_utils.dart'; +import '../utils/analyzer_constants.dart'; +import '../utils/config_utils.dart'; class ConfigRequiredFix extends ResolvedCorrectionProducer with ConfigUtils { - static const _kind = FixKind( - 'orm.fix.config_required', + static const FixKind _kind = FixKind( + configFixIdRequired, DartFixKindPriority.standard, - "Define ORM config: const config = Config(...)", + configFixMessageDefine, ); ConfigRequiredFix({required super.context}); @override - CorrectionApplicability get applicability => .singleLocation; + CorrectionApplicability get applicability => + CorrectionApplicability.singleLocation; @override FixKind get fixKind => _kind; @@ -26,39 +28,6 @@ class ConfigRequiredFix extends ResolvedCorrectionProducer with ConfigUtils { return; } - await _insertConfig(builder); - } - - Future _insertConfig(ChangeBuilder builder) async { - final eol = utils.endOfLine; - final configImport = findConfigImport(unit); - final prefix = configImport?.prefix?.name; - final needsImport = configImport == null; - - final importOffset = importInsertOffset(unit); - final configDeclOffset = configInsertOffset(unit); - final configText = buildConfigText(prefix, eol); - final importText = buildImportText(eol); - - await builder.addDartFileEdit(file, (builder) { - if (needsImport && importOffset == configDeclOffset) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - builder.write(eol); - builder.write(configText); - }); - return; - } - - if (needsImport) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - }); - } - - builder.addInsertion(configDeclOffset, (builder) { - builder.write(configText); - }); - }); + await insertConfig(builder, configDeclOffset: configInsertOffset(unit)); } } diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart index 59465d41..73b3f69a 100644 --- a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart @@ -1,23 +1,24 @@ import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; -import 'package:analyzer/source/source_range.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; -import '_config_utils.dart'; +import '../utils/analyzer_constants.dart'; +import '../utils/config_utils.dart'; class ConfigRequiredReplaceFix extends ResolvedCorrectionProducer with ConfigUtils { - static const _kind = FixKind( - 'orm.fix.config_required_replace', + static const FixKind _kind = FixKind( + configFixIdRequiredReplace, DartFixKindPriority.standard, - "Replace ORM config: const config = Config(...)", + configFixMessageReplace, ); ConfigRequiredReplaceFix({required super.context}); @override - CorrectionApplicability get applicability => .singleLocation; + CorrectionApplicability get applicability => + CorrectionApplicability.singleLocation; @override FixKind get fixKind => _kind; @@ -33,44 +34,6 @@ class ConfigRequiredReplaceFix extends ResolvedCorrectionProducer return; } - await _replaceConfig(builder, info: info); - } - - Future _replaceConfig( - ChangeBuilder builder, { - required ConfigVariableInfo info, - }) async { - final eol = utils.endOfLine; - final configImport = findConfigImport(unit); - final prefix = configImport?.prefix?.name; - final needsImport = configImport == null; - - final importOffset = importInsertOffset(unit); - final configText = buildConfigText(prefix, eol); - final importText = buildImportText(eol); - - final declaration = info.declaration; - final replaceRange = SourceRange(declaration.offset, declaration.length); - - await builder.addDartFileEdit(file, (builder) { - if (needsImport && importOffset == replaceRange.offset) { - builder.addReplacement(replaceRange, (builder) { - builder.write(importText); - builder.write(eol); - builder.write(configText); - }); - return; - } - - if (needsImport) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - }); - } - - builder.addReplacement(replaceRange, (builder) { - builder.write(configText); - }); - }); + await replaceConfig(builder, info: info); } } diff --git a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart index 605eefb5..e8547b1d 100644 --- a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart +++ b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart @@ -5,18 +5,21 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; +import '../utils/analyzer_constants.dart'; +import '../utils/config_ast_utils.dart'; + class ConfigRequiredRule extends AnalysisRule { - static const code = LintCode( - 'orm_config_required', - "Missing required 'const config = Config(...);' in orm.config.dart.", + static const LintCode code = LintCode( + configRequiredRuleName, + "Missing required '$configFixSnippet' in $ormConfigFileName.", correctionMessage: - "Add a top-level 'const config = Config(...);' to orm.config.dart.", - severity: .ERROR, + "Add a top-level '$configFixSnippet' to $ormConfigFileName.", + severity: DiagnosticSeverity.ERROR, ); ConfigRequiredRule() : super( - name: 'orm_config_required', + name: configRequiredRuleName, description: 'Ensures orm.config.dart defines a top-level const Config.', ); @@ -47,7 +50,7 @@ class _Visitor extends SimpleAstVisitor { return; } - final configFile = packageRoot.getChildAssumingFile('orm.config.dart'); + final configFile = packageRoot.getChildAssumingFile(ormConfigFileName); if (currentUnit.file.path != configFile.path) { return; } @@ -58,8 +61,8 @@ class _Visitor extends SimpleAstVisitor { } bool _hasRequiredConfig(CompilationUnit unit) { - final configImport = _findConfigImport(unit); - final hasLocalConfig = _hasLocalConfigDeclaration(unit); + final configImport = findOrmConfigImport(unit); + final hasLocalConfig = hasLocalConfigDeclaration(unit); for (final declaration in unit.declarations) { if (declaration is! TopLevelVariableDeclaration) { continue; @@ -71,7 +74,7 @@ class _Visitor extends SimpleAstVisitor { } for (final variable in variables.variables) { - if (variable.name.lexeme != 'config') { + if (!isConfigVariable(variable)) { continue; } @@ -89,38 +92,12 @@ class _Visitor extends SimpleAstVisitor { return false; } - ImportDirective? _findConfigImport(CompilationUnit unit) { - for (final directive in unit.directives) { - if (directive is! ImportDirective) { - continue; - } - final uri = directive.uri.stringValue; - if (uri == 'package:orm/config.dart') { - return directive; - } - } - return null; - } - - bool _hasLocalConfigDeclaration(CompilationUnit unit) { - for (final declaration in unit.declarations) { - if (declaration is FunctionDeclaration) { - continue; - } - if (declaration is NamedCompilationUnitMember && - declaration.name.lexeme == 'Config') { - return true; - } - } - return false; - } - bool _isOrmConfigInitializer( InstanceCreationExpression initializer, ImportDirective? configImport, bool hasLocalConfig, ) { - if (initializer.constructorName.type.name.lexeme != 'Config') { + if (initializer.constructorName.type.name.lexeme != configClassName) { return false; } @@ -129,8 +106,7 @@ class _Visitor extends SimpleAstVisitor { final library = classElement?.library; final libraryUri = library?.firstFragment.source.uri; if (libraryUri != null) { - return libraryUri.scheme == 'package' && - libraryUri.path == 'orm/config.dart'; + return libraryUri.toString() == ormConfigImportUri; } if (configImport == null) { diff --git a/pub/orm/lib/src/analyzer/utils/analyzer_constants.dart b/pub/orm/lib/src/analyzer/utils/analyzer_constants.dart new file mode 100644 index 00000000..dad70576 --- /dev/null +++ b/pub/orm/lib/src/analyzer/utils/analyzer_constants.dart @@ -0,0 +1,15 @@ +const String ormConfigFileName = 'orm.config.dart'; +const String ormConfigImportUri = 'package:orm/config.dart'; +const String configClassName = 'Config'; +const String configVariableName = 'config'; + +const String configRequiredRuleName = 'orm_config_required'; + +const String configFixSnippet = + 'const $configVariableName = $configClassName(...)'; +const String configFixMessageDefine = 'Define ORM config: $configFixSnippet'; +const String configFixMessageReplace = + 'Replace ORM config: $configFixSnippet'; + +const String configFixIdRequired = 'orm.fix.config_required'; +const String configFixIdRequiredReplace = 'orm.fix.config_required_replace'; diff --git a/pub/orm/lib/src/analyzer/utils/config_ast_utils.dart b/pub/orm/lib/src/analyzer/utils/config_ast_utils.dart new file mode 100644 index 00000000..96060fe1 --- /dev/null +++ b/pub/orm/lib/src/analyzer/utils/config_ast_utils.dart @@ -0,0 +1,33 @@ +import 'package:analyzer/dart/ast/ast.dart'; + +import 'analyzer_constants.dart'; + +ImportDirective? findOrmConfigImport(CompilationUnit unit) { + for (final directive in unit.directives) { + if (directive is! ImportDirective) { + continue; + } + final uri = directive.uri.stringValue; + if (uri == ormConfigImportUri) { + return directive; + } + } + return null; +} + +bool hasLocalConfigDeclaration(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is FunctionDeclaration) { + continue; + } + if (declaration is NamedCompilationUnitMember && + declaration.name.lexeme == configClassName) { + return true; + } + } + return false; +} + +bool isConfigVariable(VariableDeclaration variable) { + return variable.name.lexeme == configVariableName; +} diff --git a/pub/orm/lib/src/analyzer/utils/config_utils.dart b/pub/orm/lib/src/analyzer/utils/config_utils.dart new file mode 100644 index 00000000..644fec88 --- /dev/null +++ b/pub/orm/lib/src/analyzer/utils/config_utils.dart @@ -0,0 +1,150 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/source/source_range.dart'; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; + +import 'analyzer_constants.dart'; +import 'config_ast_utils.dart'; + +mixin ConfigUtils on ResolvedCorrectionProducer { + ImportDirective? findConfigImport(CompilationUnit unit) { + return findOrmConfigImport(unit); + } + + ConfigVariableInfo? findConfigVariable(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + for (final variable in declaration.variables.variables) { + if (isConfigVariable(variable)) { + return ConfigVariableInfo(declaration, variable); + } + } + } + return null; + } + + int configInsertOffset(CompilationUnit unit) { + if (unit.directives.isEmpty) { + return 0; + } + return utils.getLineNext(unit.directives.last.end); + } + + int importInsertOffset(CompilationUnit unit) { + ImportDirective? lastImport; + LibraryDirective? libraryDirective; + for (final directive in unit.directives) { + if (directive is LibraryDirective) { + libraryDirective = directive; + } else if (directive is ImportDirective) { + lastImport = directive; + } + } + + final anchor = lastImport ?? libraryDirective; + if (anchor == null) { + return 0; + } + return utils.getLineNext(anchor.end); + } + + String buildImportText(String eol) => "import '$ormConfigImportUri';$eol"; + + String buildConfigText(String? prefix, String eol) { + final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; + final provider = qualifier.isEmpty + ? '.sqlite' + : '${qualifier}DatabaseProvider.sqlite'; + return [ + 'const $configVariableName = $qualifier$configClassName(', + '${utils.oneIndent}provider: $provider,', + "${utils.oneIndent}output: '', // TODO: update output path", + ');', + '', + ].join(eol); + } + + Future insertConfig( + ChangeBuilder builder, { + required int configDeclOffset, + }) async { + final eol = utils.endOfLine; + final configImport = findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final importOffset = importInsertOffset(unit); + final configText = buildConfigText(prefix, eol); + final importText = buildImportText(eol); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importOffset == configDeclOffset) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + }); + } + + builder.addInsertion(configDeclOffset, (builder) { + builder.write(configText); + }); + }); + } + + Future replaceConfig( + ChangeBuilder builder, { + required ConfigVariableInfo info, + }) async { + final eol = utils.endOfLine; + final configImport = findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final importOffset = importInsertOffset(unit); + final configText = buildConfigText(prefix, eol); + final importText = buildImportText(eol); + + final declaration = info.declaration; + final replaceRange = SourceRange(declaration.offset, declaration.length); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importOffset == replaceRange.offset) { + builder.addReplacement(replaceRange, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + }); + } + + builder.addReplacement(replaceRange, (builder) { + builder.write(configText); + }); + }); + } +} + +class ConfigVariableInfo { + final TopLevelVariableDeclaration declaration; + final VariableDeclaration variable; + + ConfigVariableInfo(this.declaration, this.variable); + + bool get isSingle => declaration.variables.variables.length == 1; +} diff --git a/pub/orm/test/analyzer/config_required_fix_test.dart b/pub/orm/test/analyzer/config_required_fix_test.dart deleted file mode 100644 index b604675e..00000000 --- a/pub/orm/test/analyzer/config_required_fix_test.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; -import 'package:orm/src/analyzer/fixes/config_required_fix.dart'; -import 'package:orm/src/analyzer/fixes/config_required_replace_fix.dart'; -import 'package:test/test.dart'; - -void main() { - test('fix kind ids', () { - final context = StubCorrectionProducerContext.instance; - final fix = ConfigRequiredFix(context: context); - final replaceFix = ConfigRequiredReplaceFix(context: context); - - expect(fix.fixKind.id, 'orm.fix.config_required'); - expect(replaceFix.fixKind.id, 'orm.fix.config_required_replace'); - }); -} diff --git a/pub/orm/test/analyzer/config_required_rule_test.dart b/pub/orm/test/analyzer/config_required_rule_test.dart deleted file mode 100644 index 2b5331c4..00000000 --- a/pub/orm/test/analyzer/config_required_rule_test.dart +++ /dev/null @@ -1,96 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'package:analyzer/src/lint/registry.dart'; -import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; -import 'package:orm/src/analyzer/rules/config_required_rule.dart'; -import 'package:test_reflective_loader/test_reflective_loader.dart'; - -@reflectiveTest -class ConfigRequiredRuleTest extends AnalysisRuleTest { - @override - String get analysisRule => 'orm_config_required'; - - @override - void setUp() { - Registry.ruleRegistry.registerWarningRule(ConfigRequiredRule()); - super.setUp(); - newPubspecYamlFile(testPackageRootPath, 'name: orm\n'); - newFile(join(testPackageRootPath, 'lib', 'config.dart'), r''' -enum DatabaseProvider { sqlite } - -class Config { - final DatabaseProvider provider; - final String output; - - const Config({required this.provider, required this.output}); -} -'''); - } - - Future _assertMissingConfig(String content) async { - final path = join(testPackageRootPath, 'orm.config.dart'); - newFile(path, content); - await assertDiagnosticsInFile(path, [lint(0, content.length)]); - } - - Future _assertValidConfig(String content) async { - final path = join(testPackageRootPath, 'orm.config.dart'); - newFile(path, content); - await assertNoDiagnosticsInFile(path); - } - - void test_missingConfig() async { - await _assertMissingConfig(r''' -import 'package:orm/config.dart'; -'''); - } - - void test_validConfig() async { - await _assertValidConfig(r''' -import 'package:orm/config.dart'; - -const config = Config( - provider: DatabaseProvider.sqlite, - output: '', -); -'''); - } - - void test_prefixedImport() async { - await _assertValidConfig(r''' -import 'package:orm/config.dart' as orm; - -const config = orm.Config( - provider: orm.DatabaseProvider.sqlite, - output: '', -); -'''); - } - - void test_localConfigClass() async { - await _assertMissingConfig(r''' -class Config { - const Config(); -} - -const config = Config(); -'''); - } - - void test_notConst() async { - await _assertMissingConfig(r''' -import 'package:orm/config.dart'; - -final config = Config( - provider: DatabaseProvider.sqlite, - output: '', -); -'''); - } -} - -void main() { - defineReflectiveSuite(() { - defineReflectiveTests(ConfigRequiredRuleTest); - }); -} From 212a1f4fbaf6e2f6998c77513c84387479273725 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 5 Jan 2026 00:15:07 +0800 Subject: [PATCH 010/154] Remove analyzer constants and inline config-related strings --- pub/orm/lib/src/analyzer/README.md | 2 +- .../analyzer/fixes/config_required_fix.dart | 38 +++- .../fixes/config_required_replace_fix.dart | 42 +++- .../analyzer/rules/config_required_rule.dart | 19 +- .../analyzer/utils/analyzer_constants.dart | 15 -- .../src/analyzer/utils/config_ast_utils.dart | 33 --- .../lib/src/analyzer/utils/config_utils.dart | 199 +++++++----------- .../analyzer/config_required_fix_test.dart | 15 ++ .../analyzer/config_required_rule_test.dart | 96 +++++++++ 9 files changed, 268 insertions(+), 191 deletions(-) delete mode 100644 pub/orm/lib/src/analyzer/utils/analyzer_constants.dart delete mode 100644 pub/orm/lib/src/analyzer/utils/config_ast_utils.dart create mode 100644 pub/orm/test/analyzer/config_required_fix_test.dart create mode 100644 pub/orm/test/analyzer/config_required_rule_test.dart diff --git a/pub/orm/lib/src/analyzer/README.md b/pub/orm/lib/src/analyzer/README.md index d920c28b..d05f9bfe 100644 --- a/pub/orm/lib/src/analyzer/README.md +++ b/pub/orm/lib/src/analyzer/README.md @@ -6,7 +6,7 @@ Structure - `rules/`: analysis rules (diagnostics). - `fixes/`: quick fixes for rule diagnostics. - `assists/`: assists not tied to diagnostics. -- `utils/`: shared helpers and constants. +- `utils/`: shared helpers. Naming - Rule file: `*_rule.dart` (class `*Rule`). diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart index f233d372..42b24598 100644 --- a/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart @@ -3,14 +3,13 @@ import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; -import '../utils/analyzer_constants.dart'; import '../utils/config_utils.dart'; -class ConfigRequiredFix extends ResolvedCorrectionProducer with ConfigUtils { +class ConfigRequiredFix extends ResolvedCorrectionProducer { static const FixKind _kind = FixKind( - configFixIdRequired, + 'orm.fix.config_required', DartFixKindPriority.standard, - configFixMessageDefine, + 'Define ORM config: const config = Config(...)', ); ConfigRequiredFix({required super.context}); @@ -28,6 +27,35 @@ class ConfigRequiredFix extends ResolvedCorrectionProducer with ConfigUtils { return; } - await insertConfig(builder, configDeclOffset: configInsertOffset(unit)); + final eol = utils.endOfLine; + final configImport = findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final importOffset = importInsertOffset(unit); + final configDeclOffset = configInsertOffset(unit); + final configText = buildConfigText(prefix, eol, indent: utils.oneIndent); + final importText = buildImportText(eol); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importOffset == configDeclOffset) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + }); + } + + builder.addInsertion(configDeclOffset, (builder) { + builder.write(configText); + }); + }); } } diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart index 73b3f69a..4ae2c306 100644 --- a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart +++ b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart @@ -1,17 +1,16 @@ import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; +import 'package:analyzer/source/source_range.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; -import '../utils/analyzer_constants.dart'; import '../utils/config_utils.dart'; -class ConfigRequiredReplaceFix extends ResolvedCorrectionProducer - with ConfigUtils { +class ConfigRequiredReplaceFix extends ResolvedCorrectionProducer { static const FixKind _kind = FixKind( - configFixIdRequiredReplace, + 'orm.fix.config_required_replace', DartFixKindPriority.standard, - configFixMessageReplace, + 'Replace ORM config: const config = Config(...)', ); ConfigRequiredReplaceFix({required super.context}); @@ -34,6 +33,37 @@ class ConfigRequiredReplaceFix extends ResolvedCorrectionProducer return; } - await replaceConfig(builder, info: info); + final eol = utils.endOfLine; + final configImport = findConfigImport(unit); + final prefix = configImport?.prefix?.name; + final needsImport = configImport == null; + + final importOffset = importInsertOffset(unit); + final configText = buildConfigText(prefix, eol, indent: utils.oneIndent); + final importText = buildImportText(eol); + + final declaration = info.declaration; + final replaceRange = SourceRange(declaration.offset, declaration.length); + + await builder.addDartFileEdit(file, (builder) { + if (needsImport && importOffset == replaceRange.offset) { + builder.addReplacement(replaceRange, (builder) { + builder.write(importText); + builder.write(eol); + builder.write(configText); + }); + return; + } + + if (needsImport) { + builder.addInsertion(importOffset, (builder) { + builder.write(importText); + }); + } + + builder.addReplacement(replaceRange, (builder) { + builder.write(configText); + }); + }); } } diff --git a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart index e8547b1d..6131d8ca 100644 --- a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart +++ b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart @@ -5,21 +5,20 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/error/error.dart'; -import '../utils/analyzer_constants.dart'; -import '../utils/config_ast_utils.dart'; +import '../utils/config_utils.dart'; class ConfigRequiredRule extends AnalysisRule { static const LintCode code = LintCode( - configRequiredRuleName, - "Missing required '$configFixSnippet' in $ormConfigFileName.", + 'orm_config_required', + "Missing required 'const config = Config(...)' in orm.config.dart.", correctionMessage: - "Add a top-level '$configFixSnippet' to $ormConfigFileName.", + "Add a top-level 'const config = Config(...)' to orm.config.dart.", severity: DiagnosticSeverity.ERROR, ); ConfigRequiredRule() : super( - name: configRequiredRuleName, + name: 'orm_config_required', description: 'Ensures orm.config.dart defines a top-level const Config.', ); @@ -50,7 +49,7 @@ class _Visitor extends SimpleAstVisitor { return; } - final configFile = packageRoot.getChildAssumingFile(ormConfigFileName); + final configFile = packageRoot.getChildAssumingFile('orm.config.dart'); if (currentUnit.file.path != configFile.path) { return; } @@ -61,7 +60,7 @@ class _Visitor extends SimpleAstVisitor { } bool _hasRequiredConfig(CompilationUnit unit) { - final configImport = findOrmConfigImport(unit); + final configImport = findConfigImport(unit); final hasLocalConfig = hasLocalConfigDeclaration(unit); for (final declaration in unit.declarations) { if (declaration is! TopLevelVariableDeclaration) { @@ -97,7 +96,7 @@ class _Visitor extends SimpleAstVisitor { ImportDirective? configImport, bool hasLocalConfig, ) { - if (initializer.constructorName.type.name.lexeme != configClassName) { + if (initializer.constructorName.type.name.lexeme != 'Config') { return false; } @@ -106,7 +105,7 @@ class _Visitor extends SimpleAstVisitor { final library = classElement?.library; final libraryUri = library?.firstFragment.source.uri; if (libraryUri != null) { - return libraryUri.toString() == ormConfigImportUri; + return libraryUri.toString() == 'package:orm/config.dart'; } if (configImport == null) { diff --git a/pub/orm/lib/src/analyzer/utils/analyzer_constants.dart b/pub/orm/lib/src/analyzer/utils/analyzer_constants.dart deleted file mode 100644 index dad70576..00000000 --- a/pub/orm/lib/src/analyzer/utils/analyzer_constants.dart +++ /dev/null @@ -1,15 +0,0 @@ -const String ormConfigFileName = 'orm.config.dart'; -const String ormConfigImportUri = 'package:orm/config.dart'; -const String configClassName = 'Config'; -const String configVariableName = 'config'; - -const String configRequiredRuleName = 'orm_config_required'; - -const String configFixSnippet = - 'const $configVariableName = $configClassName(...)'; -const String configFixMessageDefine = 'Define ORM config: $configFixSnippet'; -const String configFixMessageReplace = - 'Replace ORM config: $configFixSnippet'; - -const String configFixIdRequired = 'orm.fix.config_required'; -const String configFixIdRequiredReplace = 'orm.fix.config_required_replace'; diff --git a/pub/orm/lib/src/analyzer/utils/config_ast_utils.dart b/pub/orm/lib/src/analyzer/utils/config_ast_utils.dart deleted file mode 100644 index 96060fe1..00000000 --- a/pub/orm/lib/src/analyzer/utils/config_ast_utils.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; - -import 'analyzer_constants.dart'; - -ImportDirective? findOrmConfigImport(CompilationUnit unit) { - for (final directive in unit.directives) { - if (directive is! ImportDirective) { - continue; - } - final uri = directive.uri.stringValue; - if (uri == ormConfigImportUri) { - return directive; - } - } - return null; -} - -bool hasLocalConfigDeclaration(CompilationUnit unit) { - for (final declaration in unit.declarations) { - if (declaration is FunctionDeclaration) { - continue; - } - if (declaration is NamedCompilationUnitMember && - declaration.name.lexeme == configClassName) { - return true; - } - } - return false; -} - -bool isConfigVariable(VariableDeclaration variable) { - return variable.name.lexeme == configVariableName; -} diff --git a/pub/orm/lib/src/analyzer/utils/config_utils.dart b/pub/orm/lib/src/analyzer/utils/config_utils.dart index 644fec88..b3b3bcaf 100644 --- a/pub/orm/lib/src/analyzer/utils/config_utils.dart +++ b/pub/orm/lib/src/analyzer/utils/config_utils.dart @@ -1,143 +1,100 @@ -import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/source/source_range.dart'; -import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; -import 'analyzer_constants.dart'; -import 'config_ast_utils.dart'; - -mixin ConfigUtils on ResolvedCorrectionProducer { - ImportDirective? findConfigImport(CompilationUnit unit) { - return findOrmConfigImport(unit); - } - - ConfigVariableInfo? findConfigVariable(CompilationUnit unit) { - for (final declaration in unit.declarations) { - if (declaration is! TopLevelVariableDeclaration) { - continue; - } - for (final variable in declaration.variables.variables) { - if (isConfigVariable(variable)) { - return ConfigVariableInfo(declaration, variable); - } - } +ImportDirective? findConfigImport(CompilationUnit unit) { + for (final directive in unit.directives) { + if (directive is! ImportDirective) { + continue; } - return null; - } - - int configInsertOffset(CompilationUnit unit) { - if (unit.directives.isEmpty) { - return 0; + final uri = directive.uri.stringValue; + if (uri == 'package:orm/config.dart') { + return directive; } - return utils.getLineNext(unit.directives.last.end); } + return null; +} - int importInsertOffset(CompilationUnit unit) { - ImportDirective? lastImport; - LibraryDirective? libraryDirective; - for (final directive in unit.directives) { - if (directive is LibraryDirective) { - libraryDirective = directive; - } else if (directive is ImportDirective) { - lastImport = directive; - } +bool hasLocalConfigDeclaration(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is FunctionDeclaration) { + continue; } - - final anchor = lastImport ?? libraryDirective; - if (anchor == null) { - return 0; + if (declaration is NamedCompilationUnitMember && + declaration.name.lexeme == 'Config') { + return true; } - return utils.getLineNext(anchor.end); - } - - String buildImportText(String eol) => "import '$ormConfigImportUri';$eol"; - - String buildConfigText(String? prefix, String eol) { - final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; - final provider = qualifier.isEmpty - ? '.sqlite' - : '${qualifier}DatabaseProvider.sqlite'; - return [ - 'const $configVariableName = $qualifier$configClassName(', - '${utils.oneIndent}provider: $provider,', - "${utils.oneIndent}output: '', // TODO: update output path", - ');', - '', - ].join(eol); } + return false; +} - Future insertConfig( - ChangeBuilder builder, { - required int configDeclOffset, - }) async { - final eol = utils.endOfLine; - final configImport = findConfigImport(unit); - final prefix = configImport?.prefix?.name; - final needsImport = configImport == null; - - final importOffset = importInsertOffset(unit); - final configText = buildConfigText(prefix, eol); - final importText = buildImportText(eol); - - await builder.addDartFileEdit(file, (builder) { - if (needsImport && importOffset == configDeclOffset) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - builder.write(eol); - builder.write(configText); - }); - return; - } +bool isConfigVariable(VariableDeclaration variable) { + return variable.name.lexeme == 'config'; +} - if (needsImport) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - }); +ConfigVariableInfo? findConfigVariable(CompilationUnit unit) { + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + for (final variable in declaration.variables.variables) { + if (isConfigVariable(variable)) { + return ConfigVariableInfo(declaration, variable); } - - builder.addInsertion(configDeclOffset, (builder) { - builder.write(configText); - }); - }); + } } + return null; +} - Future replaceConfig( - ChangeBuilder builder, { - required ConfigVariableInfo info, - }) async { - final eol = utils.endOfLine; - final configImport = findConfigImport(unit); - final prefix = configImport?.prefix?.name; - final needsImport = configImport == null; - - final importOffset = importInsertOffset(unit); - final configText = buildConfigText(prefix, eol); - final importText = buildImportText(eol); +int configInsertOffset(CompilationUnit unit) { + if (unit.directives.isEmpty) { + return 0; + } + return _nextLineOffset(unit, unit.directives.last.end); +} - final declaration = info.declaration; - final replaceRange = SourceRange(declaration.offset, declaration.length); +int importInsertOffset(CompilationUnit unit) { + ImportDirective? lastImport; + LibraryDirective? libraryDirective; + for (final directive in unit.directives) { + if (directive is LibraryDirective) { + libraryDirective = directive; + } else if (directive is ImportDirective) { + lastImport = directive; + } + } - await builder.addDartFileEdit(file, (builder) { - if (needsImport && importOffset == replaceRange.offset) { - builder.addReplacement(replaceRange, (builder) { - builder.write(importText); - builder.write(eol); - builder.write(configText); - }); - return; - } + final anchor = lastImport ?? libraryDirective; + if (anchor == null) { + return 0; + } + return _nextLineOffset(unit, anchor.end); +} - if (needsImport) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - }); - } +String buildImportText(String eol) => "import 'package:orm/config.dart';$eol"; + +String buildConfigText( + String? prefix, + String eol, { + required String indent, +}) { + final qualifier = (prefix == null || prefix.isEmpty) ? '' : '$prefix.'; + final provider = + qualifier.isEmpty ? '.sqlite' : '${qualifier}DatabaseProvider.sqlite'; + return [ + 'const config = $qualifier' 'Config(', + '${indent}provider: $provider,', + "${indent}output: '', // TODO: update output path", + ');', + '', + ].join(eol); +} - builder.addReplacement(replaceRange, (builder) { - builder.write(configText); - }); - }); +int _nextLineOffset(CompilationUnit unit, int offset) { + final lineInfo = unit.lineInfo; + final lineNumber = lineInfo.getLocation(offset).lineNumber; + if (lineNumber >= lineInfo.lineCount) { + return unit.end; } + return lineInfo.getOffsetOfLine(lineNumber); } class ConfigVariableInfo { diff --git a/pub/orm/test/analyzer/config_required_fix_test.dart b/pub/orm/test/analyzer/config_required_fix_test.dart new file mode 100644 index 00000000..b604675e --- /dev/null +++ b/pub/orm/test/analyzer/config_required_fix_test.dart @@ -0,0 +1,15 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:orm/src/analyzer/fixes/config_required_fix.dart'; +import 'package:orm/src/analyzer/fixes/config_required_replace_fix.dart'; +import 'package:test/test.dart'; + +void main() { + test('fix kind ids', () { + final context = StubCorrectionProducerContext.instance; + final fix = ConfigRequiredFix(context: context); + final replaceFix = ConfigRequiredReplaceFix(context: context); + + expect(fix.fixKind.id, 'orm.fix.config_required'); + expect(replaceFix.fixKind.id, 'orm.fix.config_required_replace'); + }); +} diff --git a/pub/orm/test/analyzer/config_required_rule_test.dart b/pub/orm/test/analyzer/config_required_rule_test.dart new file mode 100644 index 00000000..2b5331c4 --- /dev/null +++ b/pub/orm/test/analyzer/config_required_rule_test.dart @@ -0,0 +1,96 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:analyzer/src/lint/registry.dart'; +import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:orm/src/analyzer/rules/config_required_rule.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +@reflectiveTest +class ConfigRequiredRuleTest extends AnalysisRuleTest { + @override + String get analysisRule => 'orm_config_required'; + + @override + void setUp() { + Registry.ruleRegistry.registerWarningRule(ConfigRequiredRule()); + super.setUp(); + newPubspecYamlFile(testPackageRootPath, 'name: orm\n'); + newFile(join(testPackageRootPath, 'lib', 'config.dart'), r''' +enum DatabaseProvider { sqlite } + +class Config { + final DatabaseProvider provider; + final String output; + + const Config({required this.provider, required this.output}); +} +'''); + } + + Future _assertMissingConfig(String content) async { + final path = join(testPackageRootPath, 'orm.config.dart'); + newFile(path, content); + await assertDiagnosticsInFile(path, [lint(0, content.length)]); + } + + Future _assertValidConfig(String content) async { + final path = join(testPackageRootPath, 'orm.config.dart'); + newFile(path, content); + await assertNoDiagnosticsInFile(path); + } + + void test_missingConfig() async { + await _assertMissingConfig(r''' +import 'package:orm/config.dart'; +'''); + } + + void test_validConfig() async { + await _assertValidConfig(r''' +import 'package:orm/config.dart'; + +const config = Config( + provider: DatabaseProvider.sqlite, + output: '', +); +'''); + } + + void test_prefixedImport() async { + await _assertValidConfig(r''' +import 'package:orm/config.dart' as orm; + +const config = orm.Config( + provider: orm.DatabaseProvider.sqlite, + output: '', +); +'''); + } + + void test_localConfigClass() async { + await _assertMissingConfig(r''' +class Config { + const Config(); +} + +const config = Config(); +'''); + } + + void test_notConst() async { + await _assertMissingConfig(r''' +import 'package:orm/config.dart'; + +final config = Config( + provider: DatabaseProvider.sqlite, + output: '', +); +'''); + } +} + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(ConfigRequiredRuleTest); + }); +} From 95982e5e88de2d07c3a94c6a814022a2d6749878 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 5 Jan 2026 00:31:10 +0800 Subject: [PATCH 011/154] Remove unused import and add config file in test setup --- pub/orm/test/analyzer/config_required_rule_test.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pub/orm/test/analyzer/config_required_rule_test.dart b/pub/orm/test/analyzer/config_required_rule_test.dart index 2b5331c4..b7f725e7 100644 --- a/pub/orm/test/analyzer/config_required_rule_test.dart +++ b/pub/orm/test/analyzer/config_required_rule_test.dart @@ -1,20 +1,20 @@ // ignore_for_file: non_constant_identifier_names -import 'package:analyzer/src/lint/registry.dart'; import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; import 'package:orm/src/analyzer/rules/config_required_rule.dart'; import 'package:test_reflective_loader/test_reflective_loader.dart'; @reflectiveTest class ConfigRequiredRuleTest extends AnalysisRuleTest { - @override - String get analysisRule => 'orm_config_required'; - @override void setUp() { - Registry.ruleRegistry.registerWarningRule(ConfigRequiredRule()); + rule = ConfigRequiredRule(); super.setUp(); newPubspecYamlFile(testPackageRootPath, 'name: orm\n'); + newSinglePackageConfigJsonFile( + packagePath: testPackageRootPath, + name: 'orm', + ); newFile(join(testPackageRootPath, 'lib', 'config.dart'), r''' enum DatabaseProvider { sqlite } @@ -41,6 +41,7 @@ class Config { void test_missingConfig() async { await _assertMissingConfig(r''' +// ignore_for_file: unused_import import 'package:orm/config.dart'; '''); } From 5e682afa42f9361cf9dd7d0123e830aab4aa2f67 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 5 Jan 2026 01:30:16 +0800 Subject: [PATCH 012/154] Remove config replace fix and improve diagnostic node selection --- pub/orm/lib/main.dart | 5 -- .../fixes/config_required_replace_fix.dart | 69 ------------------- .../analyzer/rules/config_required_rule.dart | 16 ++++- .../analyzer/config_required_fix_test.dart | 3 - .../analyzer/config_required_rule_test.dart | 22 +++++- 5 files changed, 36 insertions(+), 79 deletions(-) delete mode 100644 pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart diff --git a/pub/orm/lib/main.dart b/pub/orm/lib/main.dart index 415eb44e..c63aed11 100644 --- a/pub/orm/lib/main.dart +++ b/pub/orm/lib/main.dart @@ -2,7 +2,6 @@ import 'package:analysis_server_plugin/plugin.dart'; import 'package:analysis_server_plugin/registry.dart'; import 'src/analyzer/fixes/config_required_fix.dart'; -import 'src/analyzer/fixes/config_required_replace_fix.dart'; import 'src/analyzer/rules/config_required_rule.dart'; class AnalysisPlugin extends Plugin { @@ -13,10 +12,6 @@ class AnalysisPlugin extends Plugin { void register(PluginRegistry registry) { registry.registerWarningRule(ConfigRequiredRule()); registry.registerFixForRule(ConfigRequiredRule.code, ConfigRequiredFix.new); - registry.registerFixForRule( - ConfigRequiredRule.code, - ConfigRequiredReplaceFix.new, - ); } } diff --git a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart b/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart deleted file mode 100644 index 4ae2c306..00000000 --- a/pub/orm/lib/src/analyzer/fixes/config_required_replace_fix.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; -import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart'; -import 'package:analyzer/source/source_range.dart'; -import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; -import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; - -import '../utils/config_utils.dart'; - -class ConfigRequiredReplaceFix extends ResolvedCorrectionProducer { - static const FixKind _kind = FixKind( - 'orm.fix.config_required_replace', - DartFixKindPriority.standard, - 'Replace ORM config: const config = Config(...)', - ); - - ConfigRequiredReplaceFix({required super.context}); - - @override - CorrectionApplicability get applicability => - CorrectionApplicability.singleLocation; - - @override - FixKind get fixKind => _kind; - - @override - Future compute(ChangeBuilder builder) async { - final info = findConfigVariable(unit); - if (info == null) { - return; - } - - if (!info.isSingle || info.declaration.metadata.isNotEmpty) { - return; - } - - final eol = utils.endOfLine; - final configImport = findConfigImport(unit); - final prefix = configImport?.prefix?.name; - final needsImport = configImport == null; - - final importOffset = importInsertOffset(unit); - final configText = buildConfigText(prefix, eol, indent: utils.oneIndent); - final importText = buildImportText(eol); - - final declaration = info.declaration; - final replaceRange = SourceRange(declaration.offset, declaration.length); - - await builder.addDartFileEdit(file, (builder) { - if (needsImport && importOffset == replaceRange.offset) { - builder.addReplacement(replaceRange, (builder) { - builder.write(importText); - builder.write(eol); - builder.write(configText); - }); - return; - } - - if (needsImport) { - builder.addInsertion(importOffset, (builder) { - builder.write(importText); - }); - } - - builder.addReplacement(replaceRange, (builder) { - builder.write(configText); - }); - }); - } -} diff --git a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart index 6131d8ca..753eaabf 100644 --- a/pub/orm/lib/src/analyzer/rules/config_required_rule.dart +++ b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart @@ -55,10 +55,24 @@ class _Visitor extends SimpleAstVisitor { } if (!_hasRequiredConfig(node)) { - rule.reportAtNode(node); + rule.reportAtNode(_diagnosticNode(node)); } } + AstNode _diagnosticNode(CompilationUnit unit) { + final configInfo = findConfigVariable(unit); + if (configInfo != null) { + return configInfo.variable; + } + if (unit.declarations.isNotEmpty) { + return unit.declarations.first; + } + if (unit.directives.isNotEmpty) { + return unit.directives.last; + } + return unit; + } + bool _hasRequiredConfig(CompilationUnit unit) { final configImport = findConfigImport(unit); final hasLocalConfig = hasLocalConfigDeclaration(unit); diff --git a/pub/orm/test/analyzer/config_required_fix_test.dart b/pub/orm/test/analyzer/config_required_fix_test.dart index b604675e..9e658b68 100644 --- a/pub/orm/test/analyzer/config_required_fix_test.dart +++ b/pub/orm/test/analyzer/config_required_fix_test.dart @@ -1,15 +1,12 @@ import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; import 'package:orm/src/analyzer/fixes/config_required_fix.dart'; -import 'package:orm/src/analyzer/fixes/config_required_replace_fix.dart'; import 'package:test/test.dart'; void main() { test('fix kind ids', () { final context = StubCorrectionProducerContext.instance; final fix = ConfigRequiredFix(context: context); - final replaceFix = ConfigRequiredReplaceFix(context: context); expect(fix.fixKind.id, 'orm.fix.config_required'); - expect(replaceFix.fixKind.id, 'orm.fix.config_required_replace'); }); } diff --git a/pub/orm/test/analyzer/config_required_rule_test.dart b/pub/orm/test/analyzer/config_required_rule_test.dart index b7f725e7..6aad30c8 100644 --- a/pub/orm/test/analyzer/config_required_rule_test.dart +++ b/pub/orm/test/analyzer/config_required_rule_test.dart @@ -1,7 +1,9 @@ // ignore_for_file: non_constant_identifier_names import 'package:analyzer_testing/analysis_rule/analysis_rule.dart'; +import 'package:analyzer/dart/ast/ast.dart'; import 'package:orm/src/analyzer/rules/config_required_rule.dart'; +import 'package:orm/src/analyzer/utils/config_utils.dart'; import 'package:test_reflective_loader/test_reflective_loader.dart'; @reflectiveTest @@ -30,7 +32,11 @@ class Config { Future _assertMissingConfig(String content) async { final path = join(testPackageRootPath, 'orm.config.dart'); newFile(path, content); - await assertDiagnosticsInFile(path, [lint(0, content.length)]); + final resolved = await resolveFile(path); + final diagnosticNode = _diagnosticNode(resolved.unit); + await assertDiagnosticsInFile(path, [ + lint(diagnosticNode.offset, diagnosticNode.length), + ]); } Future _assertValidConfig(String content) async { @@ -90,6 +96,20 @@ final config = Config( } } +AstNode _diagnosticNode(CompilationUnit unit) { + final configInfo = findConfigVariable(unit); + if (configInfo != null) { + return configInfo.variable; + } + if (unit.declarations.isNotEmpty) { + return unit.declarations.first; + } + if (unit.directives.isNotEmpty) { + return unit.directives.last; + } + return unit; +} + void main() { defineReflectiveSuite(() { defineReflectiveTests(ConfigRequiredRuleTest); From 6f544ce4a9f30e4c63ad6a93b5277a64f993690c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 5 Jan 2026 01:32:57 +0800 Subject: [PATCH 013/154] Update test methods to return Future --- pub/orm/test/analyzer/config_required_rule_test.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pub/orm/test/analyzer/config_required_rule_test.dart b/pub/orm/test/analyzer/config_required_rule_test.dart index 6aad30c8..8cd8479e 100644 --- a/pub/orm/test/analyzer/config_required_rule_test.dart +++ b/pub/orm/test/analyzer/config_required_rule_test.dart @@ -45,14 +45,14 @@ class Config { await assertNoDiagnosticsInFile(path); } - void test_missingConfig() async { + Future test_missingConfig() async { await _assertMissingConfig(r''' // ignore_for_file: unused_import import 'package:orm/config.dart'; '''); } - void test_validConfig() async { + Future test_validConfig() async { await _assertValidConfig(r''' import 'package:orm/config.dart'; @@ -63,7 +63,7 @@ const config = Config( '''); } - void test_prefixedImport() async { + Future test_prefixedImport() async { await _assertValidConfig(r''' import 'package:orm/config.dart' as orm; @@ -74,7 +74,7 @@ const config = orm.Config( '''); } - void test_localConfigClass() async { + Future test_localConfigClass() async { await _assertMissingConfig(r''' class Config { const Config(); @@ -84,7 +84,7 @@ const config = Config(); '''); } - void test_notConst() async { + Future test_notConst() async { await _assertMissingConfig(r''' import 'package:orm/config.dart'; From d02e476f8fc9ef7418a5ef4a1b9d65999f452020 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 5 Jan 2026 01:44:07 +0800 Subject: [PATCH 014/154] Change `references` and `fields` from Iterable to Set --- playground/orm.schema.dart | 14 ++++++++++++++ pub/orm/lib/src/schema/relation.dart | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 playground/orm.schema.dart diff --git a/playground/orm.schema.dart b/playground/orm.schema.dart new file mode 100644 index 00000000..464bc31a --- /dev/null +++ b/playground/orm.schema.dart @@ -0,0 +1,14 @@ +import 'package:orm/schema.dart'; + +@model +typedef User = ({@id String id, String email}); + +@model +typedef Post = ({ + @id String id, + String title, + String content, + String authorId, + + @Relation(references: {'authorId'}) User author, +}); diff --git a/pub/orm/lib/src/schema/relation.dart b/pub/orm/lib/src/schema/relation.dart index 360a33d2..50cb7f76 100644 --- a/pub/orm/lib/src/schema/relation.dart +++ b/pub/orm/lib/src/schema/relation.dart @@ -29,10 +29,10 @@ final class Relation { final String? map; /// A list of fields of the model on the other side of the relation - final Iterable references; + final Set references; /// A list of fields of the current model - final Iterable? fields; + final Set? fields; /// Defines the referential action to perform when a referenced /// entry in the referenced model is being deleted. From 9ff460334cdb943a2ff6dae8e1bf5d7509e2af04 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Mon, 5 Jan 2026 01:50:06 +0800 Subject: [PATCH 015/154] Change Relation annotation fields and references parameters --- playground/orm.schema.dart | 2 +- pub/orm/lib/src/schema/relation.dart | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/playground/orm.schema.dart b/playground/orm.schema.dart index 464bc31a..6d3a6850 100644 --- a/playground/orm.schema.dart +++ b/playground/orm.schema.dart @@ -10,5 +10,5 @@ typedef Post = ({ String content, String authorId, - @Relation(references: {'authorId'}) User author, + @Relation(fields: {'authorId'}) User author, }); diff --git a/pub/orm/lib/src/schema/relation.dart b/pub/orm/lib/src/schema/relation.dart index 50cb7f76..c25ea403 100644 --- a/pub/orm/lib/src/schema/relation.dart +++ b/pub/orm/lib/src/schema/relation.dart @@ -29,10 +29,10 @@ final class Relation { final String? map; /// A list of fields of the model on the other side of the relation - final Set references; + final Set? references; /// A list of fields of the current model - final Set? fields; + final Set fields; /// Defines the referential action to perform when a referenced /// entry in the referenced model is being deleted. @@ -45,10 +45,10 @@ final class Relation { @literal /// Creates a relation annotation describing a model relationship. const Relation({ - required this.references, + required this.fields, + this.references, this.name, this.map, - this.fields, this.onDelete, this.onUpdate, }); From 7236355c30e7dc7cd60efdcb0e7ec2a850371220 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:52:34 +0800 Subject: [PATCH 016/154] feat(runtime): add contract-first client runtime baseline --- playground/orm.schema.dart | 12 +- pub/orm/lib/orm.dart | 14 + pub/orm/lib/src/client/client.dart | 588 ++++++++++++++ pub/orm/lib/src/contract/contract.dart | 208 +++++ pub/orm/lib/src/engine/engine.dart | 37 + pub/orm/lib/src/engine/memory_engine.dart | 262 +++++++ pub/orm/lib/src/runtime/core.dart | 477 +++++++++++ pub/orm/lib/src/runtime/errors.dart | 253 ++++++ pub/orm/lib/src/runtime/plan.dart | 42 + pub/orm/lib/src/runtime/plugin.dart | 92 +++ pub/orm/lib/src/runtime/plugins/budgets.dart | 109 +++ pub/orm/lib/src/runtime/plugins/lints.dart | 97 +++ pub/orm/lib/src/runtime/types.dart | 1 + pub/orm/lib/src/schema/relation.dart | 4 +- pub/orm/lib/src/schema/schema.dart | 2 +- pub/orm/test/client/client_test.dart | 782 +++++++++++++++++++ pub/orm/test/style/no_as_cast_test.dart | 74 ++ 17 files changed, 3050 insertions(+), 4 deletions(-) create mode 100644 pub/orm/lib/orm.dart create mode 100644 pub/orm/lib/src/client/client.dart create mode 100644 pub/orm/lib/src/contract/contract.dart create mode 100644 pub/orm/lib/src/engine/engine.dart create mode 100644 pub/orm/lib/src/engine/memory_engine.dart create mode 100644 pub/orm/lib/src/runtime/core.dart create mode 100644 pub/orm/lib/src/runtime/errors.dart create mode 100644 pub/orm/lib/src/runtime/plan.dart create mode 100644 pub/orm/lib/src/runtime/plugin.dart create mode 100644 pub/orm/lib/src/runtime/plugins/budgets.dart create mode 100644 pub/orm/lib/src/runtime/plugins/lints.dart create mode 100644 pub/orm/lib/src/runtime/types.dart create mode 100644 pub/orm/test/client/client_test.dart create mode 100644 pub/orm/test/style/no_as_cast_test.dart diff --git a/playground/orm.schema.dart b/playground/orm.schema.dart index 6d3a6850..32ea5736 100644 --- a/playground/orm.schema.dart +++ b/playground/orm.schema.dart @@ -1,7 +1,14 @@ import 'package:orm/schema.dart'; @model -typedef User = ({@id String id, String email}); +typedef User = ({ + @id String id, + String email, + DateTime createdAt, + DateTime updatedAt, + + @Relation() List posts, +}); @model typedef Post = ({ @@ -10,5 +17,8 @@ typedef Post = ({ String content, String authorId, + DateTime createdAt, + DateTime updatedAt, + @Relation(fields: {'authorId'}) User author, }); diff --git a/pub/orm/lib/orm.dart b/pub/orm/lib/orm.dart new file mode 100644 index 00000000..d5ad494b --- /dev/null +++ b/pub/orm/lib/orm.dart @@ -0,0 +1,14 @@ +export 'config.dart'; +export 'core.dart'; +export 'schema.dart'; +export 'src/client/client.dart'; +export 'src/contract/contract.dart'; +export 'src/engine/engine.dart'; +export 'src/engine/memory_engine.dart'; +export 'src/runtime/core.dart'; +export 'src/runtime/errors.dart'; +export 'src/runtime/plan.dart'; +export 'src/runtime/plugin.dart'; +export 'src/runtime/plugins/budgets.dart'; +export 'src/runtime/plugins/lints.dart'; +export 'src/runtime/types.dart'; diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart new file mode 100644 index 00000000..26f53d24 --- /dev/null +++ b/pub/orm/lib/src/client/client.dart @@ -0,0 +1,588 @@ +import 'package:meta/meta.dart'; + +import '../contract/contract.dart'; +import '../core/sort_order.dart'; +import '../engine/engine.dart'; +import '../runtime/core.dart'; +import '../runtime/errors.dart'; +import '../runtime/plan.dart'; +import '../runtime/plugin.dart'; +import '../runtime/types.dart'; + +typedef CollectionFactory = + ModelDelegate Function({ + required OrmModelContext client, + required String modelName, + }); + +abstract interface class OrmModelContext { + OrmContract get contract; + + Future execute(OrmPlan plan); + + ModelDelegate model(String modelKey); + + ModelDelegate collection(String modelKey); +} + +final class OrmClient implements OrmModelContext { + @override + final OrmContract contract; + final OrmEngine engine; + final OrmRuntimeCore _runtime; + final Map _delegates = {}; + final Map _modelAliases; + final Map _collectionRegistry; + + OrmClient({ + required this.contract, + required this.engine, + List plugins = const [], + RuntimeVerifyOptions verify = const RuntimeVerifyOptions(), + RuntimeMode mode = RuntimeMode.strict, + RuntimeLog log = const SilentRuntimeLog(), + Map collections = + const {}, + }) : _runtime = OrmRuntimeCore( + contract: contract, + engine: engine, + plugins: plugins, + verify: verify, + mode: mode, + log: log, + ), + _modelAliases = _createModelAliases(contract), + _collectionRegistry = _createCollectionRegistry(contract, collections); + + bool get isConnected => _runtime.isConnected; + + Future connect() => _runtime.connect(); + + Future disconnect() async { + await _runtime.disconnect(); + _delegates.clear(); + } + + Future withConnection( + Future Function(OrmScopedClient connection) run, + ) async { + final connection = await _runtime.connection(); + final scoped = OrmScopedClient._( + contract: contract, + executePlan: connection.execute, + modelAliases: _modelAliases, + collectionRegistry: _collectionRegistry, + ); + + try { + return await run(scoped); + } finally { + await connection.release(); + } + } + + Future withTransaction( + Future Function(OrmScopedClient transaction) run, + ) async { + final connection = await _runtime.connection(); + final transaction = await connection.transaction(); + final scoped = OrmScopedClient._( + contract: contract, + executePlan: transaction.execute, + modelAliases: _modelAliases, + collectionRegistry: _collectionRegistry, + ); + + try { + final value = await run(scoped); + await transaction.commit(); + return value; + } catch (_) { + try { + await transaction.rollback(); + } catch (_) { + // Keep the original exception when rollback fails. + } + rethrow; + } finally { + await connection.release(); + } + } + + Future connection() => _runtime.connection(); + + RuntimeTelemetryEvent? telemetry() => _runtime.telemetry(); + + @override + ModelDelegate model(String modelKey) { + final modelName = _resolveModelOrThrow(modelKey: modelKey); + return _delegates.putIfAbsent(modelName, () { + final factory = _collectionRegistry[modelName]; + if (factory == null) { + return ModelDelegate(client: this, modelName: modelName); + } + return factory(client: this, modelName: modelName); + }); + } + + @override + ModelDelegate collection(String modelKey) => model(modelKey); + + @override + Future execute(OrmPlan plan) => _runtime.execute(plan); + + String _resolveModelOrThrow({required String modelKey}) { + final resolved = _resolveModel(modelKey); + if (resolved != null) { + return resolved; + } + throw ModelNotFoundException(modelKey, contract.models.keys); + } + + String? _resolveModel(String modelKey) { + final exact = _modelAliases[modelKey]; + if (exact != null) { + return exact; + } + if (modelKey.endsWith('s') && modelKey.length > 1) { + final singular = modelKey.substring(0, modelKey.length - 1); + final singularMatch = _modelAliases[singular]; + if (singularMatch != null) { + return singularMatch; + } + } + return contract.resolveModel(modelKey); + } +} + +final class OrmScopedClient implements OrmModelContext { + @override + final OrmContract contract; + final Future Function(OrmPlan plan) _executePlan; + final Map _modelAliases; + final Map _collectionRegistry; + final Map _delegates = {}; + + OrmScopedClient._({ + required this.contract, + required Future Function(OrmPlan plan) executePlan, + required Map modelAliases, + required Map collectionRegistry, + }) : _executePlan = executePlan, + _modelAliases = modelAliases, + _collectionRegistry = collectionRegistry; + + @override + ModelDelegate model(String modelKey) { + final modelName = _resolveModelOrThrow(modelKey: modelKey); + return _delegates.putIfAbsent(modelName, () { + final factory = _collectionRegistry[modelName]; + if (factory == null) { + return ModelDelegate(client: this, modelName: modelName); + } + return factory(client: this, modelName: modelName); + }); + } + + @override + ModelDelegate collection(String modelKey) => model(modelKey); + + @override + Future execute(OrmPlan plan) => _executePlan(plan); + + String _resolveModelOrThrow({required String modelKey}) { + final resolved = _resolveModel(modelKey); + if (resolved != null) { + return resolved; + } + throw ModelNotFoundException(modelKey, contract.models.keys); + } + + String? _resolveModel(String modelKey) { + final exact = _modelAliases[modelKey]; + if (exact != null) { + return exact; + } + if (modelKey.endsWith('s') && modelKey.length > 1) { + final singular = modelKey.substring(0, modelKey.length - 1); + final singularMatch = _modelAliases[singular]; + if (singularMatch != null) { + return singularMatch; + } + } + return contract.resolveModel(modelKey); + } +} + +class ModelDelegate { + final OrmModelContext _client; + final String modelName; + + ModelDelegate({required OrmModelContext client, required this.modelName}) + : _client = client; + + @protected + OrmModelContext get client => _client; + + ModelQuery query() => ModelQuery._(this, const ModelQueryState()); + + ModelQuery where(JsonMap where) => query().where(where); + + ModelQuery orderBy(List orderBy) => query().orderBy(orderBy); + + ModelQuery orderByField(String field, {SortOrder order = SortOrder.asc}) => + query().orderByField(field, order: order); + + ModelQuery skip(int value) => query().skip(value); + + ModelQuery take(int value) => query().take(value); + + ModelQuery select(List fields) => query().select(fields); + + ModelQuery selectField(String field) => query().selectField(field); + + Future> findMany({ + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List select = const [], + }) async { + final response = await _client.execute( + OrmPlan( + contractHash: _client.contract.hash, + model: modelName, + action: OrmAction.findMany, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + select: select, + ), + ); + return _readRows(response.data); + } + + Future findUnique({ + JsonMap where = const {}, + List select = const [], + }) async { + final response = await _client.execute( + OrmPlan( + contractHash: _client.contract.hash, + model: modelName, + action: OrmAction.findUnique, + where: where, + select: select, + ), + ); + return _readRow(response.data, action: 'findUnique'); + } + + Future create({ + required JsonMap data, + List select = const [], + }) async { + final response = await _client.execute( + OrmPlan( + contractHash: _client.contract.hash, + model: modelName, + action: OrmAction.create, + data: data, + select: select, + ), + ); + final row = _readRow(response.data, action: 'create'); + if (row == null) { + throw RuntimeCreateResultMissingException(model: modelName); + } + return row; + } + + Future update({ + JsonMap where = const {}, + required JsonMap data, + List select = const [], + }) async { + final response = await _client.execute( + OrmPlan( + contractHash: _client.contract.hash, + model: modelName, + action: OrmAction.update, + where: where, + data: data, + select: select, + ), + ); + return _readRow(response.data, action: 'update'); + } + + Future delete({ + JsonMap where = const {}, + List select = const [], + }) async { + final response = await _client.execute( + OrmPlan( + contractHash: _client.contract.hash, + model: modelName, + action: OrmAction.delete, + where: where, + select: select, + ), + ); + return _readRow(response.data, action: 'delete'); + } +} + +@immutable +final class ModelQueryState { + final JsonMap where; + final int? skip; + final int? take; + final List orderBy; + final List select; + + const ModelQueryState({ + this.where = const {}, + this.skip, + this.take, + this.orderBy = const [], + this.select = const [], + }); +} + +@immutable +final class ModelQuery { + final ModelDelegate _delegate; + final ModelQueryState _state; + + const ModelQuery._(this._delegate, this._state); + + JsonMap get whereClause => _state.where; + + int? get skipValue => _state.skip; + + int? get takeValue => _state.take; + + List get orderByValues => _state.orderBy; + + List get selectedFields => _state.select; + + ModelQuery where(JsonMap where, {bool merge = true}) { + final nextWhere = merge + ? Map.unmodifiable({ + ..._state.where, + ...where, + }) + : Map.unmodifiable(where); + return _next( + ModelQueryState( + where: nextWhere, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + select: _state.select, + ), + ); + } + + ModelQuery orderBy(List orderBy, {bool append = true}) { + final nextOrderBy = append + ? List.unmodifiable([ + ..._state.orderBy, + ...orderBy, + ]) + : List.unmodifiable(orderBy); + return _next( + ModelQueryState( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: nextOrderBy, + select: _state.select, + ), + ); + } + + ModelQuery orderByField(String field, {SortOrder order = SortOrder.asc}) { + return orderBy([OrmOrderBy(field, order: order)]); + } + + ModelQuery select(List fields, {bool append = false}) { + final nextSelect = append + ? List.unmodifiable([..._state.select, ...fields]) + : List.unmodifiable(fields); + return _next( + ModelQueryState( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + select: nextSelect, + ), + ); + } + + ModelQuery selectField(String field) { + return select([field], append: true); + } + + ModelQuery skip(int value) { + return _next( + ModelQueryState( + where: _state.where, + skip: value, + take: _state.take, + orderBy: _state.orderBy, + select: _state.select, + ), + ); + } + + ModelQuery take(int value) { + return _next( + ModelQueryState( + where: _state.where, + skip: _state.skip, + take: value, + orderBy: _state.orderBy, + select: _state.select, + ), + ); + } + + ModelQuery unbounded() { + return _next( + ModelQueryState( + where: _state.where, + skip: _state.skip, + take: null, + orderBy: _state.orderBy, + select: _state.select, + ), + ); + } + + Future> findMany() { + return _delegate.findMany( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + select: _state.select, + ); + } + + Future findUnique() => + _delegate.findUnique(where: _state.where, select: _state.select); + + Future create({required JsonMap data}) { + return _delegate.create(data: data, select: _state.select); + } + + Future update({required JsonMap data}) { + return _delegate.update( + where: _state.where, + data: data, + select: _state.select, + ); + } + + Future delete() => + _delegate.delete(where: _state.where, select: _state.select); + + ModelQuery _next(ModelQueryState nextState) => + ModelQuery._(_delegate, nextState); +} + +Map _createModelAliases(OrmContract contract) { + final aliases = {}; + + for (final model in contract.models.values) { + final name = model.name; + final lower = _lowercaseFirst(name); + aliases[name] = name; + aliases[lower] = name; + aliases['${lower}s'] = name; + aliases[model.table] = name; + if (!model.table.endsWith('s')) { + aliases['${model.table}s'] = name; + } + } + + for (final alias in contract.aliases.entries) { + if (contract.models.containsKey(alias.value)) { + aliases[alias.key] = alias.value; + } + } + + return aliases; +} + +Map _createCollectionRegistry( + OrmContract contract, + Map collections, +) { + if (collections.isEmpty) { + return const {}; + } + + final aliases = _createModelAliases(contract); + final registry = {}; + + for (final entry in collections.entries) { + final model = aliases[entry.key] ?? aliases[_lowercaseFirst(entry.key)]; + if (model == null) { + throw ModelNotFoundException(entry.key, contract.models.keys); + } + registry[model] = entry.value; + } + + return Map.unmodifiable(registry); +} + +List _readRows(Object? data) { + if (data == null) { + return const []; + } + if (data is! List) { + throw RuntimeResponseShapeException( + action: 'findMany', + expected: 'List>', + actual: data, + ); + } + return List.unmodifiable( + data.map((value) => _coerceRow(value, action: 'findMany')), + ); +} + +JsonMap? _readRow(Object? data, {required String action}) { + if (data == null) { + return null; + } + return _coerceRow(data, action: action); +} + +JsonMap _coerceRow(Object? value, {required String action}) { + if (value is Map) { + return Map.unmodifiable(value); + } + if (value is Map) { + return Map.unmodifiable( + value.map((key, item) => MapEntry(key.toString(), item)), + ); + } + throw RuntimeResponseShapeException( + action: action, + expected: 'Map', + actual: value, + ); +} + +String _lowercaseFirst(String value) { + if (value.isEmpty) { + return value; + } + return value[0].toLowerCase() + value.substring(1); +} diff --git a/pub/orm/lib/src/contract/contract.dart b/pub/orm/lib/src/contract/contract.dart new file mode 100644 index 00000000..add1e97f --- /dev/null +++ b/pub/orm/lib/src/contract/contract.dart @@ -0,0 +1,208 @@ +import 'package:meta/meta.dart'; + +enum RelationCardinality { one, many } + +@immutable +final class ModelRelationContract { + final String name; + final String relatedModel; + final List sourceFields; + final List targetFields; + final RelationCardinality cardinality; + + ModelRelationContract({ + required this.name, + required this.relatedModel, + required List sourceFields, + required List targetFields, + this.cardinality = RelationCardinality.many, + }) : sourceFields = List.unmodifiable(sourceFields), + targetFields = List.unmodifiable(targetFields); +} + +@immutable +final class ContractDefinitionException implements Exception { + final String code; + final String message; + final Map details; + + ContractDefinitionException({ + required this.code, + required this.message, + this.details = const {}, + }); + + @override + String toString() { + if (details.isEmpty) { + return 'ContractDefinitionException[$code]: $message'; + } + return 'ContractDefinitionException[$code]: $message | details=$details'; + } +} + +@immutable +final class ModelContract { + final String name; + final String table; + final Set fields; + final Map relations; + + ModelContract({ + required this.name, + required this.table, + required Set fields, + Map relations = const {}, + }) : fields = Set.unmodifiable(fields), + relations = Map.unmodifiable(relations); +} + +@immutable +final class OrmContract { + final String version; + final String hash; + final Map models; + final Map aliases; + + OrmContract({ + required this.version, + required this.hash, + required Map models, + Map aliases = const {}, + }) : models = Map.unmodifiable(models), + aliases = Map.unmodifiable(aliases) { + _validateRelations(this.models); + } + + bool hasModel(String key) => resolveModel(key) != null; + + String? resolveModel(String key) { + final candidates = { + key, + _uppercaseFirst(key), + _lowercaseFirst(key), + }; + + if (key.endsWith('s') && key.length > 1) { + final singular = key.substring(0, key.length - 1); + candidates.addAll({ + singular, + _uppercaseFirst(singular), + _lowercaseFirst(singular), + }); + } + + for (final candidate in candidates) { + if (models.containsKey(candidate)) { + return candidate; + } + + final alias = aliases[candidate]; + if (alias != null && models.containsKey(alias)) { + return alias; + } + } + + return null; + } + + ModelContract? modelByKey(String key) { + final model = resolveModel(key); + if (model == null) { + return null; + } + return models[model]; + } +} + +void _validateRelations(Map models) { + for (final model in models.values) { + for (final relation in model.relations.values) { + if (relation.sourceFields.isEmpty || relation.targetFields.isEmpty) { + throw ContractDefinitionException( + code: 'CONTRACT.RELATION_FIELDS_EMPTY', + message: 'Relation fields cannot be empty.', + details: { + 'model': model.name, + 'relation': relation.name, + }, + ); + } + + if (relation.sourceFields.length != relation.targetFields.length) { + throw ContractDefinitionException( + code: 'CONTRACT.RELATION_FIELD_COUNT_MISMATCH', + message: + 'Relation sourceFields and targetFields must have the same length.', + details: { + 'model': model.name, + 'relation': relation.name, + 'sourceFieldCount': relation.sourceFields.length, + 'targetFieldCount': relation.targetFields.length, + }, + ); + } + + for (final sourceField in relation.sourceFields) { + if (model.fields.contains(sourceField)) { + continue; + } + throw ContractDefinitionException( + code: 'CONTRACT.RELATION_SOURCE_FIELD_MISSING', + message: + 'Relation source field "$sourceField" does not exist on model "${model.name}".', + details: { + 'model': model.name, + 'relation': relation.name, + 'field': sourceField, + }, + ); + } + + final related = models[relation.relatedModel]; + if (related == null) { + throw ContractDefinitionException( + code: 'CONTRACT.RELATION_TARGET_MODEL_MISSING', + message: + 'Relation target model "${relation.relatedModel}" does not exist.', + details: { + 'model': model.name, + 'relation': relation.name, + 'relatedModel': relation.relatedModel, + }, + ); + } + + for (final targetField in relation.targetFields) { + if (related.fields.contains(targetField)) { + continue; + } + throw ContractDefinitionException( + code: 'CONTRACT.RELATION_TARGET_FIELD_MISSING', + message: + 'Relation target field "$targetField" does not exist on model "${relation.relatedModel}".', + details: { + 'model': model.name, + 'relation': relation.name, + 'relatedModel': relation.relatedModel, + 'field': targetField, + }, + ); + } + } + } +} + +String _uppercaseFirst(String value) { + if (value.isEmpty) { + return value; + } + return value[0].toUpperCase() + value.substring(1); +} + +String _lowercaseFirst(String value) { + if (value.isEmpty) { + return value; + } + return value[0].toLowerCase() + value.substring(1); +} diff --git a/pub/orm/lib/src/engine/engine.dart b/pub/orm/lib/src/engine/engine.dart new file mode 100644 index 00000000..aa76a7b7 --- /dev/null +++ b/pub/orm/lib/src/engine/engine.dart @@ -0,0 +1,37 @@ +import 'package:meta/meta.dart'; + +import '../runtime/plan.dart'; + +@immutable +final class EngineResponse { + final Object? data; + final int affectedRows; + + const EngineResponse({this.data, this.affectedRows = 0}); +} + +abstract interface class RuntimeQueryable { + Future execute(OrmPlan plan); +} + +abstract interface class EngineConnection implements RuntimeQueryable { + Future transaction(); + + Future release(); +} + +abstract interface class EngineTransaction implements RuntimeQueryable { + Future commit(); + + Future rollback(); +} + +abstract interface class ConnectionCapableEngine { + Future connection(); +} + +abstract interface class OrmEngine implements RuntimeQueryable { + Future open(); + + Future close(); +} diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart new file mode 100644 index 00000000..be835f9f --- /dev/null +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -0,0 +1,262 @@ +import '../core/sort_order.dart'; +import '../runtime/plan.dart'; +import '../runtime/types.dart'; +import 'engine.dart'; + +final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { + final Map> _store; + bool _opened = false; + + MemoryEngine({Map> seed = const {}}) + : _store = _cloneStore(seed); + + @override + Future open() async { + _opened = true; + } + + @override + Future close() async { + _opened = false; + } + + @override + Future execute(OrmPlan plan) async { + _ensureOpen(); + return _executeOnStore(_store, plan); + } + + EngineResponse _executeOnStore( + Map> store, + OrmPlan plan, + ) { + final bucket = store.putIfAbsent(plan.model, () => []); + + return switch (plan.action) { + OrmAction.findMany => _findMany(bucket, plan), + OrmAction.findUnique => _findUnique(bucket, plan), + OrmAction.create => _create(bucket, plan), + OrmAction.update => _update(bucket, plan), + OrmAction.delete => _delete(bucket, plan), + }; + } + + @override + Future connection() async { + _ensureOpen(); + return _MemoryConnection(this); + } + + Map> _snapshotStore() => _cloneStore(_store); + + void _replaceStore(Map> nextStore) { + _ensureOpen(); + _store + ..clear() + ..addAll(_cloneStore(nextStore)); + } + + void _ensureOpen() { + if (_opened) { + return; + } + throw StateError('MemoryEngine is closed. Call open() before execute().'); + } + + EngineResponse _findMany(List bucket, OrmPlan plan) { + var rows = bucket.where((row) => _matches(row, plan.where)).toList(); + + if (plan.orderBy.isNotEmpty) { + rows.sort((left, right) => _compareRows(left, right, plan.orderBy)); + } + + if (plan.skip case final skip?) { + rows = skip >= rows.length ? [] : rows.sublist(skip); + } + + if (plan.take case final take?) { + rows = take >= rows.length ? rows : rows.sublist(0, take); + } + + return EngineResponse( + data: rows + .map((row) => _projectRow(row, plan.select)) + .toList(growable: false), + ); + } + + EngineResponse _findUnique(List bucket, OrmPlan plan) { + final row = bucket.cast().firstWhere( + (candidate) => candidate != null && _matches(candidate, plan.where), + orElse: () => null, + ); + return EngineResponse( + data: row == null ? null : _projectRow(row, plan.select), + ); + } + + EngineResponse _create(List bucket, OrmPlan plan) { + final row = _cloneRow(plan.data); + bucket.add(row); + return EngineResponse(data: _projectRow(row, plan.select), affectedRows: 1); + } + + EngineResponse _update(List bucket, OrmPlan plan) { + for (var index = 0; index < bucket.length; index++) { + final row = bucket[index]; + if (!_matches(row, plan.where)) { + continue; + } + + final updated = {...row, ...plan.data}; + bucket[index] = updated; + return EngineResponse( + data: _projectRow(updated, plan.select), + affectedRows: 1, + ); + } + return const EngineResponse(data: null, affectedRows: 0); + } + + EngineResponse _delete(List bucket, OrmPlan plan) { + for (var index = 0; index < bucket.length; index++) { + final row = bucket[index]; + if (!_matches(row, plan.where)) { + continue; + } + + bucket.removeAt(index); + return EngineResponse( + data: _projectRow(row, plan.select), + affectedRows: 1, + ); + } + return const EngineResponse(data: null, affectedRows: 0); + } + + bool _matches(JsonMap row, JsonMap where) { + for (final entry in where.entries) { + if (!row.containsKey(entry.key)) { + return false; + } + + if (row[entry.key] != entry.value) { + return false; + } + } + return true; + } + + int _compareRows(JsonMap left, JsonMap right, List orderBy) { + for (final order in orderBy) { + final leftValue = left[order.field]; + final rightValue = right[order.field]; + final comparison = _compareValues(leftValue, rightValue); + if (comparison == 0) { + continue; + } + return order.order == SortOrder.asc ? comparison : -comparison; + } + return 0; + } + + int _compareValues(Object? left, Object? right) { + if (left == right) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + if (left is Comparable && left.runtimeType == right.runtimeType) { + return left.compareTo(right); + } + return left.toString().compareTo(right.toString()); + } +} + +JsonMap _cloneRow(JsonMap source) => Map.unmodifiable(source); + +JsonMap _projectRow(JsonMap source, List select) { + if (select.isEmpty) { + return _cloneRow(source); + } + + final projected = { + for (final field in select) field: source[field], + }; + return Map.unmodifiable(projected); +} + +final class _MemoryConnection implements EngineConnection { + final MemoryEngine _engine; + bool _released = false; + + _MemoryConnection(this._engine); + + @override + Future execute(OrmPlan plan) { + _ensureActive(); + return _engine.execute(plan); + } + + @override + Future transaction() async { + _ensureActive(); + return _MemoryTransaction(_engine); + } + + @override + Future release() async { + _released = true; + } + + void _ensureActive() { + if (_released) { + throw StateError('Memory connection has been released.'); + } + } +} + +final class _MemoryTransaction implements EngineTransaction { + final MemoryEngine _engine; + final Map> _snapshot; + bool _completed = false; + + _MemoryTransaction(this._engine) : _snapshot = _engine._snapshotStore(); + + @override + Future commit() async { + _ensureActive(); + _engine._replaceStore(_snapshot); + _completed = true; + } + + @override + Future execute(OrmPlan plan) async { + _ensureActive(); + _engine._ensureOpen(); + return _engine._executeOnStore(_snapshot, plan); + } + + @override + Future rollback() async { + _ensureActive(); + _completed = true; + } + + void _ensureActive() { + if (_completed) { + throw StateError('Memory transaction is already completed.'); + } + } +} + +Map> _cloneStore(Map> source) { + return >{ + for (final entry in source.entries) + entry.key: List.from(entry.value.map(_cloneRow)), + }; +} diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart new file mode 100644 index 00000000..16b049d9 --- /dev/null +++ b/pub/orm/lib/src/runtime/core.dart @@ -0,0 +1,477 @@ +import 'package:meta/meta.dart'; + +import '../contract/contract.dart'; +import '../engine/engine.dart'; +import 'errors.dart'; +import 'plan.dart'; +import 'plugin.dart'; +import 'types.dart'; + +typedef MarkerHashReader = Future Function(); + +abstract interface class ContractMarkerReader { + Future readContractHash(); +} + +final class CallbackMarkerReader implements ContractMarkerReader { + final MarkerHashReader _reader; + + const CallbackMarkerReader(this._reader); + + @override + Future readContractHash() => _reader(); +} + +enum RuntimeVerifyMode { startup, onFirstUse, always } + +@immutable +final class RuntimeVerifyOptions { + final RuntimeVerifyMode mode; + final bool requireMarker; + final ContractMarkerReader? markerReader; + + const RuntimeVerifyOptions({ + this.mode = RuntimeVerifyMode.onFirstUse, + this.requireMarker = false, + this.markerReader, + }); +} + +enum RuntimeTelemetryOutcome { success, runtimeError } + +@immutable +final class RuntimeTelemetryEvent { + final String model; + final OrmAction action; + final RuntimeTelemetryOutcome outcome; + final int durationMs; + final DateTime recordedAt; + + const RuntimeTelemetryEvent({ + required this.model, + required this.action, + required this.outcome, + required this.durationMs, + required this.recordedAt, + }); +} + +abstract interface class OrmRuntimeQueryable { + Future execute(OrmPlan plan); +} + +abstract interface class OrmRuntimeConnection implements OrmRuntimeQueryable { + Future transaction(); + + Future release(); +} + +abstract interface class OrmRuntimeTransaction implements OrmRuntimeQueryable { + Future commit(); + + Future rollback(); +} + +abstract interface class RuntimeCore implements OrmRuntimeQueryable { + Future connect(); + + Future disconnect(); + + bool get isConnected; + + Future connection(); + + RuntimeTelemetryEvent? telemetry(); +} + +final class OrmRuntimeCore implements RuntimeCore { + final OrmContract contract; + final OrmEngine engine; + final RuntimeVerifyOptions verify; + final RuntimeMode mode; + final RuntimeLog log; + final List _plugins; + late final PluginContext _pluginContext; + + bool _connected = false; + bool _startupVerified = false; + bool _firstUseVerified = false; + RuntimeTelemetryEvent? _telemetry; + + OrmRuntimeCore({ + required this.contract, + required this.engine, + List plugins = const [], + this.verify = const RuntimeVerifyOptions(), + this.mode = RuntimeMode.strict, + this.log = const SilentRuntimeLog(), + }) : _plugins = _normalizePlugins(plugins) { + _pluginContext = PluginContext( + contract: contract, + engine: engine, + mode: mode, + now: DateTime.now, + log: log, + ); + } + + @override + bool get isConnected => _connected; + + @override + Future connect() async { + if (_connected) { + return; + } + + await engine.open(); + try { + _connected = true; + _startupVerified = false; + _firstUseVerified = false; + + if (verify.mode == RuntimeVerifyMode.startup) { + await _verifyMarker(); + _startupVerified = true; + } + } catch (_) { + _connected = false; + _startupVerified = false; + _firstUseVerified = false; + await engine.close(); + rethrow; + } + } + + @override + Future disconnect() async { + if (!_connected) { + return; + } + + await engine.close(); + _connected = false; + _startupVerified = false; + _firstUseVerified = false; + _telemetry = null; + } + + @override + Future execute(OrmPlan plan) { + return _executeOnQueryable(plan, engine); + } + + @override + Future connection() async { + _ensureConnected(); + await _verifyForRequest(); + + if (engine case final ConnectionCapableEngine connectionEngine) { + final engineConnection = await connectionEngine.connection(); + return _RuntimeConnection(this, engineConnection); + } + + throw RuntimeConnectionNotSupportedException(); + } + + @override + RuntimeTelemetryEvent? telemetry() => _telemetry; + + Future _executeOnQueryable( + OrmPlan plan, + RuntimeQueryable queryable, + ) async { + _ensureConnected(); + _assertPlan(plan); + await _verifyForRequest(); + + final startedAt = DateTime.now(); + var rowCount = 0; + + try { + for (final plugin in _plugins) { + await plugin.beforeExecute(plan, _pluginContext); + } + + final response = await queryable.execute(plan); + final rows = _extractRows(response.data, action: plan.action.name); + rowCount = rows.length; + for (final row in rows) { + for (final plugin in _plugins) { + await plugin.onRow(row, plan, _pluginContext); + } + } + + final result = AfterExecuteResult( + rowCount: rowCount, + affectedRows: response.affectedRows, + latencyMs: DateTime.now().difference(startedAt).inMilliseconds, + completed: true, + ); + + for (final plugin in _plugins) { + await plugin.afterExecute(plan, result, _pluginContext); + } + + _telemetry = RuntimeTelemetryEvent( + model: plan.model, + action: plan.action, + outcome: RuntimeTelemetryOutcome.success, + durationMs: result.latencyMs, + recordedAt: DateTime.now(), + ); + + return response; + } catch (error, stackTrace) { + final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; + _telemetry = RuntimeTelemetryEvent( + model: plan.model, + action: plan.action, + outcome: RuntimeTelemetryOutcome.runtimeError, + durationMs: latencyMs, + recordedAt: DateTime.now(), + ); + + for (final plugin in _plugins) { + try { + await plugin.onError(plan, error, stackTrace, _pluginContext); + } catch (_) { + // Keep original error when error observers fail. + } + } + + final result = AfterExecuteResult( + rowCount: rowCount, + affectedRows: 0, + latencyMs: latencyMs, + completed: false, + ); + for (final plugin in _plugins) { + try { + await plugin.afterExecute(plan, result, _pluginContext); + } catch (_) { + // Ignore afterExecute errors on failure path. + } + } + + rethrow; + } + } + + Future _verifyForRequest() async { + switch (verify.mode) { + case RuntimeVerifyMode.startup: + if (!_startupVerified) { + await _verifyMarker(); + _startupVerified = true; + } + case RuntimeVerifyMode.onFirstUse: + if (!_firstUseVerified) { + await _verifyMarker(); + _firstUseVerified = true; + } + case RuntimeVerifyMode.always: + await _verifyMarker(); + } + } + + Future _verifyMarker() async { + final reader = verify.markerReader; + if (reader == null) { + if (verify.requireMarker) { + throw ContractMarkerMissingException(); + } + return; + } + + final markerHash = await reader.readContractHash(); + if (markerHash == null) { + if (verify.requireMarker) { + throw ContractMarkerMissingException(); + } + return; + } + + if (markerHash != contract.hash) { + throw ContractMarkerMismatchException( + expected: contract.hash, + actual: markerHash, + ); + } + } + + void _assertPlan(OrmPlan plan) { + if (plan.contractHash != contract.hash) { + throw ContractHashMismatchException( + expected: contract.hash, + actual: plan.contractHash, + ); + } + + if (!contract.models.containsKey(plan.model)) { + throw ModelNotFoundException(plan.model, contract.models.keys); + } + + final model = contract.models[plan.model]!; + _assertKnownFields(model: model, fields: plan.where.keys, source: 'where'); + _assertKnownFields(model: model, fields: plan.data.keys, source: 'data'); + _assertKnownFields( + model: model, + fields: plan.orderBy.map((entry) => entry.field), + source: 'orderBy', + ); + _assertKnownFields(model: model, fields: plan.select, source: 'select'); + + if (plan.skip case final skip? when skip < 0) { + throw PlanInvalidPaginationException(key: 'skip', value: skip); + } + + if (plan.take case final take? when take < 0) { + throw PlanInvalidPaginationException(key: 'take', value: take); + } + } + + void _assertKnownFields({ + required ModelContract model, + required Iterable fields, + required String source, + }) { + for (final field in fields) { + if (model.fields.contains(field)) { + continue; + } + throw PlanFieldNotFoundException( + model: model.name, + field: field, + source: source, + ); + } + } + + void _ensureConnected() { + if (_connected) { + return; + } + throw ClientNotConnectedException(); + } +} + +List _extractRows(Object? data, {required String action}) { + if (data == null) { + return const []; + } + if (data is List) { + return data + .map((value) => _coerceToRow(value, action: action)) + .toList(growable: false); + } + return [_coerceToRow(data, action: action)]; +} + +JsonMap _coerceToRow(Object? value, {required String action}) { + if (value is Map) { + return Map.unmodifiable(value); + } + if (value is Map) { + return Map.unmodifiable( + value.map((key, item) => MapEntry(key.toString(), item)), + ); + } + throw RuntimeResponseShapeException( + action: action, + expected: 'Map', + actual: value, + ); +} + +final class _RuntimeConnection implements OrmRuntimeConnection { + final OrmRuntimeCore _core; + final EngineConnection _inner; + bool _released = false; + + _RuntimeConnection(this._core, this._inner); + + @override + Future execute(OrmPlan plan) { + _ensureNotReleased(); + return _core._executeOnQueryable(plan, _inner); + } + + @override + Future transaction() async { + _ensureNotReleased(); + final transaction = await _inner.transaction(); + return _RuntimeTransaction(_core, transaction); + } + + @override + Future release() async { + _released = true; + await _inner.release(); + } + + void _ensureNotReleased() { + if (_released) { + throw RuntimeConnectionReleasedException(); + } + } +} + +final class _RuntimeTransaction implements OrmRuntimeTransaction { + final OrmRuntimeCore _core; + final EngineTransaction _inner; + bool _completed = false; + + _RuntimeTransaction(this._core, this._inner); + + @override + Future commit() async { + _ensureActive(); + _completed = true; + await _inner.commit(); + } + + @override + Future rollback() async { + _ensureActive(); + _completed = true; + await _inner.rollback(); + } + + @override + Future execute(OrmPlan plan) { + _ensureActive(); + return _core._executeOnQueryable(plan, _inner); + } + + void _ensureActive() { + if (_completed) { + throw RuntimeTransactionCompletedException(); + } + } +} + +List _normalizePlugins(List plugins) { + if (plugins.isEmpty) { + return const []; + } + + final seenNames = {}; + final normalized = []; + + for (final plugin in plugins) { + final name = plugin.name.trim(); + if (name.isEmpty) { + throw PluginNameEmptyException(); + } + + final canonical = name.toLowerCase(); + if (!seenNames.add(canonical)) { + throw PluginNameDuplicateException(name); + } + + normalized.add(plugin); + } + + return List.unmodifiable(normalized); +} diff --git a/pub/orm/lib/src/runtime/errors.dart b/pub/orm/lib/src/runtime/errors.dart new file mode 100644 index 00000000..39e59d21 --- /dev/null +++ b/pub/orm/lib/src/runtime/errors.dart @@ -0,0 +1,253 @@ +import 'package:meta/meta.dart'; + +enum RuntimeErrorCategory { runtime, contract, plan, plugin } + +enum RuntimeErrorSeverity { error, warn } + +@immutable +class OrmRuntimeError implements Exception { + final String code; + final RuntimeErrorCategory category; + final RuntimeErrorSeverity severity; + final String message; + final Map details; + + OrmRuntimeError({ + required this.code, + required this.category, + required this.message, + this.severity = RuntimeErrorSeverity.error, + Map details = const {}, + }) : details = Map.unmodifiable(details); + + @override + String toString() { + if (details.isEmpty) { + return 'OrmRuntimeError[$code]: $message'; + } + return 'OrmRuntimeError[$code]: $message | details=$details'; + } +} + +OrmRuntimeError runtimeError( + String code, + String message, { + RuntimeErrorCategory? category, + RuntimeErrorSeverity severity = RuntimeErrorSeverity.error, + Map details = const {}, +}) { + return OrmRuntimeError( + code: code, + category: category ?? _resolveCategory(code), + message: message, + severity: severity, + details: details, + ); +} + +RuntimeErrorCategory _resolveCategory(String code) { + final prefix = code.split('.').first; + return switch (prefix) { + 'PLAN' => RuntimeErrorCategory.plan, + 'CONTRACT' => RuntimeErrorCategory.contract, + 'PLUGIN' || 'LINT' || 'BUDGET' => RuntimeErrorCategory.plugin, + _ => RuntimeErrorCategory.runtime, + }; +} + +final class ClientNotConnectedException extends OrmRuntimeError { + ClientNotConnectedException() + : super( + code: 'RUNTIME.CLIENT_NOT_CONNECTED', + category: RuntimeErrorCategory.runtime, + message: 'Call connect() before running ORM operations.', + ); +} + +final class ContractHashMismatchException extends OrmRuntimeError { + final String expected; + final String actual; + + ContractHashMismatchException({required this.expected, required this.actual}) + : super( + code: 'PLAN.CONTRACT_HASH_MISMATCH', + category: RuntimeErrorCategory.plan, + message: 'Plan contract hash does not match runtime contract hash.', + details: {'expected': expected, 'actual': actual}, + ); +} + +final class ModelNotFoundException extends OrmRuntimeError { + final String model; + final Iterable availableModels; + + ModelNotFoundException(this.model, this.availableModels) + : super( + code: 'PLAN.MODEL_NOT_FOUND', + category: RuntimeErrorCategory.plan, + message: 'Model "$model" was not found in the active contract.', + details: { + 'model': model, + 'availableModels': availableModels.toList(growable: false), + }, + ); +} + +final class ContractMarkerMissingException extends OrmRuntimeError { + ContractMarkerMissingException() + : super( + code: 'CONTRACT.MARKER_MISSING', + category: RuntimeErrorCategory.contract, + message: + 'Contract marker is required but was not available from marker reader.', + ); +} + +final class ContractMarkerMismatchException extends OrmRuntimeError { + final String expected; + final String actual; + + ContractMarkerMismatchException({ + required this.expected, + required this.actual, + }) : super( + code: 'CONTRACT.MARKER_MISMATCH', + category: RuntimeErrorCategory.contract, + message: 'Contract marker hash does not match runtime contract hash.', + details: {'expected': expected, 'actual': actual}, + ); +} + +final class RuntimeConnectionNotSupportedException extends OrmRuntimeError { + RuntimeConnectionNotSupportedException() + : super( + code: 'RUNTIME.CONNECTION_NOT_SUPPORTED', + category: RuntimeErrorCategory.runtime, + message: 'The configured engine does not support scoped connections.', + ); +} + +final class RuntimeConnectionReleasedException extends OrmRuntimeError { + RuntimeConnectionReleasedException() + : super( + code: 'RUNTIME.CONNECTION_RELEASED', + category: RuntimeErrorCategory.runtime, + message: 'Connection is already released.', + ); +} + +final class RuntimeTransactionCompletedException extends OrmRuntimeError { + RuntimeTransactionCompletedException() + : super( + code: 'RUNTIME.TRANSACTION_COMPLETED', + category: RuntimeErrorCategory.runtime, + message: 'Transaction is already completed.', + ); +} + +final class PluginNameEmptyException extends OrmRuntimeError { + PluginNameEmptyException() + : super( + code: 'PLUGIN.NAME_EMPTY', + category: RuntimeErrorCategory.plugin, + message: 'Plugin name cannot be empty.', + ); +} + +final class PluginNameDuplicateException extends OrmRuntimeError { + final String pluginName; + + PluginNameDuplicateException(this.pluginName) + : super( + code: 'PLUGIN.NAME_DUPLICATE', + category: RuntimeErrorCategory.plugin, + message: 'Plugin "$pluginName" is registered more than once.', + details: {'plugin': pluginName}, + ); +} + +final class PlanFieldNotFoundException extends OrmRuntimeError { + final String model; + final String field; + final String source; + + PlanFieldNotFoundException({ + required this.model, + required this.field, + required this.source, + }) : super( + code: 'PLAN.FIELD_NOT_FOUND', + category: RuntimeErrorCategory.plan, + message: + 'Field "$field" from $source is not declared on model "$model".', + details: { + 'model': model, + 'field': field, + 'source': source, + }, + ); +} + +final class PlanInvalidPaginationException extends OrmRuntimeError { + PlanInvalidPaginationException({required String key, required int value}) + : super( + code: 'PLAN.INVALID_PAGINATION', + category: RuntimeErrorCategory.plan, + message: 'Pagination value "$key" must be greater than or equal to 0.', + details: {'key': key, 'value': value}, + ); +} + +final class RuntimeCreateResultMissingException extends OrmRuntimeError { + RuntimeCreateResultMissingException({required String model}) + : super( + code: 'RUNTIME.CREATE_RESULT_MISSING', + category: RuntimeErrorCategory.runtime, + message: 'create() expected a row but got null.', + details: {'model': model}, + ); +} + +final class RuntimeResponseShapeException extends OrmRuntimeError { + RuntimeResponseShapeException({ + required String action, + required String expected, + Object? actual, + }) : super( + code: 'RUNTIME.RESPONSE_SHAPE_INVALID', + category: RuntimeErrorCategory.runtime, + message: 'Engine response shape is invalid for $action.', + details: { + 'action': action, + 'expected': expected, + 'actualType': actual?.runtimeType.toString(), + }, + ); +} + +final class IncludeRelationNotFoundException extends OrmRuntimeError { + IncludeRelationNotFoundException({ + required String model, + required String relation, + required Iterable availableRelations, + }) : super( + code: 'PLAN.RELATION_NOT_FOUND', + category: RuntimeErrorCategory.plan, + message: 'Relation "$relation" was not found on model "$model".', + details: { + 'model': model, + 'relation': relation, + 'availableRelations': availableRelations.toList(growable: false), + }, + ); +} + +final class IncludeDepthExceededException extends OrmRuntimeError { + IncludeDepthExceededException({required int maxDepth}) + : super( + code: 'RUNTIME.INCLUDE_DEPTH_EXCEEDED', + category: RuntimeErrorCategory.runtime, + message: 'Include nesting depth exceeds maximum allowed depth.', + details: {'maxDepth': maxDepth}, + ); +} diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart new file mode 100644 index 00000000..ffc26b67 --- /dev/null +++ b/pub/orm/lib/src/runtime/plan.dart @@ -0,0 +1,42 @@ +import 'package:meta/meta.dart'; + +import '../core/sort_order.dart'; +import 'types.dart'; + +enum OrmAction { findMany, findUnique, create, update, delete } + +@immutable +final class OrmOrderBy { + final String field; + final SortOrder order; + + const OrmOrderBy(this.field, {this.order = SortOrder.asc}); +} + +@immutable +final class OrmPlan { + final String contractHash; + final String model; + final OrmAction action; + final JsonMap where; + final JsonMap data; + final int? skip; + final int? take; + final List orderBy; + final List select; + + OrmPlan({ + required this.contractHash, + required this.model, + required this.action, + JsonMap where = const {}, + JsonMap data = const {}, + this.skip, + this.take, + List orderBy = const [], + List select = const [], + }) : where = Map.unmodifiable(where), + data = Map.unmodifiable(data), + orderBy = List.unmodifiable(orderBy), + select = List.unmodifiable(select); +} diff --git a/pub/orm/lib/src/runtime/plugin.dart b/pub/orm/lib/src/runtime/plugin.dart new file mode 100644 index 00000000..1d4f2054 --- /dev/null +++ b/pub/orm/lib/src/runtime/plugin.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../contract/contract.dart'; +import '../engine/engine.dart'; +import 'plan.dart'; +import 'types.dart'; + +enum RuntimeMode { strict, permissive } + +abstract interface class RuntimeLog { + void info(Object? event); + + void warn(Object? event); + + void error(Object? event); +} + +final class SilentRuntimeLog implements RuntimeLog { + const SilentRuntimeLog(); + + @override + void error(Object? event) { + // Intentionally no-op. + } + + @override + void info(Object? event) { + // Intentionally no-op. + } + + @override + void warn(Object? event) { + // Intentionally no-op. + } +} + +@immutable +final class PluginContext { + final OrmContract contract; + final OrmEngine engine; + final RuntimeMode mode; + final DateTime Function() now; + final RuntimeLog log; + + const PluginContext({ + required this.contract, + required this.engine, + required this.mode, + required this.now, + required this.log, + }); +} + +@immutable +final class AfterExecuteResult { + final int rowCount; + final int affectedRows; + final int latencyMs; + final bool completed; + + const AfterExecuteResult({ + required this.rowCount, + required this.affectedRows, + required this.latencyMs, + required this.completed, + }); +} + +abstract base class OrmPlugin { + const OrmPlugin(); + + String get name; + + FutureOr beforeExecute(OrmPlan plan, PluginContext ctx) {} + + FutureOr onRow(JsonMap row, OrmPlan plan, PluginContext ctx) {} + + FutureOr afterExecute( + OrmPlan plan, + AfterExecuteResult result, + PluginContext ctx, + ) {} + + FutureOr onError( + OrmPlan plan, + Object error, + StackTrace stackTrace, + PluginContext ctx, + ) {} +} diff --git a/pub/orm/lib/src/runtime/plugins/budgets.dart b/pub/orm/lib/src/runtime/plugins/budgets.dart new file mode 100644 index 00000000..92f552f2 --- /dev/null +++ b/pub/orm/lib/src/runtime/plugins/budgets.dart @@ -0,0 +1,109 @@ +import '../errors.dart'; +import '../plan.dart'; +import '../plugin.dart'; + +enum BudgetSeverity { warn, error } + +final class BudgetsOptions { + final int maxRows; + final int maxLatencyMs; + final BudgetSeverity rowSeverity; + final BudgetSeverity latencySeverity; + + const BudgetsOptions({ + this.maxRows = 10000, + this.maxLatencyMs = 1000, + this.rowSeverity = BudgetSeverity.error, + this.latencySeverity = BudgetSeverity.warn, + }); +} + +OrmPlugin budgets({BudgetsOptions options = const BudgetsOptions()}) { + return _BudgetsPlugin(options); +} + +final class _BudgetsPlugin extends OrmPlugin { + final BudgetsOptions options; + + const _BudgetsPlugin(this.options); + + @override + String get name => 'budgets'; + + @override + void beforeExecute(OrmPlan plan, PluginContext ctx) { + if (plan.action == OrmAction.findMany && + plan.take != null && + plan.take! > options.maxRows) { + _handle( + ctx: ctx, + severity: options.rowSeverity, + code: 'BUDGET.ROWS_EXCEEDED', + message: 'Requested row budget exceeds configured maxRows limit.', + details: { + 'maxRows': options.maxRows, + 'requestedRows': plan.take, + 'model': plan.model, + }, + ); + } + } + + @override + void afterExecute( + OrmPlan plan, + AfterExecuteResult result, + PluginContext ctx, + ) { + if (result.rowCount > options.maxRows) { + _handle( + ctx: ctx, + severity: options.rowSeverity, + code: 'BUDGET.ROWS_EXCEEDED', + message: 'Observed row count exceeds configured maxRows limit.', + details: { + 'maxRows': options.maxRows, + 'rowCount': result.rowCount, + 'model': plan.model, + }, + ); + } + + if (result.latencyMs > options.maxLatencyMs) { + _handle( + ctx: ctx, + severity: options.latencySeverity, + code: 'BUDGET.LATENCY_EXCEEDED', + message: 'Execution latency exceeds configured maxLatencyMs limit.', + details: { + 'maxLatencyMs': options.maxLatencyMs, + 'latencyMs': result.latencyMs, + 'model': plan.model, + }, + ); + } + } +} + +void _handle({ + required PluginContext ctx, + required BudgetSeverity severity, + required String code, + required String message, + required Map details, +}) { + if (severity == BudgetSeverity.error) { + throw runtimeError(code, message, details: details); + } + + if (ctx.mode == RuntimeMode.strict) { + throw runtimeError(code, message, details: details); + } + + ctx.log.warn({ + 'code': code, + 'message': message, + 'details': details, + 'severity': 'warn', + }); +} diff --git a/pub/orm/lib/src/runtime/plugins/lints.dart b/pub/orm/lib/src/runtime/plugins/lints.dart new file mode 100644 index 00000000..0cc45bd5 --- /dev/null +++ b/pub/orm/lib/src/runtime/plugins/lints.dart @@ -0,0 +1,97 @@ +import '../errors.dart'; +import '../plan.dart'; +import '../plugin.dart'; + +enum LintSeverity { warn, error } + +final class LintsOptions { + final LintSeverity mutationWithoutWhere; + final LintSeverity unboundedRead; + final LintSeverity uniqueWithoutWhere; + + const LintsOptions({ + this.mutationWithoutWhere = LintSeverity.error, + this.unboundedRead = LintSeverity.warn, + this.uniqueWithoutWhere = LintSeverity.error, + }); +} + +OrmPlugin lints({LintsOptions options = const LintsOptions()}) { + return _LintsPlugin(options); +} + +final class _LintsPlugin extends OrmPlugin { + final LintsOptions options; + + const _LintsPlugin(this.options); + + @override + String get name => 'lints'; + + @override + void beforeExecute(OrmPlan plan, PluginContext ctx) { + if (_isMutation(plan) && plan.where.isEmpty) { + _handle( + ctx: ctx, + severity: options.mutationWithoutWhere, + code: 'LINT.MUTATION_WITHOUT_WHERE', + message: + 'Mutation without where clause is blocked to prevent accidental wide updates/deletes.', + details: { + 'action': plan.action.name, + 'model': plan.model, + }, + ); + } + + if (plan.action == OrmAction.findUnique && plan.where.isEmpty) { + _handle( + ctx: ctx, + severity: options.uniqueWithoutWhere, + code: 'LINT.UNIQUE_WITHOUT_WHERE', + message: 'findUnique requires a non-empty where clause.', + details: {'model': plan.model}, + ); + } + + if (plan.action == OrmAction.findMany && plan.take == null) { + _handle( + ctx: ctx, + severity: options.unboundedRead, + code: 'LINT.UNBOUNDED_READ', + message: 'Unbounded findMany may return very large result sets.', + details: {'model': plan.model}, + ); + } + } +} + +bool _isMutation(OrmPlan plan) { + return switch (plan.action) { + OrmAction.create || OrmAction.update || OrmAction.delete => true, + OrmAction.findMany || OrmAction.findUnique => false, + }; +} + +void _handle({ + required PluginContext ctx, + required LintSeverity severity, + required String code, + required String message, + required Map details, +}) { + if (severity == LintSeverity.error) { + throw runtimeError(code, message, details: details); + } + + if (ctx.mode == RuntimeMode.strict) { + throw runtimeError(code, message, details: details); + } + + ctx.log.warn({ + 'code': code, + 'message': message, + 'details': details, + 'severity': 'warn', + }); +} diff --git a/pub/orm/lib/src/runtime/types.dart b/pub/orm/lib/src/runtime/types.dart new file mode 100644 index 00000000..aa8a825e --- /dev/null +++ b/pub/orm/lib/src/runtime/types.dart @@ -0,0 +1 @@ +typedef JsonMap = Map; diff --git a/pub/orm/lib/src/schema/relation.dart b/pub/orm/lib/src/schema/relation.dart index c25ea403..6dae59c2 100644 --- a/pub/orm/lib/src/schema/relation.dart +++ b/pub/orm/lib/src/schema/relation.dart @@ -32,7 +32,7 @@ final class Relation { final Set? references; /// A list of fields of the current model - final Set fields; + final Set? fields; /// Defines the referential action to perform when a referenced /// entry in the referenced model is being deleted. @@ -45,7 +45,7 @@ final class Relation { @literal /// Creates a relation annotation describing a model relationship. const Relation({ - required this.fields, + this.fields, this.references, this.name, this.map, diff --git a/pub/orm/lib/src/schema/schema.dart b/pub/orm/lib/src/schema/schema.dart index 5e3a1aa2..9b6df023 100644 --- a/pub/orm/lib/src/schema/schema.dart +++ b/pub/orm/lib/src/schema/schema.dart @@ -7,5 +7,5 @@ final class Schema { final String schema; @literal - const Schema(@mustBeConst this.schema); + const Schema(this.schema); } diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart new file mode 100644 index 00000000..7d7e2dfa --- /dev/null +++ b/pub/orm/test/client/client_test.dart @@ -0,0 +1,782 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + final contract = OrmContract( + version: '1', + hash: 'contract-v1', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + aliases: {'users': 'User'}, + ); + + group('OrmClient + MemoryEngine', () { + test('runs CRUD flow', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + final users = client.collection('users'); + final created = await users.create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + expect(created['id'], 'u1'); + + final allRows = await users.findMany(); + expect(allRows, hasLength(1)); + expect(allRows.first['email'], 'a@example.com'); + + final unique = await users.findUnique( + where: {'id': 'u1'}, + ); + expect(unique?['id'], 'u1'); + + final updated = await users.update( + where: {'id': 'u1'}, + data: {'email': 'next@example.com'}, + ); + expect(updated?['email'], 'next@example.com'); + + final removed = await users.delete(where: {'id': 'u1'}); + expect(removed?['id'], 'u1'); + + final remaining = await users.findMany(); + expect(remaining, isEmpty); + await client.disconnect(); + }); + + test('requires explicit connect', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.model('User'); + + await expectLater( + users.findMany(), + throwsA(isA()), + ); + }); + + test('rejects plan with mismatched contract hash', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await expectLater( + client.execute( + OrmPlan( + contractHash: 'mismatch', + model: 'User', + action: OrmAction.findMany, + ), + ), + throwsA(isA()), + ); + }); + + test('supports ordering and pagination in memory engine', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': '1', 'email': 'c@x.com'}, + ); + await users.create( + data: {'id': '2', 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': '3', 'email': 'b@x.com'}, + ); + + final rows = await users.findMany( + orderBy: const [OrmOrderBy('email')], + skip: 1, + take: 1, + ); + + expect(rows.single['email'], 'b@x.com'); + await client.disconnect(); + }); + + test( + 'supports select projection for direct read/mutation methods', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + final created = await users.create( + data: {'id': 'u1', 'email': 'a@example.com'}, + select: const ['id'], + ); + expect(created.keys, ['id']); + expect(created['id'], 'u1'); + + final unique = await users.findUnique( + where: {'id': 'u1'}, + select: const ['email'], + ); + expect(unique?.keys, ['email']); + expect(unique?['email'], 'a@example.com'); + + final updated = await users.update( + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + select: const ['id'], + ); + expect(updated?.keys, ['id']); + expect(updated?['id'], 'u1'); + + final removed = await users.delete( + where: {'id': 'u1'}, + select: const ['email'], + ); + expect(removed?.keys, ['email']); + expect(removed?['email'], 'b@example.com'); + await client.disconnect(); + }, + ); + + test('supports immutable chained query state for reads', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': '1', 'email': 'c@x.com'}, + ); + await users.create( + data: {'id': '2', 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': '3', 'email': 'b@x.com'}, + ); + + final base = users.orderByField('email'); + final narrowed = base.skip(1).take(1); + + final all = await base.findMany(); + final page = await narrowed.findMany(); + + expect(all, hasLength(3)); + expect(page, hasLength(1)); + expect(page.single['email'], 'b@x.com'); + await client.disconnect(); + }); + + test('supports select projection through chained query state', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': '1', 'email': 'a@x.com'}, + ); + + final readQuery = users.where({'id': '1'}).select( + const ['email'], + ); + final selected = await readQuery.findUnique(); + expect(selected?.keys, ['email']); + expect(selected?['email'], 'a@x.com'); + + final mutationQuery = users.where({'id': '1'}).select( + const ['id'], + ); + final updated = await mutationQuery.update( + data: {'email': 'b@x.com'}, + ); + expect(updated?.keys, ['id']); + expect(updated?['id'], '1'); + await client.disconnect(); + }); + + test('supports chained query state for unique/update/delete', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + + final updated = await users + .where({'id': 'u1'}) + .update(data: {'email': 'b@example.com'}); + expect(updated?['email'], 'b@example.com'); + + final unique = await users.where({ + 'id': 'u1', + }).findUnique(); + expect(unique?['email'], 'b@example.com'); + + final removed = await users.where({'id': 'u1'}).delete(); + expect(removed?['id'], 'u1'); + + final remaining = await users.where({ + 'id': 'u1', + }).findUnique(); + expect(remaining, isNull); + await client.disconnect(); + }); + + test('supports custom collection registration and caching', () async { + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + collections: { + 'users': + ({required OrmModelContext client, required String modelName}) { + return _UsersCollection(client: client, modelName: modelName); + }, + }, + ); + await client.connect(); + + final first = client.collection('users'); + final second = client.model('User'); + + expect(first, same(second)); + expect(first, isA<_UsersCollection>()); + await client.disconnect(); + }); + + test('supports runtime connection and transaction APIs', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + final connection = await client.connection(); + await connection.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.create, + data: {'id': 'u1', 'email': 'a@example.com'}, + ), + ); + + final transaction = await connection.transaction(); + await transaction.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + ), + ); + await transaction.commit(); + await connection.release(); + + final row = await client + .model('User') + .findUnique(where: {'id': 'u1'}); + expect(row?['email'], 'b@example.com'); + await client.disconnect(); + }); + + test('withConnection exposes scoped model delegates', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await client.withConnection((connection) async { + await connection + .model('User') + .create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + }); + + final row = await client + .model('User') + .findUnique(where: {'id': 'u1'}); + expect(row?['email'], 'a@example.com'); + await client.disconnect(); + }); + + test('withTransaction commits on success', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await client.withTransaction((transaction) async { + await transaction + .model('User') + .create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + }); + + final row = await client + .model('User') + .findUnique(where: {'id': 'u1'}); + expect(row?['email'], 'a@example.com'); + await client.disconnect(); + }); + + test('withTransaction rolls back on error', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await expectLater( + () => client.withTransaction((transaction) async { + await transaction + .model('User') + .create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + throw StateError('stop'); + }), + throwsA(isA()), + ); + + final row = await client + .model('User') + .findUnique(where: {'id': 'u1'}); + expect(row, isNull); + await client.disconnect(); + }); + + test('rollback keeps original data in transaction API', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + + final connection = await client.connection(); + final transaction = await connection.transaction(); + await transaction.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + ), + ); + await transaction.rollback(); + await connection.release(); + + final row = await users.findUnique(where: {'id': 'u1'}); + expect(row?['email'], 'a@example.com'); + await client.disconnect(); + }); + + test('validates connection and transaction lifecycle states', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + final connection = await client.connection(); + await connection.release(); + expect( + () => connection.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + ), + ), + throwsA(isA()), + ); + + final connection2 = await client.connection(); + final transaction = await connection2.transaction(); + await transaction.commit(); + expect( + () => transaction.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + ), + ), + throwsA(isA()), + ); + await connection2.release(); + await client.disconnect(); + }); + + test('records telemetry for successful execution', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + await client.model('User').findMany(); + + final telemetry = client.telemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.model, 'User'); + expect(telemetry?.action, OrmAction.findMany); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + await client.disconnect(); + }); + + test('verify mode startup checks marker at connect once', () async { + var readCount = 0; + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.startup, + requireMarker: true, + markerReader: CallbackMarkerReader(() async { + readCount += 1; + return contract.hash; + }), + ), + ); + + await client.connect(); + expect(readCount, 1); + + await client.model('User').findMany(); + await client.model('User').findMany(); + expect(readCount, 1); + await client.disconnect(); + }); + + test( + 'verify mode onFirstUse checks marker once on first execution', + () async { + var readCount = 0; + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.onFirstUse, + requireMarker: true, + markerReader: CallbackMarkerReader(() async { + readCount += 1; + return contract.hash; + }), + ), + ); + + await client.connect(); + expect(readCount, 0); + + await client.model('User').findMany(); + expect(readCount, 1); + await client.model('User').findMany(); + expect(readCount, 1); + await client.disconnect(); + }, + ); + + test('verify mode always checks marker before each execution', () async { + var readCount = 0; + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.always, + requireMarker: true, + markerReader: CallbackMarkerReader(() async { + readCount += 1; + return contract.hash; + }), + ), + ); + + await client.connect(); + await client.model('User').findMany(); + await client.model('User').findMany(); + + expect(readCount, 2); + await client.disconnect(); + }); + + test('fails when marker is required but missing', () async { + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.onFirstUse, + requireMarker: true, + markerReader: CallbackMarkerReader(() async => null), + ), + ); + await client.connect(); + + await expectLater( + client.model('User').findMany(), + throwsA(isA()), + ); + await client.disconnect(); + }); + + test('fails when marker hash does not match contract hash', () async { + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.onFirstUse, + requireMarker: true, + markerReader: CallbackMarkerReader(() async => 'other-hash'), + ), + ); + await client.connect(); + + await expectLater( + client.model('User').findMany(), + throwsA(isA()), + ); + await client.disconnect(); + }); + + test('rejects unknown plan fields by contract', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await expectLater( + client.model('User').findMany(where: {'age': 1}), + throwsA(isA()), + ); + await expectLater( + client.model('User').create(data: {'age': 1}), + throwsA(isA()), + ); + await expectLater( + client + .model('User') + .findMany(orderBy: const [OrmOrderBy('age')]), + throwsA(isA()), + ); + await expectLater( + client.model('User').findMany(select: const ['age']), + throwsA(isA()), + ); + await client.disconnect(); + }); + + test('rejects negative pagination values', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await expectLater( + client.model('User').findMany(skip: -1), + throwsA(isA()), + ); + await expectLater( + client.model('User').findMany(take: -1), + throwsA(isA()), + ); + await client.disconnect(); + }); + }); + + test('invokes plugin hooks in order', () async { + final plugin = _TrackingPlugin(); + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: [plugin], + ); + await client.connect(); + await client.model('User').findMany(); + + expect(plugin.events, ['before:findMany', 'after:findMany']); + await client.disconnect(); + }); + + test('invokes onError when engine execution fails', () async { + final plugin = _TrackingPlugin(); + final client = OrmClient( + contract: contract, + engine: _ThrowingEngine(), + plugins: [plugin], + ); + await client.connect(); + + await expectLater( + client.model('User').findMany(), + throwsA(isA()), + ); + expect(plugin.events, [ + 'before:findMany', + 'error:findMany', + 'after:findMany', + ]); + expect(client.telemetry()?.outcome, RuntimeTelemetryOutcome.runtimeError); + await client.disconnect(); + }); + + test('strict mode blocks unbounded read through lints plugin', () async { + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: [lints()], + mode: RuntimeMode.strict, + ); + await client.connect(); + + await expectLater( + client.model('User').findMany(), + throwsA(isA()), + ); + await client.disconnect(); + }); + + test('permissive mode allows warn-level lints plugin', () async { + final logs = _CollectingLog(); + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: [ + lints( + options: const LintsOptions( + mutationWithoutWhere: LintSeverity.warn, + unboundedRead: LintSeverity.warn, + uniqueWithoutWhere: LintSeverity.warn, + ), + ), + ], + mode: RuntimeMode.permissive, + log: logs, + ); + await client.connect(); + + await client.model('User').findMany(); + expect(logs.warnEvents, isNotEmpty); + await client.disconnect(); + }); + + test('budgets plugin blocks when requested take exceeds maxRows', () async { + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: [budgets(options: const BudgetsOptions(maxRows: 1))], + ); + await client.connect(); + + await expectLater( + client.model('User').findMany(take: 2), + throwsA(isA()), + ); + await client.disconnect(); + }); + + test('rejects duplicate plugin names', () async { + expect( + () => OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: [_TrackingPlugin(), _TrackingPlugin()], + ), + throwsA(isA()), + ); + }); + + test('rejects empty plugin names', () async { + expect( + () => OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: const [_EmptyNamePlugin()], + ), + throwsA(isA()), + ); + }); + + test('returns structured runtime response shape errors', () async { + final client = OrmClient(contract: contract, engine: _BadShapeEngine()); + await client.connect(); + + await expectLater( + client.model('User').findMany(), + throwsA(isA()), + ); + await client.disconnect(); + }); +} + +final class _TrackingPlugin extends OrmPlugin { + final List events = []; + + @override + String get name => 'tracking'; + + @override + void beforeExecute(OrmPlan plan, PluginContext ctx) { + events.add('before:${plan.action.name}'); + } + + @override + void afterExecute( + OrmPlan plan, + AfterExecuteResult result, + PluginContext ctx, + ) { + events.add('after:${plan.action.name}'); + } + + @override + void onError( + OrmPlan plan, + Object error, + StackTrace stackTrace, + PluginContext ctx, + ) { + events.add('error:${plan.action.name}'); + } +} + +final class _ThrowingEngine implements OrmEngine { + @override + Future close() async {} + + @override + Future execute(OrmPlan plan) { + throw StateError('boom'); + } + + @override + Future open() async {} +} + +final class _BadShapeEngine implements OrmEngine { + @override + Future close() async {} + + @override + Future execute(OrmPlan plan) async { + return const EngineResponse(data: 'bad-shape'); + } + + @override + Future open() async {} +} + +final class _EmptyNamePlugin extends OrmPlugin { + const _EmptyNamePlugin(); + + @override + String get name => ' '; +} + +final class _UsersCollection extends ModelDelegate { + _UsersCollection({required super.client, required super.modelName}); +} + +final class _CollectingLog implements RuntimeLog { + final List infoEvents = []; + final List warnEvents = []; + final List errorEvents = []; + + @override + void error(Object? event) { + errorEvents.add(event); + } + + @override + void info(Object? event) { + infoEvents.add(event); + } + + @override + void warn(Object? event) { + warnEvents.add(event); + } +} diff --git a/pub/orm/test/style/no_as_cast_test.dart b/pub/orm/test/style/no_as_cast_test.dart new file mode 100644 index 00000000..908421d1 --- /dev/null +++ b/pub/orm/test/style/no_as_cast_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:test/test.dart'; + +void main() { + test('forbids as-casts in package source and tests', () { + final packageRoot = File.fromUri(Platform.script).parent.parent.parent; + final targets = [ + Directory('${packageRoot.path}${Platform.pathSeparator}lib'), + Directory('${packageRoot.path}${Platform.pathSeparator}test'), + ]; + + final violations = []; + + for (final file in _collectDartFiles(targets)) { + final result = parseString( + content: file.readAsStringSync(), + path: file.path, + throwIfDiagnostics: false, + ); + final visitor = _AsCastVisitor(); + result.unit.visitChildren(visitor); + + for (final expression in visitor.expressions) { + final location = result.lineInfo.getLocation( + expression.asOperator.offset, + ); + violations.add( + '${file.path}:${location.lineNumber}:${location.columnNumber}', + ); + } + } + + expect( + violations, + isEmpty, + reason: + 'Avoid using "as" casts. Use pattern matching or stronger static types.\n' + 'Violations:\n${violations.join('\n')}', + ); + }); +} + +Iterable _collectDartFiles(Iterable roots) sync* { + for (final root in roots) { + if (!root.existsSync()) { + continue; + } + + final entries = root.listSync(recursive: true, followLinks: false); + for (final entry in entries) { + if (entry is! File) { + continue; + } + if (!entry.path.endsWith('.dart')) { + continue; + } + yield entry; + } + } +} + +final class _AsCastVisitor extends RecursiveAstVisitor { + final List expressions = []; + + @override + void visitAsExpression(AsExpression node) { + expressions.add(node); + super.visitAsExpression(node); + } +} From a720d34d4612495def091c0bd6ce5ad40a563f52 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:56:23 +0800 Subject: [PATCH 017/154] docs(workflow): define multi-agent roles for orm delivery --- docs/ai-team-workflow.md | 124 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/ai-team-workflow.md diff --git a/docs/ai-team-workflow.md b/docs/ai-team-workflow.md new file mode 100644 index 00000000..5bfe3e47 --- /dev/null +++ b/docs/ai-team-workflow.md @@ -0,0 +1,124 @@ +# AI 团队协作规范(ORM) + +## 1. 目标 +- 以多 agents 并行协作推进 ORM 开发。 +- 角色固定为:产品、测试、开发1(Core/Runtime)、开发2(Repository/Target)。 +- 严格遵守 contract-first、plan 不可变、核心薄/目标层厚、显式编排原则。 + +## 2. 角色定义 + +### 产品(Product) +职责: +- 定义阶段目标、范围与非范围(Phase 1-5)。 +- 输出可验证需求:contract/plan 规则、能力矩阵、错误语义、验收标准。 +- 维护 DoR/DoD 与跨角色交接单。 +- 决策冲突:规则缺失、实现偏差、能力限制三类问题的归因。 + +输入:业务场景、架构原则、历史缺陷与反馈。 +输出:需求包、能力矩阵、验收清单、决策记录。 + +边界: +- 不直接实现 runtime/repository/target 代码。 +- 不绕过架构边界要求“隐式 fallback”。 + +### 测试(QA/Test) +职责: +- 维护分层测试:单元、集成、契约一致性、回归。 +- 校验 target/hash/profile 规则与插件流水线顺序。 +- 对失败用例进行分级(P0/P1/P2/P3)并给出最小复现。 +- 维护门禁:阻断项不允许合并。 + +输入:产品验收包、开发变更说明。 +输出:测试结果单(通过项/失败项/阻断级别/复现步骤/建议修复)。 + +边界: +- 不修改产品边界定义。 +- 不引入跨层实现逻辑,只定义验证与质量结论。 + +### 开发1(Core/Runtime) +职责: +- 负责 shared/core 与 runtime-core 的类型、校验、生命周期、插件编排与遥测。 +- 落实 verify mode(startup/on-first-use/always)与错误封装稳定性。 +- 提供 stream-first 执行接口,不引入隐藏 fallback。 + +负责模块: +- `shared/core`、`runtime-core`。 + +不负责模块: +- `repository client`、`targets`、解析器/语言层。 + +### 开发2(Repository/Target) +职责: +- 负责 repository collection API、include 策略、显式事务编排。 +- 负责 target adapter/driver 的实现分层与能力映射。 +- 落实 one logical query -> one lane statement。 + +负责模块: +- `repository client`、`targets`。 + +不负责模块: +- core 类型定义与 runtime-core 生命周期编排。 + +## 3. 交接协议(固定顺序) +1. 产品 -> 开发1/开发2/测试:下发需求包(含能力矩阵、错误语义、验收标准)。 +2. 开发1 <-> 开发2:只通过公开类型/SPI 对接,不越层调用内部实现。 +3. 开发1/开发2 -> 测试:提交变更说明(影响面、关键场景、已覆盖测试)。 +4. 测试 -> 全员:回传结构化测试结论与阻断级别。 +5. 产品 -> 全员:确认是否进入下一阶段或回滚到当前阶段修复。 + +## 4. 并行开发节奏(一个迭代) +1. 产品拆单: +- 产出一个最小闭环任务(可独立验证)。 +- 任务必须满足 DoR 后进入开发。 + +2. 双开发并行: +- 开发1实现核心能力或边界约束。 +- 开发2实现仓储/目标层能力。 +- 跨层接口变更先评审,再编码。 + +3. 测试门禁: +- 先跑改动相关测试,再跑回归桶。 +- 若出现 P0/P1 阻断,任务回到开发修复。 + +4. 完成判定: +- 满足 DoD,进入下一迭代。 + +## 5. DoR / DoD + +DoR(任务可开工): +- 范围/非范围明确。 +- contract/plan 校验规则明确。 +- 验收标准可执行且可测。 +- 角色交接对象明确。 + +DoD(任务完成): +- 代码、测试、文档三者闭环。 +- 不违反 contract-first、plan 不可变、显式编排原则。 +- 回归通过,风险与取舍已记录。 + +## 6. Commit 规则(阶段结果即提交) +- 有“可独立验证的最小闭环”就提交,不攒大包。 +- 一次提交只做一件事:行为变更 + 对应测试(必要时含最小文档)。 +- 接口变更与实现变更尽量拆开提交。 +- 缺陷修复提交必须包含回归测试。 +- 阻断缺陷修复优先于新功能提交。 + +推荐提交格式: +- `feat(scope): summary` +- `fix(scope): summary` +- `test(scope): summary` +- `docs(scope): summary` + +建议 scope: +- `runtime-core` +- `shared-core` +- `repository` +- `target` +- `qa` +- `workflow` + +## 7. 迭代内最小检查清单 +- 产品:需求包、验收标准、能力矩阵是否齐全。 +- 开发1:核心边界是否被污染、校验链是否完整。 +- 开发2:是否出现隐藏 fallback、是否保持单 lane statement。 +- 测试:是否覆盖正反例、是否有新增回归用例。 From d3c902a990df6596aa87e95dea6e04d64f770158 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:16:01 +0800 Subject: [PATCH 018/154] feat(repository): add include orchestration and relation query state --- pub/orm/lib/src/client/client.dart | 587 ++++++++++++++++++++++++--- pub/orm/test/client/client_test.dart | 268 ++++++++++++ 2 files changed, 796 insertions(+), 59 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 26f53d24..3fe214fd 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -15,9 +15,58 @@ typedef CollectionFactory = required String modelName, }); +enum IncludeExecutionStrategy { singleQuery, multiQuery } + +typedef IncludeExecutionStrategySelector = + IncludeExecutionStrategy Function({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }); + +const int _defaultMaxIncludeDepth = 4; + +IncludeExecutionStrategy defaultIncludeExecutionStrategySelector({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, +}) { + return IncludeExecutionStrategy.multiQuery; +} + +@immutable +final class IncludeSpec { + final JsonMap where; + final int? skip; + final int? take; + final List orderBy; + final List select; + final Map include; + + const IncludeSpec({ + JsonMap where = const {}, + this.skip, + this.take, + List orderBy = const [], + List select = const [], + Map include = const {}, + }) : where = where, + orderBy = orderBy, + select = select, + include = include; +} + abstract interface class OrmModelContext { OrmContract get contract; + IncludeExecutionStrategySelector get includeStrategySelector; + + int get maxIncludeDepth; + Future execute(OrmPlan plan); ModelDelegate model(String modelKey); @@ -33,6 +82,10 @@ final class OrmClient implements OrmModelContext { final Map _delegates = {}; final Map _modelAliases; final Map _collectionRegistry; + @override + final IncludeExecutionStrategySelector includeStrategySelector; + @override + final int maxIncludeDepth; OrmClient({ required this.contract, @@ -43,7 +96,10 @@ final class OrmClient implements OrmModelContext { RuntimeLog log = const SilentRuntimeLog(), Map collections = const {}, - }) : _runtime = OrmRuntimeCore( + this.includeStrategySelector = defaultIncludeExecutionStrategySelector, + this.maxIncludeDepth = _defaultMaxIncludeDepth, + }) : assert(maxIncludeDepth > 0, 'maxIncludeDepth must be greater than 0.'), + _runtime = OrmRuntimeCore( contract: contract, engine: engine, plugins: plugins, @@ -72,6 +128,8 @@ final class OrmClient implements OrmModelContext { executePlan: connection.execute, modelAliases: _modelAliases, collectionRegistry: _collectionRegistry, + includeStrategySelector: includeStrategySelector, + maxIncludeDepth: maxIncludeDepth, ); try { @@ -91,6 +149,8 @@ final class OrmClient implements OrmModelContext { executePlan: transaction.execute, modelAliases: _modelAliases, collectionRegistry: _collectionRegistry, + includeStrategySelector: includeStrategySelector, + maxIncludeDepth: maxIncludeDepth, ); try { @@ -162,12 +222,18 @@ final class OrmScopedClient implements OrmModelContext { final Map _modelAliases; final Map _collectionRegistry; final Map _delegates = {}; + @override + final IncludeExecutionStrategySelector includeStrategySelector; + @override + final int maxIncludeDepth; OrmScopedClient._({ required this.contract, required Future Function(OrmPlan plan) executePlan, required Map modelAliases, required Map collectionRegistry, + required this.includeStrategySelector, + required this.maxIncludeDepth, }) : _executePlan = executePlan, _modelAliases = modelAliases, _collectionRegistry = collectionRegistry; @@ -241,96 +307,454 @@ class ModelDelegate { ModelQuery selectField(String field) => query().selectField(field); + ModelQuery include(Map include) => + query().include(include); + + ModelQuery includeRelation( + String relation, { + IncludeSpec spec = const IncludeSpec(), + }) => query().includeRelation(relation, spec: spec); + Future> findMany({ JsonMap where = const {}, int? skip, int? take, List orderBy = const [], List select = const [], - }) async { - final response = await _client.execute( - OrmPlan( - contractHash: _client.contract.hash, - model: modelName, - action: OrmAction.findMany, - where: where, - skip: skip, - take: take, - orderBy: orderBy, - select: select, - ), + Map include = const {}, + }) { + return _findManyInternal( + action: OrmAction.findMany, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + select: select, + include: include, + includeDepth: 0, ); - return _readRows(response.data); } Future findUnique({ JsonMap where = const {}, List select = const [], - }) async { - final response = await _client.execute( - OrmPlan( - contractHash: _client.contract.hash, - model: modelName, - action: OrmAction.findUnique, - where: where, - select: select, - ), + Map include = const {}, + }) { + return _findUniqueInternal( + action: OrmAction.findUnique, + where: where, + select: select, + include: include, + includeDepth: 0, ); - return _readRow(response.data, action: 'findUnique'); } Future create({ required JsonMap data, List select = const [], + Map include = const {}, }) async { + final normalizedInclude = _normalizeInclude(include); final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, model: modelName, action: OrmAction.create, data: data, - select: select, + select: _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), ), ); + final row = _readRow(response.data, action: 'create'); if (row == null) { throw RuntimeCreateResultMissingException(model: modelName); } - return row; + + final hydratedRows = await _resolveIncludeRows( + action: OrmAction.create, + rows: [row], + include: normalizedInclude, + depth: 0, + ); + + return _shapeRows( + hydratedRows, + select: select, + include: normalizedInclude, + ).single; } Future update({ JsonMap where = const {}, required JsonMap data, List select = const [], + Map include = const {}, + }) { + return _runNullableMutation( + action: OrmAction.update, + where: where, + data: data, + select: select, + include: include, + responseAction: 'update', + ); + } + + Future delete({ + JsonMap where = const {}, + List select = const [], + Map include = const {}, + }) { + return _runNullableMutation( + action: OrmAction.delete, + where: where, + data: const {}, + select: select, + include: include, + responseAction: 'delete', + ); + } + + Future> _findManyInternal({ + required OrmAction action, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List select = const [], + Map include = const {}, + required int includeDepth, }) async { + final normalizedInclude = _normalizeInclude(include); final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, model: modelName, - action: OrmAction.update, + action: OrmAction.findMany, where: where, - data: data, - select: select, + skip: skip, + take: take, + orderBy: orderBy, + select: _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), ), ); - return _readRow(response.data, action: 'update'); + + final rows = _readRows(response.data); + final hydratedRows = await _resolveIncludeRows( + action: action, + rows: rows, + include: normalizedInclude, + depth: includeDepth, + ); + + return _shapeRows(hydratedRows, select: select, include: normalizedInclude); } - Future delete({ + Future _findUniqueInternal({ + required OrmAction action, JsonMap where = const {}, List select = const [], + Map include = const {}, + required int includeDepth, + }) async { + final normalizedInclude = _normalizeInclude(include); + final response = await _client.execute( + OrmPlan( + contractHash: _client.contract.hash, + model: modelName, + action: OrmAction.findUnique, + where: where, + select: _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), + ), + ); + + final row = _readRow(response.data, action: 'findUnique'); + if (row == null) { + return null; + } + + final hydratedRows = await _resolveIncludeRows( + action: action, + rows: [row], + include: normalizedInclude, + depth: includeDepth, + ); + + return _shapeRows( + hydratedRows, + select: select, + include: normalizedInclude, + ).single; + } + + Future _runNullableMutation({ + required OrmAction action, + required JsonMap where, + required JsonMap data, + required List select, + required Map include, + required String responseAction, }) async { + final normalizedInclude = _normalizeInclude(include); final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, model: modelName, - action: OrmAction.delete, + action: action, where: where, - select: select, + data: data, + select: _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), ), ); - return _readRow(response.data, action: 'delete'); + + final row = _readRow(response.data, action: responseAction); + if (row == null) { + return null; + } + + final hydratedRows = await _resolveIncludeRows( + action: action, + rows: [row], + include: normalizedInclude, + depth: 0, + ); + + return _shapeRows( + hydratedRows, + select: select, + include: normalizedInclude, + ).single; + } + + Future> _resolveIncludeRows({ + required OrmAction action, + required List rows, + required Map include, + required int depth, + }) { + if (rows.isEmpty || include.isEmpty) { + return Future>.value(rows); + } + + if (depth >= _client.maxIncludeDepth) { + throw IncludeDepthExceededException(maxDepth: _client.maxIncludeDepth); + } + + final strategy = _client.includeStrategySelector( + contract: _client.contract, + modelName: modelName, + action: action, + include: include, + depth: depth, + ); + + return switch (strategy) { + IncludeExecutionStrategy.singleQuery => _resolveIncludeRowsMultiQuery( + rows: rows, + include: include, + depth: depth, + ), + IncludeExecutionStrategy.multiQuery => _resolveIncludeRowsMultiQuery( + rows: rows, + include: include, + depth: depth, + ), + }; + } + + Future> _resolveIncludeRowsMultiQuery({ + required List rows, + required Map include, + required int depth, + }) async { + var hydrated = rows; + + for (final entry in include.entries) { + final relationName = entry.key; + final relationInclude = entry.value; + final relation = _resolveRelation( + model: modelName, + relationName: relationName, + ); + final relatedDelegate = _client.model(relation.relatedModel); + + final nextRows = []; + for (final row in hydrated) { + final relationWhere = _buildRelationWhere(row: row, relation: relation); + if (relationWhere == null) { + final emptyValue = relation.cardinality == RelationCardinality.one + ? null + : const []; + nextRows.add(_attachInclude(row, relationName, emptyValue)); + continue; + } + + final relatedWhere = { + ...relationInclude.where, + ...relationWhere, + }; + + final relatedRows = await relatedDelegate._findManyInternal( + action: OrmAction.findMany, + where: relatedWhere, + skip: relationInclude.skip, + take: relationInclude.take, + orderBy: relationInclude.orderBy, + select: relationInclude.select, + include: relationInclude.include, + includeDepth: depth + 1, + ); + + final relationValue = relation.cardinality == RelationCardinality.one + ? _firstOrNull(relatedRows) + : relatedRows; + + nextRows.add(_attachInclude(row, relationName, relationValue)); + } + + hydrated = nextRows; + } + + return hydrated; + } + + ModelRelationContract _resolveRelation({ + required String model, + required String relationName, + }) { + final modelContract = _client.contract.models[model]; + if (modelContract == null) { + throw ModelNotFoundException(model, _client.contract.models.keys); + } + + final relation = modelContract.relations[relationName]; + if (relation != null) { + return relation; + } + + throw IncludeRelationNotFoundException( + model: model, + relation: relationName, + availableRelations: modelContract.relations.keys, + ); + } + + JsonMap? _buildRelationWhere({ + required JsonMap row, + required ModelRelationContract relation, + }) { + final where = {}; + + for (var index = 0; index < relation.sourceFields.length; index++) { + final sourceField = relation.sourceFields[index]; + final targetField = relation.targetFields[index]; + if (!row.containsKey(sourceField)) { + return null; + } + + final value = row[sourceField]; + if (value == null) { + return null; + } + + where[targetField] = value; + } + + return where; + } + + List _expandSelectForInclude({ + required String model, + required List select, + required Map include, + }) { + if (select.isEmpty || include.isEmpty) { + return select; + } + + final expanded = {...select}; + for (final relationName in include.keys) { + final relation = _resolveRelation( + model: model, + relationName: relationName, + ); + expanded.addAll(relation.sourceFields); + } + + return expanded.toList(growable: false); + } + + List _shapeRows( + List rows, { + required List select, + required Map include, + }) { + if (rows.isEmpty) { + return const []; + } + + if (select.isEmpty && include.isEmpty) { + return rows; + } + + return rows + .map((row) => _shapeRow(row, select: select, include: include)) + .toList(growable: false); + } + + JsonMap _shapeRow( + JsonMap row, { + required List select, + required Map include, + }) { + if (select.isEmpty && include.isEmpty) { + return row; + } + + final shaped = {}; + if (select.isEmpty) { + shaped.addAll(row); + } else { + for (final field in select) { + shaped[field] = row[field]; + } + } + + for (final relationName in include.keys) { + if (row.containsKey(relationName)) { + shaped[relationName] = row[relationName]; + } + } + + return shaped; + } + + JsonMap _attachInclude(JsonMap row, String relation, Object? value) { + final next = {...row, relation: value}; + return next; + } + + Map _normalizeInclude(Map include) { + if (include.isEmpty) { + return const {}; + } + return include; } } @@ -341,6 +765,7 @@ final class ModelQueryState { final int? take; final List orderBy; final List select; + final Map include; const ModelQueryState({ this.where = const {}, @@ -348,6 +773,7 @@ final class ModelQueryState { this.take, this.orderBy = const [], this.select = const [], + this.include = const {}, }); } @@ -368,13 +794,12 @@ final class ModelQuery { List get selectedFields => _state.select; + Map get includeValues => _state.include; + ModelQuery where(JsonMap where, {bool merge = true}) { final nextWhere = merge - ? Map.unmodifiable({ - ..._state.where, - ...where, - }) - : Map.unmodifiable(where); + ? {..._state.where, ...where} + : {...where}; return _next( ModelQueryState( where: nextWhere, @@ -382,17 +807,15 @@ final class ModelQuery { take: _state.take, orderBy: _state.orderBy, select: _state.select, + include: _state.include, ), ); } ModelQuery orderBy(List orderBy, {bool append = true}) { final nextOrderBy = append - ? List.unmodifiable([ - ..._state.orderBy, - ...orderBy, - ]) - : List.unmodifiable(orderBy); + ? [..._state.orderBy, ...orderBy] + : [...orderBy]; return _next( ModelQueryState( where: _state.where, @@ -400,6 +823,7 @@ final class ModelQuery { take: _state.take, orderBy: nextOrderBy, select: _state.select, + include: _state.include, ), ); } @@ -410,8 +834,8 @@ final class ModelQuery { ModelQuery select(List fields, {bool append = false}) { final nextSelect = append - ? List.unmodifiable([..._state.select, ...fields]) - : List.unmodifiable(fields); + ? [..._state.select, ...fields] + : [...fields]; return _next( ModelQueryState( where: _state.where, @@ -419,6 +843,7 @@ final class ModelQuery { take: _state.take, orderBy: _state.orderBy, select: nextSelect, + include: _state.include, ), ); } @@ -427,6 +852,30 @@ final class ModelQuery { return select([field], append: true); } + ModelQuery include(Map include, {bool merge = true}) { + final nextInclude = merge + ? {..._state.include, ...include} + : {...include}; + + return _next( + ModelQueryState( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + select: _state.select, + include: nextInclude, + ), + ); + } + + ModelQuery includeRelation( + String relation, { + IncludeSpec spec = const IncludeSpec(), + }) { + return include({relation: spec}); + } + ModelQuery skip(int value) { return _next( ModelQueryState( @@ -435,6 +884,7 @@ final class ModelQuery { take: _state.take, orderBy: _state.orderBy, select: _state.select, + include: _state.include, ), ); } @@ -447,6 +897,7 @@ final class ModelQuery { take: value, orderBy: _state.orderBy, select: _state.select, + include: _state.include, ), ); } @@ -459,6 +910,7 @@ final class ModelQuery { take: null, orderBy: _state.orderBy, select: _state.select, + include: _state.include, ), ); } @@ -470,14 +922,22 @@ final class ModelQuery { take: _state.take, orderBy: _state.orderBy, select: _state.select, + include: _state.include, ); } - Future findUnique() => - _delegate.findUnique(where: _state.where, select: _state.select); + Future findUnique() => _delegate.findUnique( + where: _state.where, + select: _state.select, + include: _state.include, + ); Future create({required JsonMap data}) { - return _delegate.create(data: data, select: _state.select); + return _delegate.create( + data: data, + select: _state.select, + include: _state.include, + ); } Future update({required JsonMap data}) { @@ -485,11 +945,15 @@ final class ModelQuery { where: _state.where, data: data, select: _state.select, + include: _state.include, ); } - Future delete() => - _delegate.delete(where: _state.where, select: _state.select); + Future delete() => _delegate.delete( + where: _state.where, + select: _state.select, + include: _state.include, + ); ModelQuery _next(ModelQueryState nextState) => ModelQuery._(_delegate, nextState); @@ -538,7 +1002,7 @@ Map _createCollectionRegistry( registry[model] = entry.value; } - return Map.unmodifiable(registry); + return registry; } List _readRows(Object? data) { @@ -552,9 +1016,9 @@ List _readRows(Object? data) { actual: data, ); } - return List.unmodifiable( - data.map((value) => _coerceRow(value, action: 'findMany')), - ); + return data + .map((value) => _coerceRow(value, action: 'findMany')) + .toList(growable: false); } JsonMap? _readRow(Object? data, {required String action}) { @@ -566,12 +1030,10 @@ JsonMap? _readRow(Object? data, {required String action}) { JsonMap _coerceRow(Object? value, {required String action}) { if (value is Map) { - return Map.unmodifiable(value); + return Map.from(value); } if (value is Map) { - return Map.unmodifiable( - value.map((key, item) => MapEntry(key.toString(), item)), - ); + return value.map((key, item) => MapEntry(key.toString(), item)); } throw RuntimeResponseShapeException( action: action, @@ -580,6 +1042,13 @@ JsonMap _coerceRow(Object? value, {required String action}) { ); } +T? _firstOrNull(List values) { + if (values.isEmpty) { + return null; + } + return values.first; +} + String _lowercaseFirst(String value) { if (value.isEmpty) { return value; diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 7d7e2dfa..aa9f4cdc 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -15,6 +15,41 @@ void main() { aliases: {'users': 'User'}, ); + final relationalContract = OrmContract( + version: '1', + hash: 'contract-rel-v1', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + relations: { + 'posts': ModelRelationContract( + name: 'posts', + relatedModel: 'Post', + sourceFields: ['id'], + targetFields: ['userId'], + cardinality: RelationCardinality.many, + ), + }, + ), + 'Post': ModelContract( + name: 'Post', + table: 'posts', + fields: {'id', 'userId', 'title'}, + relations: { + 'author': ModelRelationContract( + name: 'author', + relatedModel: 'User', + sourceFields: ['userId'], + targetFields: ['id'], + cardinality: RelationCardinality.one, + ), + }, + ), + }, + aliases: {'users': 'User', 'posts': 'Post'}, + ); group('OrmClient + MemoryEngine', () { test('runs CRUD flow', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); @@ -222,6 +257,183 @@ void main() { await client.disconnect(); }); + test('supports include for one-to-many relation', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + final rows = await client + .model('User') + .findMany( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + select: const ['id', 'title'], + ), + }, + ); + + expect(rows, hasLength(2)); + final firstPosts = _readRowsValue(rows.first['posts']); + expect(firstPosts, hasLength(2)); + expect(firstPosts.first['id'], 'p1'); + expect(firstPosts.first['title'], 'Post A'); + + final secondPosts = _readRowsValue(rows.last['posts']); + expect(secondPosts, hasLength(1)); + expect(secondPosts.single['id'], 'p3'); + await client.disconnect(); + }); + + test('supports nested include for relation traversal', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + final row = await client + .model('Post') + .findUnique( + where: {'id': 'p1'}, + include: { + 'author': IncludeSpec( + select: const ['id', 'email'], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + select: const ['id'], + ), + }, + ), + }, + ); + + final author = _readRowValue(row?['author']); + expect(author?['id'], 'u1'); + expect(author?['email'], 'u1@example.com'); + + final authorPosts = _readRowsValue(author?['posts']); + expect(authorPosts, hasLength(2)); + expect(authorPosts.first['id'], 'p1'); + expect(authorPosts.last['id'], 'p2'); + await client.disconnect(); + }); + + test('throws when include relation is missing on model', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + await expectLater( + client + .model('User') + .findMany( + include: {'unknown': const IncludeSpec()}, + ), + throwsA(isA()), + ); + await client.disconnect(); + }); + + test('throws when nested include depth exceeds configured limit', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + maxIncludeDepth: 1, + ); + await client.connect(); + await _seedRelationalData(client); + + await expectLater( + client + .model('User') + .findMany( + include: { + 'posts': IncludeSpec( + include: {'author': const IncludeSpec()}, + ), + }, + ), + throwsA(isA()), + ); + await client.disconnect(); + }); + + test('keeps root select shape when include is present', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + final row = await client + .model('User') + .findUnique( + where: {'id': 'u1'}, + select: const ['email'], + include: { + 'posts': IncludeSpec(select: const ['title']), + }, + ); + + expect(row, isNotNull); + expect(row?.containsKey('email'), isTrue); + expect(row?.containsKey('posts'), isTrue); + expect(row?.containsKey('id'), isFalse); + + final posts = _readRowsValue(row?['posts']); + expect(posts, hasLength(2)); + expect(posts.first.containsKey('title'), isTrue); + expect(posts.first.containsKey('userId'), isFalse); + await client.disconnect(); + }); + + test('calls include strategy selector during include execution', () async { + var callCount = 0; + final callModels = []; + final callDepths = []; + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) { + callCount += 1; + callModels.add(modelName); + callDepths.add(depth); + return IncludeExecutionStrategy.multiQuery; + }, + ); + await client.connect(); + await _seedRelationalData(client); + + await client + .model('User') + .findMany( + include: {'posts': const IncludeSpec()}, + ); + + expect(callCount, greaterThan(0)); + expect(callModels.first, 'User'); + expect(callDepths.first, 0); + await client.disconnect(); + }); + test('supports custom collection registration and caching', () async { final client = OrmClient( contract: contract, @@ -692,6 +904,62 @@ void main() { }); } +Future _seedRelationalData(OrmClient client) async { + final users = client.model('User'); + final posts = client.model('Post'); + + await users.create( + data: {'id': 'u1', 'email': 'u1@example.com'}, + ); + await users.create( + data: {'id': 'u2', 'email': 'u2@example.com'}, + ); + + await posts.create( + data: {'id': 'p1', 'userId': 'u1', 'title': 'Post A'}, + ); + await posts.create( + data: {'id': 'p2', 'userId': 'u1', 'title': 'Post B'}, + ); + await posts.create( + data: {'id': 'p3', 'userId': 'u2', 'title': 'Post C'}, + ); +} + +JsonMap? _readRowValue(Object? value) { + if (value == null) { + return null; + } + if (value is Map) { + return Map.unmodifiable(value); + } + if (value is Map) { + return Map.unmodifiable( + value.map((key, item) => MapEntry(key.toString(), item)), + ); + } + fail('Expected row map but got ${value.runtimeType}.'); +} + +List _readRowsValue(Object? value) { + if (value == null) { + return const []; + } + if (value is! List) { + fail('Expected row list but got ${value.runtimeType}.'); + } + + final rows = []; + for (final entry in value) { + final row = _readRowValue(entry); + if (row == null) { + fail('Expected row map entry but got null.'); + } + rows.add(row); + } + return List.unmodifiable(rows); +} + final class _TrackingPlugin extends OrmPlugin { final List events = []; From 8c20e1e87bc0d55bbaf3029f1ce0507e491957ca Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:17:56 +0800 Subject: [PATCH 019/154] feat(target): add adapter-driver spi and engine bridge --- pub/orm/lib/orm.dart | 3 + pub/orm/lib/src/target/adapter.dart | 8 ++ pub/orm/lib/src/target/driver.dart | 7 ++ pub/orm/lib/src/target/engine.dart | 43 +++++++ pub/orm/test/client/client_test.dart | 99 +++++++++++++++ .../target/adapter_driver_engine_test.dart | 114 ++++++++++++++++++ 6 files changed, 274 insertions(+) create mode 100644 pub/orm/lib/src/target/adapter.dart create mode 100644 pub/orm/lib/src/target/driver.dart create mode 100644 pub/orm/lib/src/target/engine.dart create mode 100644 pub/orm/test/target/adapter_driver_engine_test.dart diff --git a/pub/orm/lib/orm.dart b/pub/orm/lib/orm.dart index d5ad494b..4b3c043c 100644 --- a/pub/orm/lib/orm.dart +++ b/pub/orm/lib/orm.dart @@ -12,3 +12,6 @@ export 'src/runtime/plugin.dart'; export 'src/runtime/plugins/budgets.dart'; export 'src/runtime/plugins/lints.dart'; export 'src/runtime/types.dart'; +export 'src/target/adapter.dart'; +export 'src/target/driver.dart'; +export 'src/target/engine.dart'; diff --git a/pub/orm/lib/src/target/adapter.dart b/pub/orm/lib/src/target/adapter.dart new file mode 100644 index 00000000..7ccef392 --- /dev/null +++ b/pub/orm/lib/src/target/adapter.dart @@ -0,0 +1,8 @@ +import '../engine/engine.dart'; +import '../runtime/plan.dart'; + +abstract interface class TargetAdapter { + TRequest lower(OrmPlan plan); + + EngineResponse decode(TRawResponse response, OrmPlan plan); +} diff --git a/pub/orm/lib/src/target/driver.dart b/pub/orm/lib/src/target/driver.dart new file mode 100644 index 00000000..a0344457 --- /dev/null +++ b/pub/orm/lib/src/target/driver.dart @@ -0,0 +1,7 @@ +abstract interface class TargetDriver { + Future open(); + + Future close(); + + Future execute(TRequest request); +} diff --git a/pub/orm/lib/src/target/engine.dart b/pub/orm/lib/src/target/engine.dart new file mode 100644 index 00000000..fef7843a --- /dev/null +++ b/pub/orm/lib/src/target/engine.dart @@ -0,0 +1,43 @@ +import '../engine/engine.dart'; +import '../runtime/plan.dart'; +import 'adapter.dart'; +import 'driver.dart'; + +final class AdapterDriverEngine implements OrmEngine { + final TargetAdapter adapter; + final TargetDriver driver; + bool _opened = false; + + AdapterDriverEngine({required this.adapter, required this.driver}); + + @override + Future open() async { + if (_opened) { + return; + } + await driver.open(); + _opened = true; + } + + @override + Future close() async { + if (!_opened) { + return; + } + await driver.close(); + _opened = false; + } + + @override + Future execute(OrmPlan plan) async { + if (!_opened) { + throw StateError( + 'AdapterDriverEngine is closed. Call open() before execute().', + ); + } + + final request = adapter.lower(plan); + final raw = await driver.execute(request); + return adapter.decode(raw, plan); + } +} diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index aa9f4cdc..7aff3bce 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -289,6 +289,105 @@ void main() { await client.disconnect(); }); + test('supports include for direct mutation methods', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final posts = client.model('Post'); + + final created = await posts.create( + data: {'id': 'p4', 'userId': 'u1', 'title': 'Post D'}, + include: { + 'author': IncludeSpec(select: const ['email']), + }, + ); + final createdAuthor = _readRowValue(created['author']); + expect(createdAuthor?['email'], 'u1@example.com'); + + final updated = await posts.update( + where: {'id': 'p4'}, + data: {'title': 'Post D2'}, + include: { + 'author': IncludeSpec(select: const ['id']), + }, + ); + final updatedAuthor = _readRowValue(updated?['author']); + expect(updatedAuthor?['id'], 'u1'); + + final deleted = await posts.delete( + where: {'id': 'p4'}, + include: { + 'author': IncludeSpec(select: const ['email']), + }, + ); + final deletedAuthor = _readRowValue(deleted?['author']); + expect(deletedAuthor?['email'], 'u1@example.com'); + await client.disconnect(); + }); + + test('supports include and includeRelation on chained query APIs', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final users = client.model('User'); + + final delegatedRows = await users + .include( + { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + take: 1, + ), + }, + ) + .findMany(); + expect(delegatedRows, hasLength(2)); + expect(_readRowsValue(delegatedRows.first['posts']), hasLength(1)); + + final base = users.query().where({'id': 'u1'}); + final withInclude = base.include( + { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + take: 1, + ), + }, + ); + + expect(base.includeValues, isEmpty); + expect(withInclude.includeValues.keys, ['posts']); + + final includeRow = await withInclude.findUnique(); + final includePosts = _readRowsValue(includeRow?['posts']); + expect(includePosts, hasLength(1)); + expect(includePosts.single['id'], 'p1'); + + final includeRelationRow = await users + .where({'id': 'u1'}) + .includeRelation( + 'posts', + spec: IncludeSpec( + orderBy: const [OrmOrderBy('id')], + include: { + 'author': IncludeSpec(select: const ['email']), + }, + ), + ) + .findUnique(); + + final relationPosts = _readRowsValue(includeRelationRow?['posts']); + expect(relationPosts, hasLength(2)); + final relationAuthor = _readRowValue(relationPosts.first['author']); + expect(relationAuthor?['email'], 'u1@example.com'); + await client.disconnect(); + }); + test('supports nested include for relation traversal', () async { final client = OrmClient( contract: relationalContract, diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart new file mode 100644 index 00000000..92e75fea --- /dev/null +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -0,0 +1,114 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + test('forwards open and close to target driver', () async { + final adapter = _TrackingAdapter(); + final driver = _TrackingDriver(); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + await engine.close(); + + expect(driver.openCount, 1); + expect(driver.closeCount, 1); + }); + + test('requires open before execute', () async { + final engine = AdapterDriverEngine( + adapter: _TrackingAdapter(), + driver: _TrackingDriver(), + ); + + await expectLater(engine.execute(_plan()), throwsA(isA())); + }); + + test('executes lowering and decode pipeline', () async { + final adapter = _TrackingAdapter(); + final driver = _TrackingDriver(); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final response = await engine.execute( + _plan(where: {'id': 'u1'}), + ); + + expect(adapter.loweredPlans, hasLength(1)); + expect(adapter.decodedRaw, ['driver:User:findMany']); + expect(driver.requests, ['User:findMany']); + expect(response.affectedRows, 1); + + final row = response.data; + expect(row, isA>()); + if (row case final Map map) { + expect(map['request'], 'User:findMany'); + expect(map['action'], 'findMany'); + expect(map['whereId'], 'u1'); + } else { + fail('Expected map response data.'); + } + + await engine.close(); + }); +} + +OrmPlan _plan({JsonMap where = const {}}) { + return OrmPlan( + contractHash: 'hash', + model: 'User', + action: OrmAction.findMany, + where: where, + ); +} + +final class _TrackingAdapter implements TargetAdapter { + final List loweredPlans = []; + final List decodedRaw = []; + + @override + String lower(OrmPlan plan) { + loweredPlans.add(plan); + return '${plan.model}:${plan.action.name}'; + } + + @override + EngineResponse decode(String response, OrmPlan plan) { + decodedRaw.add(response); + return EngineResponse( + data: { + 'request': '${plan.model}:${plan.action.name}', + 'action': plan.action.name, + 'whereId': plan.where['id'], + }, + affectedRows: 1, + ); + } +} + +final class _TrackingDriver implements TargetDriver { + int openCount = 0; + int closeCount = 0; + final List requests = []; + + @override + Future open() async { + openCount += 1; + } + + @override + Future close() async { + closeCount += 1; + } + + @override + Future execute(String request) async { + requests.add(request); + return 'driver:$request'; + } +} From 2b2f9443bdbec780f8e9919d15d64d63e37a8a61 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:19:43 +0800 Subject: [PATCH 020/154] feat(repository): add explicit nested create orchestration --- pub/orm/lib/src/client/client.dart | 157 +++++++++++++++++++++++++++ pub/orm/test/client/client_test.dart | 149 +++++++++++++++++-------- 2 files changed, 261 insertions(+), 45 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 3fe214fd..d9fa5363 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -72,6 +72,8 @@ abstract interface class OrmModelContext { ModelDelegate model(String modelKey); ModelDelegate collection(String modelKey); + + Future transaction(Future Function(OrmModelContext tx) run); } final class OrmClient implements OrmModelContext { @@ -191,6 +193,11 @@ final class OrmClient implements OrmModelContext { @override Future execute(OrmPlan plan) => _runtime.execute(plan); + @override + Future transaction(Future Function(OrmModelContext tx) run) { + return withTransaction((scoped) => run(scoped)); + } + String _resolveModelOrThrow({required String modelKey}) { final resolved = _resolveModel(modelKey); if (resolved != null) { @@ -256,6 +263,11 @@ final class OrmScopedClient implements OrmModelContext { @override Future execute(OrmPlan plan) => _executePlan(plan); + @override + Future transaction(Future Function(OrmModelContext tx) run) { + return run(this); + } + String _resolveModelOrThrow({required String modelKey}) { final resolved = _resolveModel(modelKey); if (resolved != null) { @@ -388,6 +400,23 @@ class ModelDelegate { ).single; } + Future createNested({ + required JsonMap data, + Map> create = const >{}, + List select = const [], + Map include = const {}, + }) { + return _client.transaction((tx) async { + final scoped = tx.model(modelName); + return scoped._createNestedInScope( + data: data, + create: create, + select: select, + include: include, + ); + }); + } + Future update({ JsonMap where = const {}, required JsonMap data, @@ -542,6 +571,69 @@ class ModelDelegate { ).single; } + Future _createNestedInScope({ + required JsonMap data, + required Map> create, + required List select, + required Map include, + }) async { + final normalizedCreate = _normalizeNestedCreate(create); + final normalizedInclude = _normalizeInclude(include); + + final created = await this.create( + data: data, + select: _expandSelectForNestedCreate( + model: modelName, + select: select, + create: normalizedCreate, + ), + ); + + for (final entry in normalizedCreate.entries) { + final relation = _resolveRelation( + model: modelName, + relationName: entry.key, + ); + final related = _client.model(relation.relatedModel); + for (final child in entry.value) { + final linkedData = _linkNestedData( + parent: created, + relationName: entry.key, + relation: relation, + data: child, + ); + await related.create(data: linkedData); + } + } + + final includeForReturn = { + for (final relationName in normalizedCreate.keys) + relationName: const IncludeSpec(), + ...normalizedInclude, + }; + + if (includeForReturn.isEmpty) { + return _shapeRows( + [created], + select: select, + include: const {}, + ).single; + } + + final hydratedRows = await _resolveIncludeRows( + action: OrmAction.create, + rows: [created], + include: includeForReturn, + depth: 0, + ); + + return _shapeRows( + hydratedRows, + select: select, + include: includeForReturn, + ).single; + } + Future> _resolveIncludeRows({ required OrmAction action, required List rows, @@ -700,6 +792,27 @@ class ModelDelegate { return expanded.toList(growable: false); } + List _expandSelectForNestedCreate({ + required String model, + required List select, + required Map> create, + }) { + if (select.isEmpty || create.isEmpty) { + return select; + } + + final expanded = {...select}; + for (final relationName in create.keys) { + final relation = _resolveRelation( + model: model, + relationName: relationName, + ); + expanded.addAll(relation.sourceFields); + } + + return expanded.toList(growable: false); + } + List _shapeRows( List rows, { required List select, @@ -756,6 +869,50 @@ class ModelDelegate { } return include; } + + Map> _normalizeNestedCreate( + Map> create, + ) { + if (create.isEmpty) { + return const >{}; + } + + final normalized = >{}; + for (final entry in create.entries) { + normalized[entry.key] = entry.value + .map((row) => Map.from(row)) + .toList(growable: false); + } + return normalized; + } + + JsonMap _linkNestedData({ + required JsonMap parent, + required String relationName, + required ModelRelationContract relation, + required JsonMap data, + }) { + final relationFields = {}; + for (var index = 0; index < relation.sourceFields.length; index++) { + final sourceField = relation.sourceFields[index]; + final targetField = relation.targetFields[index]; + if (!parent.containsKey(sourceField) || parent[sourceField] == null) { + throw runtimeError( + 'PLAN.RELATION_SOURCE_FIELD_MISSING', + 'Missing source field for nested create relation linking.', + details: { + 'model': modelName, + 'relation': relationName, + 'sourceField': sourceField, + 'targetField': targetField, + }, + ); + } + relationFields[targetField] = parent[sourceField]; + } + + return {...data, ...relationFields}; + } } @immutable diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 7aff3bce..14ace5cf 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -328,65 +328,124 @@ void main() { await client.disconnect(); }); - test('supports include and includeRelation on chained query APIs', () async { + test( + 'supports explicit nested create orchestration with transaction', + () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + + final created = await client + .model('User') + .createNested( + data: {'id': 'u3', 'email': 'u3@example.com'}, + create: >{ + 'posts': [ + {'id': 'p4', 'title': 'Post D'}, + {'id': 'p5', 'title': 'Post E'}, + ], + }, + ); + + expect(created['id'], 'u3'); + final createdPosts = _readRowsValue(created['posts']); + expect(createdPosts, hasLength(2)); + expect(createdPosts.first['userId'], 'u3'); + + final persistedPosts = await client + .model('Post') + .findMany(where: {'userId': 'u3'}); + expect(persistedPosts, hasLength(2)); + await client.disconnect(); + }, + ); + + test('nested create rolls back when child mutation fails', () async { final client = OrmClient( contract: relationalContract, engine: MemoryEngine(), ); await client.connect(); - await _seedRelationalData(client); - final users = client.model('User'); - final delegatedRows = await users - .include( - { - 'posts': IncludeSpec( - orderBy: const [OrmOrderBy('id')], - take: 1, - ), - }, - ) - .findMany(); - expect(delegatedRows, hasLength(2)); - expect(_readRowsValue(delegatedRows.first['posts']), hasLength(1)); - - final base = users.query().where({'id': 'u1'}); - final withInclude = base.include( - { + await expectLater( + client + .model('User') + .createNested( + data: {'id': 'u4', 'email': 'u4@example.com'}, + create: >{ + 'posts': [ + {'id': 'p6', 'title': 'Post F', 'bad': 1}, + ], + }, + ), + throwsA(isA()), + ); + + final rolledBackUser = await client + .model('User') + .findUnique(where: {'id': 'u4'}); + expect(rolledBackUser, isNull); + await client.disconnect(); + }); + + test( + 'supports include and includeRelation on chained query APIs', + () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final users = client.model('User'); + + final delegatedRows = await users.include({ 'posts': IncludeSpec( orderBy: const [OrmOrderBy('id')], take: 1, ), - }, - ); + }).findMany(); + expect(delegatedRows, hasLength(2)); + expect(_readRowsValue(delegatedRows.first['posts']), hasLength(1)); - expect(base.includeValues, isEmpty); - expect(withInclude.includeValues.keys, ['posts']); + final base = users.query().where({'id': 'u1'}); + final withInclude = base.include({ + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + take: 1, + ), + }); - final includeRow = await withInclude.findUnique(); - final includePosts = _readRowsValue(includeRow?['posts']); - expect(includePosts, hasLength(1)); - expect(includePosts.single['id'], 'p1'); + expect(base.includeValues, isEmpty); + expect(withInclude.includeValues.keys, ['posts']); - final includeRelationRow = await users - .where({'id': 'u1'}) - .includeRelation( - 'posts', - spec: IncludeSpec( - orderBy: const [OrmOrderBy('id')], - include: { - 'author': IncludeSpec(select: const ['email']), - }, - ), - ) - .findUnique(); + final includeRow = await withInclude.findUnique(); + final includePosts = _readRowsValue(includeRow?['posts']); + expect(includePosts, hasLength(1)); + expect(includePosts.single['id'], 'p1'); - final relationPosts = _readRowsValue(includeRelationRow?['posts']); - expect(relationPosts, hasLength(2)); - final relationAuthor = _readRowValue(relationPosts.first['author']); - expect(relationAuthor?['email'], 'u1@example.com'); - await client.disconnect(); - }); + final includeRelationRow = await users + .where({'id': 'u1'}) + .includeRelation( + 'posts', + spec: IncludeSpec( + orderBy: const [OrmOrderBy('id')], + include: { + 'author': IncludeSpec(select: const ['email']), + }, + ), + ) + .findUnique(); + + final relationPosts = _readRowsValue(includeRelationRow?['posts']); + expect(relationPosts, hasLength(2)); + final relationAuthor = _readRowValue(relationPosts.first['author']); + expect(relationAuthor?['email'], 'u1@example.com'); + await client.disconnect(); + }, + ); test('supports nested include for relation traversal', () async { final client = OrmClient( From 4503dbc371a64c31751925478808667d91d04997 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:22:31 +0800 Subject: [PATCH 021/154] feat(client): add upsert and batch helper query operations --- pub/orm/lib/src/client/client.dart | 133 +++++++++++++++++++++++++++ pub/orm/test/client/client_test.dart | 110 ++++++++++++++++++++++ 2 files changed, 243 insertions(+) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index d9fa5363..a9ec1e53 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -361,6 +361,40 @@ class ModelDelegate { ); } + Future findFirst({ + JsonMap where = const {}, + int? skip, + List orderBy = const [], + List select = const [], + Map include = const {}, + }) async { + final rows = await _findManyInternal( + action: OrmAction.findMany, + where: where, + skip: skip, + take: 1, + orderBy: orderBy, + select: select, + include: include, + includeDepth: 0, + ); + return _firstOrNull(rows); + } + + Future count({JsonMap where = const {}}) async { + final rows = await _findManyInternal( + action: OrmAction.findMany, + where: where, + includeDepth: 0, + ); + return rows.length; + } + + Future exists({JsonMap where = const {}}) async { + final row = await findFirst(where: where, select: const []); + return row != null; + } + Future create({ required JsonMap data, List select = const [], @@ -417,6 +451,73 @@ class ModelDelegate { }); } + Future> createMany({ + required List data, + List select = const [], + Map include = const {}, + }) { + return _client.transaction((tx) async { + final scoped = tx.model(modelName); + final rows = []; + for (final item in data) { + final created = await scoped.create( + data: item, + select: select, + include: include, + ); + rows.add(created); + } + return rows; + }); + } + + Future deleteMany({JsonMap where = const {}}) { + return _client.transaction((tx) async { + final scoped = tx.model(modelName); + var deleted = 0; + while (true) { + final row = await scoped.delete(where: where); + if (row == null) { + break; + } + deleted += 1; + } + return deleted; + }); + } + + Future upsert({ + required JsonMap where, + required JsonMap create, + required JsonMap update, + List select = const [], + Map include = const {}, + }) { + return _client.transaction((tx) async { + final scoped = tx.model(modelName); + final existing = await scoped.findUnique(where: where); + if (existing == null) { + return scoped.create(data: create, select: select, include: include); + } + + final updated = await scoped.update( + where: where, + data: update, + select: select, + include: include, + ); + if (updated != null) { + return updated; + } + + throw runtimeError( + 'RUNTIME.UPSERT_UPDATE_MISSING', + 'Upsert update branch did not return a row.', + details: {'model': modelName, 'where': where}, + ); + }); + } + Future update({ JsonMap where = const {}, required JsonMap data, @@ -1089,6 +1190,18 @@ final class ModelQuery { include: _state.include, ); + Future findFirst() => _delegate.findFirst( + where: _state.where, + skip: _state.skip, + orderBy: _state.orderBy, + select: _state.select, + include: _state.include, + ); + + Future count() => _delegate.count(where: _state.where); + + Future exists() => _delegate.exists(where: _state.where); + Future create({required JsonMap data}) { return _delegate.create( data: data, @@ -1097,6 +1210,26 @@ final class ModelQuery { ); } + Future> createMany({required List data}) { + return _delegate.createMany( + data: data, + select: _state.select, + include: _state.include, + ); + } + + Future deleteMany() => _delegate.deleteMany(where: _state.where); + + Future upsert({required JsonMap create, required JsonMap update}) { + return _delegate.upsert( + where: _state.where, + create: create, + update: update, + select: _state.select, + include: _state.include, + ); + } + Future update({required JsonMap data}) { return _delegate.update( where: _state.where, diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 14ace5cf..df8267e7 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -257,6 +257,116 @@ void main() { await client.disconnect(); }); + test('supports findFirst, count and exists helpers', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'b@x.com'}, + ); + await users.create( + data: {'id': 'u2', 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 'u3', 'email': 'c@x.com'}, + ); + + final first = await users.findFirst( + orderBy: const [OrmOrderBy('email')], + ); + expect(first?['id'], 'u2'); + + final total = await users.count(); + expect(total, 3); + + final existsU1 = await users.exists(where: {'id': 'u1'}); + final existsUx = await users.exists(where: {'id': 'ux'}); + expect(existsU1, isTrue); + expect(existsUx, isFalse); + await client.disconnect(); + }); + + test('supports upsert create and update branches', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + final created = await users.upsert( + where: {'id': 'u1'}, + create: {'id': 'u1', 'email': 'a@example.com'}, + update: {'email': 'b@example.com'}, + ); + expect(created['email'], 'a@example.com'); + + final updated = await users.upsert( + where: {'id': 'u1'}, + create: {'id': 'u1', 'email': 'x@example.com'}, + update: {'email': 'b@example.com'}, + ); + expect(updated['email'], 'b@example.com'); + await client.disconnect(); + }); + + test('supports createMany and deleteMany helpers', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + final createdRows = await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'a@x.com'}, + {'id': 'u3', 'email': 'b@x.com'}, + ], + ); + expect(createdRows, hasLength(3)); + + final deleted = await users.deleteMany( + where: {'email': 'a@x.com'}, + ); + expect(deleted, 2); + + final remaining = await users.count(); + expect(remaining, 1); + await client.disconnect(); + }); + + test( + 'supports query state helpers for first/count/exists/upsert/deleteMany', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + final users = client.model('User'); + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 'u2', 'email': 'a@x.com'}, + ); + + final query = users.where({'email': 'a@x.com'}); + final first = await query.orderByField('id').findFirst(); + expect(first?['id'], 'u1'); + expect(await query.count(), 2); + expect(await query.exists(), isTrue); + + final upserted = await users + .where({'id': 'u3'}) + .upsert( + create: {'id': 'u3', 'email': 'z@x.com'}, + update: {'email': 'q@x.com'}, + ); + expect(upserted['id'], 'u3'); + + final removed = await query.deleteMany(); + expect(removed, 2); + expect(await users.count(), 1); + await client.disconnect(); + }, + ); + test('supports include for one-to-many relation', () async { final client = OrmClient( contract: relationalContract, From ba19bd8fe36aa18c95ce3f963f74f525f6a31143 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:24:24 +0800 Subject: [PATCH 022/154] feat(repository): add capability-driven mutation returning fallback --- pub/orm/lib/src/client/client.dart | 58 ++++++++++++++- pub/orm/lib/src/contract/contract.dart | 16 ++++- pub/orm/test/client/client_test.dart | 97 ++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 3 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index a9ec1e53..942e5942 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -35,6 +35,9 @@ IncludeExecutionStrategy defaultIncludeExecutionStrategySelector({ required Map include, required int depth, }) { + if (contract.capabilities.includeSingleQuery) { + return IncludeExecutionStrategy.singleQuery; + } return IncludeExecutionStrategy.multiQuery; } @@ -415,7 +418,15 @@ class ModelDelegate { ), ); - final row = _readRow(response.data, action: 'create'); + var row = _readRow(response.data, action: 'create'); + if (row == null) { + if (_client.contract.capabilities.mutationReturning && + response.affectedRows > 0) { + throw RuntimeCreateResultMissingException(model: modelName); + } + row = _fallbackCreateRow(data: data); + } + if (row == null) { throw RuntimeCreateResultMissingException(model: modelName); } @@ -638,6 +649,22 @@ class ModelDelegate { required String responseAction, }) async { final normalizedInclude = _normalizeInclude(include); + JsonMap? preDeleteRow; + if (action == OrmAction.delete && + !(_client.contract.capabilities.mutationReturning)) { + preDeleteRow = await _findUniqueInternal( + action: OrmAction.findUnique, + where: where, + select: _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), + include: const {}, + includeDepth: 0, + ); + } + final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, @@ -653,7 +680,27 @@ class ModelDelegate { ), ); - final row = _readRow(response.data, action: responseAction); + var row = _readRow(response.data, action: responseAction); + if (row == null && + response.affectedRows > 0 && + !(_client.contract.capabilities.mutationReturning)) { + row = switch (action) { + OrmAction.update => await _findUniqueInternal( + action: OrmAction.findUnique, + where: where, + select: _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), + include: const {}, + includeDepth: 0, + ), + OrmAction.delete => preDeleteRow, + _ => row, + }; + } + if (row == null) { return null; } @@ -964,6 +1011,13 @@ class ModelDelegate { return next; } + JsonMap? _fallbackCreateRow({required JsonMap data}) { + if (_client.contract.capabilities.mutationReturning) { + return null; + } + return Map.from(data); + } + Map _normalizeInclude(Map include) { if (include.isEmpty) { return const {}; diff --git a/pub/orm/lib/src/contract/contract.dart b/pub/orm/lib/src/contract/contract.dart index add1e97f..c3285ddb 100644 --- a/pub/orm/lib/src/contract/contract.dart +++ b/pub/orm/lib/src/contract/contract.dart @@ -52,23 +52,37 @@ final class ModelContract { required this.name, required this.table, required Set fields, - Map relations = const {}, + Map relations = + const {}, }) : fields = Set.unmodifiable(fields), relations = Map.unmodifiable(relations); } +@immutable +final class ContractCapabilities { + final bool includeSingleQuery; + final bool mutationReturning; + + const ContractCapabilities({ + this.includeSingleQuery = false, + this.mutationReturning = true, + }); +} + @immutable final class OrmContract { final String version; final String hash; final Map models; final Map aliases; + final ContractCapabilities capabilities; OrmContract({ required this.version, required this.hash, required Map models, Map aliases = const {}, + this.capabilities = const ContractCapabilities(), }) : models = Map.unmodifiable(models), aliases = Map.unmodifiable(aliases) { _validateRelations(this.models); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index df8267e7..470cab10 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -51,6 +51,33 @@ void main() { aliases: {'users': 'User', 'posts': 'Post'}, ); group('OrmClient + MemoryEngine', () { + test('default include strategy selector follows contract capabilities', () { + final multi = defaultIncludeExecutionStrategySelector( + contract: contract, + modelName: 'User', + action: OrmAction.findMany, + include: const {'posts': IncludeSpec()}, + depth: 0, + ); + expect(multi, IncludeExecutionStrategy.multiQuery); + + final singleContract = OrmContract( + version: '1', + hash: 'contract-single', + models: contract.models, + aliases: contract.aliases, + capabilities: const ContractCapabilities(includeSingleQuery: true), + ); + final single = defaultIncludeExecutionStrategySelector( + contract: singleContract, + modelName: 'User', + action: OrmAction.findMany, + include: const {'posts': IncludeSpec()}, + depth: 0, + ); + expect(single, IncludeExecutionStrategy.singleQuery); + }); + test('runs CRUD flow', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -332,6 +359,53 @@ void main() { await client.disconnect(); }); + test( + 'falls back for create/update/delete when mutation returning is disabled', + () async { + final noReturningContract = OrmContract( + version: contract.version, + hash: contract.hash, + models: contract.models, + aliases: contract.aliases, + capabilities: const ContractCapabilities(mutationReturning: false), + ); + final client = OrmClient( + contract: noReturningContract, + engine: _NoMutationReturnEngine(inner: MemoryEngine()), + ); + await client.connect(); + final users = client.model('User'); + + final created = await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + select: const ['id', 'email'], + ); + expect(created['id'], 'u1'); + expect(created['email'], 'a@x.com'); + + final updated = await users.update( + where: {'id': 'u1'}, + data: {'email': 'b@x.com'}, + select: const ['id', 'email'], + ); + expect(updated?['id'], 'u1'); + expect(updated?['email'], 'b@x.com'); + + final removed = await users.delete( + where: {'id': 'u1'}, + select: const ['id', 'email'], + ); + expect(removed?['id'], 'u1'); + expect(removed?['email'], 'b@x.com'); + + final remaining = await users.findUnique( + where: {'id': 'u1'}, + ); + expect(remaining, isNull); + await client.disconnect(); + }, + ); + test( 'supports query state helpers for first/count/exists/upsert/deleteMany', () async { @@ -1285,6 +1359,29 @@ final class _BadShapeEngine implements OrmEngine { Future open() async {} } +final class _NoMutationReturnEngine implements OrmEngine { + final OrmEngine inner; + + _NoMutationReturnEngine({required this.inner}); + + @override + Future close() => inner.close(); + + @override + Future execute(OrmPlan plan) async { + final response = await inner.execute(plan); + if (plan.action == OrmAction.create || + plan.action == OrmAction.update || + plan.action == OrmAction.delete) { + return EngineResponse(affectedRows: response.affectedRows); + } + return response; + } + + @override + Future open() => inner.open(); +} + final class _EmptyNamePlugin extends OrmPlugin { const _EmptyNamePlugin(); From 762da4f76808b394cf43c8da9af2d3540067d432 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:26:06 +0800 Subject: [PATCH 023/154] feat(sql-runtime): add sql-family lowering and decode adapter --- pub/orm/lib/orm.dart | 2 + pub/orm/lib/src/sql/adapter.dart | 167 +++++++++++++++++++++++++ pub/orm/lib/src/sql/types.dart | 25 ++++ pub/orm/test/sql/sql_adapter_test.dart | 154 +++++++++++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 pub/orm/lib/src/sql/adapter.dart create mode 100644 pub/orm/lib/src/sql/types.dart create mode 100644 pub/orm/test/sql/sql_adapter_test.dart diff --git a/pub/orm/lib/orm.dart b/pub/orm/lib/orm.dart index 4b3c043c..7cf3af81 100644 --- a/pub/orm/lib/orm.dart +++ b/pub/orm/lib/orm.dart @@ -12,6 +12,8 @@ export 'src/runtime/plugin.dart'; export 'src/runtime/plugins/budgets.dart'; export 'src/runtime/plugins/lints.dart'; export 'src/runtime/types.dart'; +export 'src/sql/adapter.dart'; +export 'src/sql/types.dart'; export 'src/target/adapter.dart'; export 'src/target/driver.dart'; export 'src/target/engine.dart'; diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart new file mode 100644 index 00000000..d627b4e2 --- /dev/null +++ b/pub/orm/lib/src/sql/adapter.dart @@ -0,0 +1,167 @@ +import '../contract/contract.dart'; +import '../engine/engine.dart'; +import '../runtime/errors.dart'; +import '../runtime/plan.dart'; +import '../runtime/types.dart'; +import '../target/adapter.dart'; +import 'types.dart'; + +final class SqlAdapter implements TargetAdapter { + final OrmContract contract; + final String identifierQuote; + + SqlAdapter({this.identifierQuote = '"', required this.contract}); + + @override + SqlStatement lower(OrmPlan plan) { + final model = contract.models[plan.model]; + if (model == null) { + throw ModelNotFoundException(plan.model, contract.models.keys); + } + + final params = []; + final whereClause = _buildWhereClause(plan.where, params); + final orderByClause = _buildOrderByClause(plan.orderBy); + + return switch (plan.action) { + OrmAction.findMany => SqlStatement( + action: plan.action, + text: + 'SELECT ${_buildSelectColumns(plan.select)} FROM ${_id(model.table)}' + '$whereClause$orderByClause${_buildLimitOffsetClause(plan, params)}', + parameters: params, + ), + OrmAction.findUnique => SqlStatement( + action: plan.action, + text: + 'SELECT ${_buildSelectColumns(plan.select)} FROM ${_id(model.table)}' + '$whereClause$orderByClause LIMIT 1', + parameters: params, + ), + OrmAction.create => _lowerCreate(plan: plan, table: model.table), + OrmAction.update => _lowerUpdate(plan: plan, table: model.table), + OrmAction.delete => SqlStatement( + action: plan.action, + text: 'DELETE FROM ${_id(model.table)}$whereClause', + parameters: params, + ), + }; + } + + @override + EngineResponse decode(SqlResult response, OrmPlan plan) { + return switch (plan.action) { + OrmAction.findMany => EngineResponse( + data: response.rows, + affectedRows: response.affectedRows, + ), + OrmAction.findUnique => EngineResponse( + data: _firstOrNull(response.rows), + affectedRows: response.affectedRows, + ), + OrmAction.create || + OrmAction.update || + OrmAction.delete => EngineResponse( + data: _firstOrNull(response.rows), + affectedRows: response.affectedRows, + ), + }; + } + + SqlStatement _lowerCreate({required OrmPlan plan, required String table}) { + final columns = plan.data.keys.toList(growable: false); + final values = columns + .map((column) => plan.data[column]) + .toList(growable: false); + final placeholders = List.filled(columns.length, '?').join(', '); + + return SqlStatement( + action: plan.action, + text: + 'INSERT INTO ${_id(table)} (${columns.map(_id).join(', ')}) ' + 'VALUES ($placeholders)', + parameters: values, + ); + } + + SqlStatement _lowerUpdate({required OrmPlan plan, required String table}) { + final setColumns = plan.data.keys.toList(growable: false); + final setValues = setColumns + .map((column) => plan.data[column]) + .toList(growable: false); + + final params = [...setValues]; + final wherePart = _buildWhereClause(plan.where, params); + + return SqlStatement( + action: plan.action, + text: + 'UPDATE ${_id(table)} SET ' + '${setColumns.map((column) => '${_id(column)} = ?').join(', ')}' + '$wherePart', + parameters: params, + ); + } + + String _buildSelectColumns(List select) { + if (select.isEmpty) { + return '*'; + } + return select.map(_id).join(', '); + } + + String _buildWhereClause(JsonMap where, List params) { + if (where.isEmpty) { + return ''; + } + + final predicates = []; + for (final entry in where.entries) { + predicates.add('${_id(entry.key)} = ?'); + params.add(entry.value); + } + + return ' WHERE ${predicates.join(' AND ')}'; + } + + String _buildOrderByClause(List orderBy) { + if (orderBy.isEmpty) { + return ''; + } + + final clauses = orderBy.map((entry) { + final direction = entry.order.name.toUpperCase(); + return '${_id(entry.field)} $direction'; + }); + + return ' ORDER BY ${clauses.join(', ')}'; + } + + String _buildLimitOffsetClause(OrmPlan plan, List params) { + final clauses = []; + + if (plan.take case final take?) { + clauses.add(' LIMIT ?'); + params.add(take); + } + + if (plan.skip case final skip?) { + if (plan.take == null) { + clauses.add(' LIMIT -1'); + } + clauses.add(' OFFSET ?'); + params.add(skip); + } + + return clauses.join(); + } + + String _id(String value) => '$identifierQuote$value$identifierQuote'; +} + +T? _firstOrNull(List values) { + if (values.isEmpty) { + return null; + } + return values.first; +} diff --git a/pub/orm/lib/src/sql/types.dart b/pub/orm/lib/src/sql/types.dart new file mode 100644 index 00000000..15fa2bde --- /dev/null +++ b/pub/orm/lib/src/sql/types.dart @@ -0,0 +1,25 @@ +import 'package:meta/meta.dart'; + +import '../runtime/plan.dart'; +import '../runtime/types.dart'; + +@immutable +final class SqlStatement { + final OrmAction action; + final String text; + final List parameters; + + SqlStatement({ + required this.action, + required this.text, + List parameters = const [], + }) : parameters = List.from(parameters, growable: false); +} + +@immutable +final class SqlResult { + final List rows; + final int affectedRows; + + const SqlResult({this.rows = const [], this.affectedRows = 0}); +} diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart new file mode 100644 index 00000000..bd095cf1 --- /dev/null +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -0,0 +1,154 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + final contract = OrmContract( + version: '1', + hash: 'hash', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + ); + + test('lowers findMany with where/order/pagination/select', () { + final adapter = SqlAdapter(contract: contract); + final plan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: {'email': 'a@example.com'}, + orderBy: const [OrmOrderBy('id')], + take: 10, + skip: 5, + select: const ['id', 'email'], + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT "id", "email" FROM "users" WHERE "email" = ? ' + 'ORDER BY "id" ASC LIMIT ? OFFSET ?', + ); + expect(statement.parameters, ['a@example.com', 10, 5]); + }); + + test('lowers mutation statements', () { + final adapter = SqlAdapter(contract: contract); + + final createStatement = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.create, + data: {'id': 'u1', 'email': 'a@example.com'}, + ), + ); + expect( + createStatement.text, + 'INSERT INTO "users" ("id", "email") VALUES (?, ?)', + ); + expect(createStatement.parameters, ['u1', 'a@example.com']); + + final updateStatement = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + ), + ); + expect( + updateStatement.text, + 'UPDATE "users" SET "email" = ? WHERE "id" = ?', + ); + expect(updateStatement.parameters, ['b@example.com', 'u1']); + + final deleteStatement = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.delete, + where: {'id': 'u1'}, + ), + ); + expect(deleteStatement.text, 'DELETE FROM "users" WHERE "id" = ?'); + expect(deleteStatement.parameters, ['u1']); + }); + + test('decodes SQL result by action response shape', () { + final adapter = SqlAdapter(contract: contract); + + final findMany = adapter.decode( + const SqlResult( + rows: [ + {'id': 'u1'}, + {'id': 'u2'}, + ], + ), + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + ), + ); + expect(findMany.data, isA>()); + + final findUnique = adapter.decode( + const SqlResult( + rows: [ + {'id': 'u1'}, + ], + ), + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findUnique, + ), + ); + if (findUnique.data case final Map row) { + expect(row['id'], 'u1'); + } else { + fail('Expected row map for findUnique decode.'); + } + + final mutation = adapter.decode( + const SqlResult( + rows: [ + {'id': 'u1'}, + ], + affectedRows: 1, + ), + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + ), + ); + expect(mutation.affectedRows, 1); + if (mutation.data case final Map row) { + expect(row['id'], 'u1'); + } else { + fail('Expected row map for mutation decode.'); + } + }); + + test('throws when lowering unknown model', () { + final adapter = SqlAdapter(contract: contract); + + expect( + () => adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'Missing', + action: OrmAction.findMany, + ), + ), + throwsA(isA()), + ); + }); +} From 032e2eee72e9b62745305c56115a443f850d7f22 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:29:33 +0800 Subject: [PATCH 024/154] feat(runtime): enforce target storage and profile hash plan checks --- pub/orm/lib/src/client/client.dart | 12 +++++ pub/orm/lib/src/contract/contract.dart | 9 +++- pub/orm/lib/src/runtime/core.dart | 25 ++++++++++ pub/orm/lib/src/runtime/errors.dart | 45 +++++++++++++++++ pub/orm/lib/src/runtime/plan.dart | 6 +++ pub/orm/test/client/client_test.dart | 68 ++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 1 deletion(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 942e5942..29f924c8 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -407,6 +407,9 @@ class ModelDelegate { final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, + target: _client.contract.target, + storageHash: _client.contract.markerStorageHash, + profileHash: _client.contract.profileHash, model: modelName, action: OrmAction.create, data: data, @@ -574,6 +577,9 @@ class ModelDelegate { final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, + target: _client.contract.target, + storageHash: _client.contract.markerStorageHash, + profileHash: _client.contract.profileHash, model: modelName, action: OrmAction.findMany, where: where, @@ -610,6 +616,9 @@ class ModelDelegate { final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, + target: _client.contract.target, + storageHash: _client.contract.markerStorageHash, + profileHash: _client.contract.profileHash, model: modelName, action: OrmAction.findUnique, where: where, @@ -668,6 +677,9 @@ class ModelDelegate { final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, + target: _client.contract.target, + storageHash: _client.contract.markerStorageHash, + profileHash: _client.contract.profileHash, model: modelName, action: action, where: where, diff --git a/pub/orm/lib/src/contract/contract.dart b/pub/orm/lib/src/contract/contract.dart index c3285ddb..508a9d3f 100644 --- a/pub/orm/lib/src/contract/contract.dart +++ b/pub/orm/lib/src/contract/contract.dart @@ -73,6 +73,9 @@ final class ContractCapabilities { final class OrmContract { final String version; final String hash; + final String target; + final String markerStorageHash; + final String? profileHash; final Map models; final Map aliases; final ContractCapabilities capabilities; @@ -80,10 +83,14 @@ final class OrmContract { OrmContract({ required this.version, required this.hash, + this.target = 'generic', + String? markerStorageHash, + this.profileHash, required Map models, Map aliases = const {}, this.capabilities = const ContractCapabilities(), - }) : models = Map.unmodifiable(models), + }) : markerStorageHash = markerStorageHash ?? hash, + models = Map.unmodifiable(models), aliases = Map.unmodifiable(aliases) { _validateRelations(this.models); } diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 16b049d9..3a45e3a9 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -308,6 +308,31 @@ final class OrmRuntimeCore implements RuntimeCore { ); } + final planTarget = plan.target ?? contract.target; + if (planTarget != contract.target) { + throw PlanTargetMismatchException( + expected: contract.target, + actual: planTarget, + ); + } + + final planStorageHash = plan.storageHash ?? contract.markerStorageHash; + if (planStorageHash != contract.markerStorageHash) { + throw PlanStorageHashMismatchException( + expected: contract.markerStorageHash, + actual: planStorageHash, + ); + } + + final expectedProfileHash = contract.profileHash; + final planProfileHash = plan.profileHash ?? expectedProfileHash; + if (planProfileHash != expectedProfileHash) { + throw PlanProfileHashMismatchException( + expected: expectedProfileHash, + actual: planProfileHash, + ); + } + if (!contract.models.containsKey(plan.model)) { throw ModelNotFoundException(plan.model, contract.models.keys); } diff --git a/pub/orm/lib/src/runtime/errors.dart b/pub/orm/lib/src/runtime/errors.dart index 39e59d21..48a325a1 100644 --- a/pub/orm/lib/src/runtime/errors.dart +++ b/pub/orm/lib/src/runtime/errors.dart @@ -77,6 +77,51 @@ final class ContractHashMismatchException extends OrmRuntimeError { ); } +final class PlanTargetMismatchException extends OrmRuntimeError { + final String expected; + final String actual; + + PlanTargetMismatchException({required this.expected, required this.actual}) + : super( + code: 'PLAN.TARGET_MISMATCH', + category: RuntimeErrorCategory.plan, + message: 'Plan target does not match runtime contract target.', + details: {'expected': expected, 'actual': actual}, + ); +} + +final class PlanStorageHashMismatchException extends OrmRuntimeError { + final String expected; + final String actual; + + PlanStorageHashMismatchException({ + required this.expected, + required this.actual, + }) : super( + code: 'PLAN.STORAGE_HASH_MISMATCH', + category: RuntimeErrorCategory.plan, + message: + 'Plan storage hash does not match contract marker storage hash.', + details: {'expected': expected, 'actual': actual}, + ); +} + +final class PlanProfileHashMismatchException extends OrmRuntimeError { + final String? expected; + final String? actual; + + PlanProfileHashMismatchException({ + required this.expected, + required this.actual, + }) : super( + code: 'PLAN.PROFILE_HASH_MISMATCH', + category: RuntimeErrorCategory.plan, + message: + 'Plan profile hash does not match runtime contract profile hash.', + details: {'expected': expected, 'actual': actual}, + ); +} + final class ModelNotFoundException extends OrmRuntimeError { final String model; final Iterable availableModels; diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index ffc26b67..adb3a4a9 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -16,6 +16,9 @@ final class OrmOrderBy { @immutable final class OrmPlan { final String contractHash; + final String? target; + final String? storageHash; + final String? profileHash; final String model; final OrmAction action; final JsonMap where; @@ -27,6 +30,9 @@ final class OrmPlan { OrmPlan({ required this.contractHash, + this.target, + this.storageHash, + this.profileHash, required this.model, required this.action, JsonMap where = const {}, diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 470cab10..de835bb1 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -137,6 +137,74 @@ void main() { ); }); + test( + 'rejects plan with mismatched target/storage/profile metadata', + () async { + final profileContract = OrmContract( + version: '1', + hash: 'contract-meta-v1', + target: 'sql-family', + markerStorageHash: 'storage-v1', + profileHash: 'profile-v1', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + ); + final client = OrmClient( + contract: profileContract, + engine: MemoryEngine(), + ); + await client.connect(); + + await expectLater( + client.execute( + OrmPlan( + contractHash: profileContract.hash, + target: 'other-target', + storageHash: profileContract.markerStorageHash, + profileHash: profileContract.profileHash, + model: 'User', + action: OrmAction.findMany, + ), + ), + throwsA(isA()), + ); + + await expectLater( + client.execute( + OrmPlan( + contractHash: profileContract.hash, + target: profileContract.target, + storageHash: 'other-storage', + profileHash: profileContract.profileHash, + model: 'User', + action: OrmAction.findMany, + ), + ), + throwsA(isA()), + ); + + await expectLater( + client.execute( + OrmPlan( + contractHash: profileContract.hash, + target: profileContract.target, + storageHash: profileContract.markerStorageHash, + profileHash: 'other-profile', + model: 'User', + action: OrmAction.findMany, + ), + ), + throwsA(isA()), + ); + await client.disconnect(); + }, + ); + test('supports ordering and pagination in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); From 051c818c0e90cf851fc2306e0b357f11d3e93eef Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:30:51 +0800 Subject: [PATCH 025/154] feat(client): add stream-first query apis for delegate and query state --- pub/orm/lib/src/client/client.dart | 33 ++++++++++++++++++++++++++++ pub/orm/test/client/client_test.dart | 31 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 29f924c8..860eefed 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -350,6 +350,28 @@ class ModelDelegate { ); } + Stream streamMany({ + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List select = const [], + Map include = const {}, + }) async* { + final rows = await findMany( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + select: select, + include: include, + ); + + for (final row in rows) { + yield row; + } + } + Future findUnique({ JsonMap where = const {}, List select = const [], @@ -1250,6 +1272,17 @@ final class ModelQuery { ); } + Stream stream() { + return _delegate.streamMany( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + select: _state.select, + include: _state.include, + ); + } + Future findUnique() => _delegate.findUnique( where: _state.where, select: _state.select, diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index de835bb1..a13c323f 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -382,6 +382,37 @@ void main() { await client.disconnect(); }); + test('supports stream-first reads on delegate and query', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'c@x.com'}, + ); + await users.create( + data: {'id': 'u2', 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 'u3', 'email': 'b@x.com'}, + ); + + final delegateRows = await users + .streamMany(orderBy: const [OrmOrderBy('email')]) + .toList(); + expect(delegateRows, hasLength(3)); + expect(delegateRows.first['id'], 'u2'); + + final queryRows = await users + .orderByField('email') + .take(2) + .stream() + .toList(); + expect(queryRows, hasLength(2)); + expect(queryRows.last['id'], 'u3'); + await client.disconnect(); + }); + test('supports upsert create and update branches', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); From 6e6fab4029da1e9b91906cb061cb4cff1382fcd2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:35:44 +0800 Subject: [PATCH 026/154] test(repository): add connection and transaction regression coverage --- pub/orm/test/client/client_test.dart | 151 +++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index a13c323f..cb993622 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -949,6 +949,23 @@ void main() { await client.disconnect(); }); + test('withConnection executes callback and always releases connection', () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await client.withConnection((connection) async { + final rows = await connection.model('User').findMany(); + expect(rows, isEmpty); + }); + + expect(engine.connectionCount, 1); + expect(engine.connectionExecutePlans, hasLength(1)); + expect(engine.connectionExecutePlans.single.action, OrmAction.findMany); + expect(engine.releaseCount, 1); + await client.disconnect(); + }); + test('withTransaction commits on success', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -968,6 +985,26 @@ void main() { await client.disconnect(); }); + test('withTransaction success branch commits and releases connection', () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await client.withTransaction((transaction) async { + final rows = await transaction.model('User').findMany(); + expect(rows, isEmpty); + }); + + expect(engine.connectionCount, 1); + expect(engine.transactionCount, 1); + expect(engine.transactionExecutePlans, hasLength(1)); + expect(engine.transactionExecutePlans.single.action, OrmAction.findMany); + expect(engine.commitCount, 1); + expect(engine.rollbackCount, 0); + expect(engine.releaseCount, 1); + await client.disconnect(); + }); + test('withTransaction rolls back on error', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -991,6 +1028,47 @@ void main() { await client.disconnect(); }); + test('withTransaction error branch rolls back and releases connection', () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await expectLater( + () => client.withTransaction((transaction) async { + await transaction.model('User').findMany(); + throw StateError('stop'); + }), + throwsA(isA()), + ); + + expect(engine.connectionCount, 1); + expect(engine.transactionCount, 1); + expect(engine.transactionExecutePlans, hasLength(1)); + expect(engine.transactionExecutePlans.single.action, OrmAction.findMany); + expect(engine.commitCount, 0); + expect(engine.rollbackCount, 1); + expect(engine.releaseCount, 1); + await client.disconnect(); + }); + + test( + 'throws RuntimeConnectionNotSupportedException when engine has no connection support', + () async { + final client = OrmClient(contract: contract, engine: _ThrowingEngine()); + await client.connect(); + + await expectLater( + client.withConnection((_) async => null), + throwsA(isA()), + ); + await expectLater( + client.withTransaction((_) async => null), + throwsA(isA()), + ); + await client.disconnect(); + }, + ); + test('rollback keeps original data in transaction API', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -1481,6 +1559,79 @@ final class _NoMutationReturnEngine implements OrmEngine { Future open() => inner.open(); } +final class _TrackingConnectionEngine + implements OrmEngine, ConnectionCapableEngine { + var connectionCount = 0; + var transactionCount = 0; + var releaseCount = 0; + var commitCount = 0; + var rollbackCount = 0; + final List connectionExecutePlans = []; + final List transactionExecutePlans = []; + + @override + Future close() async {} + + @override + Future connection() async { + connectionCount += 1; + return _TrackingEngineConnection(this); + } + + @override + Future execute(OrmPlan plan) async { + return const EngineResponse(data: []); + } + + @override + Future open() async {} +} + +final class _TrackingEngineConnection implements EngineConnection { + final _TrackingConnectionEngine _engine; + + _TrackingEngineConnection(this._engine); + + @override + Future execute(OrmPlan plan) async { + _engine.connectionExecutePlans.add(plan); + return const EngineResponse(data: []); + } + + @override + Future release() async { + _engine.releaseCount += 1; + } + + @override + Future transaction() async { + _engine.transactionCount += 1; + return _TrackingEngineTransaction(_engine); + } +} + +final class _TrackingEngineTransaction implements EngineTransaction { + final _TrackingConnectionEngine _engine; + + _TrackingEngineTransaction(this._engine); + + @override + Future commit() async { + _engine.commitCount += 1; + } + + @override + Future execute(OrmPlan plan) async { + _engine.transactionExecutePlans.add(plan); + return const EngineResponse(data: []); + } + + @override + Future rollback() async { + _engine.rollbackCount += 1; + } +} + final class _EmptyNamePlugin extends OrmPlugin { const _EmptyNamePlugin(); From 7cca8fb36a933a4c8fdd78907d0c107c4222b367 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:37:50 +0800 Subject: [PATCH 027/154] feat(sql): add marker reader for runtime verification --- pub/orm/lib/orm.dart | 1 + pub/orm/lib/src/sql/marker_reader.dart | 186 ++++++++++++++ pub/orm/test/sql/sql_marker_reader_test.dart | 250 +++++++++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 pub/orm/lib/src/sql/marker_reader.dart create mode 100644 pub/orm/test/sql/sql_marker_reader_test.dart diff --git a/pub/orm/lib/orm.dart b/pub/orm/lib/orm.dart index 7cf3af81..42fea593 100644 --- a/pub/orm/lib/orm.dart +++ b/pub/orm/lib/orm.dart @@ -13,6 +13,7 @@ export 'src/runtime/plugins/budgets.dart'; export 'src/runtime/plugins/lints.dart'; export 'src/runtime/types.dart'; export 'src/sql/adapter.dart'; +export 'src/sql/marker_reader.dart'; export 'src/sql/types.dart'; export 'src/target/adapter.dart'; export 'src/target/driver.dart'; diff --git a/pub/orm/lib/src/sql/marker_reader.dart b/pub/orm/lib/src/sql/marker_reader.dart new file mode 100644 index 00000000..b46ac3f8 --- /dev/null +++ b/pub/orm/lib/src/sql/marker_reader.dart @@ -0,0 +1,186 @@ +import 'package:meta/meta.dart'; + +import '../runtime/core.dart'; +import '../runtime/errors.dart'; +import '../runtime/types.dart'; + +typedef SqlMarkerQueryRunner = + Future Function(SqlMarkerQuery query); + +@immutable +final class SqlMarkerQuery { + final String sql; + final List parameters; + + SqlMarkerQuery({ + required this.sql, + List parameters = const [], + }) : parameters = List.from(parameters, growable: false); +} + +@immutable +final class SqlMarkerQueryResult { + final List rows; + + const SqlMarkerQueryResult({this.rows = const []}); +} + +abstract interface class SqlMarkerQueryExecutor { + Future query(SqlMarkerQuery query); +} + +final class CallbackSqlMarkerQueryExecutor implements SqlMarkerQueryExecutor { + final SqlMarkerQueryRunner _runner; + + const CallbackSqlMarkerQueryExecutor(this._runner); + + @override + Future query(SqlMarkerQuery query) => _runner(query); +} + +final class SqlContractMarkerReader implements ContractMarkerReader { + static const String defaultHashColumn = 'storage_hash'; + + final SqlMarkerQueryExecutor executor; + final SqlMarkerQuery query; + final String hashColumn; + + SqlContractMarkerReader({ + required this.executor, + SqlMarkerQuery? query, + this.hashColumn = defaultHashColumn, + }) : query = + query ?? + SqlMarkerQuery( + sql: 'SELECT storage_hash FROM orm_contract.marker WHERE id = ?', + parameters: const [1], + ) { + if (hashColumn.trim().isEmpty) { + throw ArgumentError.value(hashColumn, 'hashColumn', 'must not be empty'); + } + } + + @override + Future readContractHash() async { + final SqlMarkerQueryResult result; + try { + result = await executor.query(query); + } catch (error, stackTrace) { + if (error is OrmRuntimeError) { + rethrow; + } + Error.throwWithStackTrace( + SqlMarkerQueryExecutionException( + sql: query.sql, + causeType: error.runtimeType.toString(), + ), + stackTrace, + ); + } + + final rows = result.rows; + if (rows.isEmpty) { + return null; + } + + if (rows.length > 1) { + throw SqlMarkerMultipleRowsException(rowCount: rows.length); + } + + final row = rows.single; + if (!row.containsKey(hashColumn)) { + throw SqlMarkerColumnMissingException( + column: hashColumn, + availableColumns: row.keys, + ); + } + + final markerHash = row[hashColumn]; + if (markerHash == null) { + throw SqlMarkerHashNullException(column: hashColumn); + } + + if (markerHash is! String) { + throw SqlMarkerHashTypeException( + column: hashColumn, + actualType: markerHash.runtimeType.toString(), + ); + } + + final normalized = markerHash.trim(); + if (normalized.isEmpty) { + throw SqlMarkerHashEmptyException(column: hashColumn); + } + + return normalized; + } +} + +final class SqlMarkerQueryExecutionException extends OrmRuntimeError { + SqlMarkerQueryExecutionException({ + required String sql, + required String causeType, + }) : super( + code: 'RUNTIME.SQL_MARKER_QUERY_FAILED', + category: RuntimeErrorCategory.runtime, + message: 'SQL marker query execution failed.', + details: {'sql': sql, 'causeType': causeType}, + ); +} + +final class SqlMarkerMultipleRowsException extends OrmRuntimeError { + SqlMarkerMultipleRowsException({required int rowCount}) + : super( + code: 'RUNTIME.SQL_MARKER_MULTIPLE_ROWS', + category: RuntimeErrorCategory.runtime, + message: 'SQL marker query must return at most one row.', + details: {'rowCount': rowCount}, + ); +} + +final class SqlMarkerColumnMissingException extends OrmRuntimeError { + SqlMarkerColumnMissingException({ + required String column, + required Iterable availableColumns, + }) : super( + code: 'RUNTIME.SQL_MARKER_COLUMN_MISSING', + category: RuntimeErrorCategory.runtime, + message: 'SQL marker query result is missing required column.', + details: { + 'column': column, + 'availableColumns': availableColumns.toList(growable: false), + }, + ); +} + +final class SqlMarkerHashNullException extends OrmRuntimeError { + SqlMarkerHashNullException({required String column}) + : super( + code: 'RUNTIME.SQL_MARKER_HASH_NULL', + category: RuntimeErrorCategory.runtime, + message: 'SQL marker hash column value cannot be null.', + details: {'column': column}, + ); +} + +final class SqlMarkerHashTypeException extends OrmRuntimeError { + SqlMarkerHashTypeException({ + required String column, + required String actualType, + }) : super( + code: 'RUNTIME.SQL_MARKER_HASH_TYPE_INVALID', + category: RuntimeErrorCategory.runtime, + message: 'SQL marker hash column value must be a string.', + details: {'column': column, 'actualType': actualType}, + ); +} + +final class SqlMarkerHashEmptyException extends OrmRuntimeError { + SqlMarkerHashEmptyException({required String column}) + : super( + code: 'RUNTIME.SQL_MARKER_HASH_EMPTY', + category: RuntimeErrorCategory.runtime, + message: 'SQL marker hash column value cannot be empty.', + details: {'column': column}, + ); +} diff --git a/pub/orm/test/sql/sql_marker_reader_test.dart b/pub/orm/test/sql/sql_marker_reader_test.dart new file mode 100644 index 00000000..36d63562 --- /dev/null +++ b/pub/orm/test/sql/sql_marker_reader_test.dart @@ -0,0 +1,250 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + group('SqlContractMarkerReader', () { + test('implements ContractMarkerReader for runtime verify', () { + final reader = SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult(), + ), + ); + + expect(reader, isA()); + }); + + test('reads marker hash from single-row result', () async { + SqlMarkerQuery? capturedQuery; + final reader = SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor((query) async { + capturedQuery = query; + return const SqlMarkerQueryResult( + rows: [ + {'storage_hash': 'hash-v1'}, + ], + ); + }), + ); + + final markerHash = await reader.readContractHash(); + expect(markerHash, 'hash-v1'); + expect( + capturedQuery?.sql, + 'SELECT storage_hash FROM orm_contract.marker WHERE id = ?', + ); + expect(capturedQuery?.parameters, [1]); + }); + + test('supports custom query and hash column', () async { + SqlMarkerQuery? capturedQuery; + final reader = SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor((query) async { + capturedQuery = query; + return const SqlMarkerQueryResult( + rows: [ + {'core_hash': 'hash-v2'}, + ], + ); + }), + query: SqlMarkerQuery( + sql: 'SELECT core_hash FROM contract_marker WHERE marker_id = ?', + parameters: const [7], + ), + hashColumn: 'core_hash', + ); + + final markerHash = await reader.readContractHash(); + expect(markerHash, 'hash-v2'); + expect( + capturedQuery?.sql, + 'SELECT core_hash FROM contract_marker WHERE marker_id = ?', + ); + expect(capturedQuery?.parameters, [7]); + }); + + test('returns null for empty result', () async { + final reader = SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult(rows: []), + ), + ); + + final markerHash = await reader.readContractHash(); + expect(markerHash, isNull); + }); + + test('throws stable error when result has multiple rows', () async { + final reader = SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult( + rows: [ + {'storage_hash': 'hash-v1'}, + {'storage_hash': 'hash-v2'}, + ], + ), + ), + ); + + await expectLater( + reader.readContractHash(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'RUNTIME.SQL_MARKER_MULTIPLE_ROWS', + ) + .having((error) => error.details['rowCount'], 'rowCount', 2), + ), + ); + }); + + test('throws stable error when required column is missing', () async { + final reader = SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult( + rows: [ + {'core_hash': 'hash-v1'}, + ], + ), + ), + ); + + await expectLater( + reader.readContractHash(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'RUNTIME.SQL_MARKER_COLUMN_MISSING', + ) + .having( + (error) => error.details['column'], + 'column', + 'storage_hash', + ), + ), + ); + }); + + test('throws stable error when hash type is invalid', () async { + final reader = SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult( + rows: [ + {'storage_hash': 123}, + ], + ), + ), + ); + + await expectLater( + reader.readContractHash(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'RUNTIME.SQL_MARKER_HASH_TYPE_INVALID', + ) + .having( + (error) => error.details['actualType'], + 'actualType', + 'int', + ), + ), + ); + }); + + test('wraps executor errors with stable marker query code', () async { + final reader = SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => throw StateError('driver broken'), + ), + ); + + await expectLater( + reader.readContractHash(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'RUNTIME.SQL_MARKER_QUERY_FAILED', + ) + .having( + (error) => error.details['causeType'], + 'causeType', + 'StateError', + ), + ), + ); + }); + }); + + group('SqlContractMarkerReader runtime integration', () { + test('can be consumed by RuntimeVerifyOptions', () async { + final contract = _contract(hash: 'hash-v1'); + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.onFirstUse, + requireMarker: true, + markerReader: SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult( + rows: [ + {'storage_hash': 'hash-v1'}, + ], + ), + ), + ), + ), + ); + + await client.connect(); + await expectLater(client.model('User').findMany(), completes); + await client.disconnect(); + }); + + test('empty marker result maps to missing marker when required', () async { + final contract = _contract(hash: 'hash-v1'); + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.onFirstUse, + requireMarker: true, + markerReader: SqlContractMarkerReader( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult(), + ), + ), + ), + ); + + await client.connect(); + await expectLater( + client.model('User').findMany(), + throwsA(isA()), + ); + await client.disconnect(); + }); + }); +} + +OrmContract _contract({required String hash}) { + return OrmContract( + version: '1', + hash: hash, + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + ); +} From f5787a87fe58fcd5f5f298d23fac96efd254f977 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:41:14 +0800 Subject: [PATCH 028/154] feat(target): add optional connection and transaction driver SPI --- pub/orm/lib/src/target/driver.dart | 20 ++ pub/orm/lib/src/target/engine.dart | 110 ++++++++- .../target/adapter_driver_engine_test.dart | 210 ++++++++++++++++++ 3 files changed, 335 insertions(+), 5 deletions(-) diff --git a/pub/orm/lib/src/target/driver.dart b/pub/orm/lib/src/target/driver.dart index a0344457..8df748e2 100644 --- a/pub/orm/lib/src/target/driver.dart +++ b/pub/orm/lib/src/target/driver.dart @@ -5,3 +5,23 @@ abstract interface class TargetDriver { Future execute(TRequest request); } + +abstract interface class TargetDriverConnection { + Future execute(TRequest request); + + Future> transaction(); + + Future release(); +} + +abstract interface class TargetDriverTransaction { + Future execute(TRequest request); + + Future commit(); + + Future rollback(); +} + +abstract interface class TargetDriverConnectionCapable { + Future> connection(); +} diff --git a/pub/orm/lib/src/target/engine.dart b/pub/orm/lib/src/target/engine.dart index fef7843a..43457ff8 100644 --- a/pub/orm/lib/src/target/engine.dart +++ b/pub/orm/lib/src/target/engine.dart @@ -1,9 +1,11 @@ import '../engine/engine.dart'; +import '../runtime/errors.dart'; import '../runtime/plan.dart'; import 'adapter.dart'; import 'driver.dart'; -final class AdapterDriverEngine implements OrmEngine { +final class AdapterDriverEngine + implements OrmEngine, ConnectionCapableEngine { final TargetAdapter adapter; final TargetDriver driver; bool _opened = false; @@ -30,14 +32,112 @@ final class AdapterDriverEngine implements OrmEngine { @override Future execute(OrmPlan plan) async { - if (!_opened) { - throw StateError( - 'AdapterDriverEngine is closed. Call open() before execute().', + _ensureOpen(); + + final request = adapter.lower(plan); + final raw = await driver.execute(request); + return adapter.decode(raw, plan); + } + + @override + Future connection() async { + _ensureOpen(); + + if (driver + case final TargetDriverConnectionCapable + connectionDriver) { + final connection = await connectionDriver.connection(); + return _AdapterDriverConnection( + adapter: adapter, + connection: connection, ); } + throw RuntimeConnectionNotSupportedException(); + } + + void _ensureOpen() { + if (_opened) { + return; + } + throw StateError( + 'AdapterDriverEngine is closed. Call open() before execute().', + ); + } +} + +final class _AdapterDriverConnection + implements EngineConnection { + final TargetAdapter adapter; + final TargetDriverConnection connection; + bool _released = false; + + _AdapterDriverConnection({required this.adapter, required this.connection}); + + @override + Future execute(OrmPlan plan) async { + _ensureActive(); final request = adapter.lower(plan); - final raw = await driver.execute(request); + final raw = await connection.execute(request); return adapter.decode(raw, plan); } + + @override + Future transaction() async { + _ensureActive(); + final transaction = await connection.transaction(); + return _AdapterDriverTransaction( + adapter: adapter, + transaction: transaction, + ); + } + + @override + Future release() async { + _released = true; + await connection.release(); + } + + void _ensureActive() { + if (_released) { + throw StateError('Adapter driver connection has been released.'); + } + } +} + +final class _AdapterDriverTransaction + implements EngineTransaction { + final TargetAdapter adapter; + final TargetDriverTransaction transaction; + bool _completed = false; + + _AdapterDriverTransaction({required this.adapter, required this.transaction}); + + @override + Future commit() async { + _ensureActive(); + _completed = true; + await transaction.commit(); + } + + @override + Future execute(OrmPlan plan) async { + _ensureActive(); + final request = adapter.lower(plan); + final raw = await transaction.execute(request); + return adapter.decode(raw, plan); + } + + @override + Future rollback() async { + _ensureActive(); + _completed = true; + await transaction.rollback(); + } + + void _ensureActive() { + if (_completed) { + throw StateError('Adapter driver transaction is already completed.'); + } + } } diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index 92e75fea..56103272 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -17,6 +17,22 @@ void main() { expect(driver.closeCount, 1); }); + test('keeps open and close idempotent', () async { + final driver = _TrackingDriver(); + final engine = AdapterDriverEngine( + adapter: _TrackingAdapter(), + driver: driver, + ); + + await engine.open(); + await engine.open(); + await engine.close(); + await engine.close(); + + expect(driver.openCount, 1); + expect(driver.closeCount, 1); + }); + test('requires open before execute', () async { final engine = AdapterDriverEngine( adapter: _TrackingAdapter(), @@ -26,6 +42,32 @@ void main() { await expectLater(engine.execute(_plan()), throwsA(isA())); }); + test('requires open before connection', () async { + final engine = AdapterDriverEngine( + adapter: _TrackingAdapter(), + driver: _ConnectionCapableTrackingDriver(), + ); + + await expectLater(engine.connection(), throwsA(isA())); + }); + + test( + 'throws not supported when driver has no connection capability', + () async { + final engine = AdapterDriverEngine( + adapter: _TrackingAdapter(), + driver: _TrackingDriver(), + ); + + await engine.open(); + await expectLater( + engine.connection(), + throwsA(isA()), + ); + await engine.close(); + }, + ); + test('executes lowering and decode pipeline', () async { final adapter = _TrackingAdapter(); final driver = _TrackingDriver(); @@ -56,6 +98,89 @@ void main() { await engine.close(); }); + + test('supports connection lifecycle when driver is capable', () async { + final adapter = _TrackingAdapter(); + final driver = _ConnectionCapableTrackingDriver(); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final connection = await engine.connection(); + final response = await connection.execute( + _plan(where: {'id': 'u1'}), + ); + + expect(driver.connectionCount, 1); + expect(driver.connections.single.requests, ['User:findMany']); + expect(adapter.decodedRaw, ['connection:User:findMany']); + expect(response.affectedRows, 1); + + await connection.release(); + expect(driver.connections.single.releaseCount, 1); + await expectLater(connection.execute(_plan()), throwsA(isA())); + await expectLater(connection.transaction(), throwsA(isA())); + await engine.close(); + }); + + test('forwards transaction commit and marks transaction completed', () async { + final adapter = _TrackingAdapter(); + final driver = _ConnectionCapableTrackingDriver(); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final connection = await engine.connection(); + final transaction = await connection.transaction(); + await transaction.execute(_plan(where: {'id': 'u2'})); + await transaction.commit(); + + final inner = driver.connections.single.transactions.single; + expect(inner.requests, ['User:findMany']); + expect(adapter.decodedRaw, ['transaction:User:findMany']); + expect(inner.commitCount, 1); + expect(inner.rollbackCount, 0); + + await expectLater(transaction.execute(_plan()), throwsA(isA())); + await expectLater(transaction.commit(), throwsA(isA())); + await expectLater(transaction.rollback(), throwsA(isA())); + await connection.release(); + await engine.close(); + }); + + test( + 'forwards transaction rollback and marks transaction completed', + () async { + final driver = _ConnectionCapableTrackingDriver(); + final engine = AdapterDriverEngine( + adapter: _TrackingAdapter(), + driver: driver, + ); + + await engine.open(); + final connection = await engine.connection(); + final transaction = await connection.transaction(); + await transaction.execute(_plan(where: {'id': 'u3'})); + await transaction.rollback(); + + final inner = driver.connections.single.transactions.single; + expect(inner.commitCount, 0); + expect(inner.rollbackCount, 1); + + await expectLater( + transaction.execute(_plan()), + throwsA(isA()), + ); + await expectLater(transaction.commit(), throwsA(isA())); + await expectLater(transaction.rollback(), throwsA(isA())); + await connection.release(); + await engine.close(); + }, + ); } OrmPlan _plan({JsonMap where = const {}}) { @@ -112,3 +237,88 @@ final class _TrackingDriver implements TargetDriver { return 'driver:$request'; } } + +final class _ConnectionCapableTrackingDriver + implements + TargetDriver, + TargetDriverConnectionCapable { + int openCount = 0; + int closeCount = 0; + int connectionCount = 0; + final List requests = []; + final List<_TrackingConnection> connections = <_TrackingConnection>[]; + + @override + Future open() async { + openCount += 1; + } + + @override + Future close() async { + closeCount += 1; + } + + @override + Future execute(String request) async { + requests.add(request); + return 'driver:$request'; + } + + @override + Future> connection() async { + connectionCount += 1; + final connection = _TrackingConnection(); + connections.add(connection); + return connection; + } +} + +final class _TrackingConnection + implements TargetDriverConnection { + int releaseCount = 0; + int transactionCount = 0; + final List requests = []; + final List<_TrackingTransaction> transactions = <_TrackingTransaction>[]; + + @override + Future execute(String request) async { + requests.add(request); + return 'connection:$request'; + } + + @override + Future release() async { + releaseCount += 1; + } + + @override + Future> transaction() async { + transactionCount += 1; + final transaction = _TrackingTransaction(); + transactions.add(transaction); + return transaction; + } +} + +final class _TrackingTransaction + implements TargetDriverTransaction { + int commitCount = 0; + int rollbackCount = 0; + final List requests = []; + + @override + Future commit() async { + commitCount += 1; + } + + @override + Future rollback() async { + rollbackCount += 1; + } + + @override + Future execute(String request) async { + requests.add(request); + return 'transaction:$request'; + } +} From e14310ac8b5e5ce5015f689248c9799267c031e6 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:44:16 +0800 Subject: [PATCH 029/154] feat(sql): add mutation returning lowering strategy --- pub/orm/lib/src/sql/adapter.dart | 31 ++++++++--- pub/orm/test/sql/sql_adapter_test.dart | 77 ++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index d627b4e2..02cadd51 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -40,11 +40,7 @@ final class SqlAdapter implements TargetAdapter { ), OrmAction.create => _lowerCreate(plan: plan, table: model.table), OrmAction.update => _lowerUpdate(plan: plan, table: model.table), - OrmAction.delete => SqlStatement( - action: plan.action, - text: 'DELETE FROM ${_id(model.table)}$whereClause', - parameters: params, - ), + OrmAction.delete => _lowerDelete(plan: plan, table: model.table), }; } @@ -79,7 +75,7 @@ final class SqlAdapter implements TargetAdapter { action: plan.action, text: 'INSERT INTO ${_id(table)} (${columns.map(_id).join(', ')}) ' - 'VALUES ($placeholders)', + 'VALUES ($placeholders)${_buildMutationReturningClause(plan.select)}', parameters: values, ); } @@ -98,7 +94,20 @@ final class SqlAdapter implements TargetAdapter { text: 'UPDATE ${_id(table)} SET ' '${setColumns.map((column) => '${_id(column)} = ?').join(', ')}' - '$wherePart', + '$wherePart${_buildMutationReturningClause(plan.select)}', + parameters: params, + ); + } + + SqlStatement _lowerDelete({required OrmPlan plan, required String table}) { + final params = []; + final wherePart = _buildWhereClause(plan.where, params); + + return SqlStatement( + action: plan.action, + text: + 'DELETE FROM ${_id(table)}' + '$wherePart${_buildMutationReturningClause(plan.select)}', parameters: params, ); } @@ -110,6 +119,14 @@ final class SqlAdapter implements TargetAdapter { return select.map(_id).join(', '); } + String _buildMutationReturningClause(List select) { + if (!contract.capabilities.mutationReturning) { + return ''; + } + + return ' RETURNING ${_buildSelectColumns(select)}'; + } + String _buildWhereClause(JsonMap where, List params) { if (where.isEmpty) { return ''; diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index bd095cf1..80c26e94 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -2,17 +2,22 @@ import 'package:orm/orm.dart'; import 'package:test/test.dart'; void main() { - final contract = OrmContract( - version: '1', - hash: 'hash', - models: { - 'User': ModelContract( - name: 'User', - table: 'users', - fields: {'id', 'email'}, - ), - }, - ); + OrmContract buildContract({bool mutationReturning = true}) { + return OrmContract( + version: '1', + hash: 'hash', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + capabilities: ContractCapabilities(mutationReturning: mutationReturning), + ); + } + + final contract = buildContract(); test('lowers findMany with where/order/pagination/select', () { final adapter = SqlAdapter(contract: contract); @@ -37,6 +42,7 @@ void main() { }); test('lowers mutation statements', () { + final contract = buildContract(mutationReturning: false); final adapter = SqlAdapter(contract: contract); final createStatement = adapter.lower( @@ -80,6 +86,55 @@ void main() { expect(deleteStatement.parameters, ['u1']); }); + test('lowers mutation statements with returning clause when enabled', () { + final adapter = SqlAdapter(contract: buildContract()); + + final createDefaultSelect = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.create, + data: {'id': 'u1', 'email': 'a@example.com'}, + ), + ); + expect( + createDefaultSelect.text, + 'INSERT INTO "users" ("id", "email") VALUES (?, ?) RETURNING *', + ); + expect(createDefaultSelect.parameters, ['u1', 'a@example.com']); + + final updateSelectedColumns = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + select: const ['id'], + ), + ); + expect( + updateSelectedColumns.text, + 'UPDATE "users" SET "email" = ? WHERE "id" = ? RETURNING "id"', + ); + expect(updateSelectedColumns.parameters, ['b@example.com', 'u1']); + + final deleteSelectedColumns = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.delete, + where: {'id': 'u1'}, + select: const ['id', 'email'], + ), + ); + expect( + deleteSelectedColumns.text, + 'DELETE FROM "users" WHERE "id" = ? RETURNING "id", "email"', + ); + expect(deleteSelectedColumns.parameters, ['u1']); + }); + test('decodes SQL result by action response shape', () { final adapter = SqlAdapter(contract: contract); From 4b26f92e688b1e3059603848d1150b989d469369 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:47:38 +0800 Subject: [PATCH 030/154] test(client): add single-query include regression cases --- pub/orm/test/client/client_test.dart | 297 ++++++++++++++++++++++----- 1 file changed, 246 insertions(+), 51 deletions(-) diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index cb993622..60598ddb 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -572,6 +572,136 @@ void main() { await client.disconnect(); }); + test( + 'singleQuery include matches multiQuery semantics for one-to-many', + () async { + Future> readWithStrategy( + IncludeExecutionStrategy strategy, + ) async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => strategy, + ); + await client.connect(); + try { + await _seedRelationalData(client); + final rows = await client + .model('User') + .findMany( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + select: const ['id', 'title'], + ), + }, + ); + return rows; + } finally { + await client.disconnect(); + } + } + + final singleRows = await readWithStrategy( + IncludeExecutionStrategy.singleQuery, + ); + final multiRows = await readWithStrategy( + IncludeExecutionStrategy.multiQuery, + ); + + expect(singleRows, equals(multiRows)); + expect(singleRows, hasLength(2)); + expect(_readRowsValue(singleRows.first['posts']), hasLength(2)); + expect(_readRowsValue(singleRows.last['posts']), hasLength(1)); + }, + ); + + test('singleQuery include avoids parent fanout by execute count', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient( + contract: relationalContract, + engine: engine, + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => IncludeExecutionStrategy.singleQuery, + ); + await client.connect(); + try { + await _seedRelationalData(client); + engine.reset(); + + final rows = await client + .model('User') + .findMany( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }, + ); + + expect(rows, hasLength(2)); + final findManyPlans = engine.executedPlans + .where((plan) => plan.action == OrmAction.findMany) + .toList(growable: false); + expect( + findManyPlans.length, + lessThanOrEqualTo(2), + reason: + 'singleQuery include should execute at most one parent read ' + 'and one relation read for one-to-many includes.', + ); + } finally { + await client.disconnect(); + } + }); + + test( + 'singleQuery include throws structured error for unsupported response shape', + () async { + final client = OrmClient( + contract: relationalContract, + engine: _BadRelatedFindManyShapeEngine(inner: MemoryEngine()), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => IncludeExecutionStrategy.singleQuery, + ); + await client.connect(); + try { + await _seedRelationalData(client); + await expectLater( + client + .model('User') + .findMany( + include: {'posts': const IncludeSpec()}, + ), + throwsA(isA()), + ); + } finally { + await client.disconnect(); + } + }, + ); + test('supports include for direct mutation methods', () async { final client = OrmClient( contract: relationalContract, @@ -949,22 +1079,25 @@ void main() { await client.disconnect(); }); - test('withConnection executes callback and always releases connection', () async { - final engine = _TrackingConnectionEngine(); - final client = OrmClient(contract: contract, engine: engine); - await client.connect(); + test( + 'withConnection executes callback and always releases connection', + () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); - await client.withConnection((connection) async { - final rows = await connection.model('User').findMany(); - expect(rows, isEmpty); - }); + await client.withConnection((connection) async { + final rows = await connection.model('User').findMany(); + expect(rows, isEmpty); + }); - expect(engine.connectionCount, 1); - expect(engine.connectionExecutePlans, hasLength(1)); - expect(engine.connectionExecutePlans.single.action, OrmAction.findMany); - expect(engine.releaseCount, 1); - await client.disconnect(); - }); + expect(engine.connectionCount, 1); + expect(engine.connectionExecutePlans, hasLength(1)); + expect(engine.connectionExecutePlans.single.action, OrmAction.findMany); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); test('withTransaction commits on success', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); @@ -985,25 +1118,31 @@ void main() { await client.disconnect(); }); - test('withTransaction success branch commits and releases connection', () async { - final engine = _TrackingConnectionEngine(); - final client = OrmClient(contract: contract, engine: engine); - await client.connect(); + test( + 'withTransaction success branch commits and releases connection', + () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); - await client.withTransaction((transaction) async { - final rows = await transaction.model('User').findMany(); - expect(rows, isEmpty); - }); + await client.withTransaction((transaction) async { + final rows = await transaction.model('User').findMany(); + expect(rows, isEmpty); + }); - expect(engine.connectionCount, 1); - expect(engine.transactionCount, 1); - expect(engine.transactionExecutePlans, hasLength(1)); - expect(engine.transactionExecutePlans.single.action, OrmAction.findMany); - expect(engine.commitCount, 1); - expect(engine.rollbackCount, 0); - expect(engine.releaseCount, 1); - await client.disconnect(); - }); + expect(engine.connectionCount, 1); + expect(engine.transactionCount, 1); + expect(engine.transactionExecutePlans, hasLength(1)); + expect( + engine.transactionExecutePlans.single.action, + OrmAction.findMany, + ); + expect(engine.commitCount, 1); + expect(engine.rollbackCount, 0); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); test('withTransaction rolls back on error', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); @@ -1028,28 +1167,34 @@ void main() { await client.disconnect(); }); - test('withTransaction error branch rolls back and releases connection', () async { - final engine = _TrackingConnectionEngine(); - final client = OrmClient(contract: contract, engine: engine); - await client.connect(); + test( + 'withTransaction error branch rolls back and releases connection', + () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); - await expectLater( - () => client.withTransaction((transaction) async { - await transaction.model('User').findMany(); - throw StateError('stop'); - }), - throwsA(isA()), - ); + await expectLater( + () => client.withTransaction((transaction) async { + await transaction.model('User').findMany(); + throw StateError('stop'); + }), + throwsA(isA()), + ); - expect(engine.connectionCount, 1); - expect(engine.transactionCount, 1); - expect(engine.transactionExecutePlans, hasLength(1)); - expect(engine.transactionExecutePlans.single.action, OrmAction.findMany); - expect(engine.commitCount, 0); - expect(engine.rollbackCount, 1); - expect(engine.releaseCount, 1); - await client.disconnect(); - }); + expect(engine.connectionCount, 1); + expect(engine.transactionCount, 1); + expect(engine.transactionExecutePlans, hasLength(1)); + expect( + engine.transactionExecutePlans.single.action, + OrmAction.findMany, + ); + expect(engine.commitCount, 0); + expect(engine.rollbackCount, 1); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); test( 'throws RuntimeConnectionNotSupportedException when engine has no connection support', @@ -1559,6 +1704,56 @@ final class _NoMutationReturnEngine implements OrmEngine { Future open() => inner.open(); } +final class _CountingEngine implements OrmEngine { + final OrmEngine inner; + var executeCount = 0; + final List executedPlans = []; + + _CountingEngine({required this.inner}); + + @override + Future close() => inner.close(); + + @override + Future execute(OrmPlan plan) async { + executeCount += 1; + executedPlans.add(plan); + return inner.execute(plan); + } + + @override + Future open() => inner.open(); + + void reset() { + executeCount = 0; + executedPlans.clear(); + } +} + +final class _BadRelatedFindManyShapeEngine implements OrmEngine { + final OrmEngine inner; + final String relatedModel; + + _BadRelatedFindManyShapeEngine({ + required this.inner, + this.relatedModel = 'Post', + }); + + @override + Future close() => inner.close(); + + @override + Future execute(OrmPlan plan) async { + if (plan.model == relatedModel && plan.action == OrmAction.findMany) { + return const EngineResponse(data: 'bad-shape'); + } + return inner.execute(plan); + } + + @override + Future open() => inner.open(); +} + final class _TrackingConnectionEngine implements OrmEngine, ConnectionCapableEngine { var connectionCount = 0; From 77fe341f0c2bf10312e3ba4ed34ee3a37df4c0c4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:48:10 +0800 Subject: [PATCH 031/154] feat(client): implement singleQuery include merge path --- pub/orm/lib/src/client/client.dart | 249 ++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 1 deletion(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 860eefed..cc19faa2 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -839,7 +839,7 @@ class ModelDelegate { ); return switch (strategy) { - IncludeExecutionStrategy.singleQuery => _resolveIncludeRowsMultiQuery( + IncludeExecutionStrategy.singleQuery => _resolveIncludeRowsSingleQuery( rows: rows, include: include, depth: depth, @@ -852,6 +852,75 @@ class ModelDelegate { }; } + Future> _resolveIncludeRowsSingleQuery({ + required List rows, + required Map include, + required int depth, + }) async { + var hydrated = rows; + + for (final entry in include.entries) { + final relationName = entry.key; + final relationInclude = entry.value; + final relation = _resolveRelation( + model: modelName, + relationName: relationName, + ); + final relatedDelegate = _client.model(relation.relatedModel); + _validateIncludePagination(include: relationInclude); + + final relatedRows = await _loadRelationRowsSingleQuery( + relatedDelegate: relatedDelegate, + relation: relation, + relationInclude: relationInclude, + depth: depth, + ); + final rowsByRelationKey = _groupRowsByRelationFields( + rows: relatedRows, + fields: relation.targetFields, + ); + + final nextRows = []; + for (final row in hydrated) { + final relationWhere = _buildRelationWhere(row: row, relation: relation); + if (relationWhere == null) { + final emptyValue = relation.cardinality == RelationCardinality.one + ? null + : const []; + nextRows.add(_attachInclude(row, relationName, emptyValue)); + continue; + } + + final relationKey = _buildRelationMergeKeyFromRow( + row: relationWhere, + fields: relation.targetFields, + ); + final matchedRows = relationKey == null + ? const [] + : (rowsByRelationKey[relationKey] ?? const []); + final windowRows = _sliceRows( + rows: matchedRows, + skip: relationInclude.skip, + take: relationInclude.take, + ); + final shapedRows = relatedDelegate._shapeRows( + windowRows, + select: relationInclude.select, + include: relationInclude.include, + ); + final relationValue = relation.cardinality == RelationCardinality.one + ? _firstOrNull(shapedRows) + : shapedRows; + + nextRows.add(_attachInclude(row, relationName, relationValue)); + } + + hydrated = nextRows; + } + + return hydrated; + } + Future> _resolveIncludeRowsMultiQuery({ required List rows, required Map include, @@ -953,6 +1022,147 @@ class ModelDelegate { return where; } + Future> _loadRelationRowsSingleQuery({ + required ModelDelegate relatedDelegate, + required ModelRelationContract relation, + required IncludeSpec relationInclude, + required int depth, + }) { + final baseWhere = _buildSingleQueryRelationBaseWhere( + includeWhere: relationInclude.where, + relation: relation, + ); + + return relatedDelegate._findManyInternal( + action: OrmAction.findMany, + where: baseWhere, + orderBy: relationInclude.orderBy, + select: _buildSingleQueryRelationSelect( + include: relationInclude, + relation: relation, + ), + include: relationInclude.include, + includeDepth: depth + 1, + ); + } + + JsonMap _buildSingleQueryRelationBaseWhere({ + required JsonMap includeWhere, + required ModelRelationContract relation, + }) { + if (includeWhere.isEmpty) { + return const {}; + } + + final targetFields = relation.targetFields.toSet(); + final baseWhere = {}; + var removedTargetField = false; + for (final entry in includeWhere.entries) { + if (targetFields.contains(entry.key)) { + removedTargetField = true; + continue; + } + baseWhere[entry.key] = entry.value; + } + + if (!removedTargetField) { + return includeWhere; + } + + if (baseWhere.isEmpty) { + return const {}; + } + return baseWhere; + } + + List _buildSingleQueryRelationSelect({ + required IncludeSpec include, + required ModelRelationContract relation, + }) { + if (include.select.isEmpty) { + return const []; + } + + final expanded = {...include.select, ...relation.targetFields}; + return expanded.toList(growable: false); + } + + void _validateIncludePagination({required IncludeSpec include}) { + if (include.skip case final skip?) { + if (skip < 0) { + throw PlanInvalidPaginationException(key: 'skip', value: skip); + } + } + if (include.take case final take?) { + if (take < 0) { + throw PlanInvalidPaginationException(key: 'take', value: take); + } + } + } + + Map<_RelationMergeKey, List> _groupRowsByRelationFields({ + required List rows, + required List fields, + }) { + final grouped = <_RelationMergeKey, List>{}; + for (final row in rows) { + final key = _buildRelationMergeKeyFromRow(row: row, fields: fields); + if (key == null) { + continue; + } + grouped.putIfAbsent(key, () => []).add(row); + } + return grouped; + } + + _RelationMergeKey? _buildRelationMergeKeyFromRow({ + required JsonMap row, + required List fields, + }) { + final values = []; + for (final field in fields) { + if (!row.containsKey(field)) { + return null; + } + final value = row[field]; + if (value == null) { + return null; + } + values.add(value); + } + + return _RelationMergeKey(values); + } + + List _sliceRows({ + required List rows, + required int? skip, + required int? take, + }) { + if (rows.isEmpty) { + return const []; + } + + var window = rows; + if (skip case final offset?) { + if (offset >= window.length) { + return const []; + } + window = window.sublist(offset); + } + + if (take case final limit?) { + if (limit == 0) { + return const []; + } + if (limit < window.length) { + window = window.sublist(0, limit); + } + } + + return List.from(window, growable: false); + } + List _expandSelectForInclude({ required String model, required List select, @@ -1348,6 +1558,28 @@ final class ModelQuery { ModelQuery._(_delegate, nextState); } +@immutable +final class _RelationMergeKey { + final List parts; + + _RelationMergeKey(List values) + : parts = List.unmodifiable(values); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! _RelationMergeKey) { + return false; + } + return _listEquals(parts, other.parts); + } + + @override + int get hashCode => Object.hashAll(parts); +} + Map _createModelAliases(OrmContract contract) { final aliases = {}; @@ -1438,6 +1670,21 @@ T? _firstOrNull(List values) { return values.first; } +bool _listEquals(List left, List right) { + if (identical(left, right)) { + return true; + } + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index++) { + if (left[index] != right[index]) { + return false; + } + } + return true; +} + String _lowercaseFirst(String value) { if (value.isEmpty) { return value; From 0276ae17ef314f9a43a0455511d8ccb7b088ef0d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:51:19 +0800 Subject: [PATCH 032/154] feat(sql): add model field codec pipeline --- pub/orm/lib/orm.dart | 1 + pub/orm/lib/src/sql/adapter.dart | 166 +++++++++++++++++++++++++++---- pub/orm/lib/src/sql/codec.dart | 78 +++++++++++++++ 3 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 pub/orm/lib/src/sql/codec.dart diff --git a/pub/orm/lib/orm.dart b/pub/orm/lib/orm.dart index 42fea593..fa831d47 100644 --- a/pub/orm/lib/orm.dart +++ b/pub/orm/lib/orm.dart @@ -13,6 +13,7 @@ export 'src/runtime/plugins/budgets.dart'; export 'src/runtime/plugins/lints.dart'; export 'src/runtime/types.dart'; export 'src/sql/adapter.dart'; +export 'src/sql/codec.dart'; export 'src/sql/marker_reader.dart'; export 'src/sql/types.dart'; export 'src/target/adapter.dart'; diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 02cadd51..79f729ff 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -4,13 +4,19 @@ import '../runtime/errors.dart'; import '../runtime/plan.dart'; import '../runtime/types.dart'; import '../target/adapter.dart'; +import 'codec.dart'; import 'types.dart'; final class SqlAdapter implements TargetAdapter { final OrmContract contract; final String identifierQuote; + final SqlFieldCodecResolver? codecResolver; - SqlAdapter({this.identifierQuote = '"', required this.contract}); + SqlAdapter({ + this.identifierQuote = '"', + required this.contract, + this.codecResolver, + }); @override SqlStatement lower(OrmPlan plan) { @@ -20,7 +26,11 @@ final class SqlAdapter implements TargetAdapter { } final params = []; - final whereClause = _buildWhereClause(plan.where, params); + final whereClause = _buildWhereClause( + model: plan.model, + where: plan.where, + params: params, + ); final orderByClause = _buildOrderByClause(plan.orderBy); return switch (plan.action) { @@ -38,36 +48,79 @@ final class SqlAdapter implements TargetAdapter { '$whereClause$orderByClause LIMIT 1', parameters: params, ), - OrmAction.create => _lowerCreate(plan: plan, table: model.table), - OrmAction.update => _lowerUpdate(plan: plan, table: model.table), - OrmAction.delete => _lowerDelete(plan: plan, table: model.table), + OrmAction.create => _lowerCreate( + plan: plan, + table: model.table, + model: plan.model, + ), + OrmAction.update => _lowerUpdate( + plan: plan, + table: model.table, + model: plan.model, + ), + OrmAction.delete => _lowerDelete( + plan: plan, + table: model.table, + model: plan.model, + ), }; } @override EngineResponse decode(SqlResult response, OrmPlan plan) { + final resolver = codecResolver; + if (resolver == null) { + return switch (plan.action) { + OrmAction.findMany => EngineResponse( + data: response.rows, + affectedRows: response.affectedRows, + ), + OrmAction.findUnique => EngineResponse( + data: _firstOrNull(response.rows), + affectedRows: response.affectedRows, + ), + OrmAction.create || + OrmAction.update || + OrmAction.delete => EngineResponse( + data: _firstOrNull(response.rows), + affectedRows: response.affectedRows, + ), + }; + } + + final decodedRows = _decodeRows(model: plan.model, rows: response.rows); return switch (plan.action) { OrmAction.findMany => EngineResponse( - data: response.rows, + data: decodedRows, affectedRows: response.affectedRows, ), OrmAction.findUnique => EngineResponse( - data: _firstOrNull(response.rows), + data: _firstOrNull(decodedRows), affectedRows: response.affectedRows, ), OrmAction.create || OrmAction.update || OrmAction.delete => EngineResponse( - data: _firstOrNull(response.rows), + data: _firstOrNull(decodedRows), affectedRows: response.affectedRows, ), }; } - SqlStatement _lowerCreate({required OrmPlan plan, required String table}) { + SqlStatement _lowerCreate({ + required OrmPlan plan, + required String table, + required String model, + }) { final columns = plan.data.keys.toList(growable: false); final values = columns - .map((column) => plan.data[column]) + .map( + (column) => _encodeValue( + model: model, + field: column, + value: plan.data[column], + ), + ) .toList(growable: false); final placeholders = List.filled(columns.length, '?').join(', '); @@ -80,14 +133,28 @@ final class SqlAdapter implements TargetAdapter { ); } - SqlStatement _lowerUpdate({required OrmPlan plan, required String table}) { + SqlStatement _lowerUpdate({ + required OrmPlan plan, + required String table, + required String model, + }) { final setColumns = plan.data.keys.toList(growable: false); final setValues = setColumns - .map((column) => plan.data[column]) + .map( + (column) => _encodeValue( + model: model, + field: column, + value: plan.data[column], + ), + ) .toList(growable: false); final params = [...setValues]; - final wherePart = _buildWhereClause(plan.where, params); + final wherePart = _buildWhereClause( + model: model, + where: plan.where, + params: params, + ); return SqlStatement( action: plan.action, @@ -99,9 +166,17 @@ final class SqlAdapter implements TargetAdapter { ); } - SqlStatement _lowerDelete({required OrmPlan plan, required String table}) { + SqlStatement _lowerDelete({ + required OrmPlan plan, + required String table, + required String model, + }) { final params = []; - final wherePart = _buildWhereClause(plan.where, params); + final wherePart = _buildWhereClause( + model: model, + where: plan.where, + params: params, + ); return SqlStatement( action: plan.action, @@ -127,7 +202,11 @@ final class SqlAdapter implements TargetAdapter { return ' RETURNING ${_buildSelectColumns(select)}'; } - String _buildWhereClause(JsonMap where, List params) { + String _buildWhereClause({ + required String model, + required JsonMap where, + required List params, + }) { if (where.isEmpty) { return ''; } @@ -135,7 +214,9 @@ final class SqlAdapter implements TargetAdapter { final predicates = []; for (final entry in where.entries) { predicates.add('${_id(entry.key)} = ?'); - params.add(entry.value); + params.add( + _encodeValue(model: model, field: entry.key, value: entry.value), + ); } return ' WHERE ${predicates.join(' AND ')}'; @@ -174,6 +255,57 @@ final class SqlAdapter implements TargetAdapter { } String _id(String value) => '$identifierQuote$value$identifierQuote'; + + List _decodeRows({ + required String model, + required List rows, + }) { + if (rows.isEmpty) { + return rows; + } + + final decoded = []; + for (final row in rows) { + decoded.add(_decodeRow(model: model, row: row)); + } + return decoded; + } + + JsonMap _decodeRow({required String model, required JsonMap row}) { + final decoded = {}; + for (final entry in row.entries) { + decoded[entry.key] = _decodeValue( + model: model, + field: entry.key, + value: entry.value, + ); + } + return decoded; + } + + Object? _encodeValue({ + required String model, + required String field, + required Object? value, + }) { + final codec = codecResolver?.resolve(model: model, field: field); + if (codec == null) { + return value; + } + return codec.encode(value); + } + + Object? _decodeValue({ + required String model, + required String field, + required Object? value, + }) { + final codec = codecResolver?.resolve(model: model, field: field); + if (codec == null) { + return value; + } + return codec.decode(value); + } } T? _firstOrNull(List values) { diff --git a/pub/orm/lib/src/sql/codec.dart b/pub/orm/lib/src/sql/codec.dart new file mode 100644 index 00000000..67d64f6f --- /dev/null +++ b/pub/orm/lib/src/sql/codec.dart @@ -0,0 +1,78 @@ +import 'package:meta/meta.dart'; + +typedef SqlCodecFn = Object? Function(Object? value); + +abstract interface class SqlFieldCodec { + Object? encode(Object? value); + + Object? decode(Object? value); +} + +final class SqlLambdaFieldCodec implements SqlFieldCodec { + final SqlCodecFn _encode; + final SqlCodecFn _decode; + + SqlLambdaFieldCodec({SqlCodecFn? encode, SqlCodecFn? decode}) + : _encode = encode ?? _identityCodec, + _decode = decode ?? _identityCodec; + + @override + Object? encode(Object? value) => _encode(value); + + @override + Object? decode(Object? value) => _decode(value); +} + +abstract interface class SqlFieldCodecResolver { + SqlFieldCodec? resolve({required String model, required String field}); +} + +@immutable +final class SqlCodecRegistry implements SqlFieldCodecResolver { + final Map> _entries; + + SqlCodecRegistry({Map> entries = const {}}) + : _entries = _freezeEntries(entries); + + @override + SqlFieldCodec? resolve({required String model, required String field}) { + final fields = _entries[model]; + if (fields == null) { + return null; + } + return fields[field]; + } + + SqlCodecRegistry withField({ + required String model, + required String field, + required SqlFieldCodec codec, + }) { + final nextEntries = >{}; + for (final entry in _entries.entries) { + nextEntries[entry.key] = Map.from(entry.value); + } + + final fields = nextEntries.putIfAbsent( + model, + () => {}, + ); + fields[field] = codec; + + return SqlCodecRegistry(entries: nextEntries); + } +} + +Map> _freezeEntries( + Map> entries, +) { + final next = >{}; + for (final entry in entries.entries) { + next[entry.key] = Map.unmodifiable( + Map.from(entry.value), + ); + } + return Map>.unmodifiable(next); +} + +Object? _identityCodec(Object? value) => value; From 08783d64706c3ced4086d7091ba246a5cccb2a18 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:53:17 +0800 Subject: [PATCH 033/154] test(sql): add codec coverage for adapter encode and decode --- pub/orm/test/sql/sql_adapter_test.dart | 180 +++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 80c26e94..60aa7345 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -192,6 +192,186 @@ void main() { } }); + test('applies codec encode for where/data and decode for rows', () { + final codecRegistry = SqlCodecRegistry().withField( + model: 'User', + field: 'email', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'wire:$value', + decode: (value) => value == null ? null : 'app:$value', + ), + ); + final adapter = SqlAdapter( + contract: contract, + codecResolver: codecRegistry, + ); + + final update = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + where: {'email': 'find@example.com', 'id': 'u1'}, + data: {'email': 'next@example.com', 'id': 'u1'}, + ), + ); + expect(update.parameters, [ + 'wire:next@example.com', + 'u1', + 'wire:find@example.com', + 'u1', + ]); + + final decoded = adapter.decode( + const SqlResult( + rows: [ + {'email': 'wire:db@example.com', 'id': 'u1'}, + ], + ), + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findUnique, + ), + ); + if (decoded.data case final Map row) { + expect(row, { + 'email': 'app:wire:db@example.com', + 'id': 'u1', + }); + } else { + fail('Expected row map for codec decode.'); + } + }); + + test('keeps default no-codec behavior unchanged', () { + final adapterWithoutCodec = SqlAdapter(contract: contract); + final adapterWithEmptyCodec = SqlAdapter( + contract: contract, + codecResolver: SqlCodecRegistry(), + ); + + final plan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + where: {'email': 'find@example.com', 'id': 'u1'}, + data: {'email': 'next@example.com'}, + ); + + final withoutCodecStatement = adapterWithoutCodec.lower(plan); + final emptyCodecStatement = adapterWithEmptyCodec.lower(plan); + expect( + emptyCodecStatement.parameters, + withoutCodecStatement.parameters, + reason: 'Empty codec registry should not mutate parameters.', + ); + expect(emptyCodecStatement.parameters, [ + 'next@example.com', + 'find@example.com', + 'u1', + ]); + + final response = const SqlResult( + rows: [ + {'id': 'u1', 'email': 'db@example.com'}, + ], + ); + final decodePlan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findUnique, + ); + + final decodedWithoutCodec = adapterWithoutCodec.decode( + response, + decodePlan, + ); + final decodedWithEmptyCodec = adapterWithEmptyCodec.decode( + response, + decodePlan, + ); + expect(decodedWithEmptyCodec.data, decodedWithoutCodec.data); + expect(decodedWithEmptyCodec.data, { + 'id': 'u1', + 'email': 'db@example.com', + }); + }); + + test('matches codecs by model and field and only transforms hit fields', () { + final codecRegistry = SqlCodecRegistry() + .withField( + model: 'User', + field: 'email', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'user-email:$value', + decode: (value) => value == null ? null : 'user-row:$value', + ), + ) + .withField( + model: 'OtherModel', + field: 'email', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'other:$value', + decode: (value) => value == null ? null : 'other:$value', + ), + ) + .withField( + model: 'User', + field: 'otherField', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'otherField:$value', + decode: (value) => value == null ? null : 'otherField:$value', + ), + ); + + final adapter = SqlAdapter( + contract: contract, + codecResolver: codecRegistry, + ); + final statement = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + data: {'id': 'u2', 'email': 'next@example.com'}, + where: {'id': 'u1', 'email': 'find@example.com'}, + ), + ); + expect(statement.parameters, [ + 'u2', + 'user-email:next@example.com', + 'u1', + 'user-email:find@example.com', + ]); + + final decoded = adapter.decode( + const SqlResult( + rows: [ + { + 'id': 'u1', + 'email': 'wire@example.com', + 'unmapped': 'keep', + }, + ], + ), + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findUnique, + ), + ); + if (decoded.data case final Map row) { + expect(row, { + 'id': 'u1', + 'email': 'user-row:wire@example.com', + 'unmapped': 'keep', + }); + } else { + fail('Expected row map for model+field codec matching.'); + } + }); + test('throws when lowering unknown model', () { final adapter = SqlAdapter(contract: contract); From 0b3ce06c608e452a39bf32eb96c433021f355267 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:56:03 +0800 Subject: [PATCH 034/154] feat(client): add updateNested with explicit transaction --- pub/orm/lib/src/client/client.dart | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index cc19faa2..0f28eff3 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -487,6 +487,25 @@ class ModelDelegate { }); } + Future updateNested({ + JsonMap where = const {}, + required JsonMap data, + Map> create = const >{}, + List select = const [], + Map include = const {}, + }) { + return _client.transaction((tx) async { + final scoped = tx.model(modelName); + return scoped._updateNestedInScope( + where: where, + data: data, + create: create, + select: select, + include: include, + ); + }); + } + Future> createMany({ required List data, List select = const [], @@ -816,6 +835,75 @@ class ModelDelegate { ).single; } + Future _updateNestedInScope({ + required JsonMap where, + required JsonMap data, + required Map> create, + required List select, + required Map include, + }) async { + final normalizedCreate = _normalizeNestedCreate(create); + final normalizedInclude = _normalizeInclude(include); + + final updated = await this.update( + where: where, + data: data, + select: _expandSelectForNestedCreate( + model: modelName, + select: select, + create: normalizedCreate, + ), + ); + + if (updated == null) { + return null; + } + + for (final entry in normalizedCreate.entries) { + final relation = _resolveRelation( + model: modelName, + relationName: entry.key, + ); + final related = _client.model(relation.relatedModel); + for (final child in entry.value) { + final linkedData = _linkNestedData( + parent: updated, + relationName: entry.key, + relation: relation, + data: child, + ); + await related.create(data: linkedData); + } + } + + final includeForReturn = { + for (final relationName in normalizedCreate.keys) + relationName: const IncludeSpec(), + ...normalizedInclude, + }; + + if (includeForReturn.isEmpty) { + return _shapeRows( + [updated], + select: select, + include: const {}, + ).single; + } + + final hydratedRows = await _resolveIncludeRows( + action: OrmAction.update, + rows: [updated], + include: includeForReturn, + depth: 0, + ); + + return _shapeRows( + hydratedRows, + select: select, + include: includeForReturn, + ).single; + } + Future> _resolveIncludeRows({ required OrmAction action, required List rows, @@ -1548,6 +1636,19 @@ final class ModelQuery { ); } + Future updateNested({ + required JsonMap data, + Map> create = const >{}, + }) { + return _delegate.updateNested( + where: _state.where, + data: data, + create: create, + select: _state.select, + include: _state.include, + ); + } + Future delete() => _delegate.delete( where: _state.where, select: _state.select, From c616c00ad33cb93b45caf813e878be59d9dcfb6a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:56:09 +0800 Subject: [PATCH 035/154] feat(sql): add runtime verify helper options --- pub/orm/lib/orm.dart | 1 + pub/orm/lib/src/sql/verify.dart | 20 +++++ pub/orm/test/sql/sql_marker_reader_test.dart | 79 +++++++++++++++----- 3 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 pub/orm/lib/src/sql/verify.dart diff --git a/pub/orm/lib/orm.dart b/pub/orm/lib/orm.dart index fa831d47..5feb4d57 100644 --- a/pub/orm/lib/orm.dart +++ b/pub/orm/lib/orm.dart @@ -16,6 +16,7 @@ export 'src/sql/adapter.dart'; export 'src/sql/codec.dart'; export 'src/sql/marker_reader.dart'; export 'src/sql/types.dart'; +export 'src/sql/verify.dart'; export 'src/target/adapter.dart'; export 'src/target/driver.dart'; export 'src/target/engine.dart'; diff --git a/pub/orm/lib/src/sql/verify.dart b/pub/orm/lib/src/sql/verify.dart new file mode 100644 index 00000000..13dc72a8 --- /dev/null +++ b/pub/orm/lib/src/sql/verify.dart @@ -0,0 +1,20 @@ +import '../runtime/core.dart'; +import 'marker_reader.dart'; + +RuntimeVerifyOptions sqlRuntimeVerifyOptions({ + required SqlMarkerQueryExecutor executor, + RuntimeVerifyMode mode = RuntimeVerifyMode.onFirstUse, + bool requireMarker = true, + SqlMarkerQuery? query, + String hashColumn = SqlContractMarkerReader.defaultHashColumn, +}) { + return RuntimeVerifyOptions( + mode: mode, + requireMarker: requireMarker, + markerReader: SqlContractMarkerReader( + executor: executor, + query: query, + hashColumn: hashColumn, + ), + ); +} diff --git a/pub/orm/test/sql/sql_marker_reader_test.dart b/pub/orm/test/sql/sql_marker_reader_test.dart index 36d63562..bcd6698c 100644 --- a/pub/orm/test/sql/sql_marker_reader_test.dart +++ b/pub/orm/test/sql/sql_marker_reader_test.dart @@ -183,22 +183,67 @@ void main() { }); }); - group('SqlContractMarkerReader runtime integration', () { - test('can be consumed by RuntimeVerifyOptions', () async { + group('sqlRuntimeVerifyOptions', () { + test('creates runtime verify options with defaults', () { + final options = sqlRuntimeVerifyOptions( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult(), + ), + ); + + expect(options.mode, RuntimeVerifyMode.onFirstUse); + expect(options.requireMarker, isTrue); + expect(options.markerReader, isA()); + + final reader = options.markerReader! as SqlContractMarkerReader; + expect(reader.hashColumn, SqlContractMarkerReader.defaultHashColumn); + expect( + reader.query.sql, + 'SELECT storage_hash FROM orm_contract.marker WHERE id = ?', + ); + expect(reader.query.parameters, [1]); + }); + + test('supports overriding helper options', () { + final options = sqlRuntimeVerifyOptions( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult(), + ), + mode: RuntimeVerifyMode.always, + requireMarker: false, + query: SqlMarkerQuery( + sql: 'SELECT core_hash FROM contract_marker WHERE marker_id = ?', + parameters: const [7], + ), + hashColumn: 'core_hash', + ); + + expect(options.mode, RuntimeVerifyMode.always); + expect(options.requireMarker, isFalse); + expect(options.markerReader, isA()); + + final reader = options.markerReader! as SqlContractMarkerReader; + expect( + reader.query.sql, + 'SELECT core_hash FROM contract_marker WHERE marker_id = ?', + ); + expect(reader.query.parameters, [7]); + expect(reader.hashColumn, 'core_hash'); + }); + }); + + group('sqlRuntimeVerifyOptions runtime integration', () { + test('can be consumed by runtime client', () async { final contract = _contract(hash: 'hash-v1'); final client = OrmClient( contract: contract, engine: MemoryEngine(), - verify: RuntimeVerifyOptions( - mode: RuntimeVerifyMode.onFirstUse, - requireMarker: true, - markerReader: SqlContractMarkerReader( - executor: CallbackSqlMarkerQueryExecutor( - (_) async => const SqlMarkerQueryResult( - rows: [ - {'storage_hash': 'hash-v1'}, - ], - ), + verify: sqlRuntimeVerifyOptions( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult( + rows: [ + {'storage_hash': 'hash-v1'}, + ], ), ), ), @@ -214,13 +259,9 @@ void main() { final client = OrmClient( contract: contract, engine: MemoryEngine(), - verify: RuntimeVerifyOptions( - mode: RuntimeVerifyMode.onFirstUse, - requireMarker: true, - markerReader: SqlContractMarkerReader( - executor: CallbackSqlMarkerQueryExecutor( - (_) async => const SqlMarkerQueryResult(), - ), + verify: sqlRuntimeVerifyOptions( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult(), ), ), ); From 4838aade453ae39004c71ce3f097c2f0fd5c399f Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:57:25 +0800 Subject: [PATCH 036/154] test(client): add updateNested regression coverage --- pub/orm/test/client/client_test.dart | 114 +++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 60598ddb..5fbb53fe 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -803,6 +803,120 @@ void main() { await client.disconnect(); }); + test( + 'updateNested updates parent and creates child rows with include payload', + () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + final updated = await client + .model('User') + .updateNested( + where: {'id': 'u1'}, + data: {'email': 'u1+updated@example.com'}, + create: >{ + 'posts': [ + {'id': 'p4', 'title': 'Post D'}, + ], + }, + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }, + ); + + expect(updated, isNotNull); + expect(updated?['email'], 'u1+updated@example.com'); + final includedPosts = _readRowsValue(updated?['posts']); + expect(includedPosts, hasLength(3)); + expect(includedPosts.last['id'], 'p4'); + expect(includedPosts.last['userId'], 'u1'); + + final persistedUser = await client + .model('User') + .findUnique(where: {'id': 'u1'}); + expect(persistedUser?['email'], 'u1+updated@example.com'); + + final persistedChild = await client + .model('Post') + .findUnique(where: {'id': 'p4'}); + expect(persistedChild?['userId'], 'u1'); + await client.disconnect(); + }, + ); + + test('updateNested returns null when parent record is missing', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + final updated = await client + .model('User') + .updateNested( + where: {'id': 'ux'}, + data: {'email': 'missing@example.com'}, + create: >{ + 'posts': [ + {'id': 'p9', 'title': 'Post Missing Parent'}, + ], + }, + ); + + expect(updated, isNull); + final createdChild = await client + .model('Post') + .findUnique(where: {'id': 'p9'}); + expect(createdChild, isNull); + await client.disconnect(); + }); + + test('updateNested rolls back when child create fails', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + await expectLater( + client + .model('User') + .updateNested( + where: {'id': 'u1'}, + data: {'email': 'u1+rollback@example.com'}, + create: >{ + 'posts': [ + { + 'id': 'p10', + 'title': 'Post Rollback', + 'bad': 1, + }, + ], + }, + ), + throwsA(isA()), + ); + + final rolledBackUser = await client + .model('User') + .findUnique(where: {'id': 'u1'}); + expect(rolledBackUser?['email'], 'u1@example.com'); + + final rolledBackChild = await client + .model('Post') + .findUnique(where: {'id': 'p10'}); + expect(rolledBackChild, isNull); + await client.disconnect(); + }); + test( 'supports include and includeRelation on chained query APIs', () async { From f2af1cd416cb4f3b6268f5f4ea881b68e07251ea Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:40:03 +0800 Subject: [PATCH 037/154] feat(generator): add typed client template writer --- pub/orm/lib/src/generator/model.dart | 81 ++ pub/orm/lib/src/generator/writer.dart | 1047 +++++++++++++++++++++++++ 2 files changed, 1128 insertions(+) create mode 100644 pub/orm/lib/src/generator/model.dart create mode 100644 pub/orm/lib/src/generator/writer.dart diff --git a/pub/orm/lib/src/generator/model.dart b/pub/orm/lib/src/generator/model.dart new file mode 100644 index 00000000..0cc6db7b --- /dev/null +++ b/pub/orm/lib/src/generator/model.dart @@ -0,0 +1,81 @@ +import 'package:meta/meta.dart'; + +enum TypedFieldKind { scalar, relation } + +enum TypedScalarType { string, integer, floating, boolean, dateTime, json } + +extension TypedScalarTypeDartType on TypedScalarType { + String get dartType { + return switch (this) { + TypedScalarType.string => 'String', + TypedScalarType.integer => 'int', + TypedScalarType.floating => 'double', + TypedScalarType.boolean => 'bool', + TypedScalarType.dateTime => 'DateTime', + TypedScalarType.json => 'Object', + }; + } +} + +@immutable +final class TypedClientSchema { + final List models; + + TypedClientSchema({required List models}) + : models = List.unmodifiable(models); +} + +@immutable +final class TypedModel { + final String name; + final String runtimeName; + final List fields; + + TypedModel({ + required this.name, + String? runtimeName, + required List fields, + }) : runtimeName = runtimeName ?? name, + fields = List.unmodifiable(fields); +} + +@immutable +final class TypedField { + final String name; + final TypedFieldKind kind; + final TypedScalarType? scalarType; + final String? relationModel; + final bool isNullable; + final bool isList; + final bool includeInWhere; + final bool includeInCreate; + final bool includeInUpdate; + + const TypedField.scalar({ + required this.name, + required TypedScalarType type, + this.isNullable = false, + this.isList = false, + this.includeInWhere = true, + this.includeInCreate = true, + this.includeInUpdate = true, + }) : kind = TypedFieldKind.scalar, + scalarType = type, + relationModel = null; + + const TypedField.relation({ + required this.name, + required String model, + this.isNullable = true, + this.isList = false, + this.includeInWhere = true, + this.includeInCreate = true, + this.includeInUpdate = true, + }) : kind = TypedFieldKind.relation, + scalarType = null, + relationModel = model; + + bool get isScalar => kind == TypedFieldKind.scalar; + + bool get isRelation => kind == TypedFieldKind.relation; +} diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart new file mode 100644 index 00000000..0fe2c55b --- /dev/null +++ b/pub/orm/lib/src/generator/writer.dart @@ -0,0 +1,1047 @@ +import 'model.dart'; + +final class TypedClientWriterOptions { + final String ormImport; + final String? libraryName; + final String? banner; + + const TypedClientWriterOptions({ + this.ormImport = 'package:orm/orm.dart', + this.libraryName, + this.banner, + }); +} + +final class TypedClientWriter { + const TypedClientWriter(); + + String write({ + required TypedClientSchema schema, + TypedClientWriterOptions options = const TypedClientWriterOptions(), + }) { + final resolvedModels = _resolveModels(schema.models); + final modelLookup = {}; + for (final model in resolvedModels) { + modelLookup[model.model.name] = model; + modelLookup[model.model.runtimeName] = model; + } + + final buffer = StringBuffer(); + _writeHeader(buffer: buffer, options: options); + _writeGeneratedClientClass(buffer: buffer, models: resolvedModels); + + for (final model in resolvedModels) { + _writeTypedDelegateClass(buffer: buffer, model: model); + } + + for (final model in resolvedModels) { + _writeDataOrInputClass( + buffer: buffer, + model: model, + classKind: _TemplateClassKind.data, + lookup: modelLookup, + ); + _writeDataOrInputClass( + buffer: buffer, + model: model, + classKind: _TemplateClassKind.where, + lookup: modelLookup, + ); + _writeDataOrInputClass( + buffer: buffer, + model: model, + classKind: _TemplateClassKind.create, + lookup: modelLookup, + ); + _writeDataOrInputClass( + buffer: buffer, + model: model, + classKind: _TemplateClassKind.update, + lookup: modelLookup, + ); + } + + _writeJsonHelpers(buffer); + return buffer.toString(); + } + + void _writeHeader({ + required StringBuffer buffer, + required TypedClientWriterOptions options, + }) { + final libraryName = options.libraryName?.trim(); + if (libraryName != null && libraryName.isNotEmpty) { + buffer.writeln('library $libraryName;'); + buffer.writeln(); + } + + final banner = options.banner?.trim(); + if (banner != null && banner.isNotEmpty) { + final lines = banner.split('\n'); + for (final line in lines) { + if (line.isEmpty) { + buffer.writeln('//'); + continue; + } + buffer.writeln('// $line'); + } + } else { + buffer.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.'); + } + buffer.writeln('// ignore_for_file: unused_element'); + + buffer.writeln(); + buffer.writeln("import '${options.ormImport}';"); + buffer.writeln(); + } + + void _writeGeneratedClientClass({ + required StringBuffer buffer, + required List<_ResolvedModel> models, + }) { + buffer.writeln('class GeneratedOrmClient {'); + buffer.writeln(' final OrmModelContext _context;'); + buffer.writeln(); + buffer.writeln(' GeneratedOrmClient(this._context);'); + buffer.writeln(); + + for (final model in models) { + buffer.writeln( + ' late final ${model.delegateClassName} ${model.getterName} =', + ); + buffer.writeln( + " ${model.delegateClassName}(_context.model('${_escapeString(model.model.runtimeName)}'));", + ); + buffer.writeln(); + } + + buffer.writeln('}'); + buffer.writeln(); + } + + void _writeTypedDelegateClass({ + required StringBuffer buffer, + required _ResolvedModel model, + }) { + buffer.writeln('class ${model.delegateClassName} {'); + buffer.writeln(' final ModelDelegate _delegate;'); + buffer.writeln(); + buffer.writeln(' const ${model.delegateClassName}(this._delegate);'); + buffer.writeln(); + + buffer.writeln(' Future> findMany({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln(' List orderBy = const [],'); + buffer.writeln(' List select = const [],'); + buffer.writeln( + ' Map include = const {},', + ); + buffer.writeln(' }) async {'); + buffer.writeln(' final rows = await _delegate.findMany('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln( + ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> findUnique({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' List select = const [],'); + buffer.writeln( + ' Map include = const {},', + ); + buffer.writeln(' }) async {'); + buffer.writeln(' final row = await _delegate.findUnique('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> findFirst({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' List orderBy = const [],'); + buffer.writeln(' List select = const [],'); + buffer.writeln( + ' Map include = const {},', + ); + buffer.writeln(' }) async {'); + buffer.writeln(' final row = await _delegate.findFirst('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}> create({'); + buffer.writeln(' required ${model.createInputClassName} data,'); + buffer.writeln(' List select = const [],'); + buffer.writeln( + ' Map include = const {},', + ); + buffer.writeln(' }) async {'); + buffer.writeln(' final row = await _delegate.create('); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> update({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln(' List select = const [],'); + buffer.writeln( + ' Map include = const {},', + ); + buffer.writeln(' }) async {'); + buffer.writeln(' final row = await _delegate.update('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> delete({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' List select = const [],'); + buffer.writeln( + ' Map include = const {},', + ); + buffer.writeln(' }) async {'); + buffer.writeln(' final row = await _delegate.delete('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}> upsert({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.createInputClassName} create,'); + buffer.writeln(' required ${model.updateInputClassName} update,'); + buffer.writeln(' List select = const [],'); + buffer.writeln( + ' Map include = const {},', + ); + buffer.writeln(' }) async {'); + buffer.writeln(' final row = await _delegate.upsert('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' create: create.toJson(),'); + buffer.writeln(' update: update.toJson(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future count({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return _delegate.count(where: where.toJson());'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future exists({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return _delegate.exists(where: where.toJson());'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Stream<${model.dataClassName}> stream({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln(' List orderBy = const [],'); + buffer.writeln(' List select = const [],'); + buffer.writeln( + ' Map include = const {},', + ); + buffer.writeln(' }) async* {'); + buffer.writeln(' await for (final row in _delegate.streamMany('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' )) {'); + buffer.writeln(' yield ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + + buffer.writeln('}'); + buffer.writeln(); + } + + void _writeDataOrInputClass({ + required StringBuffer buffer, + required _ResolvedModel model, + required _TemplateClassKind classKind, + required Map lookup, + }) { + final className = _className(model, classKind); + final fields = _buildFieldBindings(_fieldsForClass(model.model, classKind)); + + buffer.writeln('class $className {'); + + for (final field in fields) { + final type = _fieldType( + field: field.field, + classKind: classKind, + lookup: lookup, + ); + buffer.writeln(' final $type ${field.memberName};'); + } + + if (fields.isNotEmpty) { + buffer.writeln(); + buffer.writeln(' const $className({'); + for (final field in fields) { + final isOptional = _isOptionalField(field.field, classKind: classKind); + final prefix = isOptional ? '' : 'required '; + buffer.writeln(' ${prefix}this.${field.memberName},'); + } + buffer.writeln(' });'); + } else { + buffer.writeln(); + buffer.writeln(' const $className();'); + } + + buffer.writeln(); + buffer.writeln( + ' factory $className.fromJson(Map json) {', + ); + buffer.writeln(' return $className('); + for (final field in fields) { + final decodeExpression = _decodeExpression( + field: field.field, + classKind: classKind, + accessor: "json['${_escapeString(field.field.name)}']", + lookup: lookup, + ); + if (_isOptionalField(field.field, classKind: classKind)) { + buffer.writeln(' ${field.memberName}: $decodeExpression,'); + } else { + final type = _fieldType( + field: field.field, + classKind: classKind, + lookup: lookup, + ); + final nonNullableType = _stripNullable(type); + buffer.writeln( + " ${field.memberName}: _requiredValue<$nonNullableType>($decodeExpression, '${_escapeString(field.field.name)}'),", + ); + } + } + buffer.writeln(' );'); + buffer.writeln(' }'); + + buffer.writeln(); + buffer.writeln(' Map toJson() {'); + buffer.writeln(' return {'); + for (final field in fields) { + final isOptional = _isOptionalField(field.field, classKind: classKind); + final memberName = isOptional ? '${field.memberName}!' : field.memberName; + final valueExpression = _encodeExpression( + field: field.field, + classKind: classKind, + memberName: memberName, + lookup: lookup, + ); + + if (isOptional) { + buffer.writeln( + " if (${field.memberName} != null) '${_escapeString(field.field.name)}': $valueExpression,", + ); + } else { + buffer.writeln( + " '${_escapeString(field.field.name)}': $valueExpression,", + ); + } + } + buffer.writeln(' };'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + } + + void _writeJsonHelpers(StringBuffer buffer) { + buffer.writeln( + 'typedef _FromJsonFactory = T Function(Map json);', + ); + buffer.writeln(); + buffer.writeln('T _requiredValue(T? value, String fieldName) {'); + buffer.writeln(' if (value == null) {'); + buffer.writeln( + " throw FormatException('Missing required field: \$fieldName.');", + ); + buffer.writeln(' }'); + buffer.writeln(' return value;'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('String? _readString(Object? value) {'); + buffer.writeln(' if (value is String) {'); + buffer.writeln(' return value;'); + buffer.writeln(' }'); + buffer.writeln(' return null;'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('int? _readInt(Object? value) {'); + buffer.writeln(' if (value is int) {'); + buffer.writeln(' return value;'); + buffer.writeln(' }'); + buffer.writeln(' if (value is num) {'); + buffer.writeln(' return value.toInt();'); + buffer.writeln(' }'); + buffer.writeln(' return null;'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('double? _readDouble(Object? value) {'); + buffer.writeln(' if (value is double) {'); + buffer.writeln(' return value;'); + buffer.writeln(' }'); + buffer.writeln(' if (value is num) {'); + buffer.writeln(' return value.toDouble();'); + buffer.writeln(' }'); + buffer.writeln(' return null;'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('bool? _readBool(Object? value) {'); + buffer.writeln(' if (value is bool) {'); + buffer.writeln(' return value;'); + buffer.writeln(' }'); + buffer.writeln(' return null;'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('DateTime? _readDateTime(Object? value) {'); + buffer.writeln(' if (value is DateTime) {'); + buffer.writeln(' return value;'); + buffer.writeln(' }'); + buffer.writeln(' if (value is String) {'); + buffer.writeln(' return DateTime.tryParse(value);'); + buffer.writeln(' }'); + buffer.writeln(' return null;'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('Object? _readJsonValue(Object? value) {'); + buffer.writeln(' return value;'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('List? _readStringList(Object? value) {'); + buffer.writeln(' if (value is! List) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' final result = [];'); + buffer.writeln(' for (final item in value) {'); + buffer.writeln(' if (item is String) {'); + buffer.writeln(' result.add(item);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return List.unmodifiable(result);'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('List? _readIntList(Object? value) {'); + buffer.writeln(' if (value is! List) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' final result = [];'); + buffer.writeln(' for (final item in value) {'); + buffer.writeln(' if (item is int) {'); + buffer.writeln(' result.add(item);'); + buffer.writeln(' continue;'); + buffer.writeln(' }'); + buffer.writeln(' if (item is num) {'); + buffer.writeln(' result.add(item.toInt());'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return List.unmodifiable(result);'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('List? _readDoubleList(Object? value) {'); + buffer.writeln(' if (value is! List) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' final result = [];'); + buffer.writeln(' for (final item in value) {'); + buffer.writeln(' if (item is double) {'); + buffer.writeln(' result.add(item);'); + buffer.writeln(' continue;'); + buffer.writeln(' }'); + buffer.writeln(' if (item is num) {'); + buffer.writeln(' result.add(item.toDouble());'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return List.unmodifiable(result);'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('List? _readBoolList(Object? value) {'); + buffer.writeln(' if (value is! List) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' final result = [];'); + buffer.writeln(' for (final item in value) {'); + buffer.writeln(' if (item is bool) {'); + buffer.writeln(' result.add(item);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return List.unmodifiable(result);'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('List? _readDateTimeList(Object? value) {'); + buffer.writeln(' if (value is! List) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' final result = [];'); + buffer.writeln(' for (final item in value) {'); + buffer.writeln(' final parsed = _readDateTime(item);'); + buffer.writeln(' if (parsed != null) {'); + buffer.writeln(' result.add(parsed);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return List.unmodifiable(result);'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('List? _readJsonList(Object? value) {'); + buffer.writeln(' if (value is! List) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return List.unmodifiable(value);'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('Map? _readJsonMap(Object? value) {'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln(' return value;'); + buffer.writeln(' }'); + buffer.writeln(' if (value is! Map) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' final result = {};'); + buffer.writeln(' for (final entry in value.entries) {'); + buffer.writeln(' final key = entry.key;'); + buffer.writeln(' if (key is String) {'); + buffer.writeln(' result[key] = entry.value;'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return result;'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln( + 'List>? _readJsonMapList(Object? value) {', + ); + buffer.writeln(' if (value is! List) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' final result = >[];'); + buffer.writeln(' for (final item in value) {'); + buffer.writeln(' final map = _readJsonMap(item);'); + buffer.writeln(' if (map != null) {'); + buffer.writeln(' result.add(map);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return List>.unmodifiable(result);'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln( + 'T? _readRelation(Object? value, _FromJsonFactory fromJson) {', + ); + buffer.writeln(' final map = _readJsonMap(value);'); + buffer.writeln(' if (map == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return fromJson(map);'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('List? _readRelationList('); + buffer.writeln(' Object? value,'); + buffer.writeln(' _FromJsonFactory fromJson,'); + buffer.writeln(') {'); + buffer.writeln(' final maps = _readJsonMapList(value);'); + buffer.writeln(' if (maps == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' final result = [];'); + buffer.writeln(' for (final map in maps) {'); + buffer.writeln(' result.add(fromJson(map));'); + buffer.writeln(' }'); + buffer.writeln(' return List.unmodifiable(result);'); + buffer.writeln('}'); + buffer.writeln(); + } + + List<_ResolvedModel> _resolveModels(List models) { + final resolved = <_ResolvedModel>[]; + final usedClassNames = {}; + final usedGetterNames = {}; + + for (final model in models) { + final classBaseName = _makeUnique( + base: _toUpperCamelIdentifier(model.name, fallback: 'Model'), + used: usedClassNames, + ); + final getterName = _makeUnique( + base: _toLowerCamelIdentifier(model.name, fallback: 'model'), + used: usedGetterNames, + ); + resolved.add( + _ResolvedModel( + model: model, + classBaseName: classBaseName, + getterName: getterName, + ), + ); + } + + return resolved; + } + + Iterable _fieldsForClass( + TypedModel model, + _TemplateClassKind classKind, + ) { + return switch (classKind) { + _TemplateClassKind.data => model.fields, + _TemplateClassKind.where => model.fields.where( + (field) => field.includeInWhere, + ), + _TemplateClassKind.create => model.fields.where( + (field) => field.includeInCreate, + ), + _TemplateClassKind.update => model.fields.where( + (field) => field.includeInUpdate, + ), + }; + } + + List<_FieldBinding> _buildFieldBindings(Iterable fields) { + final bindings = <_FieldBinding>[]; + final usedNames = {}; + for (final field in fields) { + final memberName = _makeUnique( + base: _toLowerCamelIdentifier(field.name, fallback: 'field'), + used: usedNames, + ); + bindings.add(_FieldBinding(field: field, memberName: memberName)); + } + return bindings; + } + + String _className(_ResolvedModel model, _TemplateClassKind classKind) { + return switch (classKind) { + _TemplateClassKind.data => model.dataClassName, + _TemplateClassKind.where => model.whereInputClassName, + _TemplateClassKind.create => model.createInputClassName, + _TemplateClassKind.update => model.updateInputClassName, + }; + } + + bool _isOptionalField( + TypedField field, { + required _TemplateClassKind classKind, + }) { + if (field.isRelation) { + return true; + } + + return switch (classKind) { + _TemplateClassKind.data => true, + _TemplateClassKind.where => true, + _TemplateClassKind.create => field.isNullable, + _TemplateClassKind.update => true, + }; + } + + String _fieldType({ + required TypedField field, + required _TemplateClassKind classKind, + required Map lookup, + }) { + final optional = _isOptionalField(field, classKind: classKind); + final baseType = _baseType( + field: field, + classKind: classKind, + lookup: lookup, + ); + + if (optional) { + return '$baseType?'; + } + return baseType; + } + + String _baseType({ + required TypedField field, + required _TemplateClassKind classKind, + required Map lookup, + }) { + if (field.isRelation) { + final relationModelName = field.relationModel; + final relation = relationModelName == null + ? null + : lookup[relationModelName]; + final elementType = relation == null + ? 'Map' + : '${relation.classBaseName}${_classSuffix(classKind)}'; + + if (field.isList) { + return 'List<$elementType>'; + } + return elementType; + } + + final scalarType = field.scalarType; + if (scalarType == null) { + return 'Object'; + } + + if (field.isList) { + if (scalarType == TypedScalarType.json) { + return 'List'; + } + return 'List<${scalarType.dartType}>'; + } + + return scalarType.dartType; + } + + String _decodeExpression({ + required TypedField field, + required _TemplateClassKind classKind, + required String accessor, + required Map lookup, + }) { + if (field.isRelation) { + final relationModelName = field.relationModel; + final relation = relationModelName == null + ? null + : lookup[relationModelName]; + + if (relation == null) { + if (field.isList) { + return '_readJsonMapList($accessor)'; + } + return '_readJsonMap($accessor)'; + } + + final relationClass = + '${relation.classBaseName}${_classSuffix(classKind)}'; + if (field.isList) { + return '_readRelationList($accessor, $relationClass.fromJson)'; + } + return '_readRelation($accessor, $relationClass.fromJson)'; + } + + return _decodeScalar(field, accessor: accessor); + } + + String _decodeScalar(TypedField field, {required String accessor}) { + final scalarType = field.scalarType; + if (scalarType == null) { + return '_readJsonValue($accessor)'; + } + + if (field.isList) { + return switch (scalarType) { + TypedScalarType.string => '_readStringList($accessor)', + TypedScalarType.integer => '_readIntList($accessor)', + TypedScalarType.floating => '_readDoubleList($accessor)', + TypedScalarType.boolean => '_readBoolList($accessor)', + TypedScalarType.dateTime => '_readDateTimeList($accessor)', + TypedScalarType.json => '_readJsonList($accessor)', + }; + } + + return switch (scalarType) { + TypedScalarType.string => '_readString($accessor)', + TypedScalarType.integer => '_readInt($accessor)', + TypedScalarType.floating => '_readDouble($accessor)', + TypedScalarType.boolean => '_readBool($accessor)', + TypedScalarType.dateTime => '_readDateTime($accessor)', + TypedScalarType.json => '_readJsonValue($accessor)', + }; + } + + String _encodeExpression({ + required TypedField field, + required _TemplateClassKind classKind, + required String memberName, + required Map lookup, + }) { + if (field.isRelation) { + final relationModelName = field.relationModel; + final relation = relationModelName == null + ? null + : lookup[relationModelName]; + + if (relation == null) { + return memberName; + } + + if (field.isList) { + return '$memberName.map((value) => value.toJson()).toList(growable: false)'; + } + return '$memberName.toJson()'; + } + + final scalarType = field.scalarType; + if (scalarType == TypedScalarType.dateTime) { + if (field.isList) { + return '$memberName.map((value) => value.toIso8601String()).toList(growable: false)'; + } + return '$memberName.toIso8601String()'; + } + + return memberName; + } + + String _classSuffix(_TemplateClassKind classKind) { + return switch (classKind) { + _TemplateClassKind.data => 'Data', + _TemplateClassKind.where => 'WhereInput', + _TemplateClassKind.create => 'CreateInput', + _TemplateClassKind.update => 'UpdateInput', + }; + } + + String _makeUnique({required String base, required Set used}) { + if (!used.contains(base)) { + used.add(base); + return base; + } + + var index = 2; + while (true) { + final candidate = '$base$index'; + if (!used.contains(candidate)) { + used.add(candidate); + return candidate; + } + index += 1; + } + } + + String _toUpperCamelIdentifier(String raw, {required String fallback}) { + final sanitized = _sanitize(raw); + if (sanitized.isEmpty) { + return fallback; + } + + final first = sanitized.first; + final head = _capitalize(first); + final tail = sanitized.skip(1).map(_capitalize).join(); + final identifier = '$head$tail'; + return _avoidKeyword(identifier, suffix: 'Type'); + } + + String _toLowerCamelIdentifier(String raw, {required String fallback}) { + final sanitized = _sanitize(raw); + if (sanitized.isEmpty) { + return fallback; + } + + final first = sanitized.first; + final head = first.toLowerCase(); + final tail = sanitized.skip(1).map(_capitalize).join(); + final identifier = '$head$tail'; + return _avoidKeyword(identifier, suffix: 'Value'); + } + + List _sanitize(String raw) { + final replaced = raw.replaceAll(RegExp(r'[^A-Za-z0-9]+'), ' '); + final segments = replaced + .split(RegExp(r'\s+')) + .where((segment) => segment.isNotEmpty) + .toList(growable: false); + + if (segments.isEmpty) { + return const []; + } + + final normalized = []; + for (final segment in segments) { + final withPrefix = RegExp(r'^[0-9]').hasMatch(segment) + ? 'n$segment' + : segment; + normalized.add(withPrefix); + } + return normalized; + } + + String _capitalize(String value) { + if (value.isEmpty) { + return value; + } + final lower = value.toLowerCase(); + return '${lower[0].toUpperCase()}${lower.substring(1)}'; + } + + String _avoidKeyword(String name, {required String suffix}) { + if (_dartKeywords.contains(name)) { + return '$name$suffix'; + } + return name; + } + + String _stripNullable(String type) { + if (type.endsWith('?')) { + return type.substring(0, type.length - 1); + } + return type; + } + + String _escapeString(String value) { + return value + .replaceAll(r'\\', r'\\\\') + .replaceAll("'", r"\\'") + .replaceAll('\n', r'\\n') + .replaceAll('\r', r'\\r'); + } +} + +enum _TemplateClassKind { data, where, create, update } + +final class _ResolvedModel { + final TypedModel model; + final String classBaseName; + final String getterName; + + const _ResolvedModel({ + required this.model, + required this.classBaseName, + required this.getterName, + }); + + String get delegateClassName => '${classBaseName}TypedDelegate'; + + String get dataClassName => '${classBaseName}Data'; + + String get whereInputClassName => '${classBaseName}WhereInput'; + + String get createInputClassName => '${classBaseName}CreateInput'; + + String get updateInputClassName => '${classBaseName}UpdateInput'; +} + +final class _FieldBinding { + final TypedField field; + final String memberName; + + const _FieldBinding({required this.field, required this.memberName}); +} + +const Set _dartKeywords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'Function', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +}; From 60038e92ac3579b86483b59faa85571963c8e605 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:43:59 +0800 Subject: [PATCH 038/154] test(generator): cover generate command core behavior --- .../fixtures/config_output/orm.config.dart | 8 + .../fixtures/config_output/orm.schema.dart | 8 + .../fixtures/default_output/orm.config.dart | 8 + .../fixtures/default_output/orm.schema.dart | 8 + .../fixtures/missing_config/orm.schema.dart | 8 + pub/orm/test/generator/generate_test.dart | 241 ++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 pub/orm/test/generator/fixtures/config_output/orm.config.dart create mode 100644 pub/orm/test/generator/fixtures/config_output/orm.schema.dart create mode 100644 pub/orm/test/generator/fixtures/default_output/orm.config.dart create mode 100644 pub/orm/test/generator/fixtures/default_output/orm.schema.dart create mode 100644 pub/orm/test/generator/fixtures/missing_config/orm.schema.dart create mode 100644 pub/orm/test/generator/generate_test.dart diff --git a/pub/orm/test/generator/fixtures/config_output/orm.config.dart b/pub/orm/test/generator/fixtures/config_output/orm.config.dart new file mode 100644 index 00000000..bce5d04d --- /dev/null +++ b/pub/orm/test/generator/fixtures/config_output/orm.config.dart @@ -0,0 +1,8 @@ +class Config { + final String? output; + final String? schema; + + const Config({this.output, this.schema}); +} + +const config = Config(output: 'generated/typed_client.g.dart'); diff --git a/pub/orm/test/generator/fixtures/config_output/orm.schema.dart b/pub/orm/test/generator/fixtures/config_output/orm.schema.dart new file mode 100644 index 00000000..6dbe9e65 --- /dev/null +++ b/pub/orm/test/generator/fixtures/config_output/orm.schema.dart @@ -0,0 +1,8 @@ +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +@model +typedef User = ({int id, String email}); diff --git a/pub/orm/test/generator/fixtures/default_output/orm.config.dart b/pub/orm/test/generator/fixtures/default_output/orm.config.dart new file mode 100644 index 00000000..70d8b1fd --- /dev/null +++ b/pub/orm/test/generator/fixtures/default_output/orm.config.dart @@ -0,0 +1,8 @@ +class Config { + final String? output; + final String? schema; + + const Config({this.output, this.schema}); +} + +const config = Config(); diff --git a/pub/orm/test/generator/fixtures/default_output/orm.schema.dart b/pub/orm/test/generator/fixtures/default_output/orm.schema.dart new file mode 100644 index 00000000..220a706e --- /dev/null +++ b/pub/orm/test/generator/fixtures/default_output/orm.schema.dart @@ -0,0 +1,8 @@ +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +@model +typedef User = ({int id, String email, DateTime createdAt}); diff --git a/pub/orm/test/generator/fixtures/missing_config/orm.schema.dart b/pub/orm/test/generator/fixtures/missing_config/orm.schema.dart new file mode 100644 index 00000000..da49f9c0 --- /dev/null +++ b/pub/orm/test/generator/fixtures/missing_config/orm.schema.dart @@ -0,0 +1,8 @@ +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +@model +typedef User = ({int id}); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart new file mode 100644 index 00000000..fcc0d5bf --- /dev/null +++ b/pub/orm/test/generator/generate_test.dart @@ -0,0 +1,241 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + final packageRoot = _resolvePackageRoot(); + final generatorEntry = File(_path([packageRoot, 'bin', 'orm.dart'])); + final fixturesRoot = Directory( + _path([packageRoot, 'test', 'generator', 'fixtures']), + ); + + group( + 'generate command', + skip: !generatorEntry.existsSync() + ? 'Generator public entry not found at ${generatorEntry.path}.' + : false, + () { + test('uses default path when output is empty', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'default_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runGenerate( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + final defaultOutput = File( + _path([fixtureDir.path, 'lib', 'orm_client.g.dart']), + ); + expect( + defaultOutput.existsSync(), + isTrue, + reason: + 'Expected default output at ${defaultOutput.path}.\n${run.debugOutput}', + ); + }); + + test('uses config.output path for generated files', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'config_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runGenerate( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + final outputFile = File( + _path([fixtureDir.path, 'generated', 'typed_client.g.dart']), + ); + expect( + outputFile.existsSync(), + isTrue, + reason: + 'Expected generated output at ${outputFile.path}.\n${run.debugOutput}', + ); + }); + + test( + 'generated code contains typed delegate and typed input/data markers', + () async { + final fixtureDir = _copyFixture(fixturesRoot, 'config_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runGenerate( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + var generatedDartFiles = _findDartFiles( + Directory(_path([fixtureDir.path, 'generated'])), + ); + if (generatedDartFiles.isEmpty) { + generatedDartFiles = _findDartFiles( + Directory(_path([fixtureDir.path, 'lib'])), + ); + } + + expect( + generatedDartFiles, + isNotEmpty, + reason: 'Expected generated Dart files to assert content.', + ); + + final generatedSource = generatedDartFiles + .map((file) => file.readAsStringSync()) + .join('\n'); + + expect( + RegExp( + r'\b(UserDelegate|UserModelDelegateExtension)\b', + ).hasMatch(generatedSource), + isTrue, + reason: 'Missing typed delegate marker in generated source.', + ); + + expect( + RegExp( + r'\b(User[A-Za-z0-9_]*(Input|Data)|UserRow)\b', + ).hasMatch(generatedSource), + isTrue, + reason: 'Missing typed input/data marker in generated source.', + ); + }, + ); + + test('prints actionable error message for invalid config', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'missing_config'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runGenerate( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, isNot(0), reason: run.debugOutput); + final combined = run.combinedOutput.toLowerCase(); + expect( + _containsAny(combined, [ + 'config', + 'output', + 'schema', + 'generator', + 'missing', + 'option', + ]), + isTrue, + reason: 'Expected helpful error keywords.\n${run.debugOutput}', + ); + }); + }, + ); +} + +String _resolvePackageRoot() { + final current = Directory.current.path; + final nested = _path([current, 'pub', 'orm']); + + if (File(_path([current, 'pubspec.yaml'])).existsSync() && + Directory(_path([current, 'test'])).existsSync()) { + return current; + } + + return nested; +} + +Directory _copyFixture(Directory fixturesRoot, String name) { + final source = Directory(_path([fixturesRoot.path, name])); + if (!source.existsSync()) { + throw StateError('Missing fixture directory: ${source.path}'); + } + + final target = Directory.systemTemp.createTempSync('orm_generate_${name}_'); + for (final entity in source.listSync(recursive: true, followLinks: false)) { + final relative = entity.path.substring(source.path.length + 1); + final destinationPath = _path([target.path, relative]); + if (entity is Directory) { + Directory(destinationPath).createSync(recursive: true); + continue; + } + if (entity is File) { + final destination = File(destinationPath); + destination.parent.createSync(recursive: true); + entity.copySync(destination.path); + } + } + + return target; +} + +Future<_GenerateRun> _runGenerate({ + required String entryPath, + required String workingDirectory, +}) async { + final args = [entryPath, 'generate']; + final result = await Process.run( + 'dart', + args, + workingDirectory: workingDirectory, + ); + return _GenerateRun( + args: args, + exitCode: result.exitCode, + stdout: '${result.stdout}', + stderr: '${result.stderr}', + ); +} + +bool _containsAny(String text, List markers) { + for (final marker in markers) { + if (text.contains(marker)) { + return true; + } + } + return false; +} + +List _findDartFiles(Directory directory) { + if (!directory.existsSync()) { + return const []; + } + return directory + .listSync(recursive: true, followLinks: false) + .whereType() + .where((file) => file.path.endsWith('.dart')) + .toList(); +} + +String _path(List parts) => parts.join(Platform.pathSeparator); + +final class _GenerateRun { + final List args; + final int exitCode; + final String stdout; + final String stderr; + + const _GenerateRun({ + required this.args, + required this.exitCode, + required this.stdout, + required this.stderr, + }); + + String get combinedOutput => '$stdout\n$stderr'; + + String get debugOutput { + final buffer = StringBuffer(); + buffer.writeln('args: dart ${args.join(' ')}'); + buffer.writeln('exitCode: $exitCode'); + buffer.writeln('stdout:'); + buffer.writeln(stdout.trim()); + buffer.writeln('stderr:'); + buffer.writeln(stderr.trim()); + return buffer.toString().trimRight(); + } +} From 9e4ab611d8006557c609886214e66e627de3c620 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:44:19 +0800 Subject: [PATCH 039/154] feat(generator): add generate command and typed client emitter --- pub/orm/bin/orm.dart | 74 +++++ pub/orm/lib/orm.dart | 1 + pub/orm/lib/src/generator/client_emitter.dart | 258 ++++++++++++++++++ pub/orm/lib/src/generator/command.dart | 95 +++++++ pub/orm/lib/src/generator/config_loader.dart | 201 ++++++++++++++ pub/orm/lib/src/generator/error.dart | 36 +++ pub/orm/lib/src/generator/generator.dart | 1 + pub/orm/lib/src/generator/schema_loader.dart | 170 ++++++++++++ pub/orm/lib/src/generator/snapshot.dart | 34 +++ pub/orm/pubspec.yaml | 3 + 10 files changed, 873 insertions(+) create mode 100644 pub/orm/bin/orm.dart create mode 100644 pub/orm/lib/src/generator/client_emitter.dart create mode 100644 pub/orm/lib/src/generator/command.dart create mode 100644 pub/orm/lib/src/generator/config_loader.dart create mode 100644 pub/orm/lib/src/generator/error.dart create mode 100644 pub/orm/lib/src/generator/generator.dart create mode 100644 pub/orm/lib/src/generator/schema_loader.dart create mode 100644 pub/orm/lib/src/generator/snapshot.dart diff --git a/pub/orm/bin/orm.dart b/pub/orm/bin/orm.dart new file mode 100644 index 00000000..8550c0c3 --- /dev/null +++ b/pub/orm/bin/orm.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:orm/orm.dart'; + +void main(List args) { + if (args.isEmpty || _isHelp(args.first)) { + _printUsage(); + exitCode = args.isEmpty ? 64 : 0; + return; + } + + final command = args.first; + final commandArgs = args.sublist(1); + + switch (command) { + case 'generate': + if (commandArgs.isNotEmpty && _isHelp(commandArgs.first)) { + _printGenerateHelp(); + exitCode = 0; + return; + } + + if (commandArgs.isNotEmpty) { + stderr.writeln( + 'Unexpected arguments for generate: ${commandArgs.join(' ')}', + ); + _printGenerateHelp(stream: stderr); + exitCode = 64; + return; + } + + exitCode = runGenerateCommand( + cwd: Directory.current, + out: stdout, + err: stderr, + ); + return; + default: + stderr.writeln('Unknown command: $command'); + _printUsage(stream: stderr); + exitCode = 64; + } +} + +bool _isHelp(String value) => + value == '--help' || value == '-h' || value == 'help'; + +void _printUsage({IOSink? stream}) { + final sink = stream ?? stdout; + sink.writeln('ORM CLI'); + sink.writeln('Usage: dart run orm '); + sink.writeln(''); + sink.writeln('Commands:'); + sink.writeln( + ' generate Generate typed client from orm.config.dart and schema', + ); + sink.writeln(''); + sink.writeln('Run `dart run orm generate --help` for generate details.'); +} + +void _printGenerateHelp({IOSink? stream}) { + final sink = stream ?? stdout; + sink.writeln('Generate typed client.'); + sink.writeln('Usage: dart run orm generate'); + sink.writeln(''); + sink.writeln('Input files from current working directory:'); + sink.writeln(' - orm.config.dart'); + sink.writeln( + ' - schema path from config.schema, or orm.schema.dart by default', + ); + sink.writeln(''); + sink.writeln('Output:'); + sink.writeln(' - config.output, or lib/orm_client.g.dart by default'); +} diff --git a/pub/orm/lib/orm.dart b/pub/orm/lib/orm.dart index 5feb4d57..81dc4feb 100644 --- a/pub/orm/lib/orm.dart +++ b/pub/orm/lib/orm.dart @@ -1,6 +1,7 @@ export 'config.dart'; export 'core.dart'; export 'schema.dart'; +export 'src/generator/generator.dart'; export 'src/client/client.dart'; export 'src/contract/contract.dart'; export 'src/engine/engine.dart'; diff --git a/pub/orm/lib/src/generator/client_emitter.dart b/pub/orm/lib/src/generator/client_emitter.dart new file mode 100644 index 00000000..06831c77 --- /dev/null +++ b/pub/orm/lib/src/generator/client_emitter.dart @@ -0,0 +1,258 @@ +import 'snapshot.dart'; + +String emitTypedClient({ + required SchemaSnapshot schema, + required String schemaImportPath, +}) { + final buffer = StringBuffer() + ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.') + ..writeln('// ignore_for_file: unused_import') + ..writeln() + ..writeln("import 'package:orm/orm.dart';") + ..writeln("import '$schemaImportPath';") + ..writeln(); + + for (final model in schema.models) { + buffer + ..writeln(_emitRowType(model)) + ..writeln(_emitDelegate(model)); + } + + buffer + ..writeln('final class OrmTypedClient {') + ..writeln(' final OrmModelContext _context;') + ..writeln('') + ..writeln(' const OrmTypedClient(this._context);') + ..writeln(''); + + for (final model in schema.models) { + final getterName = _toLowerCamel(model.name); + buffer.writeln( + ' ${model.name}Delegate get $getterName => ${model.name}Delegate(_context.collection(${_singleQuoted(model.name)}));', + ); + } + + buffer + ..writeln('}') + ..writeln('') + ..writeln('extension OrmTypedClientExtension on OrmModelContext {') + ..writeln(' OrmTypedClient get typed => OrmTypedClient(this);') + ..writeln('}'); + + return buffer.toString(); +} + +String _emitRowType(SchemaModelDefinition model) { + final buffer = StringBuffer()..writeln('typedef ${model.name}Row = ({'); + + for (final field in model.fields) { + buffer.writeln(' ${field.typeSource} ${field.name},'); + } + + buffer.writeln('});'); + return buffer.toString(); +} + +String _emitDelegate(SchemaModelDefinition model) { + final rowType = '${model.name}Row'; + final className = '${model.name}Delegate'; + + final buffer = StringBuffer() + ..writeln('final class $className {') + ..writeln(' final ModelDelegate _delegate;') + ..writeln('') + ..writeln(' const $className(this._delegate);') + ..writeln('') + ..writeln(' ModelDelegate get raw => _delegate;') + ..writeln('') + ..writeln(' ModelQuery query() => _delegate.query();') + ..writeln('') + ..writeln( + ' Future> findMany({' + 'JsonMap where = const {}, ' + 'int? skip, ' + 'int? take, ' + 'List orderBy = const [], ' + 'List select = const [], ' + 'Map include = const {}' + '}) async {', + ) + ..writeln(' final rows = await _delegate.findMany(') + ..writeln(' where: where,') + ..writeln(' skip: skip,') + ..writeln(' take: take,') + ..writeln(' orderBy: orderBy,') + ..writeln(' select: select,') + ..writeln(' include: include,') + ..writeln(' );') + ..writeln( + ' return rows.map(_to${model.name}Row).toList(growable: false);', + ) + ..writeln(' }') + ..writeln('') + ..writeln( + ' Stream<$rowType> streamMany({' + 'JsonMap where = const {}, ' + 'int? skip, ' + 'int? take, ' + 'List orderBy = const [], ' + 'List select = const [], ' + 'Map include = const {}' + '}) async* {', + ) + ..writeln(' await for (final row in _delegate.streamMany(') + ..writeln(' where: where,') + ..writeln(' skip: skip,') + ..writeln(' take: take,') + ..writeln(' orderBy: orderBy,') + ..writeln(' select: select,') + ..writeln(' include: include,') + ..writeln(' )) {') + ..writeln(' yield _to${model.name}Row(row);') + ..writeln(' }') + ..writeln(' }') + ..writeln('') + ..writeln( + ' Future<$rowType?> findUnique({' + 'JsonMap where = const {}, ' + 'List select = const [], ' + 'Map include = const {}' + '}) async {', + ) + ..writeln( + ' final row = await _delegate.findUnique(where: where, select: select, include: include);', + ) + ..writeln(' if (row == null) {') + ..writeln(' return null;') + ..writeln(' }') + ..writeln(' return _to${model.name}Row(row);') + ..writeln(' }') + ..writeln('') + ..writeln( + ' Future<$rowType?> findFirst({' + 'JsonMap where = const {}, ' + 'int? skip, ' + 'int? take, ' + 'List orderBy = const [], ' + 'List select = const [], ' + 'Map include = const {}' + '}) async {', + ) + ..writeln(' final row = await _delegate.findFirst(') + ..writeln(' where: where,') + ..writeln(' skip: skip,') + ..writeln(' take: take,') + ..writeln(' orderBy: orderBy,') + ..writeln(' select: select,') + ..writeln(' include: include,') + ..writeln(' );') + ..writeln(' if (row == null) {') + ..writeln(' return null;') + ..writeln(' }') + ..writeln(' return _to${model.name}Row(row);') + ..writeln(' }') + ..writeln('') + ..writeln( + ' Future<$rowType> create({' + 'required JsonMap data, ' + 'List select = const [], ' + 'Map include = const {}' + '}) async {', + ) + ..writeln( + ' final row = await _delegate.create(data: data, select: select, include: include);', + ) + ..writeln(' return _to${model.name}Row(row);') + ..writeln(' }') + ..writeln('') + ..writeln( + ' Future> createMany({' + 'required List data, ' + 'List select = const [], ' + 'Map include = const {}' + '}) async {', + ) + ..writeln( + ' final rows = await _delegate.createMany(data: data, select: select, include: include);', + ) + ..writeln( + ' return rows.map(_to${model.name}Row).toList(growable: false);', + ) + ..writeln(' }') + ..writeln('') + ..writeln( + ' Future<$rowType?> update({' + 'JsonMap where = const {}, ' + 'required JsonMap data, ' + 'List select = const [], ' + 'Map include = const {}' + '}) async {', + ) + ..writeln(' final row = await _delegate.update(') + ..writeln(' where: where,') + ..writeln(' data: data,') + ..writeln(' select: select,') + ..writeln(' include: include,') + ..writeln(' );') + ..writeln(' if (row == null) {') + ..writeln(' return null;') + ..writeln(' }') + ..writeln(' return _to${model.name}Row(row);') + ..writeln(' }') + ..writeln('') + ..writeln( + ' Future<$rowType?> delete({' + 'JsonMap where = const {}, ' + 'List select = const [], ' + 'Map include = const {}' + '}) async {', + ) + ..writeln(' final row = await _delegate.delete(') + ..writeln(' where: where,') + ..writeln(' select: select,') + ..writeln(' include: include,') + ..writeln(' );') + ..writeln(' if (row == null) {') + ..writeln(' return null;') + ..writeln(' }') + ..writeln(' return _to${model.name}Row(row);') + ..writeln(' }') + ..writeln('') + ..writeln( + ' Future count({JsonMap where = const {}}) => _delegate.count(where: where);', + ) + ..writeln('') + ..writeln( + ' Future exists({JsonMap where = const {}}) => _delegate.exists(where: where);', + ) + ..writeln('') + ..writeln(' $rowType fromJson(JsonMap row) => _to${model.name}Row(row);') + ..writeln('') + ..writeln(' $rowType _to${model.name}Row(JsonMap row) {') + ..writeln(' return ('); + + for (final field in model.fields) { + buffer.writeln( + " ${field.name}: row[${_singleQuoted(field.name)}] as ${field.typeSource},", + ); + } + + buffer + ..writeln(' );') + ..writeln(' }') + ..writeln('}'); + + return buffer.toString(); +} + +String _toLowerCamel(String value) { + if (value.isEmpty) { + return value; + } + final first = value.substring(0, 1).toLowerCase(); + return '$first${value.substring(1)}'; +} + +String _singleQuoted(String value) { + return "'${value.replaceAll("'", "\\'")}'"; +} diff --git a/pub/orm/lib/src/generator/command.dart b/pub/orm/lib/src/generator/command.dart new file mode 100644 index 00000000..93685d15 --- /dev/null +++ b/pub/orm/lib/src/generator/command.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'client_emitter.dart'; +import 'config_loader.dart'; +import 'error.dart'; +import 'schema_loader.dart'; + +int runGenerateCommand({Directory? cwd, IOSink? out, IOSink? err}) { + final workingDirectory = cwd ?? Directory.current; + final output = out ?? stdout; + final error = err ?? stderr; + + try { + final config = loadGeneratorConfig(cwd: workingDirectory); + final schema = loadSchema(cwd: workingDirectory, config: config); + final outputFile = _resolveOutputFile( + cwd: workingDirectory, + configuredOutputPath: config.outputPath, + ); + + final schemaImportPath = _relativeImportPath( + fromDirectoryPath: outputFile.parent.path, + targetFilePath: schema.schemaFile.path, + ); + + final generated = emitTypedClient( + schema: schema, + schemaImportPath: schemaImportPath, + ); + + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(generated); + + output.writeln('Generated typed client: ${outputFile.path}'); + return 0; + } on GeneratorException catch (exception) { + error.writeln(exception.formatForCli()); + return 1; + } catch (exception) { + error.writeln('Generate failed: Unexpected error.'); + error.writeln(exception); + return 1; + } +} + +File _resolveOutputFile({ + required Directory cwd, + required String configuredOutputPath, +}) { + final configuredFile = File(configuredOutputPath); + if (configuredFile.isAbsolute) { + return configuredFile; + } + + return File(_join(cwd.path, configuredOutputPath)); +} + +String _relativeImportPath({ + required String fromDirectoryPath, + required String targetFilePath, +}) { + final fromSegments = _pathSegments(fromDirectoryPath); + final targetSegments = _pathSegments(targetFilePath); + + var commonLength = 0; + while (commonLength < fromSegments.length && + commonLength < targetSegments.length && + fromSegments[commonLength] == targetSegments[commonLength]) { + commonLength += 1; + } + + final upwardCount = fromSegments.length - commonLength; + final segments = [ + ...List.filled(upwardCount, '..'), + ...targetSegments.sublist(commonLength), + ]; + + if (segments.isEmpty) { + return './${File(targetFilePath).uri.pathSegments.last}'; + } + + return segments.join('/'); +} + +List _pathSegments(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.split('/').where((segment) => segment.isNotEmpty).toList(); +} + +String _join(String base, String child) { + if (base.endsWith(Platform.pathSeparator)) { + return '$base$child'; + } + return '$base${Platform.pathSeparator}$child'; +} diff --git a/pub/orm/lib/src/generator/config_loader.dart b/pub/orm/lib/src/generator/config_loader.dart new file mode 100644 index 00000000..4e4136e6 --- /dev/null +++ b/pub/orm/lib/src/generator/config_loader.dart @@ -0,0 +1,201 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; + +import 'error.dart'; +import 'snapshot.dart'; + +const _defaultOutputPath = 'lib/orm_client.g.dart'; + +GeneratorConfigSnapshot loadGeneratorConfig({required Directory cwd}) { + final configFile = File(_join(cwd.path, 'orm.config.dart')); + if (!configFile.existsSync()) { + throw GeneratorException( + 'Cannot find orm.config.dart in current working directory.', + path: configFile.path, + hint: 'Create orm.config.dart with a top-level config declaration.', + ); + } + + final source = configFile.readAsStringSync(); + final parsed = parseString( + content: source, + path: configFile.path, + throwIfDiagnostics: false, + ); + + if (parsed.errors.isNotEmpty) { + final diagnostic = parsed.errors.first; + final location = parsed.lineInfo.getLocation(diagnostic.offset); + throw GeneratorException( + 'orm.config.dart contains invalid Dart syntax.', + path: configFile.path, + line: location.lineNumber, + column: location.columnNumber, + hint: diagnostic.message, + ); + } + + final configArguments = _findConfigArguments( + parsed.unit, + configFilePath: configFile.path, + ); + if (configArguments == null) { + throw GeneratorException( + 'Missing top-level config declaration.', + path: configFile.path, + hint: "Define: const config = Config(provider: ..., output: '...');", + ); + } + + final namedArguments = _readNamedArguments(configArguments); + final output = _readStringArg( + namedArguments, + key: 'output', + file: configFile, + defaultValue: _defaultOutputPath, + ); + final schema = _readNullableStringArg( + namedArguments, + key: 'schema', + file: configFile, + ); + + return GeneratorConfigSnapshot( + configFile: configFile, + outputPath: output, + schemaPath: schema, + ); +} + +ArgumentList? _findConfigArguments( + CompilationUnit unit, { + required String configFilePath, +}) { + for (final declaration in unit.declarations) { + if (declaration is! TopLevelVariableDeclaration) { + continue; + } + + for (final variable in declaration.variables.variables) { + if (variable.name.lexeme != 'config') { + continue; + } + + final initializer = variable.initializer; + if (initializer == null) { + throw GeneratorException( + 'Top-level config must initialize Config(...).', + path: configFilePath, + hint: "Use: const config = Config(provider: ..., output: '...');", + ); + } + + if (initializer is InstanceCreationExpression) { + final typeName = initializer.constructorName.type.name.lexeme; + if (typeName != 'Config') { + throw GeneratorException( + 'Top-level config must be an instance of Config.', + path: configFilePath, + hint: 'Update config initializer to Config(...).', + ); + } + return initializer.argumentList; + } + + if (initializer is MethodInvocation && + initializer.methodName.name == 'Config') { + return initializer.argumentList; + } + + throw GeneratorException( + 'Top-level config must initialize Config(...).', + path: configFilePath, + hint: "Use: const config = Config(provider: ..., output: '...');", + ); + } + } + + return null; +} + +Map _readNamedArguments(ArgumentList argumentList) { + final named = {}; + for (final argument in argumentList.arguments) { + if (argument is! NamedExpression) { + continue; + } + + final key = argument.name.label.name; + named[key] = argument.expression; + } + return named; +} + +String _readStringArg( + Map namedArguments, { + required String key, + required File file, + required String defaultValue, +}) { + final expression = namedArguments[key]; + if (expression == null) { + return defaultValue; + } + + final value = _readSimpleStringLiteral(expression); + if (value == null) { + throw GeneratorException( + 'Config.$key must be a string literal.', + path: file.path, + hint: "Example: $key: 'path/to/file.dart'", + ); + } + + if (value.trim().isEmpty) { + return defaultValue; + } + + return value; +} + +String? _readNullableStringArg( + Map namedArguments, { + required String key, + required File file, +}) { + if (!namedArguments.containsKey(key)) { + return null; + } + + final expression = namedArguments[key]!; + if (expression is NullLiteral) { + return null; + } + + final value = _readSimpleStringLiteral(expression); + if (value == null || value.trim().isEmpty) { + throw GeneratorException( + 'Config.$key must be null or a non-empty string literal.', + path: file.path, + hint: "Example: $key: 'orm.schema.dart'", + ); + } + + return value; +} + +String? _readSimpleStringLiteral(Expression expression) { + if (expression is SimpleStringLiteral) { + return expression.value; + } + return null; +} + +String _join(String base, String child) { + if (base.endsWith(Platform.pathSeparator)) { + return '$base$child'; + } + return '$base${Platform.pathSeparator}$child'; +} diff --git a/pub/orm/lib/src/generator/error.dart b/pub/orm/lib/src/generator/error.dart new file mode 100644 index 00000000..90e7ffe5 --- /dev/null +++ b/pub/orm/lib/src/generator/error.dart @@ -0,0 +1,36 @@ +final class GeneratorException implements Exception { + final String message; + final String? path; + final int? line; + final int? column; + final String? hint; + + const GeneratorException( + this.message, { + this.path, + this.line, + this.column, + this.hint, + }); + + String formatForCli() { + final buffer = StringBuffer()..writeln('Generate failed: $message'); + + if (path != null && path!.isNotEmpty) { + buffer.writeln('File: $path'); + } + + if (line != null && column != null) { + buffer.writeln('Location: $line:$column'); + } + + if (hint != null && hint!.isNotEmpty) { + buffer.writeln('Hint: $hint'); + } + + return buffer.toString().trimRight(); + } + + @override + String toString() => formatForCli(); +} diff --git a/pub/orm/lib/src/generator/generator.dart b/pub/orm/lib/src/generator/generator.dart new file mode 100644 index 00000000..280eea24 --- /dev/null +++ b/pub/orm/lib/src/generator/generator.dart @@ -0,0 +1 @@ +export 'command.dart' show runGenerateCommand; diff --git a/pub/orm/lib/src/generator/schema_loader.dart b/pub/orm/lib/src/generator/schema_loader.dart new file mode 100644 index 00000000..c78633af --- /dev/null +++ b/pub/orm/lib/src/generator/schema_loader.dart @@ -0,0 +1,170 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; + +import 'error.dart'; +import 'snapshot.dart'; + +SchemaSnapshot loadSchema({ + required Directory cwd, + required GeneratorConfigSnapshot config, +}) { + final schemaFile = _resolveSchemaFile(cwd: cwd, config: config); + if (!schemaFile.existsSync()) { + throw GeneratorException( + 'Cannot find schema file.', + path: schemaFile.path, + hint: config.schemaPath == null + ? "Set config.schema in orm.config.dart or create 'orm.schema.dart'." + : 'Check config.schema path in orm.config.dart.', + ); + } + + final source = schemaFile.readAsStringSync(); + final parsed = parseString( + content: source, + path: schemaFile.path, + throwIfDiagnostics: false, + ); + + if (parsed.errors.isNotEmpty) { + final diagnostic = parsed.errors.first; + final location = parsed.lineInfo.getLocation(diagnostic.offset); + throw GeneratorException( + 'Schema file has invalid Dart syntax.', + path: schemaFile.path, + line: location.lineNumber, + column: location.columnNumber, + hint: diagnostic.message, + ); + } + + final models = _readModels(parsed.unit, schemaFile: schemaFile); + if (models.isEmpty) { + throw GeneratorException( + 'Schema does not declare any @model typedef.', + path: schemaFile.path, + hint: 'Add at least one @model typedef using a named record type.', + ); + } + + return SchemaSnapshot(schemaFile: schemaFile, models: models); +} + +File _resolveSchemaFile({ + required Directory cwd, + required GeneratorConfigSnapshot config, +}) { + final configured = config.schemaPath; + if (configured == null || configured.trim().isEmpty) { + return File(_join(cwd.path, 'orm.schema.dart')); + } + + final configuredFile = File(configured); + if (configuredFile.isAbsolute) { + return configuredFile; + } + + return File(_join(cwd.path, configured)); +} + +List _readModels( + CompilationUnit unit, { + required File schemaFile, +}) { + final names = {}; + final models = []; + + for (final declaration in unit.declarations) { + if (declaration is! GenericTypeAlias) { + continue; + } + + if (!_hasAnnotation(declaration.metadata, 'model')) { + continue; + } + + final modelName = declaration.name.lexeme; + if (!names.add(modelName)) { + throw GeneratorException( + 'Duplicate model name: $modelName.', + path: schemaFile.path, + hint: 'Use unique names for each @model typedef.', + ); + } + + if (declaration.typeParameters != null) { + throw GeneratorException( + 'Model $modelName cannot declare type parameters.', + path: schemaFile.path, + hint: 'Use a non-generic typedef for @model declarations.', + ); + } + + final type = declaration.type; + if (type is! RecordTypeAnnotation) { + throw GeneratorException( + 'Model $modelName must be a record typedef.', + path: schemaFile.path, + hint: 'Example: typedef $modelName = ({String id});', + ); + } + + if (type.positionalFields.isNotEmpty) { + throw GeneratorException( + 'Model $modelName must use named record fields.', + path: schemaFile.path, + hint: 'Example: typedef $modelName = ({String id, String name});', + ); + } + + final named = type.namedFields; + final namedFields = + named?.fields ?? const []; + if (namedFields.isEmpty) { + throw GeneratorException( + 'Model $modelName has no fields.', + path: schemaFile.path, + hint: 'Define at least one named field in the record typedef.', + ); + } + + final fields = []; + for (final field in namedFields) { + final fieldName = field.name.lexeme; + final fieldTypeSource = field.type.toSource().trim(); + if (fieldTypeSource.isEmpty) { + throw GeneratorException( + 'Model $modelName field $fieldName has invalid type.', + path: schemaFile.path, + hint: 'Set an explicit field type in the record declaration.', + ); + } + + fields.add( + SchemaFieldDefinition(name: fieldName, typeSource: fieldTypeSource), + ); + } + + models.add(SchemaModelDefinition(name: modelName, fields: fields)); + } + + return models; +} + +bool _hasAnnotation(NodeList metadata, String name) { + for (final annotation in metadata) { + if (annotation.name.name == name) { + return true; + } + } + return false; +} + +String _join(String base, String child) { + if (base.endsWith(Platform.pathSeparator)) { + return '$base$child'; + } + return '$base${Platform.pathSeparator}$child'; +} diff --git a/pub/orm/lib/src/generator/snapshot.dart b/pub/orm/lib/src/generator/snapshot.dart new file mode 100644 index 00000000..d6960ddf --- /dev/null +++ b/pub/orm/lib/src/generator/snapshot.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +final class GeneratorConfigSnapshot { + final File configFile; + final String outputPath; + final String? schemaPath; + + const GeneratorConfigSnapshot({ + required this.configFile, + required this.outputPath, + required this.schemaPath, + }); +} + +final class SchemaFieldDefinition { + final String name; + final String typeSource; + + const SchemaFieldDefinition({required this.name, required this.typeSource}); +} + +final class SchemaModelDefinition { + final String name; + final List fields; + + const SchemaModelDefinition({required this.name, required this.fields}); +} + +final class SchemaSnapshot { + final File schemaFile; + final List models; + + const SchemaSnapshot({required this.schemaFile, required this.models}); +} diff --git a/pub/orm/pubspec.yaml b/pub/orm/pubspec.yaml index 59cfa70c..3a5204eb 100644 --- a/pub/orm/pubspec.yaml +++ b/pub/orm/pubspec.yaml @@ -3,6 +3,9 @@ description: A starting point for Dart libraries or applications. version: 6.0.0-dev.1 # repository: https://github.com/my_org/my_repo +executables: + orm: + resolution: workspace environment: sdk: ^3.10.1 From fef02c0de37ad986fbc3b4424c05aa486736f92c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:50:57 +0800 Subject: [PATCH 040/154] fix(generator): route generate through typed writer output --- pub/orm/lib/src/generator/client_emitter.dart | 337 ++++++------------ pub/orm/lib/src/generator/command.dart | 42 +-- pub/orm/lib/src/generator/writer.dart | 2 +- 3 files changed, 104 insertions(+), 277 deletions(-) diff --git a/pub/orm/lib/src/generator/client_emitter.dart b/pub/orm/lib/src/generator/client_emitter.dart index 06831c77..5de5d630 100644 --- a/pub/orm/lib/src/generator/client_emitter.dart +++ b/pub/orm/lib/src/generator/client_emitter.dart @@ -1,258 +1,125 @@ +import 'model.dart'; import 'snapshot.dart'; +import 'writer.dart'; -String emitTypedClient({ - required SchemaSnapshot schema, - required String schemaImportPath, -}) { - final buffer = StringBuffer() - ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.') - ..writeln('// ignore_for_file: unused_import') - ..writeln() - ..writeln("import 'package:orm/orm.dart';") - ..writeln("import '$schemaImportPath';") - ..writeln(); - - for (final model in schema.models) { - buffer - ..writeln(_emitRowType(model)) - ..writeln(_emitDelegate(model)); - } +String emitTypedClient({required SchemaSnapshot schema}) { + final typedModels = schema.models.map(_toTypedModel).toList(growable: false); + final typedSchema = TypedClientSchema(models: typedModels); + return const TypedClientWriter().write(schema: typedSchema); +} - buffer - ..writeln('final class OrmTypedClient {') - ..writeln(' final OrmModelContext _context;') - ..writeln('') - ..writeln(' const OrmTypedClient(this._context);') - ..writeln(''); +TypedModel _toTypedModel(SchemaModelDefinition model) { + final typedFields = model.fields.map(_toTypedField).toList(growable: false); + return TypedModel(name: model.name, fields: typedFields); +} - for (final model in schema.models) { - final getterName = _toLowerCamel(model.name); - buffer.writeln( - ' ${model.name}Delegate get $getterName => ${model.name}Delegate(_context.collection(${_singleQuoted(model.name)}));', +TypedField _toTypedField(SchemaFieldDefinition field) { + final parsed = _parseType(field.typeSource); + if (parsed.isRelation) { + final relationModel = parsed.relationModel; + if (relationModel == null) { + throw StateError('Expected relation model for ${field.name}.'); + } + return TypedField.relation( + name: field.name, + model: relationModel, + isNullable: parsed.isNullable, + isList: parsed.isList, + includeInWhere: false, + includeInCreate: false, + includeInUpdate: false, ); } - buffer - ..writeln('}') - ..writeln('') - ..writeln('extension OrmTypedClientExtension on OrmModelContext {') - ..writeln(' OrmTypedClient get typed => OrmTypedClient(this);') - ..writeln('}'); - - return buffer.toString(); + final scalarType = parsed.scalarType; + if (scalarType == null) { + throw StateError('Expected scalar type for ${field.name}.'); + } + return TypedField.scalar( + name: field.name, + type: scalarType, + isNullable: parsed.isNullable, + isList: parsed.isList, + ); } -String _emitRowType(SchemaModelDefinition model) { - final buffer = StringBuffer()..writeln('typedef ${model.name}Row = ({'); +_ParsedType _parseType(String source) { + final normalizedSource = source.trim(); + var typeSource = normalizedSource; + var isNullable = false; + var isList = false; - for (final field in model.fields) { - buffer.writeln(' ${field.typeSource} ${field.name},'); + if (typeSource.endsWith('?')) { + isNullable = true; + typeSource = typeSource.substring(0, typeSource.length - 1).trim(); } - buffer.writeln('});'); - return buffer.toString(); -} - -String _emitDelegate(SchemaModelDefinition model) { - final rowType = '${model.name}Row'; - final className = '${model.name}Delegate'; - - final buffer = StringBuffer() - ..writeln('final class $className {') - ..writeln(' final ModelDelegate _delegate;') - ..writeln('') - ..writeln(' const $className(this._delegate);') - ..writeln('') - ..writeln(' ModelDelegate get raw => _delegate;') - ..writeln('') - ..writeln(' ModelQuery query() => _delegate.query();') - ..writeln('') - ..writeln( - ' Future> findMany({' - 'JsonMap where = const {}, ' - 'int? skip, ' - 'int? take, ' - 'List orderBy = const [], ' - 'List select = const [], ' - 'Map include = const {}' - '}) async {', - ) - ..writeln(' final rows = await _delegate.findMany(') - ..writeln(' where: where,') - ..writeln(' skip: skip,') - ..writeln(' take: take,') - ..writeln(' orderBy: orderBy,') - ..writeln(' select: select,') - ..writeln(' include: include,') - ..writeln(' );') - ..writeln( - ' return rows.map(_to${model.name}Row).toList(growable: false);', - ) - ..writeln(' }') - ..writeln('') - ..writeln( - ' Stream<$rowType> streamMany({' - 'JsonMap where = const {}, ' - 'int? skip, ' - 'int? take, ' - 'List orderBy = const [], ' - 'List select = const [], ' - 'Map include = const {}' - '}) async* {', - ) - ..writeln(' await for (final row in _delegate.streamMany(') - ..writeln(' where: where,') - ..writeln(' skip: skip,') - ..writeln(' take: take,') - ..writeln(' orderBy: orderBy,') - ..writeln(' select: select,') - ..writeln(' include: include,') - ..writeln(' )) {') - ..writeln(' yield _to${model.name}Row(row);') - ..writeln(' }') - ..writeln(' }') - ..writeln('') - ..writeln( - ' Future<$rowType?> findUnique({' - 'JsonMap where = const {}, ' - 'List select = const [], ' - 'Map include = const {}' - '}) async {', - ) - ..writeln( - ' final row = await _delegate.findUnique(where: where, select: select, include: include);', - ) - ..writeln(' if (row == null) {') - ..writeln(' return null;') - ..writeln(' }') - ..writeln(' return _to${model.name}Row(row);') - ..writeln(' }') - ..writeln('') - ..writeln( - ' Future<$rowType?> findFirst({' - 'JsonMap where = const {}, ' - 'int? skip, ' - 'int? take, ' - 'List orderBy = const [], ' - 'List select = const [], ' - 'Map include = const {}' - '}) async {', - ) - ..writeln(' final row = await _delegate.findFirst(') - ..writeln(' where: where,') - ..writeln(' skip: skip,') - ..writeln(' take: take,') - ..writeln(' orderBy: orderBy,') - ..writeln(' select: select,') - ..writeln(' include: include,') - ..writeln(' );') - ..writeln(' if (row == null) {') - ..writeln(' return null;') - ..writeln(' }') - ..writeln(' return _to${model.name}Row(row);') - ..writeln(' }') - ..writeln('') - ..writeln( - ' Future<$rowType> create({' - 'required JsonMap data, ' - 'List select = const [], ' - 'Map include = const {}' - '}) async {', - ) - ..writeln( - ' final row = await _delegate.create(data: data, select: select, include: include);', - ) - ..writeln(' return _to${model.name}Row(row);') - ..writeln(' }') - ..writeln('') - ..writeln( - ' Future> createMany({' - 'required List data, ' - 'List select = const [], ' - 'Map include = const {}' - '}) async {', - ) - ..writeln( - ' final rows = await _delegate.createMany(data: data, select: select, include: include);', - ) - ..writeln( - ' return rows.map(_to${model.name}Row).toList(growable: false);', - ) - ..writeln(' }') - ..writeln('') - ..writeln( - ' Future<$rowType?> update({' - 'JsonMap where = const {}, ' - 'required JsonMap data, ' - 'List select = const [], ' - 'Map include = const {}' - '}) async {', - ) - ..writeln(' final row = await _delegate.update(') - ..writeln(' where: where,') - ..writeln(' data: data,') - ..writeln(' select: select,') - ..writeln(' include: include,') - ..writeln(' );') - ..writeln(' if (row == null) {') - ..writeln(' return null;') - ..writeln(' }') - ..writeln(' return _to${model.name}Row(row);') - ..writeln(' }') - ..writeln('') - ..writeln( - ' Future<$rowType?> delete({' - 'JsonMap where = const {}, ' - 'List select = const [], ' - 'Map include = const {}' - '}) async {', - ) - ..writeln(' final row = await _delegate.delete(') - ..writeln(' where: where,') - ..writeln(' select: select,') - ..writeln(' include: include,') - ..writeln(' );') - ..writeln(' if (row == null) {') - ..writeln(' return null;') - ..writeln(' }') - ..writeln(' return _to${model.name}Row(row);') - ..writeln(' }') - ..writeln('') - ..writeln( - ' Future count({JsonMap where = const {}}) => _delegate.count(where: where);', - ) - ..writeln('') - ..writeln( - ' Future exists({JsonMap where = const {}}) => _delegate.exists(where: where);', - ) - ..writeln('') - ..writeln(' $rowType fromJson(JsonMap row) => _to${model.name}Row(row);') - ..writeln('') - ..writeln(' $rowType _to${model.name}Row(JsonMap row) {') - ..writeln(' return ('); + final listMatch = RegExp(r'^List<(.+)>$').firstMatch(typeSource); + if (listMatch != null) { + isList = true; + typeSource = listMatch.group(1)!.trim(); + if (typeSource.endsWith('?')) { + typeSource = typeSource.substring(0, typeSource.length - 1).trim(); + } + } - for (final field in model.fields) { - buffer.writeln( - " ${field.name}: row[${_singleQuoted(field.name)}] as ${field.typeSource},", + final scalarType = _scalarTypeFor(typeSource); + if (scalarType != null) { + return _ParsedType.scalar( + scalarType: scalarType, + isNullable: isNullable, + isList: isList, ); } - buffer - ..writeln(' );') - ..writeln(' }') - ..writeln('}'); + return _ParsedType.relation( + relationModel: _relationModelName(typeSource), + isNullable: isNullable, + isList: isList, + ); +} - return buffer.toString(); +TypedScalarType? _scalarTypeFor(String source) { + final compact = source.replaceAll(RegExp(r'\s+'), ''); + return switch (compact) { + 'String' => TypedScalarType.string, + 'int' => TypedScalarType.integer, + 'double' || 'num' => TypedScalarType.floating, + 'bool' => TypedScalarType.boolean, + 'DateTime' => TypedScalarType.dateTime, + 'Object' || 'dynamic' => TypedScalarType.json, + _ when compact.startsWith('Map<') => TypedScalarType.json, + _ => null, + }; } -String _toLowerCamel(String value) { - if (value.isEmpty) { - return value; +String _relationModelName(String source) { + final compact = source.replaceAll(RegExp(r'\s+'), ''); + final tokens = compact.split('.'); + final last = tokens.last; + if (last.isEmpty) { + return source; } - final first = value.substring(0, 1).toLowerCase(); - return '$first${value.substring(1)}'; + return last; } -String _singleQuoted(String value) { - return "'${value.replaceAll("'", "\\'")}'"; +final class _ParsedType { + final TypedScalarType? scalarType; + final String? relationModel; + final bool isNullable; + final bool isList; + + const _ParsedType.scalar({ + required this.scalarType, + required this.isNullable, + required this.isList, + }) : relationModel = null; + + const _ParsedType.relation({ + required this.relationModel, + required this.isNullable, + required this.isList, + }) : scalarType = null; + + bool get isRelation => relationModel != null; } diff --git a/pub/orm/lib/src/generator/command.dart b/pub/orm/lib/src/generator/command.dart index 93685d15..835b9828 100644 --- a/pub/orm/lib/src/generator/command.dart +++ b/pub/orm/lib/src/generator/command.dart @@ -18,15 +18,7 @@ int runGenerateCommand({Directory? cwd, IOSink? out, IOSink? err}) { configuredOutputPath: config.outputPath, ); - final schemaImportPath = _relativeImportPath( - fromDirectoryPath: outputFile.parent.path, - targetFilePath: schema.schemaFile.path, - ); - - final generated = emitTypedClient( - schema: schema, - schemaImportPath: schemaImportPath, - ); + final generated = emitTypedClient(schema: schema); outputFile.parent.createSync(recursive: true); outputFile.writeAsStringSync(generated); @@ -55,38 +47,6 @@ File _resolveOutputFile({ return File(_join(cwd.path, configuredOutputPath)); } -String _relativeImportPath({ - required String fromDirectoryPath, - required String targetFilePath, -}) { - final fromSegments = _pathSegments(fromDirectoryPath); - final targetSegments = _pathSegments(targetFilePath); - - var commonLength = 0; - while (commonLength < fromSegments.length && - commonLength < targetSegments.length && - fromSegments[commonLength] == targetSegments[commonLength]) { - commonLength += 1; - } - - final upwardCount = fromSegments.length - commonLength; - final segments = [ - ...List.filled(upwardCount, '..'), - ...targetSegments.sublist(commonLength), - ]; - - if (segments.isEmpty) { - return './${File(targetFilePath).uri.pathSegments.last}'; - } - - return segments.join('/'); -} - -List _pathSegments(String path) { - final normalized = path.replaceAll('\\', '/'); - return normalized.split('/').where((segment) => segment.isNotEmpty).toList(); -} - String _join(String base, String child) { if (base.endsWith(Platform.pathSeparator)) { return '$base$child'; diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 0fe2c55b..f7de457f 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -958,7 +958,7 @@ final class _ResolvedModel { required this.getterName, }); - String get delegateClassName => '${classBaseName}TypedDelegate'; + String get delegateClassName => '${classBaseName}Delegate'; String get dataClassName => '${classBaseName}Data'; From ecb0086e18cb1fafd7211e449c0a443a71ab4627 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:06:25 +0800 Subject: [PATCH 041/154] feat(generator): add typed query dsl for select include and orderBy --- pub/orm/lib/src/generator/writer.dart | 325 +++++++++++++++++++--- pub/orm/test/generator/generate_test.dart | 21 ++ 2 files changed, 303 insertions(+), 43 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index f7de457f..1a148095 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -31,6 +31,7 @@ final class TypedClientWriter { _writeGeneratedClientClass(buffer: buffer, models: resolvedModels); for (final model in resolvedModels) { + _writeQueryDslClasses(buffer: buffer, model: model, lookup: modelLookup); _writeTypedDelegateClass(buffer: buffer, model: model); } @@ -119,6 +120,180 @@ final class TypedClientWriter { buffer.writeln(); } + void _writeQueryDslClasses({ + required StringBuffer buffer, + required _ResolvedModel model, + required Map lookup, + }) { + final scalarFields = model.model.fields + .where((field) => field.isScalar) + .toList(growable: false); + final relationFields = model.model.fields + .where((field) => field.isRelation) + .toList(growable: false); + + buffer.writeln('class ${model.orderByClassName} {'); + buffer.writeln(' final OrmOrderBy value;'); + buffer.writeln(); + buffer.writeln(' const ${model.orderByClassName}._(this.value);'); + buffer.writeln(); + for (final field in scalarFields) { + final methodName = _toLowerCamelIdentifier(field.name, fallback: 'field'); + buffer.writeln( + ' static ${model.orderByClassName} $methodName({SortOrder order = SortOrder.asc}) {', + ); + buffer.writeln( + " return ${model.orderByClassName}._(OrmOrderBy('${_escapeString(field.name)}', order: order));", + ); + buffer.writeln(' }'); + buffer.writeln(); + } + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class ${model.selectClassName} {'); + for (final field in scalarFields) { + final memberName = _toLowerCamelIdentifier(field.name, fallback: 'field'); + buffer.writeln(' final bool $memberName;'); + } + if (scalarFields.isNotEmpty) { + buffer.writeln(); + buffer.writeln(' const ${model.selectClassName}({'); + for (final field in scalarFields) { + final memberName = _toLowerCamelIdentifier( + field.name, + fallback: 'field', + ); + buffer.writeln(' this.$memberName = false,'); + } + buffer.writeln(' });'); + } else { + buffer.writeln(); + buffer.writeln(' const ${model.selectClassName}();'); + } + buffer.writeln(); + buffer.writeln(' List toFields() {'); + if (scalarFields.isEmpty) { + buffer.writeln(' return const [];'); + } else { + buffer.writeln(' final fields = [];'); + for (final field in scalarFields) { + final memberName = _toLowerCamelIdentifier( + field.name, + fallback: 'field', + ); + buffer.writeln( + " if ($memberName) fields.add('${_escapeString(field.name)}');", + ); + } + buffer.writeln(' return List.unmodifiable(fields);'); + } + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + + for (final relation in relationFields) { + final relationModelName = relation.relationModel; + final relationModel = relationModelName == null + ? null + : lookup[relationModelName]; + if (relationModel == null) { + continue; + } + final includeClassName = _relationIncludeClassName( + owner: model, + relationFieldName: relation.name, + ); + buffer.writeln('class $includeClassName {'); + buffer.writeln(' final ${relationModel.whereInputClassName} where;'); + buffer.writeln(' final int? skip;'); + buffer.writeln(' final int? take;'); + buffer.writeln( + ' final List<${relationModel.orderByClassName}> orderBy;', + ); + buffer.writeln(' final ${relationModel.selectClassName}? select;'); + buffer.writeln(' final ${relationModel.includeClassName}? include;'); + buffer.writeln(); + buffer.writeln(' const $includeClassName({'); + buffer.writeln( + ' this.where = const ${relationModel.whereInputClassName}(),', + ); + buffer.writeln(' this.skip,'); + buffer.writeln(' this.take,'); + buffer.writeln( + ' this.orderBy = const <${relationModel.orderByClassName}>[],', + ); + buffer.writeln(' this.select,'); + buffer.writeln(' this.include,'); + buffer.writeln(' });'); + buffer.writeln(); + buffer.writeln(' IncludeSpec toIncludeSpec() {'); + buffer.writeln(' return IncludeSpec('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln( + ' orderBy: orderBy.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' select: select?.toFields() ?? const [],'); + buffer.writeln( + ' include: include?.toIncludeMap() ?? const {},', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + } + + buffer.writeln('class ${model.includeClassName} {'); + for (final relation in relationFields) { + final includeClassName = _relationIncludeClassName( + owner: model, + relationFieldName: relation.name, + ); + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + buffer.writeln(' final $includeClassName? $memberName;'); + } + if (relationFields.isNotEmpty) { + buffer.writeln(); + buffer.writeln(' const ${model.includeClassName}({'); + for (final relation in relationFields) { + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + buffer.writeln(' this.$memberName,'); + } + buffer.writeln(' });'); + } else { + buffer.writeln(); + buffer.writeln(' const ${model.includeClassName}();'); + } + buffer.writeln(); + buffer.writeln(' Map toIncludeMap() {'); + if (relationFields.isEmpty) { + buffer.writeln(' return const {};'); + } else { + buffer.writeln(' final include = {};'); + for (final relation in relationFields) { + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + buffer.writeln( + " if ($memberName != null) include['${_escapeString(relation.name)}'] = $memberName!.toIncludeSpec();", + ); + } + buffer.writeln(' return include;'); + } + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + } + void _writeTypedDelegateClass({ required StringBuffer buffer, required _ResolvedModel model, @@ -135,19 +310,28 @@ final class TypedClientWriter { ); buffer.writeln(' int? skip,'); buffer.writeln(' int? take,'); - buffer.writeln(' List orderBy = const [],'); - buffer.writeln(' List select = const [],'); buffer.writeln( - ' Map include = const {},', + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) async {'); + buffer.writeln( + ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln( + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', + ); buffer.writeln(' final rows = await _delegate.findMany('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); - buffer.writeln(' orderBy: orderBy,'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' orderBy: runtimeOrderBy,'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); buffer.writeln( ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', @@ -157,15 +341,19 @@ final class TypedClientWriter { buffer.writeln(' Future<${model.dataClassName}?> findUnique({'); buffer.writeln(' required ${model.whereInputClassName} where,'); - buffer.writeln(' List select = const [],'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) async {'); buffer.writeln( - ' Map include = const {},', + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' }) async {'); buffer.writeln(' final row = await _delegate.findUnique('); buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); @@ -179,18 +367,27 @@ final class TypedClientWriter { ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); buffer.writeln(' int? skip,'); - buffer.writeln(' List orderBy = const [],'); - buffer.writeln(' List select = const [],'); buffer.writeln( - ' Map include = const {},', + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) async {'); + buffer.writeln( + ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln( + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', + ); buffer.writeln(' final row = await _delegate.findFirst('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' skip: skip,'); - buffer.writeln(' orderBy: orderBy,'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' orderBy: runtimeOrderBy,'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); @@ -201,15 +398,19 @@ final class TypedClientWriter { buffer.writeln(' Future<${model.dataClassName}> create({'); buffer.writeln(' required ${model.createInputClassName} data,'); - buffer.writeln(' List select = const [],'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) async {'); buffer.writeln( - ' Map include = const {},', + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' }) async {'); buffer.writeln(' final row = await _delegate.create('); buffer.writeln(' data: data.toJson(),'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); @@ -218,16 +419,20 @@ final class TypedClientWriter { buffer.writeln(' Future<${model.dataClassName}?> update({'); buffer.writeln(' required ${model.whereInputClassName} where,'); buffer.writeln(' required ${model.updateInputClassName} data,'); - buffer.writeln(' List select = const [],'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) async {'); buffer.writeln( - ' Map include = const {},', + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' }) async {'); buffer.writeln(' final row = await _delegate.update('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' data: data.toJson(),'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); @@ -238,15 +443,19 @@ final class TypedClientWriter { buffer.writeln(' Future<${model.dataClassName}?> delete({'); buffer.writeln(' required ${model.whereInputClassName} where,'); - buffer.writeln(' List select = const [],'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) async {'); buffer.writeln( - ' Map include = const {},', + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' }) async {'); buffer.writeln(' final row = await _delegate.delete('); buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); @@ -259,17 +468,21 @@ final class TypedClientWriter { buffer.writeln(' required ${model.whereInputClassName} where,'); buffer.writeln(' required ${model.createInputClassName} create,'); buffer.writeln(' required ${model.updateInputClassName} update,'); - buffer.writeln(' List select = const [],'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) async {'); buffer.writeln( - ' Map include = const {},', + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' }) async {'); buffer.writeln(' final row = await _delegate.upsert('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' create: create.toJson(),'); buffer.writeln(' update: update.toJson(),'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); @@ -299,19 +512,28 @@ final class TypedClientWriter { ); buffer.writeln(' int? skip,'); buffer.writeln(' int? take,'); - buffer.writeln(' List orderBy = const [],'); - buffer.writeln(' List select = const [],'); buffer.writeln( - ' Map include = const {},', + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) async* {'); + buffer.writeln( + ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln( + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', + ); buffer.writeln(' await for (final row in _delegate.streamMany('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); - buffer.writeln(' orderBy: orderBy,'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' orderBy: runtimeOrderBy,'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' )) {'); buffer.writeln(' yield ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); @@ -850,6 +1072,17 @@ final class TypedClientWriter { }; } + String _relationIncludeClassName({ + required _ResolvedModel owner, + required String relationFieldName, + }) { + final relationPart = _toUpperCamelIdentifier( + relationFieldName, + fallback: 'Relation', + ); + return '${owner.classBaseName}${relationPart}Include'; + } + String _makeUnique({required String base, required Set used}) { if (!used.contains(base)) { used.add(base); @@ -967,6 +1200,12 @@ final class _ResolvedModel { String get createInputClassName => '${classBaseName}CreateInput'; String get updateInputClassName => '${classBaseName}UpdateInput'; + + String get orderByClassName => '${classBaseName}OrderBy'; + + String get selectClassName => '${classBaseName}Select'; + + String get includeClassName => '${classBaseName}Include'; } final class _FieldBinding { diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index fcc0d5bf..a83cefb7 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -106,6 +106,27 @@ void main() { isTrue, reason: 'Missing typed input/data marker in generated source.', ); + + expect( + RegExp(r'\bclass UserOrderBy\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed orderBy DSL class in generated source.', + ); + expect( + RegExp(r'\bclass UserSelect\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed select DSL class in generated source.', + ); + expect( + RegExp(r'\bclass UserInclude\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed include DSL class in generated source.', + ); + expect( + generatedSource.contains('List orderBy'), + isTrue, + reason: 'Expected typed delegate signature to use UserOrderBy.', + ); }, ); From 2a24f05dec6afef98d1bac814f7314e3101d96cb Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:43:48 +0800 Subject: [PATCH 042/154] feat(generator): add typed where filter inputs --- pub/orm/lib/src/generator/client_emitter.dart | 1 + pub/orm/lib/src/generator/writer.dart | 198 +++++++++++++++++- pub/orm/test/generator/generate_test.dart | 18 ++ 3 files changed, 214 insertions(+), 3 deletions(-) diff --git a/pub/orm/lib/src/generator/client_emitter.dart b/pub/orm/lib/src/generator/client_emitter.dart index 5de5d630..0aba1f10 100644 --- a/pub/orm/lib/src/generator/client_emitter.dart +++ b/pub/orm/lib/src/generator/client_emitter.dart @@ -40,6 +40,7 @@ TypedField _toTypedField(SchemaFieldDefinition field) { type: scalarType, isNullable: parsed.isNullable, isList: parsed.isList, + includeInWhere: !parsed.isList, ); } diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 1a148095..08cb5bc8 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -28,6 +28,7 @@ final class TypedClientWriter { final buffer = StringBuffer(); _writeHeader(buffer: buffer, options: options); + _writeWhereFilterClasses(buffer); _writeGeneratedClientClass(buffer: buffer, models: resolvedModels); for (final model in resolvedModels) { @@ -96,6 +97,165 @@ final class TypedClientWriter { buffer.writeln(); } + void _writeWhereFilterClasses(StringBuffer buffer) { + buffer.writeln('class StringWhereFilter {'); + buffer.writeln(' final String? equals;'); + buffer.writeln(); + buffer.writeln(' const StringWhereFilter({this.equals});'); + buffer.writeln(); + buffer.writeln( + ' factory StringWhereFilter.fromJsonValue(Object? value) {', + ); + buffer.writeln(' if (value is String) {'); + buffer.writeln(' return StringWhereFilter(equals: value);'); + buffer.writeln(' }'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln( + " return StringWhereFilter(equals: _readString(value['equals']));", + ); + buffer.writeln(' }'); + buffer.writeln(' return const StringWhereFilter();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(); + buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class IntWhereFilter {'); + buffer.writeln(' final int? equals;'); + buffer.writeln(); + buffer.writeln(' const IntWhereFilter({this.equals});'); + buffer.writeln(); + buffer.writeln(' factory IntWhereFilter.fromJsonValue(Object? value) {'); + buffer.writeln(' if (value is int) {'); + buffer.writeln(' return IntWhereFilter(equals: value);'); + buffer.writeln(' }'); + buffer.writeln(' if (value is num) {'); + buffer.writeln(' return IntWhereFilter(equals: value.toInt());'); + buffer.writeln(' }'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln( + " return IntWhereFilter(equals: _readInt(value['equals']));", + ); + buffer.writeln(' }'); + buffer.writeln(' return const IntWhereFilter();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(); + buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class DoubleWhereFilter {'); + buffer.writeln(' final double? equals;'); + buffer.writeln(); + buffer.writeln(' const DoubleWhereFilter({this.equals});'); + buffer.writeln(); + buffer.writeln( + ' factory DoubleWhereFilter.fromJsonValue(Object? value) {', + ); + buffer.writeln(' if (value is double) {'); + buffer.writeln(' return DoubleWhereFilter(equals: value);'); + buffer.writeln(' }'); + buffer.writeln(' if (value is num) {'); + buffer.writeln(' return DoubleWhereFilter(equals: value.toDouble());'); + buffer.writeln(' }'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln( + " return DoubleWhereFilter(equals: _readDouble(value['equals']));", + ); + buffer.writeln(' }'); + buffer.writeln(' return const DoubleWhereFilter();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(); + buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class BoolWhereFilter {'); + buffer.writeln(' final bool? equals;'); + buffer.writeln(); + buffer.writeln(' const BoolWhereFilter({this.equals});'); + buffer.writeln(); + buffer.writeln(' factory BoolWhereFilter.fromJsonValue(Object? value) {'); + buffer.writeln(' if (value is bool) {'); + buffer.writeln(' return BoolWhereFilter(equals: value);'); + buffer.writeln(' }'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln( + " return BoolWhereFilter(equals: _readBool(value['equals']));", + ); + buffer.writeln(' }'); + buffer.writeln(' return const BoolWhereFilter();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(); + buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class DateTimeWhereFilter {'); + buffer.writeln(' final DateTime? equals;'); + buffer.writeln(); + buffer.writeln(' const DateTimeWhereFilter({this.equals});'); + buffer.writeln(); + buffer.writeln( + ' factory DateTimeWhereFilter.fromJsonValue(Object? value) {', + ); + buffer.writeln(' if (value is DateTime) {'); + buffer.writeln(' return DateTimeWhereFilter(equals: value);'); + buffer.writeln(' }'); + buffer.writeln(' if (value is String) {'); + buffer.writeln( + ' return DateTimeWhereFilter(equals: DateTime.tryParse(value));', + ); + buffer.writeln(' }'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln( + " return DateTimeWhereFilter(equals: _readDateTime(value['equals']));", + ); + buffer.writeln(' }'); + buffer.writeln(' return const DateTimeWhereFilter();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() => equals?.toIso8601String();'); + buffer.writeln(); + buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class JsonWhereFilter {'); + buffer.writeln(' final Object? equals;'); + buffer.writeln(); + buffer.writeln(' const JsonWhereFilter({this.equals});'); + buffer.writeln(); + buffer.writeln(' factory JsonWhereFilter.fromJsonValue(Object? value) {'); + buffer.writeln( + ' if (value is Map && value.containsKey(\'equals\')) {', + ); + buffer.writeln(" return JsonWhereFilter(equals: value['equals']);"); + buffer.writeln(' }'); + buffer.writeln( + ' if (value is Map || value is List || value is String || value is num || value is bool || value == null) {', + ); + buffer.writeln(' return JsonWhereFilter(equals: value);'); + buffer.writeln(' }'); + buffer.writeln(' return const JsonWhereFilter();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(); + buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln('}'); + buffer.writeln(); + } + void _writeGeneratedClientClass({ required StringBuffer buffer, required List<_ResolvedModel> models, @@ -618,11 +778,19 @@ final class TypedClientWriter { memberName: memberName, lookup: lookup, ); + final isWhereScalarFilter = + classKind == _TemplateClassKind.where && field.field.isScalar; if (isOptional) { - buffer.writeln( - " if (${field.memberName} != null) '${_escapeString(field.field.name)}': $valueExpression,", - ); + if (isWhereScalarFilter) { + buffer.writeln( + " if (${field.memberName} != null && !${field.memberName}!.isEmpty) '${_escapeString(field.field.name)}': $valueExpression,", + ); + } else { + buffer.writeln( + " if (${field.memberName} != null) '${_escapeString(field.field.name)}': $valueExpression,", + ); + } } else { buffer.writeln( " '${_escapeString(field.field.name)}': $valueExpression,", @@ -943,6 +1111,10 @@ final class TypedClientWriter { required _TemplateClassKind classKind, required Map lookup, }) { + if (classKind == _TemplateClassKind.where && field.isScalar) { + return _whereFilterClassName(field.scalarType); + } + if (field.isRelation) { final relationModelName = field.relationModel; final relation = relationModelName == null @@ -979,6 +1151,11 @@ final class TypedClientWriter { required String accessor, required Map lookup, }) { + if (classKind == _TemplateClassKind.where && field.isScalar) { + final filterClass = _whereFilterClassName(field.scalarType); + return '$filterClass.fromJsonValue($accessor)'; + } + if (field.isRelation) { final relationModelName = field.relationModel; final relation = relationModelName == null @@ -1036,6 +1213,10 @@ final class TypedClientWriter { required String memberName, required Map lookup, }) { + if (classKind == _TemplateClassKind.where && field.isScalar) { + return '$memberName.toJsonValue()'; + } + if (field.isRelation) { final relationModelName = field.relationModel; final relation = relationModelName == null @@ -1063,6 +1244,17 @@ final class TypedClientWriter { return memberName; } + String _whereFilterClassName(TypedScalarType? scalarType) { + return switch (scalarType) { + TypedScalarType.string => 'StringWhereFilter', + TypedScalarType.integer => 'IntWhereFilter', + TypedScalarType.floating => 'DoubleWhereFilter', + TypedScalarType.boolean => 'BoolWhereFilter', + TypedScalarType.dateTime => 'DateTimeWhereFilter', + TypedScalarType.json || null => 'JsonWhereFilter', + }; + } + String _classSuffix(_TemplateClassKind classKind) { return switch (classKind) { _TemplateClassKind.data => 'Data', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index a83cefb7..02eb3ad9 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -122,6 +122,24 @@ void main() { isTrue, reason: 'Missing typed include DSL class in generated source.', ); + expect( + RegExp(r'\bclass StringWhereFilter\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing string where filter class in generated source.', + ); + expect( + RegExp(r'\bclass IntWhereFilter\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing int where filter class in generated source.', + ); + expect( + RegExp( + r'class\s+UserWhereInput\s*\{[\s\S]*?final\s+IntWhereFilter\?\s+id;[\s\S]*?final\s+StringWhereFilter\?\s+email;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserWhereInput fields to use typed where filter classes.', + ); expect( generatedSource.contains('List orderBy'), isTrue, From db5b3a9343c9237d29fd13a9ca166093fb6a6786 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:50:19 +0800 Subject: [PATCH 043/154] feat(generator): add typed whereUnique inputs for unique operations --- pub/orm/lib/src/generator/client_emitter.dart | 7 + pub/orm/lib/src/generator/model.dart | 3 + pub/orm/lib/src/generator/schema_loader.dart | 17 +- pub/orm/lib/src/generator/snapshot.dart | 7 +- pub/orm/lib/src/generator/writer.dart | 51 ++++- pub/orm/test/generator/generate_test.dart | 211 +++++++++++------- 6 files changed, 204 insertions(+), 92 deletions(-) diff --git a/pub/orm/lib/src/generator/client_emitter.dart b/pub/orm/lib/src/generator/client_emitter.dart index 0aba1f10..4c81d2fa 100644 --- a/pub/orm/lib/src/generator/client_emitter.dart +++ b/pub/orm/lib/src/generator/client_emitter.dart @@ -41,6 +41,9 @@ TypedField _toTypedField(SchemaFieldDefinition field) { isNullable: parsed.isNullable, isList: parsed.isList, includeInWhere: !parsed.isList, + includeInWhereUnique: + (field.isId || _isConventionalIdFieldName(field.name)) && + !parsed.isList, ); } @@ -104,6 +107,10 @@ String _relationModelName(String source) { return last; } +bool _isConventionalIdFieldName(String name) { + return name.trim().toLowerCase() == 'id'; +} + final class _ParsedType { final TypedScalarType? scalarType; final String? relationModel; diff --git a/pub/orm/lib/src/generator/model.dart b/pub/orm/lib/src/generator/model.dart index 0cc6db7b..b3deae14 100644 --- a/pub/orm/lib/src/generator/model.dart +++ b/pub/orm/lib/src/generator/model.dart @@ -48,6 +48,7 @@ final class TypedField { final bool isNullable; final bool isList; final bool includeInWhere; + final bool includeInWhereUnique; final bool includeInCreate; final bool includeInUpdate; @@ -57,6 +58,7 @@ final class TypedField { this.isNullable = false, this.isList = false, this.includeInWhere = true, + this.includeInWhereUnique = false, this.includeInCreate = true, this.includeInUpdate = true, }) : kind = TypedFieldKind.scalar, @@ -69,6 +71,7 @@ final class TypedField { this.isNullable = true, this.isList = false, this.includeInWhere = true, + this.includeInWhereUnique = false, this.includeInCreate = true, this.includeInUpdate = true, }) : kind = TypedFieldKind.relation, diff --git a/pub/orm/lib/src/generator/schema_loader.dart b/pub/orm/lib/src/generator/schema_loader.dart index c78633af..de6ddc80 100644 --- a/pub/orm/lib/src/generator/schema_loader.dart +++ b/pub/orm/lib/src/generator/schema_loader.dart @@ -134,6 +134,7 @@ List _readModels( for (final field in namedFields) { final fieldName = field.name.lexeme; final fieldTypeSource = field.type.toSource().trim(); + final isId = _hasAnnotationIgnoreCase(field.metadata, 'id'); if (fieldTypeSource.isEmpty) { throw GeneratorException( 'Model $modelName field $fieldName has invalid type.', @@ -143,7 +144,11 @@ List _readModels( } fields.add( - SchemaFieldDefinition(name: fieldName, typeSource: fieldTypeSource), + SchemaFieldDefinition( + name: fieldName, + typeSource: fieldTypeSource, + isId: isId, + ), ); } @@ -162,6 +167,16 @@ bool _hasAnnotation(NodeList metadata, String name) { return false; } +bool _hasAnnotationIgnoreCase(NodeList metadata, String name) { + final normalized = name.toLowerCase(); + for (final annotation in metadata) { + if (annotation.name.name.toLowerCase() == normalized) { + return true; + } + } + return false; +} + String _join(String base, String child) { if (base.endsWith(Platform.pathSeparator)) { return '$base$child'; diff --git a/pub/orm/lib/src/generator/snapshot.dart b/pub/orm/lib/src/generator/snapshot.dart index d6960ddf..cb4a4a42 100644 --- a/pub/orm/lib/src/generator/snapshot.dart +++ b/pub/orm/lib/src/generator/snapshot.dart @@ -15,8 +15,13 @@ final class GeneratorConfigSnapshot { final class SchemaFieldDefinition { final String name; final String typeSource; + final bool isId; - const SchemaFieldDefinition({required this.name, required this.typeSource}); + const SchemaFieldDefinition({ + required this.name, + required this.typeSource, + this.isId = false, + }); } final class SchemaModelDefinition { diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 08cb5bc8..a0cd3892 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -49,6 +49,12 @@ final class TypedClientWriter { classKind: _TemplateClassKind.where, lookup: modelLookup, ); + _writeDataOrInputClass( + buffer: buffer, + model: model, + classKind: _TemplateClassKind.whereUnique, + lookup: modelLookup, + ); _writeDataOrInputClass( buffer: buffer, model: model, @@ -500,7 +506,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Future<${model.dataClassName}?> findUnique({'); - buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) async {'); @@ -577,7 +583,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Future<${model.dataClassName}?> update({'); - buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' required ${model.updateInputClassName} data,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); @@ -602,7 +608,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Future<${model.dataClassName}?> delete({'); - buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) async {'); @@ -625,7 +631,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Future<${model.dataClassName}> upsert({'); - buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' required ${model.createInputClassName} create,'); buffer.writeln(' required ${model.updateInputClassName} update,'); buffer.writeln(' ${model.selectClassName}? select,'); @@ -779,7 +785,7 @@ final class TypedClientWriter { lookup: lookup, ); final isWhereScalarFilter = - classKind == _TemplateClassKind.where && field.field.isScalar; + _isWhereFilterClassKind(classKind) && field.field.isScalar; if (isOptional) { if (isWhereScalarFilter) { @@ -1041,6 +1047,9 @@ final class TypedClientWriter { _TemplateClassKind.where => model.fields.where( (field) => field.includeInWhere, ), + _TemplateClassKind.whereUnique => model.fields.where( + _includeInWhereUnique, + ), _TemplateClassKind.create => model.fields.where( (field) => field.includeInCreate, ), @@ -1067,6 +1076,7 @@ final class TypedClientWriter { return switch (classKind) { _TemplateClassKind.data => model.dataClassName, _TemplateClassKind.where => model.whereInputClassName, + _TemplateClassKind.whereUnique => model.whereUniqueInputClassName, _TemplateClassKind.create => model.createInputClassName, _TemplateClassKind.update => model.updateInputClassName, }; @@ -1083,6 +1093,7 @@ final class TypedClientWriter { return switch (classKind) { _TemplateClassKind.data => true, _TemplateClassKind.where => true, + _TemplateClassKind.whereUnique => true, _TemplateClassKind.create => field.isNullable, _TemplateClassKind.update => true, }; @@ -1111,7 +1122,7 @@ final class TypedClientWriter { required _TemplateClassKind classKind, required Map lookup, }) { - if (classKind == _TemplateClassKind.where && field.isScalar) { + if (_isWhereFilterClassKind(classKind) && field.isScalar) { return _whereFilterClassName(field.scalarType); } @@ -1151,7 +1162,7 @@ final class TypedClientWriter { required String accessor, required Map lookup, }) { - if (classKind == _TemplateClassKind.where && field.isScalar) { + if (_isWhereFilterClassKind(classKind) && field.isScalar) { final filterClass = _whereFilterClassName(field.scalarType); return '$filterClass.fromJsonValue($accessor)'; } @@ -1213,7 +1224,7 @@ final class TypedClientWriter { required String memberName, required Map lookup, }) { - if (classKind == _TemplateClassKind.where && field.isScalar) { + if (_isWhereFilterClassKind(classKind) && field.isScalar) { return '$memberName.toJsonValue()'; } @@ -1259,11 +1270,31 @@ final class TypedClientWriter { return switch (classKind) { _TemplateClassKind.data => 'Data', _TemplateClassKind.where => 'WhereInput', + _TemplateClassKind.whereUnique => 'WhereUniqueInput', _TemplateClassKind.create => 'CreateInput', _TemplateClassKind.update => 'UpdateInput', }; } + bool _isWhereFilterClassKind(_TemplateClassKind classKind) { + return classKind == _TemplateClassKind.where || + classKind == _TemplateClassKind.whereUnique; + } + + bool _includeInWhereUnique(TypedField field) { + if (!field.isScalar || field.isList) { + return false; + } + if (field.includeInWhereUnique) { + return true; + } + return _isConventionalIdFieldName(field.name) && field.includeInWhere; + } + + bool _isConventionalIdFieldName(String name) { + return name.trim().toLowerCase() == 'id'; + } + String _relationIncludeClassName({ required _ResolvedModel owner, required String relationFieldName, @@ -1370,7 +1401,7 @@ final class TypedClientWriter { } } -enum _TemplateClassKind { data, where, create, update } +enum _TemplateClassKind { data, where, whereUnique, create, update } final class _ResolvedModel { final TypedModel model; @@ -1389,6 +1420,8 @@ final class _ResolvedModel { String get whereInputClassName => '${classBaseName}WhereInput'; + String get whereUniqueInputClassName => '${classBaseName}WhereUniqueInput'; + String get createInputClassName => '${classBaseName}CreateInput'; String get updateInputClassName => '${classBaseName}UpdateInput'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 02eb3ad9..f4999640 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -59,94 +59,143 @@ void main() { ); }); - test( - 'generated code contains typed delegate and typed input/data markers', - () async { - final fixtureDir = _copyFixture(fixturesRoot, 'config_output'); - addTearDown(() => fixtureDir.deleteSync(recursive: true)); - - final run = await _runGenerate( - entryPath: generatorEntry.path, - workingDirectory: fixtureDir.path, - ); + test('generated code contains typed delegate and typed input/data markers', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'config_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runGenerate( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); - expect(run.exitCode, 0, reason: run.debugOutput); + expect(run.exitCode, 0, reason: run.debugOutput); - var generatedDartFiles = _findDartFiles( - Directory(_path([fixtureDir.path, 'generated'])), - ); - if (generatedDartFiles.isEmpty) { - generatedDartFiles = _findDartFiles( - Directory(_path([fixtureDir.path, 'lib'])), - ); - } - - expect( - generatedDartFiles, - isNotEmpty, - reason: 'Expected generated Dart files to assert content.', + var generatedDartFiles = _findDartFiles( + Directory(_path([fixtureDir.path, 'generated'])), + ); + if (generatedDartFiles.isEmpty) { + generatedDartFiles = _findDartFiles( + Directory(_path([fixtureDir.path, 'lib'])), ); + } - final generatedSource = generatedDartFiles - .map((file) => file.readAsStringSync()) - .join('\n'); + expect( + generatedDartFiles, + isNotEmpty, + reason: 'Expected generated Dart files to assert content.', + ); - expect( - RegExp( - r'\b(UserDelegate|UserModelDelegateExtension)\b', - ).hasMatch(generatedSource), - isTrue, - reason: 'Missing typed delegate marker in generated source.', - ); + final generatedSource = generatedDartFiles + .map((file) => file.readAsStringSync()) + .join('\n'); - expect( - RegExp( - r'\b(User[A-Za-z0-9_]*(Input|Data)|UserRow)\b', - ).hasMatch(generatedSource), - isTrue, - reason: 'Missing typed input/data marker in generated source.', - ); + expect( + RegExp( + r'\b(UserDelegate|UserModelDelegateExtension)\b', + ).hasMatch(generatedSource), + isTrue, + reason: 'Missing typed delegate marker in generated source.', + ); - expect( - RegExp(r'\bclass UserOrderBy\b').hasMatch(generatedSource), - isTrue, - reason: 'Missing typed orderBy DSL class in generated source.', - ); - expect( - RegExp(r'\bclass UserSelect\b').hasMatch(generatedSource), - isTrue, - reason: 'Missing typed select DSL class in generated source.', - ); - expect( - RegExp(r'\bclass UserInclude\b').hasMatch(generatedSource), - isTrue, - reason: 'Missing typed include DSL class in generated source.', - ); - expect( - RegExp(r'\bclass StringWhereFilter\b').hasMatch(generatedSource), - isTrue, - reason: 'Missing string where filter class in generated source.', - ); - expect( - RegExp(r'\bclass IntWhereFilter\b').hasMatch(generatedSource), - isTrue, - reason: 'Missing int where filter class in generated source.', - ); - expect( - RegExp( - r'class\s+UserWhereInput\s*\{[\s\S]*?final\s+IntWhereFilter\?\s+id;[\s\S]*?final\s+StringWhereFilter\?\s+email;', - ).hasMatch(generatedSource), - isTrue, - reason: - 'Expected UserWhereInput fields to use typed where filter classes.', - ); - expect( - generatedSource.contains('List orderBy'), - isTrue, - reason: 'Expected typed delegate signature to use UserOrderBy.', - ); - }, - ); + expect( + RegExp( + r'\b(User[A-Za-z0-9_]*(Input|Data)|UserRow)\b', + ).hasMatch(generatedSource), + isTrue, + reason: 'Missing typed input/data marker in generated source.', + ); + expect( + RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed where unique input class in generated source.', + ); + expect( + RegExp( + r'class\s+UserWhereUniqueInput\s*\{[\s\S]*?final\s+IntWhereFilter\?\s+id;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserWhereUniqueInput to expose typed unique id filter.', + ); + expect( + RegExp( + r'Future\s+findUnique\(\{\s*required\s+UserWhereUniqueInput\s+where,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected findUnique where parameter to use UserWhereUniqueInput.', + ); + expect( + RegExp( + r'Future\s+update\(\{\s*required\s+UserWhereUniqueInput\s+where,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected update where parameter to use UserWhereUniqueInput.', + ); + expect( + RegExp( + r'Future\s+delete\(\{\s*required\s+UserWhereUniqueInput\s+where,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected delete where parameter to use UserWhereUniqueInput.', + ); + expect( + RegExp( + r'Future\s+upsert\(\{\s*required\s+UserWhereUniqueInput\s+where,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected upsert where parameter to use UserWhereUniqueInput.', + ); + expect( + RegExp( + r'Future>\s+findMany\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected non-unique findMany to keep UserWhereInput.', + ); + + expect( + RegExp(r'\bclass UserOrderBy\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed orderBy DSL class in generated source.', + ); + expect( + RegExp(r'\bclass UserSelect\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed select DSL class in generated source.', + ); + expect( + RegExp(r'\bclass UserInclude\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed include DSL class in generated source.', + ); + expect( + RegExp(r'\bclass StringWhereFilter\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing string where filter class in generated source.', + ); + expect( + RegExp(r'\bclass IntWhereFilter\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing int where filter class in generated source.', + ); + expect( + RegExp( + r'class\s+UserWhereInput\s*\{[\s\S]*?final\s+IntWhereFilter\?\s+id;[\s\S]*?final\s+StringWhereFilter\?\s+email;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserWhereInput fields to use typed where filter classes.', + ); + expect( + generatedSource.contains('List orderBy'), + isTrue, + reason: 'Expected typed delegate signature to use UserOrderBy.', + ); + }); test('prints actionable error message for invalid config', () async { final fixtureDir = _copyFixture(fixturesRoot, 'missing_config'); From b1bdf77b008196f3368d401edaa73252e7d8d5f4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:54:34 +0800 Subject: [PATCH 044/154] feat(generator): add immutable query builder dsl --- pub/orm/lib/src/generator/writer.dart | 192 ++++++++++++++++++++++ pub/orm/test/generator/generate_test.dart | 31 ++++ 2 files changed, 223 insertions(+) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index a0cd3892..30d1d405 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -470,6 +470,30 @@ final class TypedClientWriter { buffer.writeln(' const ${model.delegateClassName}(this._delegate);'); buffer.writeln(); + buffer.writeln(' ${model.queryClassName} query({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln( + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) {'); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: this,'); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future> findMany({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -707,6 +731,172 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); + _writeTypedQueryClass(buffer: buffer, model: model); + } + + void _writeTypedQueryClass({ + required StringBuffer buffer, + required _ResolvedModel model, + }) { + buffer.writeln('class ${model.queryClassName} {'); + buffer.writeln(' final ${model.delegateClassName} _delegate;'); + buffer.writeln(' final ${model.whereInputClassName} _where;'); + buffer.writeln(' final int? _skip;'); + buffer.writeln(' final int? _take;'); + buffer.writeln(' final List<${model.orderByClassName}> _orderBy;'); + buffer.writeln(' final ${model.selectClassName}? _select;'); + buffer.writeln(' final ${model.includeClassName}? _include;'); + buffer.writeln(); + buffer.writeln(' ${model.queryClassName}._({'); + buffer.writeln(' required ${model.delegateClassName} delegate,'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required int? skip,'); + buffer.writeln(' required int? take,'); + buffer.writeln(' required List<${model.orderByClassName}> orderBy,'); + buffer.writeln(' required ${model.selectClassName}? select,'); + buffer.writeln(' required ${model.includeClassName}? include,'); + buffer.writeln(' }) : _delegate = delegate,'); + buffer.writeln(' _where = where,'); + buffer.writeln(' _skip = skip,'); + buffer.writeln(' _take = take,'); + buffer.writeln( + ' _orderBy = List<${model.orderByClassName}>.unmodifiable(orderBy),', + ); + buffer.writeln(' _select = select,'); + buffer.writeln(' _include = include;'); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} where(${model.whereInputClassName} where) {', + ); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' ${model.queryClassName} skip(int? skip) {'); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' ${model.queryClassName} take(int? take) {'); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} orderBy(List<${model.orderByClassName}> orderBy) {', + ); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} select(${model.selectClassName}? select) {', + ); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} include(${model.includeClassName}? include) {', + ); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future> all() {'); + buffer.writeln(' return _delegate.findMany('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> first() {'); + buffer.writeln(' return _delegate.findFirst('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Stream<${model.dataClassName}> stream() {'); + buffer.writeln(' return _delegate.stream('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future count() {'); + buffer.writeln(' return _delegate.count(where: _where);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future exists() {'); + buffer.writeln(' return _delegate.exists(where: _where);'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); } void _writeDataOrInputClass({ @@ -1416,6 +1606,8 @@ final class _ResolvedModel { String get delegateClassName => '${classBaseName}Delegate'; + String get queryClassName => '${classBaseName}Query'; + String get dataClassName => '${classBaseName}Data'; String get whereInputClassName => '${classBaseName}WhereInput'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index f4999640..62be56a8 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -104,6 +104,37 @@ void main() { isTrue, reason: 'Missing typed input/data marker in generated source.', ); + expect( + RegExp(r'\bclass UserQuery\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing chain query class UserQuery in generated source.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?\bUserQuery\s+query\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserDelegate.query(...) in generated source.', + ); + expect( + RegExp(r'\bUserQuery\s+where\(').hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.where(...) in generated source.', + ); + expect( + RegExp( + r'\bFuture>\s+all\s*\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.all() in generated source.', + ); + expect( + RegExp( + r'\bFuture\s+first\s*\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.first() in generated source.', + ); expect( RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), isTrue, From 57689ca442949c663bb7d1a95e5c7cac28bc1ef4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:35:24 +0800 Subject: [PATCH 045/154] feat(runtime): support rich where operators across generator and sql --- pub/orm/lib/src/engine/memory_engine.dart | 154 ++++++++++- pub/orm/lib/src/generator/writer.dart | 313 +++++++++++++++++++--- pub/orm/lib/src/sql/adapter.dart | 149 +++++++++- pub/orm/test/client/client_test.dart | 49 ++++ pub/orm/test/generator/generate_test.dart | 21 ++ pub/orm/test/sql/sql_adapter_test.dart | 123 +++++++++ 6 files changed, 767 insertions(+), 42 deletions(-) diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index be835f9f..d1044d47 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -3,6 +3,28 @@ import '../runtime/plan.dart'; import '../runtime/types.dart'; import 'engine.dart'; +const List _whereOperatorOrder = [ + 'equals', + 'not', + 'in', + 'notIn', + 'gt', + 'gte', + 'lt', + 'lte', +]; + +const Set _whereOperators = { + 'equals', + 'not', + 'in', + 'notIn', + 'gt', + 'gte', + 'lt', + 'lte', +}; + final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { final Map> _store; bool _opened = false; @@ -136,17 +158,145 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { bool _matches(JsonMap row, JsonMap where) { for (final entry in where.entries) { - if (!row.containsKey(entry.key)) { + if (!_matchesWhereField( + row: row, + field: entry.key, + condition: entry.value, + )) { return false; } + } + return true; + } + + bool _matchesWhereField({ + required JsonMap row, + required String field, + required Object? condition, + }) { + if (!row.containsKey(field)) { + return false; + } + + final actualValue = row[field]; + final operatorMap = _coerceOperatorMap(condition); + if (operatorMap == null) { + return actualValue == condition; + } + + for (final operator in _whereOperatorOrder) { + if (!operatorMap.containsKey(operator)) { + continue; + } - if (row[entry.key] != entry.value) { + final operand = operatorMap[operator]; + final matched = switch (operator) { + 'equals' => actualValue == operand, + 'not' => actualValue != operand, + 'in' => _matchIn(actualValue, operand), + 'notIn' => _matchNotIn(actualValue, operand), + 'gt' => _matchComparison(actualValue, operand, operator), + 'gte' => _matchComparison(actualValue, operand, operator), + 'lt' => _matchComparison(actualValue, operand, operator), + 'lte' => _matchComparison(actualValue, operand, operator), + _ => false, + }; + + if (!matched) { return false; } } + return true; } + Map? _coerceOperatorMap(Object? value) { + if (value is! Map) { + return null; + } + if (value.isEmpty) { + return null; + } + + final normalized = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) { + return null; + } + if (!_whereOperators.contains(key)) { + return null; + } + normalized[key] = entry.value; + } + + return normalized; + } + + bool _matchIn(Object? actualValue, Object? operand) { + final values = _coerceListOperand(operand); + if (values.isEmpty) { + return false; + } + return values.contains(actualValue); + } + + bool _matchNotIn(Object? actualValue, Object? operand) { + final values = _coerceListOperand(operand); + if (values.isEmpty) { + return true; + } + return !values.contains(actualValue); + } + + List _coerceListOperand(Object? operand) { + if (operand is List) { + return operand; + } + if (operand is List) { + return List.from(operand); + } + return const []; + } + + bool _matchComparison(Object? actualValue, Object? operand, String operator) { + final comparison = _compareWhereValues(actualValue, operand); + if (comparison == null) { + return false; + } + return switch (operator) { + 'gt' => comparison > 0, + 'gte' => comparison >= 0, + 'lt' => comparison < 0, + 'lte' => comparison <= 0, + _ => false, + }; + } + + int? _compareWhereValues(Object? left, Object? right) { + if (left == null || right == null) { + return null; + } + if (left is num && right is num) { + return left.compareTo(right); + } + if (left is String && right is String) { + return left.compareTo(right); + } + if (left is DateTime && right is DateTime) { + return left.compareTo(right); + } + if (left is bool && right is bool) { + final leftInt = left ? 1 : 0; + final rightInt = right ? 1 : 0; + return leftInt.compareTo(rightInt); + } + if (left is Comparable && left.runtimeType == right.runtimeType) { + return left.compareTo(right); + } + return null; + } + int _compareRows(JsonMap left, JsonMap right, List orderBy) { for (final order in orderBy) { final leftValue = left[order.field]; diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 30d1d405..874f096b 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -106,8 +106,24 @@ final class TypedClientWriter { void _writeWhereFilterClasses(StringBuffer buffer) { buffer.writeln('class StringWhereFilter {'); buffer.writeln(' final String? equals;'); - buffer.writeln(); - buffer.writeln(' const StringWhereFilter({this.equals});'); + buffer.writeln(' final String? not;'); + buffer.writeln(' final List? inValues;'); + buffer.writeln(' final List? notIn;'); + buffer.writeln(' final String? gt;'); + buffer.writeln(' final String? gte;'); + buffer.writeln(' final String? lt;'); + buffer.writeln(' final String? lte;'); + buffer.writeln(); + buffer.writeln(' const StringWhereFilter({'); + buffer.writeln(' this.equals,'); + buffer.writeln(' this.not,'); + buffer.writeln(' this.inValues,'); + buffer.writeln(' this.notIn,'); + buffer.writeln(' this.gt,'); + buffer.writeln(' this.gte,'); + buffer.writeln(' this.lt,'); + buffer.writeln(' this.lte,'); + buffer.writeln(' });'); buffer.writeln(); buffer.writeln( ' factory StringWhereFilter.fromJsonValue(Object? value) {', @@ -116,23 +132,73 @@ final class TypedClientWriter { buffer.writeln(' return StringWhereFilter(equals: value);'); buffer.writeln(' }'); buffer.writeln(' if (value is Map) {'); - buffer.writeln( - " return StringWhereFilter(equals: _readString(value['equals']));", - ); + buffer.writeln(' return StringWhereFilter('); + buffer.writeln(" equals: _readString(value['equals']),"); + buffer.writeln(" not: _readString(value['not']),"); + buffer.writeln(" inValues: _readStringList(value['in']),"); + buffer.writeln(" notIn: _readStringList(value['notIn']),"); + buffer.writeln(" gt: _readString(value['gt']),"); + buffer.writeln(" gte: _readString(value['gte']),"); + buffer.writeln(" lt: _readString(value['lt']),"); + buffer.writeln(" lte: _readString(value['lte']),"); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(' return const StringWhereFilter();'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln( + ' if (equals != null && not == null && inValues == null && notIn == null && gt == null && gte == null && lt == null && lte == null) {', + ); + buffer.writeln(' return equals;'); + buffer.writeln(' }'); + buffer.writeln(' return {'); + buffer.writeln(" if (equals != null) 'equals': equals,"); + buffer.writeln(" if (not != null) 'not': not,"); + buffer.writeln(" if (inValues != null) 'in': inValues,"); + buffer.writeln(" if (notIn != null) 'notIn': notIn,"); + buffer.writeln(" if (gt != null) 'gt': gt,"); + buffer.writeln(" if (gte != null) 'gte': gte,"); + buffer.writeln(" if (lt != null) 'lt': lt,"); + buffer.writeln(" if (lte != null) 'lte': lte,"); + buffer.writeln(' };'); + buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln(' bool get isEmpty =>'); + buffer.writeln(' equals == null &&'); + buffer.writeln(' not == null &&'); + buffer.writeln(' inValues == null &&'); + buffer.writeln(' notIn == null &&'); + buffer.writeln(' gt == null &&'); + buffer.writeln(' gte == null &&'); + buffer.writeln(' lt == null &&'); + buffer.writeln(' lte == null;'); buffer.writeln('}'); buffer.writeln(); buffer.writeln('class IntWhereFilter {'); buffer.writeln(' final int? equals;'); - buffer.writeln(); - buffer.writeln(' const IntWhereFilter({this.equals});'); + buffer.writeln(' final int? not;'); + buffer.writeln(' final List? inValues;'); + buffer.writeln(' final List? notIn;'); + buffer.writeln(' final int? gt;'); + buffer.writeln(' final int? gte;'); + buffer.writeln(' final int? lt;'); + buffer.writeln(' final int? lte;'); + buffer.writeln(); + buffer.writeln(' const IntWhereFilter({'); + buffer.writeln(' this.equals,'); + buffer.writeln(' this.not,'); + buffer.writeln(' this.inValues,'); + buffer.writeln(' this.notIn,'); + buffer.writeln(' this.gt,'); + buffer.writeln(' this.gte,'); + buffer.writeln(' this.lt,'); + buffer.writeln(' this.lte,'); + buffer.writeln(' });'); buffer.writeln(); buffer.writeln(' factory IntWhereFilter.fromJsonValue(Object? value) {'); buffer.writeln(' if (value is int) {'); @@ -142,23 +208,73 @@ final class TypedClientWriter { buffer.writeln(' return IntWhereFilter(equals: value.toInt());'); buffer.writeln(' }'); buffer.writeln(' if (value is Map) {'); - buffer.writeln( - " return IntWhereFilter(equals: _readInt(value['equals']));", - ); + buffer.writeln(' return IntWhereFilter('); + buffer.writeln(" equals: _readInt(value['equals']),"); + buffer.writeln(" not: _readInt(value['not']),"); + buffer.writeln(" inValues: _readIntList(value['in']),"); + buffer.writeln(" notIn: _readIntList(value['notIn']),"); + buffer.writeln(" gt: _readInt(value['gt']),"); + buffer.writeln(" gte: _readInt(value['gte']),"); + buffer.writeln(" lt: _readInt(value['lt']),"); + buffer.writeln(" lte: _readInt(value['lte']),"); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(' return const IntWhereFilter();'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln( + ' if (equals != null && not == null && inValues == null && notIn == null && gt == null && gte == null && lt == null && lte == null) {', + ); + buffer.writeln(' return equals;'); + buffer.writeln(' }'); + buffer.writeln(' return {'); + buffer.writeln(" if (equals != null) 'equals': equals,"); + buffer.writeln(" if (not != null) 'not': not,"); + buffer.writeln(" if (inValues != null) 'in': inValues,"); + buffer.writeln(" if (notIn != null) 'notIn': notIn,"); + buffer.writeln(" if (gt != null) 'gt': gt,"); + buffer.writeln(" if (gte != null) 'gte': gte,"); + buffer.writeln(" if (lt != null) 'lt': lt,"); + buffer.writeln(" if (lte != null) 'lte': lte,"); + buffer.writeln(' };'); + buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln(' bool get isEmpty =>'); + buffer.writeln(' equals == null &&'); + buffer.writeln(' not == null &&'); + buffer.writeln(' inValues == null &&'); + buffer.writeln(' notIn == null &&'); + buffer.writeln(' gt == null &&'); + buffer.writeln(' gte == null &&'); + buffer.writeln(' lt == null &&'); + buffer.writeln(' lte == null;'); buffer.writeln('}'); buffer.writeln(); buffer.writeln('class DoubleWhereFilter {'); buffer.writeln(' final double? equals;'); - buffer.writeln(); - buffer.writeln(' const DoubleWhereFilter({this.equals});'); + buffer.writeln(' final double? not;'); + buffer.writeln(' final List? inValues;'); + buffer.writeln(' final List? notIn;'); + buffer.writeln(' final double? gt;'); + buffer.writeln(' final double? gte;'); + buffer.writeln(' final double? lt;'); + buffer.writeln(' final double? lte;'); + buffer.writeln(); + buffer.writeln(' const DoubleWhereFilter({'); + buffer.writeln(' this.equals,'); + buffer.writeln(' this.not,'); + buffer.writeln(' this.inValues,'); + buffer.writeln(' this.notIn,'); + buffer.writeln(' this.gt,'); + buffer.writeln(' this.gte,'); + buffer.writeln(' this.lt,'); + buffer.writeln(' this.lte,'); + buffer.writeln(' });'); buffer.writeln(); buffer.writeln( ' factory DoubleWhereFilter.fromJsonValue(Object? value) {', @@ -170,46 +286,126 @@ final class TypedClientWriter { buffer.writeln(' return DoubleWhereFilter(equals: value.toDouble());'); buffer.writeln(' }'); buffer.writeln(' if (value is Map) {'); - buffer.writeln( - " return DoubleWhereFilter(equals: _readDouble(value['equals']));", - ); + buffer.writeln(' return DoubleWhereFilter('); + buffer.writeln(" equals: _readDouble(value['equals']),"); + buffer.writeln(" not: _readDouble(value['not']),"); + buffer.writeln(" inValues: _readDoubleList(value['in']),"); + buffer.writeln(" notIn: _readDoubleList(value['notIn']),"); + buffer.writeln(" gt: _readDouble(value['gt']),"); + buffer.writeln(" gte: _readDouble(value['gte']),"); + buffer.writeln(" lt: _readDouble(value['lt']),"); + buffer.writeln(" lte: _readDouble(value['lte']),"); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(' return const DoubleWhereFilter();'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln( + ' if (equals != null && not == null && inValues == null && notIn == null && gt == null && gte == null && lt == null && lte == null) {', + ); + buffer.writeln(' return equals;'); + buffer.writeln(' }'); + buffer.writeln(' return {'); + buffer.writeln(" if (equals != null) 'equals': equals,"); + buffer.writeln(" if (not != null) 'not': not,"); + buffer.writeln(" if (inValues != null) 'in': inValues,"); + buffer.writeln(" if (notIn != null) 'notIn': notIn,"); + buffer.writeln(" if (gt != null) 'gt': gt,"); + buffer.writeln(" if (gte != null) 'gte': gte,"); + buffer.writeln(" if (lt != null) 'lt': lt,"); + buffer.writeln(" if (lte != null) 'lte': lte,"); + buffer.writeln(' };'); + buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln(' bool get isEmpty =>'); + buffer.writeln(' equals == null &&'); + buffer.writeln(' not == null &&'); + buffer.writeln(' inValues == null &&'); + buffer.writeln(' notIn == null &&'); + buffer.writeln(' gt == null &&'); + buffer.writeln(' gte == null &&'); + buffer.writeln(' lt == null &&'); + buffer.writeln(' lte == null;'); buffer.writeln('}'); buffer.writeln(); buffer.writeln('class BoolWhereFilter {'); buffer.writeln(' final bool? equals;'); + buffer.writeln(' final bool? not;'); + buffer.writeln(' final List? inValues;'); + buffer.writeln(' final List? notIn;'); buffer.writeln(); - buffer.writeln(' const BoolWhereFilter({this.equals});'); + buffer.writeln(' const BoolWhereFilter({'); + buffer.writeln(' this.equals,'); + buffer.writeln(' this.not,'); + buffer.writeln(' this.inValues,'); + buffer.writeln(' this.notIn,'); + buffer.writeln(' });'); buffer.writeln(); buffer.writeln(' factory BoolWhereFilter.fromJsonValue(Object? value) {'); buffer.writeln(' if (value is bool) {'); buffer.writeln(' return BoolWhereFilter(equals: value);'); buffer.writeln(' }'); buffer.writeln(' if (value is Map) {'); - buffer.writeln( - " return BoolWhereFilter(equals: _readBool(value['equals']));", - ); + buffer.writeln(' return BoolWhereFilter('); + buffer.writeln(" equals: _readBool(value['equals']),"); + buffer.writeln(" not: _readBool(value['not']),"); + buffer.writeln(" inValues: _readBoolList(value['in']),"); + buffer.writeln(" notIn: _readBoolList(value['notIn']),"); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(' return const BoolWhereFilter();'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln( + ' if (equals != null && not == null && inValues == null && notIn == null) {', + ); + buffer.writeln(' return equals;'); + buffer.writeln(' }'); + buffer.writeln(' return {'); + buffer.writeln(" if (equals != null) 'equals': equals,"); + buffer.writeln(" if (not != null) 'not': not,"); + buffer.writeln(" if (inValues != null) 'in': inValues,"); + buffer.writeln(" if (notIn != null) 'notIn': notIn,"); + buffer.writeln(' };'); + buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln(' bool get isEmpty =>'); + buffer.writeln(' equals == null &&'); + buffer.writeln(' not == null &&'); + buffer.writeln(' inValues == null &&'); + buffer.writeln(' notIn == null;'); buffer.writeln('}'); buffer.writeln(); buffer.writeln('class DateTimeWhereFilter {'); buffer.writeln(' final DateTime? equals;'); - buffer.writeln(); - buffer.writeln(' const DateTimeWhereFilter({this.equals});'); + buffer.writeln(' final DateTime? not;'); + buffer.writeln(' final List? inValues;'); + buffer.writeln(' final List? notIn;'); + buffer.writeln(' final DateTime? gt;'); + buffer.writeln(' final DateTime? gte;'); + buffer.writeln(' final DateTime? lt;'); + buffer.writeln(' final DateTime? lte;'); + buffer.writeln(); + buffer.writeln(' const DateTimeWhereFilter({'); + buffer.writeln(' this.equals,'); + buffer.writeln(' this.not,'); + buffer.writeln(' this.inValues,'); + buffer.writeln(' this.notIn,'); + buffer.writeln(' this.gt,'); + buffer.writeln(' this.gte,'); + buffer.writeln(' this.lt,'); + buffer.writeln(' this.lte,'); + buffer.writeln(' });'); buffer.writeln(); buffer.writeln( ' factory DateTimeWhereFilter.fromJsonValue(Object? value) {', @@ -223,16 +419,56 @@ final class TypedClientWriter { ); buffer.writeln(' }'); buffer.writeln(' if (value is Map) {'); - buffer.writeln( - " return DateTimeWhereFilter(equals: _readDateTime(value['equals']));", - ); + buffer.writeln(' return DateTimeWhereFilter('); + buffer.writeln(" equals: _readDateTime(value['equals']),"); + buffer.writeln(" not: _readDateTime(value['not']),"); + buffer.writeln(" inValues: _readDateTimeList(value['in']),"); + buffer.writeln(" notIn: _readDateTimeList(value['notIn']),"); + buffer.writeln(" gt: _readDateTime(value['gt']),"); + buffer.writeln(" gte: _readDateTime(value['gte']),"); + buffer.writeln(" lt: _readDateTime(value['lt']),"); + buffer.writeln(" lte: _readDateTime(value['lte']),"); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(' return const DateTimeWhereFilter();'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Object? toJsonValue() => equals?.toIso8601String();'); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln( + ' if (equals != null && not == null && inValues == null && notIn == null && gt == null && gte == null && lt == null && lte == null) {', + ); + buffer.writeln(' return equals!.toIso8601String();'); + buffer.writeln(' }'); + buffer.writeln(' return {'); + buffer.writeln( + " if (equals != null) 'equals': equals!.toIso8601String(),", + ); + buffer.writeln(" if (not != null) 'not': not!.toIso8601String(),"); + buffer.writeln( + " if (inValues != null) 'in': inValues!.map((value) => value.toIso8601String()).toList(growable: false),", + ); + buffer.writeln( + " if (notIn != null) 'notIn': notIn!.map((value) => value.toIso8601String()).toList(growable: false),", + ); + buffer.writeln(" if (gt != null) 'gt': gt!.toIso8601String(),"); + buffer.writeln(" if (gte != null) 'gte': gte!.toIso8601String(),"); + buffer.writeln(" if (lt != null) 'lt': lt!.toIso8601String(),"); + buffer.writeln(" if (lte != null) 'lte': lte!.toIso8601String(),"); + buffer.writeln(' };'); + buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' bool get isEmpty => equals == null;'); + buffer.writeln(' bool get isEmpty =>'); + buffer.writeln(' equals == null &&'); + buffer.writeln(' not == null &&'); + buffer.writeln(' inValues == null &&'); + buffer.writeln(' notIn == null &&'); + buffer.writeln(' gt == null &&'); + buffer.writeln(' gte == null &&'); + buffer.writeln(' lt == null &&'); + buffer.writeln(' lte == null;'); buffer.writeln('}'); buffer.writeln(); @@ -243,7 +479,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' factory JsonWhereFilter.fromJsonValue(Object? value) {'); buffer.writeln( - ' if (value is Map && value.containsKey(\'equals\')) {', + ' if (value is Map && value.length == 1 && value.containsKey(\'equals\')) {', ); buffer.writeln(" return JsonWhereFilter(equals: value['equals']);"); buffer.writeln(' }'); @@ -255,7 +491,12 @@ final class TypedClientWriter { buffer.writeln(' return const JsonWhereFilter();'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Object? toJsonValue() => equals;'); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(" return {'equals': equals};"); + buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' bool get isEmpty => equals == null;'); buffer.writeln('}'); diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 79f729ff..51622eab 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -7,6 +7,28 @@ import '../target/adapter.dart'; import 'codec.dart'; import 'types.dart'; +const List _whereOperatorOrder = [ + 'equals', + 'not', + 'in', + 'notIn', + 'gt', + 'gte', + 'lt', + 'lte', +]; + +const Set _whereOperators = { + 'equals', + 'not', + 'in', + 'notIn', + 'gt', + 'gte', + 'lt', + 'lte', +}; + final class SqlAdapter implements TargetAdapter { final OrmContract contract; final String identifierQuote; @@ -213,15 +235,134 @@ final class SqlAdapter implements TargetAdapter { final predicates = []; for (final entry in where.entries) { - predicates.add('${_id(entry.key)} = ?'); - params.add( - _encodeValue(model: model, field: entry.key, value: entry.value), - ); + final operatorMap = _coerceOperatorMap(entry.value); + if (operatorMap == null) { + predicates.add('${_id(entry.key)} = ?'); + params.add( + _encodeWhereValue(model: model, field: entry.key, value: entry.value), + ); + continue; + } + + for (final operator in _whereOperatorOrder) { + if (!operatorMap.containsKey(operator)) { + continue; + } + _appendWhereOperatorPredicate( + predicates: predicates, + params: params, + model: model, + field: entry.key, + operator: operator, + operand: operatorMap[operator], + ); + } } return ' WHERE ${predicates.join(' AND ')}'; } + Map? _coerceOperatorMap(Object? value) { + if (value is! Map) { + return null; + } + if (value.isEmpty) { + return null; + } + + final normalized = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) { + return null; + } + if (!_whereOperators.contains(key)) { + return null; + } + normalized[key] = entry.value; + } + return normalized; + } + + void _appendWhereOperatorPredicate({ + required List predicates, + required List params, + required String model, + required String field, + required String operator, + required Object? operand, + }) { + final idField = _id(field); + + switch (operator) { + case 'equals': + predicates.add('$idField = ?'); + params.add( + _encodeWhereValue(model: model, field: field, value: operand), + ); + case 'not': + predicates.add('$idField <> ?'); + params.add( + _encodeWhereValue(model: model, field: field, value: operand), + ); + case 'gt': + predicates.add('$idField > ?'); + params.add( + _encodeWhereValue(model: model, field: field, value: operand), + ); + case 'gte': + predicates.add('$idField >= ?'); + params.add( + _encodeWhereValue(model: model, field: field, value: operand), + ); + case 'lt': + predicates.add('$idField < ?'); + params.add( + _encodeWhereValue(model: model, field: field, value: operand), + ); + case 'lte': + predicates.add('$idField <= ?'); + params.add( + _encodeWhereValue(model: model, field: field, value: operand), + ); + case 'in' || 'notIn': + final values = _coerceListOperand(operand); + if (values.isEmpty) { + predicates.add(operator == 'in' ? '1 = 0' : '1 = 1'); + return; + } + + final placeholders = List.filled(values.length, '?').join(', '); + final sqlOperator = operator == 'in' ? 'IN' : 'NOT IN'; + predicates.add('$idField $sqlOperator ($placeholders)'); + for (final value in values) { + params.add( + _encodeWhereValue(model: model, field: field, value: value), + ); + } + default: + throw StateError('Unsupported where operator: $operator'); + } + } + + List _coerceListOperand(Object? value) { + if (value is List) { + return value; + } + if (value is List) { + return List.from(value); + } + return const []; + } + + Object? _encodeWhereValue({ + required String model, + required String field, + required Object? value, + }) { + return _encodeValue(model: model, field: field, value: value); + } + String _buildOrderByClause(List orderBy) { if (orderBy.isEmpty) { return ''; diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 5fbb53fe..c5aa33b2 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -230,6 +230,55 @@ void main() { await client.disconnect(); }); + test('supports where operators gt/in/notIn in memory engine', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': 'b@x.com'}); + await users.create(data: {'id': 3, 'email': 'c@x.com'}); + await users.create(data: {'id': 4, 'email': 'd@x.com'}); + + final gtRows = await users.findMany( + where: { + 'id': {'gt': 2}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect(gtRows.map((row) => row['id']).toList(growable: false), [ + 3, + 4, + ]); + + final inRows = await users.findMany( + where: { + 'email': { + 'in': ['a@x.com', 'c@x.com'], + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect(inRows.map((row) => row['id']).toList(growable: false), [ + 1, + 3, + ]); + + final notInRows = await users.findMany( + where: { + 'id': { + 'notIn': [2, 3], + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + notInRows.map((row) => row['id']).toList(growable: false), + [1, 4], + ); + await client.disconnect(); + }); + test( 'supports select projection for direct read/mutation methods', () async { diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 62be56a8..bd0f1554 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -208,6 +208,27 @@ void main() { isTrue, reason: 'Missing string where filter class in generated source.', ); + expect( + RegExp( + r"class\s+StringWhereFilter\s*\{[\s\S]*?value\['in'\]", + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected StringWhereFilter to contain in operator marker.', + ); + expect( + RegExp( + r"class\s+StringWhereFilter\s*\{[\s\S]*?value\['not'\]", + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected StringWhereFilter to contain not operator marker.', + ); + expect( + RegExp( + r"class\s+StringWhereFilter\s*\{[\s\S]*?value\['gt'\]", + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected StringWhereFilter to contain gt operator marker.', + ); expect( RegExp(r'\bclass IntWhereFilter\b').hasMatch(generatedSource), isTrue, diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 60aa7345..42843c21 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -41,6 +41,88 @@ void main() { expect(statement.parameters, ['a@example.com', 10, 5]); }); + test('lowers where operators with deterministic SQL and parameters', () { + final adapter = SqlAdapter(contract: contract); + final plan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: { + 'email': { + 'lt': 'z@example.com', + 'gte': 'a@example.com', + 'in': ['a@example.com', 'b@example.com'], + 'notIn': ['x@example.com'], + 'not': 'blocked@example.com', + }, + 'id': {'lte': 'u9', 'gt': 'u0', 'equals': 'u1'}, + }, + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT * FROM "users" WHERE ' + '"email" <> ? AND ' + '"email" IN (?, ?) AND ' + '"email" NOT IN (?) AND ' + '"email" >= ? AND ' + '"email" < ? AND ' + '"id" = ? AND ' + '"id" > ? AND ' + '"id" <= ?', + ); + expect(statement.parameters, [ + 'blocked@example.com', + 'a@example.com', + 'b@example.com', + 'x@example.com', + 'a@example.com', + 'z@example.com', + 'u1', + 'u0', + 'u9', + ]); + }); + + test( + 'keeps scalar where compatibility and does not misclassify normal maps', + () { + final adapter = SqlAdapter(contract: contract); + final jsonPayload = {'profile': 'standard'}; + final plan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: {'id': 'u1', 'email': jsonPayload}, + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT * FROM "users" WHERE "id" = ? AND "email" = ?', + ); + expect(statement.parameters, ['u1', jsonPayload]); + }, + ); + + test('uses deterministic empty semantics for in/notIn', () { + final adapter = SqlAdapter(contract: contract); + final plan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: { + 'id': {'in': const []}, + 'email': {'notIn': const []}, + }, + ); + + final statement = adapter.lower(plan); + expect(statement.text, 'SELECT * FROM "users" WHERE 1 = 0 AND 1 = 1'); + expect(statement.parameters, isEmpty); + }); + test('lowers mutation statements', () { final contract = buildContract(mutationReturning: false); final adapter = SqlAdapter(contract: contract); @@ -244,6 +326,47 @@ void main() { } }); + test('encodes where operator values via codec resolver', () { + final codecRegistry = SqlCodecRegistry().withField( + model: 'User', + field: 'email', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'wire:$value', + decode: (value) => value, + ), + ); + final adapter = SqlAdapter( + contract: contract, + codecResolver: codecRegistry, + ); + + final statement = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: { + 'email': { + 'in': ['a@example.com', 'b@example.com'], + 'gt': 'm@example.com', + }, + 'id': {'not': 'u9'}, + }, + ), + ); + + expect( + statement.text, + 'SELECT * FROM "users" WHERE "email" IN (?, ?) AND "email" > ? AND "id" <> ?', + ); + expect(statement.parameters, [ + 'wire:a@example.com', + 'wire:b@example.com', + 'wire:m@example.com', + 'u9', + ]); + }); + test('keeps default no-codec behavior unchanged', () { final adapterWithoutCodec = SqlAdapter(contract: contract); final adapterWithEmptyCodec = SqlAdapter( From af97a1eaa3937fddffab15cf684aec4975a91103 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:39:53 +0800 Subject: [PATCH 046/154] feat(runtime): add string pattern where operators --- pub/orm/lib/src/engine/memory_engine.dart | 26 ++++++++ pub/orm/lib/src/generator/writer.dart | 19 +++++- pub/orm/lib/src/sql/adapter.dart | 50 +++++++++++++++ pub/orm/test/client/client_test.dart | 56 +++++++++++++++++ pub/orm/test/generator/generate_test.dart | 24 ++++++++ pub/orm/test/sql/sql_adapter_test.dart | 74 ++++++++++++++++++++++- 6 files changed, 246 insertions(+), 3 deletions(-) diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index d1044d47..26cd4688 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -8,6 +8,9 @@ const List _whereOperatorOrder = [ 'not', 'in', 'notIn', + 'contains', + 'startsWith', + 'endsWith', 'gt', 'gte', 'lt', @@ -19,6 +22,9 @@ const Set _whereOperators = { 'not', 'in', 'notIn', + 'contains', + 'startsWith', + 'endsWith', 'gt', 'gte', 'lt', @@ -195,6 +201,9 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { 'not' => actualValue != operand, 'in' => _matchIn(actualValue, operand), 'notIn' => _matchNotIn(actualValue, operand), + 'contains' => _matchStringOperation(actualValue, operand, operator), + 'startsWith' => _matchStringOperation(actualValue, operand, operator), + 'endsWith' => _matchStringOperation(actualValue, operand, operator), 'gt' => _matchComparison(actualValue, operand, operator), 'gte' => _matchComparison(actualValue, operand, operator), 'lt' => _matchComparison(actualValue, operand, operator), @@ -249,6 +258,23 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { return !values.contains(actualValue); } + bool _matchStringOperation( + Object? actualValue, + Object? operand, + String operator, + ) { + if (actualValue is! String || operand is! String) { + return false; + } + + return switch (operator) { + 'contains' => actualValue.contains(operand), + 'startsWith' => actualValue.startsWith(operand), + 'endsWith' => actualValue.endsWith(operand), + _ => false, + }; + } + List _coerceListOperand(Object? operand) { if (operand is List) { return operand; diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 874f096b..34f56006 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -113,6 +113,9 @@ final class TypedClientWriter { buffer.writeln(' final String? gte;'); buffer.writeln(' final String? lt;'); buffer.writeln(' final String? lte;'); + buffer.writeln(' final String? contains;'); + buffer.writeln(' final String? startsWith;'); + buffer.writeln(' final String? endsWith;'); buffer.writeln(); buffer.writeln(' const StringWhereFilter({'); buffer.writeln(' this.equals,'); @@ -123,6 +126,9 @@ final class TypedClientWriter { buffer.writeln(' this.gte,'); buffer.writeln(' this.lt,'); buffer.writeln(' this.lte,'); + buffer.writeln(' this.contains,'); + buffer.writeln(' this.startsWith,'); + buffer.writeln(' this.endsWith,'); buffer.writeln(' });'); buffer.writeln(); buffer.writeln( @@ -141,6 +147,9 @@ final class TypedClientWriter { buffer.writeln(" gte: _readString(value['gte']),"); buffer.writeln(" lt: _readString(value['lt']),"); buffer.writeln(" lte: _readString(value['lte']),"); + buffer.writeln(" contains: _readString(value['contains']),"); + buffer.writeln(" startsWith: _readString(value['startsWith']),"); + buffer.writeln(" endsWith: _readString(value['endsWith']),"); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(' return const StringWhereFilter();'); @@ -151,7 +160,7 @@ final class TypedClientWriter { buffer.writeln(' return null;'); buffer.writeln(' }'); buffer.writeln( - ' if (equals != null && not == null && inValues == null && notIn == null && gt == null && gte == null && lt == null && lte == null) {', + ' if (equals != null && not == null && inValues == null && notIn == null && gt == null && gte == null && lt == null && lte == null && contains == null && startsWith == null && endsWith == null) {', ); buffer.writeln(' return equals;'); buffer.writeln(' }'); @@ -164,6 +173,9 @@ final class TypedClientWriter { buffer.writeln(" if (gte != null) 'gte': gte,"); buffer.writeln(" if (lt != null) 'lt': lt,"); buffer.writeln(" if (lte != null) 'lte': lte,"); + buffer.writeln(" if (contains != null) 'contains': contains,"); + buffer.writeln(" if (startsWith != null) 'startsWith': startsWith,"); + buffer.writeln(" if (endsWith != null) 'endsWith': endsWith,"); buffer.writeln(' };'); buffer.writeln(' }'); buffer.writeln(); @@ -175,7 +187,10 @@ final class TypedClientWriter { buffer.writeln(' gt == null &&'); buffer.writeln(' gte == null &&'); buffer.writeln(' lt == null &&'); - buffer.writeln(' lte == null;'); + buffer.writeln(' lte == null &&'); + buffer.writeln(' contains == null &&'); + buffer.writeln(' startsWith == null &&'); + buffer.writeln(' endsWith == null;'); buffer.writeln('}'); buffer.writeln(); diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 51622eab..e846b2b7 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -12,6 +12,9 @@ const List _whereOperatorOrder = [ 'not', 'in', 'notIn', + 'contains', + 'startsWith', + 'endsWith', 'gt', 'gte', 'lt', @@ -23,6 +26,9 @@ const Set _whereOperators = { 'not', 'in', 'notIn', + 'contains', + 'startsWith', + 'endsWith', 'gt', 'gte', 'lt', @@ -310,6 +316,19 @@ final class SqlAdapter implements TargetAdapter { params.add( _encodeWhereValue(model: model, field: field, value: operand), ); + case 'contains' || 'startsWith' || 'endsWith': + final likePattern = _encodeLikePattern( + model: model, + field: field, + operator: operator, + operand: operand, + ); + if (likePattern == null) { + predicates.add('1 = 0'); + return; + } + predicates.add("$idField LIKE ? ESCAPE '\\'"); + params.add(likePattern); case 'gte': predicates.add('$idField >= ?'); params.add( @@ -363,6 +382,37 @@ final class SqlAdapter implements TargetAdapter { return _encodeValue(model: model, field: field, value: value); } + String? _encodeLikePattern({ + required String model, + required String field, + required String operator, + required Object? operand, + }) { + final encodedValue = _encodeWhereValue( + model: model, + field: field, + value: operand, + ); + if (encodedValue is! String) { + return null; + } + + final escaped = _escapeLikePattern(encodedValue); + return switch (operator) { + 'contains' => '%$escaped%', + 'startsWith' => '$escaped%', + 'endsWith' => '%$escaped', + _ => null, + }; + } + + String _escapeLikePattern(String value) { + return value + .replaceAll('\\', '\\\\') + .replaceAll('%', '\\%') + .replaceAll('_', '\\_'); + } + String _buildOrderByClause(List orderBy) { if (orderBy.isEmpty) { return ''; diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index c5aa33b2..12380a66 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -279,6 +279,62 @@ void main() { await client.disconnect(); }); + test( + 'supports string where operators contains/startsWith/endsWith in memory engine', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'alpha@example.com'}, + ); + await users.create( + data: {'id': 'u2', 'email': 'beta@example.com'}, + ); + await users.create( + data: {'id': 'u3', 'email': 'alphonse@example.com'}, + ); + await users.create( + data: {'id': 'u4', 'email': 'gamma@sample.com'}, + ); + + final containsRows = await users.findMany( + where: { + 'email': {'contains': 'example.com'}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + containsRows.map((row) => row['id']).toList(growable: false), + ['u1', 'u2', 'u3'], + ); + + final startsWithRows = await users.findMany( + where: { + 'email': {'startsWith': 'alph'}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + startsWithRows.map((row) => row['id']).toList(growable: false), + ['u1', 'u3'], + ); + + final endsWithRows = await users.findMany( + where: { + 'email': {'endsWith': 'sample.com'}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + endsWithRows.map((row) => row['id']).toList(growable: false), + ['u4'], + ); + await client.disconnect(); + }, + ); + test( 'supports select projection for direct read/mutation methods', () async { diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index bd0f1554..56235587 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -229,6 +229,30 @@ void main() { isTrue, reason: 'Expected StringWhereFilter to contain gt operator marker.', ); + expect( + RegExp( + r"class\s+StringWhereFilter\s*\{[\s\S]*?value\['contains'\]", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected StringWhereFilter to contain contains operator marker.', + ); + expect( + RegExp( + r"class\s+StringWhereFilter\s*\{[\s\S]*?value\['startsWith'\]", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected StringWhereFilter to contain startsWith operator marker.', + ); + expect( + RegExp( + r"class\s+StringWhereFilter\s*\{[\s\S]*?value\['endsWith'\]", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected StringWhereFilter to contain endsWith operator marker.', + ); expect( RegExp(r'\bclass IntWhereFilter\b').hasMatch(generatedSource), isTrue, diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 42843c21..49a308fe 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -85,11 +85,40 @@ void main() { ]); }); + test('lowers string operators with LIKE and escaped patterns', () { + final adapter = SqlAdapter(contract: contract); + final plan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: { + 'email': { + 'contains': 'a%b', + 'startsWith': 'x_y', + 'endsWith': 'tail\\', + }, + }, + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + "SELECT * FROM \"users\" WHERE " + "\"email\" LIKE ? ESCAPE '\\' AND " + "\"email\" LIKE ? ESCAPE '\\' AND " + "\"email\" LIKE ? ESCAPE '\\'", + ); + expect(statement.parameters, ['%a\\%b%', 'x\\_y%', '%tail\\\\']); + }); + test( 'keeps scalar where compatibility and does not misclassify normal maps', () { final adapter = SqlAdapter(contract: contract); - final jsonPayload = {'profile': 'standard'}; + final jsonPayload = { + 'contains': 'literal', + 'profile': 'standard', + }; final plan = OrmPlan( contractHash: contract.hash, model: 'User', @@ -367,6 +396,49 @@ void main() { ]); }); + test('encodes string where operators via codec resolver', () { + final codecRegistry = SqlCodecRegistry().withField( + model: 'User', + field: 'email', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'wire:$value', + decode: (value) => value, + ), + ); + final adapter = SqlAdapter( + contract: contract, + codecResolver: codecRegistry, + ); + + final statement = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: { + 'email': { + 'contains': 'example', + 'startsWith': 'head', + 'endsWith': 'tail', + }, + }, + ), + ); + + expect( + statement.text, + "SELECT * FROM \"users\" WHERE " + "\"email\" LIKE ? ESCAPE '\\' AND " + "\"email\" LIKE ? ESCAPE '\\' AND " + "\"email\" LIKE ? ESCAPE '\\'", + ); + expect(statement.parameters, [ + '%wire:example%', + 'wire:head%', + '%wire:tail', + ]); + }); + test('keeps default no-codec behavior unchanged', () { final adapterWithoutCodec = SqlAdapter(contract: contract); final adapterWithEmptyCodec = SqlAdapter( From 2abeb72d30a67cf8e27e978191ab84a6a56ff6b4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:47:52 +0800 Subject: [PATCH 047/154] feat(runtime): support logical where composition --- pub/orm/lib/src/engine/memory_engine.dart | 111 +++++++++- pub/orm/lib/src/generator/writer.dart | 33 ++- pub/orm/lib/src/runtime/core.dart | 75 ++++++- pub/orm/lib/src/sql/adapter.dart | 241 ++++++++++++++++++++-- pub/orm/test/client/client_test.dart | 96 +++++++++ pub/orm/test/generator/generate_test.dart | 23 +++ pub/orm/test/sql/sql_adapter_test.dart | 42 ++++ 7 files changed, 597 insertions(+), 24 deletions(-) diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index 26cd4688..780ba4d1 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -164,17 +164,86 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { bool _matches(JsonMap row, JsonMap where) { for (final entry in where.entries) { - if (!_matchesWhereField( - row: row, - field: entry.key, - condition: entry.value, - )) { + final matched = switch (entry.key) { + 'AND' => _matchesWhereAnd(row, entry.value), + 'OR' => _matchesWhereOr(row, entry.value), + 'NOT' => _matchesWhereNot(row, entry.value), + _ => _matchesWhereField( + row: row, + field: entry.key, + condition: entry.value, + ), + }; + if (!matched) { return false; } } return true; } + bool _matchesWhereAnd(JsonMap row, Object? operand) { + final whereList = _coerceWhereList(operand); + if (whereList != null) { + if (whereList.isEmpty) { + return true; + } + for (final where in whereList) { + if (!_matches(row, where)) { + return false; + } + } + return true; + } + + final where = _coerceWhereMap(operand); + if (where != null) { + return _matches(row, where); + } + return false; + } + + bool _matchesWhereOr(JsonMap row, Object? operand) { + final whereList = _coerceWhereList(operand); + if (whereList != null) { + if (whereList.isEmpty) { + return false; + } + for (final where in whereList) { + if (_matches(row, where)) { + return true; + } + } + return false; + } + + final where = _coerceWhereMap(operand); + if (where != null) { + return _matches(row, where); + } + return false; + } + + bool _matchesWhereNot(JsonMap row, Object? operand) { + final whereList = _coerceWhereList(operand); + if (whereList != null) { + if (whereList.isEmpty) { + return true; + } + for (final where in whereList) { + if (_matches(row, where)) { + return false; + } + } + return true; + } + + final where = _coerceWhereMap(operand); + if (where != null) { + return !_matches(row, where); + } + return false; + } + bool _matchesWhereField({ required JsonMap row, required String field, @@ -242,6 +311,38 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { return normalized; } + JsonMap? _coerceWhereMap(Object? value) { + if (value is! Map) { + return null; + } + + final normalized = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) { + return null; + } + normalized[key] = entry.value; + } + return normalized; + } + + List? _coerceWhereList(Object? value) { + if (value is! List) { + return null; + } + + final whereList = []; + for (final item in value) { + final where = _coerceWhereMap(item); + if (where == null) { + return null; + } + whereList.add(where); + } + return whereList; + } + bool _matchIn(Object? actualValue, Object? operand) { final values = _coerceListOperand(operand); if (values.isEmpty) { diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 34f56006..6b5dd4b7 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1163,6 +1163,7 @@ final class TypedClientWriter { }) { final className = _className(model, classKind); final fields = _buildFieldBindings(_fieldsForClass(model.model, classKind)); + final includeLogicalWhere = classKind == _TemplateClassKind.where; buffer.writeln('class $className {'); @@ -1174,8 +1175,13 @@ final class TypedClientWriter { ); buffer.writeln(' final $type ${field.memberName};'); } + if (includeLogicalWhere) { + buffer.writeln(' final List<$className>? and;'); + buffer.writeln(' final List<$className>? or;'); + buffer.writeln(' final $className? not;'); + } - if (fields.isNotEmpty) { + if (fields.isNotEmpty || includeLogicalWhere) { buffer.writeln(); buffer.writeln(' const $className({'); for (final field in fields) { @@ -1183,6 +1189,11 @@ final class TypedClientWriter { final prefix = isOptional ? '' : 'required '; buffer.writeln(' ${prefix}this.${field.memberName},'); } + if (includeLogicalWhere) { + buffer.writeln(' this.and,'); + buffer.writeln(' this.or,'); + buffer.writeln(' this.not,'); + } buffer.writeln(' });'); } else { buffer.writeln(); @@ -1215,6 +1226,17 @@ final class TypedClientWriter { ); } } + if (includeLogicalWhere) { + buffer.writeln( + " and: _readRelationList(json['AND'], $className.fromJson),", + ); + buffer.writeln( + " or: _readRelationList(json['OR'], $className.fromJson),", + ); + buffer.writeln( + " not: _readRelation(json['NOT'], $className.fromJson),", + ); + } buffer.writeln(' );'); buffer.writeln(' }'); @@ -1249,6 +1271,15 @@ final class TypedClientWriter { ); } } + if (includeLogicalWhere) { + buffer.writeln( + " if (and != null) 'AND': and!.map((value) => value.toJson()).toList(growable: false),", + ); + buffer.writeln( + " if (or != null) 'OR': or!.map((value) => value.toJson()).toList(growable: false),", + ); + buffer.writeln(" if (not != null) 'NOT': not!.toJson(),"); + } buffer.writeln(' };'); buffer.writeln(' }'); buffer.writeln('}'); diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 3a45e3a9..f7d5d563 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -8,6 +8,7 @@ import 'plugin.dart'; import 'types.dart'; typedef MarkerHashReader = Future Function(); +const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; abstract interface class ContractMarkerReader { Future readContractHash(); @@ -338,7 +339,7 @@ final class OrmRuntimeCore implements RuntimeCore { } final model = contract.models[plan.model]!; - _assertKnownFields(model: model, fields: plan.where.keys, source: 'where'); + _assertWhereFields(model: model, where: plan.where, source: 'where'); _assertKnownFields(model: model, fields: plan.data.keys, source: 'data'); _assertKnownFields( model: model, @@ -373,6 +374,46 @@ final class OrmRuntimeCore implements RuntimeCore { } } + void _assertWhereFields({ + required ModelContract model, + required JsonMap where, + required String source, + }) { + for (final entry in where.entries) { + final key = entry.key; + if (_whereLogicalKeys.contains(key)) { + _assertWhereLogicalOperand( + model: model, + operand: entry.value, + source: source, + ); + continue; + } + _assertKnownFields(model: model, fields: [key], source: source); + } + } + + void _assertWhereLogicalOperand({ + required ModelContract model, + required Object? operand, + required String source, + }) { + final nestedWhere = _coerceWhereMap(operand); + if (nestedWhere != null) { + _assertWhereFields(model: model, where: nestedWhere, source: source); + return; + } + + final nestedWhereList = _coerceWhereList(operand); + if (nestedWhereList == null) { + return; + } + + for (final item in nestedWhereList) { + _assertWhereFields(model: model, where: item, source: source); + } + } + void _ensureConnected() { if (_connected) { return; @@ -381,6 +422,38 @@ final class OrmRuntimeCore implements RuntimeCore { } } +JsonMap? _coerceWhereMap(Object? value) { + if (value is! Map) { + return null; + } + + final normalized = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) { + return null; + } + normalized[key] = entry.value; + } + return normalized; +} + +List? _coerceWhereList(Object? value) { + if (value is! List) { + return null; + } + + final whereList = []; + for (final item in value) { + final where = _coerceWhereMap(item); + if (where == null) { + return null; + } + whereList.add(where); + } + return whereList; +} + List _extractRows(Object? data, {required String action}) { if (data == null) { return const []; diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index e846b2b7..b261eb58 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -35,6 +35,8 @@ const Set _whereOperators = { 'lte', }; +const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; + final class SqlAdapter implements TargetAdapter { final OrmContract contract; final String identifierQuote; @@ -239,33 +241,206 @@ final class SqlAdapter implements TargetAdapter { return ''; } + final predicate = _buildWhereExpression( + model: model, + where: where, + params: params, + ); + return ' WHERE $predicate'; + } + + String _buildWhereExpression({ + required String model, + required JsonMap where, + required List params, + }) { final predicates = []; + for (final entry in where.entries) { - final operatorMap = _coerceOperatorMap(entry.value); - if (operatorMap == null) { - predicates.add('${_id(entry.key)} = ?'); - params.add( - _encodeWhereValue(model: model, field: entry.key, value: entry.value), + final key = entry.key; + if (_whereLogicalKeys.contains(key)) { + predicates.add( + _buildWhereLogicalPredicate( + model: model, + key: key, + operand: entry.value, + params: params, + ), ); continue; } - for (final operator in _whereOperatorOrder) { - if (!operatorMap.containsKey(operator)) { - continue; - } - _appendWhereOperatorPredicate( - predicates: predicates, - params: params, + predicates.addAll( + _buildWhereFieldPredicates( model: model, - field: entry.key, - operator: operator, - operand: operatorMap[operator], - ); + field: key, + condition: entry.value, + params: params, + ), + ); + } + + if (predicates.isEmpty) { + return '1 = 1'; + } + + return predicates.join(' AND '); + } + + String _buildWhereLogicalPredicate({ + required String model, + required String key, + required Object? operand, + required List params, + }) { + return switch (key) { + 'AND' => _buildWhereAndPredicate( + model: model, + operand: operand, + params: params, + ), + 'OR' => _buildWhereOrPredicate( + model: model, + operand: operand, + params: params, + ), + 'NOT' => _buildWhereNotPredicate( + model: model, + operand: operand, + params: params, + ), + _ => '1 = 0', + }; + } + + String _buildWhereAndPredicate({ + required String model, + required Object? operand, + required List params, + }) { + final where = _coerceWhereMap(operand); + if (where != null) { + final nested = _buildWhereExpression( + model: model, + where: where, + params: params, + ); + return '($nested)'; + } + + final whereList = _coerceWhereList(operand); + if (whereList == null) { + return '1 = 0'; + } + if (whereList.isEmpty) { + return '1 = 1'; + } + + final predicates = whereList + .map( + (item) => + _buildWhereExpression(model: model, where: item, params: params), + ) + .toList(growable: false); + return '(${predicates.join(' AND ')})'; + } + + String _buildWhereOrPredicate({ + required String model, + required Object? operand, + required List params, + }) { + final where = _coerceWhereMap(operand); + if (where != null) { + final nested = _buildWhereExpression( + model: model, + where: where, + params: params, + ); + return '($nested)'; + } + + final whereList = _coerceWhereList(operand); + if (whereList == null) { + return '1 = 0'; + } + if (whereList.isEmpty) { + return '1 = 0'; + } + + final predicates = whereList + .map( + (item) => + _buildWhereExpression(model: model, where: item, params: params), + ) + .toList(growable: false); + return '(${predicates.join(' OR ')})'; + } + + String _buildWhereNotPredicate({ + required String model, + required Object? operand, + required List params, + }) { + final where = _coerceWhereMap(operand); + if (where != null) { + final nested = _buildWhereExpression( + model: model, + where: where, + params: params, + ); + return 'NOT ($nested)'; + } + + final whereList = _coerceWhereList(operand); + if (whereList == null) { + return '1 = 0'; + } + if (whereList.isEmpty) { + return '1 = 1'; + } + + final predicates = whereList + .map( + (item) => + _buildWhereExpression(model: model, where: item, params: params), + ) + .map((item) => 'NOT ($item)') + .toList(growable: false); + return '(${predicates.join(' AND ')})'; + } + + List _buildWhereFieldPredicates({ + required String model, + required String field, + required Object? condition, + required List params, + }) { + final predicates = []; + final operatorMap = _coerceOperatorMap(condition); + if (operatorMap == null) { + predicates.add('${_id(field)} = ?'); + params.add( + _encodeWhereValue(model: model, field: field, value: condition), + ); + return predicates; + } + + for (final operator in _whereOperatorOrder) { + if (!operatorMap.containsKey(operator)) { + continue; } + _appendWhereOperatorPredicate( + predicates: predicates, + params: params, + model: model, + field: field, + operator: operator, + operand: operatorMap[operator], + ); } - return ' WHERE ${predicates.join(' AND ')}'; + return predicates; } Map? _coerceOperatorMap(Object? value) { @@ -290,6 +465,38 @@ final class SqlAdapter implements TargetAdapter { return normalized; } + JsonMap? _coerceWhereMap(Object? value) { + if (value is! Map) { + return null; + } + + final normalized = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) { + return null; + } + normalized[key] = entry.value; + } + return normalized; + } + + List? _coerceWhereList(Object? value) { + if (value is! List) { + return null; + } + + final whereList = []; + for (final item in value) { + final where = _coerceWhereMap(item); + if (where == null) { + return null; + } + whereList.add(where); + } + return whereList; + } + void _appendWhereOperatorPredicate({ required List predicates, required List params, diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 12380a66..1d89bf21 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -335,6 +335,76 @@ void main() { }, ); + test( + 'supports logical AND/OR/NOT where composition in memory engine', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 1, 'email': 'a@example.com'}, + ); + await users.create( + data: {'id': 2, 'email': 'b@example.com'}, + ); + await users.create( + data: {'id': 3, 'email': 'alpha@sample.com'}, + ); + await users.create( + data: {'id': 4, 'email': 'z@sample.com'}, + ); + + final andRows = await users.findMany( + where: { + 'AND': [ + { + 'id': {'gt': 1}, + }, + { + 'email': {'contains': 'example.com'}, + }, + ], + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + andRows.map((row) => row['id']).toList(growable: false), + [2], + ); + + final orRows = await users.findMany( + where: { + 'OR': [ + {'id': 1}, + {'id': 4}, + ], + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + orRows.map((row) => row['id']).toList(growable: false), + [1, 4], + ); + + final notRows = await users.findMany( + where: { + 'NOT': [ + { + 'email': {'endsWith': 'sample.com'}, + }, + ], + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + notRows.map((row) => row['id']).toList(growable: false), + [1, 2], + ); + await client.disconnect(); + }, + ); + test( 'supports select projection for direct read/mutation methods', () async { @@ -1625,10 +1695,36 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); + await expectLater( + client + .model('User') + .findMany( + where: { + 'OR': [ + {'id': 'u1'}, + {'email': 'a@x.com'}, + ], + }, + ), + completes, + ); await expectLater( client.model('User').findMany(where: {'age': 1}), throwsA(isA()), ); + await expectLater( + client + .model('User') + .findMany( + where: { + 'AND': [ + {'id': 'u1'}, + {'age': 1}, + ], + }, + ), + throwsA(isA()), + ); await expectLater( client.model('User').create(data: {'age': 1}), throwsA(isA()), diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 56235587..eea423e8 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -266,6 +266,29 @@ void main() { reason: 'Expected UserWhereInput fields to use typed where filter classes.', ); + expect( + RegExp( + r"class\s+UserWhereInput\s*\{[\s\S]*?\['AND'\]", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserWhereInput to include AND logical field marker.', + ); + expect( + RegExp( + r"class\s+UserWhereInput\s*\{[\s\S]*?\['OR'\]", + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserWhereInput to include OR logical field marker.', + ); + expect( + RegExp( + r"class\s+UserWhereInput\s*\{[\s\S]*?\['NOT'\]", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserWhereInput to include NOT logical field marker.', + ); expect( generatedSource.contains('List orderBy'), isTrue, diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 49a308fe..0e722a64 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -111,6 +111,48 @@ void main() { expect(statement.parameters, ['%a\\%b%', 'x\\_y%', '%tail\\\\']); }); + test('lowers logical where AND/OR/NOT with field filters', () { + final adapter = SqlAdapter(contract: contract); + final plan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: { + 'id': 'u1', + 'AND': [ + { + 'email': {'startsWith': 'a'}, + }, + { + 'OR': [ + {'email': 'a@example.com'}, + {'email': 'b@example.com'}, + ], + }, + ], + 'NOT': { + 'email': {'contains': 'blocked'}, + }, + }, + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + "SELECT * FROM \"users\" WHERE " + "\"id\" = ? AND " + "(\"email\" LIKE ? ESCAPE '\\' AND (\"email\" = ? OR \"email\" = ?)) AND " + "NOT (\"email\" LIKE ? ESCAPE '\\')", + ); + expect(statement.parameters, [ + 'u1', + 'a%', + 'a@example.com', + 'b@example.com', + '%blocked%', + ]); + }); + test( 'keeps scalar where compatibility and does not misclassify normal maps', () { From 96fa5ede701f83e31d0fa6f3ab732c6b26b89193 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:02:35 +0800 Subject: [PATCH 048/154] feat(generator): add to-many relation where filter DSL --- pub/orm/lib/src/generator/client_emitter.dart | 2 +- pub/orm/lib/src/generator/writer.dart | 125 +++++++++++++++++- .../fixtures/relation_output/orm.config.dart | 8 ++ .../fixtures/relation_output/orm.schema.dart | 11 ++ pub/orm/test/generator/generate_test.dart | 62 +++++++++ 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 pub/orm/test/generator/fixtures/relation_output/orm.config.dart create mode 100644 pub/orm/test/generator/fixtures/relation_output/orm.schema.dart diff --git a/pub/orm/lib/src/generator/client_emitter.dart b/pub/orm/lib/src/generator/client_emitter.dart index 4c81d2fa..dd37ced7 100644 --- a/pub/orm/lib/src/generator/client_emitter.dart +++ b/pub/orm/lib/src/generator/client_emitter.dart @@ -25,7 +25,7 @@ TypedField _toTypedField(SchemaFieldDefinition field) { model: relationModel, isNullable: parsed.isNullable, isList: parsed.isList, - includeInWhere: false, + includeInWhere: parsed.isList, includeInCreate: false, includeInUpdate: false, ); diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 6b5dd4b7..c06da14d 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -36,6 +36,14 @@ final class TypedClientWriter { _writeTypedDelegateClass(buffer: buffer, model: model); } + for (final model in resolvedModels) { + _writeRelationWhereFilterClasses( + buffer: buffer, + model: model, + lookup: modelLookup, + ); + } + for (final model in resolvedModels) { _writeDataOrInputClass( buffer: buffer, @@ -1155,6 +1163,71 @@ final class TypedClientWriter { buffer.writeln(); } + void _writeRelationWhereFilterClasses({ + required StringBuffer buffer, + required _ResolvedModel model, + required Map lookup, + }) { + final relationFields = model.model.fields + .where((field) => field.isRelation && field.isList) + .toList(growable: false); + + for (final relation in relationFields) { + final relationModelName = relation.relationModel; + final relationModel = relationModelName == null + ? null + : lookup[relationModelName]; + if (relationModel == null) { + continue; + } + + final className = _relationWhereFilterClassName( + owner: model, + relationFieldName: relation.name, + ); + buffer.writeln('class $className {'); + buffer.writeln(' final ${relationModel.whereInputClassName}? some;'); + buffer.writeln(' final ${relationModel.whereInputClassName}? every;'); + buffer.writeln(' final ${relationModel.whereInputClassName}? none;'); + buffer.writeln(); + buffer.writeln(' const $className({this.some, this.every, this.none});'); + buffer.writeln(); + buffer.writeln(' factory $className.fromJsonValue(Object? value) {'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln(' return $className('); + buffer.writeln( + " some: _readRelation(value['some'], ${relationModel.whereInputClassName}.fromJson),", + ); + buffer.writeln( + " every: _readRelation(value['every'], ${relationModel.whereInputClassName}.fromJson),", + ); + buffer.writeln( + " none: _readRelation(value['none'], ${relationModel.whereInputClassName}.fromJson),", + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' return const $className();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return {'); + buffer.writeln(" if (some != null) 'some': some!.toJson(),"); + buffer.writeln(" if (every != null) 'every': every!.toJson(),"); + buffer.writeln(" if (none != null) 'none': none!.toJson(),"); + buffer.writeln(' };'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' bool get isEmpty => some == null && every == null && none == null;', + ); + buffer.writeln('}'); + buffer.writeln(); + } + } + void _writeDataOrInputClass({ required StringBuffer buffer, required _ResolvedModel model, @@ -1169,6 +1242,7 @@ final class TypedClientWriter { for (final field in fields) { final type = _fieldType( + owner: model, field: field.field, classKind: classKind, lookup: lookup, @@ -1207,6 +1281,7 @@ final class TypedClientWriter { buffer.writeln(' return $className('); for (final field in fields) { final decodeExpression = _decodeExpression( + owner: model, field: field.field, classKind: classKind, accessor: "json['${_escapeString(field.field.name)}']", @@ -1216,6 +1291,7 @@ final class TypedClientWriter { buffer.writeln(' ${field.memberName}: $decodeExpression,'); } else { final type = _fieldType( + owner: model, field: field.field, classKind: classKind, lookup: lookup, @@ -1247,16 +1323,18 @@ final class TypedClientWriter { final isOptional = _isOptionalField(field.field, classKind: classKind); final memberName = isOptional ? '${field.memberName}!' : field.memberName; final valueExpression = _encodeExpression( + owner: model, field: field.field, classKind: classKind, memberName: memberName, lookup: lookup, ); - final isWhereScalarFilter = - _isWhereFilterClassKind(classKind) && field.field.isScalar; + final isWhereFilter = + (_isWhereFilterClassKind(classKind) && field.field.isScalar) || + _isRelationWhereFilterField(field: field.field, classKind: classKind); if (isOptional) { - if (isWhereScalarFilter) { + if (isWhereFilter) { buffer.writeln( " if (${field.memberName} != null && !${field.memberName}!.isEmpty) '${_escapeString(field.field.name)}': $valueExpression,", ); @@ -1577,12 +1655,14 @@ final class TypedClientWriter { } String _fieldType({ + required _ResolvedModel owner, required TypedField field, required _TemplateClassKind classKind, required Map lookup, }) { final optional = _isOptionalField(field, classKind: classKind); final baseType = _baseType( + owner: owner, field: field, classKind: classKind, lookup: lookup, @@ -1595,6 +1675,7 @@ final class TypedClientWriter { } String _baseType({ + required _ResolvedModel owner, required TypedField field, required _TemplateClassKind classKind, required Map lookup, @@ -1602,6 +1683,12 @@ final class TypedClientWriter { if (_isWhereFilterClassKind(classKind) && field.isScalar) { return _whereFilterClassName(field.scalarType); } + if (_isRelationWhereFilterField(field: field, classKind: classKind)) { + return _relationWhereFilterClassName( + owner: owner, + relationFieldName: field.name, + ); + } if (field.isRelation) { final relationModelName = field.relationModel; @@ -1634,6 +1721,7 @@ final class TypedClientWriter { } String _decodeExpression({ + required _ResolvedModel owner, required TypedField field, required _TemplateClassKind classKind, required String accessor, @@ -1643,6 +1731,13 @@ final class TypedClientWriter { final filterClass = _whereFilterClassName(field.scalarType); return '$filterClass.fromJsonValue($accessor)'; } + if (_isRelationWhereFilterField(field: field, classKind: classKind)) { + final filterClass = _relationWhereFilterClassName( + owner: owner, + relationFieldName: field.name, + ); + return '$filterClass.fromJsonValue($accessor)'; + } if (field.isRelation) { final relationModelName = field.relationModel; @@ -1696,6 +1791,7 @@ final class TypedClientWriter { } String _encodeExpression({ + required _ResolvedModel owner, required TypedField field, required _TemplateClassKind classKind, required String memberName, @@ -1704,6 +1800,9 @@ final class TypedClientWriter { if (_isWhereFilterClassKind(classKind) && field.isScalar) { return '$memberName.toJsonValue()'; } + if (_isRelationWhereFilterField(field: field, classKind: classKind)) { + return '$memberName.toJsonValue()'; + } if (field.isRelation) { final relationModelName = field.relationModel; @@ -1758,6 +1857,15 @@ final class TypedClientWriter { classKind == _TemplateClassKind.whereUnique; } + bool _isRelationWhereFilterField({ + required TypedField field, + required _TemplateClassKind classKind, + }) { + return classKind == _TemplateClassKind.where && + field.isRelation && + field.isList; + } + bool _includeInWhereUnique(TypedField field) { if (!field.isScalar || field.isList) { return false; @@ -1783,6 +1891,17 @@ final class TypedClientWriter { return '${owner.classBaseName}${relationPart}Include'; } + String _relationWhereFilterClassName({ + required _ResolvedModel owner, + required String relationFieldName, + }) { + final relationPart = _toUpperCamelIdentifier( + relationFieldName, + fallback: 'Relation', + ); + return '${owner.classBaseName}${relationPart}RelationWhereFilter'; + } + String _makeUnique({required String base, required Set used}) { if (!used.contains(base)) { used.add(base); diff --git a/pub/orm/test/generator/fixtures/relation_output/orm.config.dart b/pub/orm/test/generator/fixtures/relation_output/orm.config.dart new file mode 100644 index 00000000..bce5d04d --- /dev/null +++ b/pub/orm/test/generator/fixtures/relation_output/orm.config.dart @@ -0,0 +1,8 @@ +class Config { + final String? output; + final String? schema; + + const Config({this.output, this.schema}); +} + +const config = Config(output: 'generated/typed_client.g.dart'); diff --git a/pub/orm/test/generator/fixtures/relation_output/orm.schema.dart b/pub/orm/test/generator/fixtures/relation_output/orm.schema.dart new file mode 100644 index 00000000..b4a23f5b --- /dev/null +++ b/pub/orm/test/generator/fixtures/relation_output/orm.schema.dart @@ -0,0 +1,11 @@ +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +@model +typedef User = ({String id, String email, List posts}); + +@model +typedef Post = ({String id, String userId, String title, User? author}); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index eea423e8..b9065e74 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -296,6 +296,68 @@ void main() { ); }); + test('generates relation where some/every/none filter classes', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'relation_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runGenerate( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + var generatedDartFiles = _findDartFiles( + Directory(_path([fixtureDir.path, 'generated'])), + ); + if (generatedDartFiles.isEmpty) { + generatedDartFiles = _findDartFiles( + Directory(_path([fixtureDir.path, 'lib'])), + ); + } + expect( + generatedDartFiles, + isNotEmpty, + reason: 'Expected generated Dart files to assert relation where DSL.', + ); + + final generatedSource = generatedDartFiles + .map((file) => file.readAsStringSync()) + .join('\n'); + + expect( + RegExp( + r'\bclass UserPostsRelationWhereFilter\b', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected relation where filter class for User.posts.', + ); + expect( + RegExp( + r'class\s+UserPostsRelationWhereFilter\s*\{[\s\S]*?final\s+PostWhereInput\?\s+some;[\s\S]*?final\s+PostWhereInput\?\s+every;[\s\S]*?final\s+PostWhereInput\?\s+none;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation where filter to expose some/every/none typed operands.', + ); + expect( + RegExp( + r'class\s+UserWhereInput\s*\{[\s\S]*?final\s+UserPostsRelationWhereFilter\?\s+posts;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserWhereInput relation field to use relation where filter class.', + ); + expect( + RegExp( + r"if\s*\(posts\s*!=\s*null\s*&&\s*!posts!\.isEmpty\)\s*'posts':\s*posts!\.toJsonValue\(\)", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation where filter serialization to skip empty filter.', + ); + }); + test('prints actionable error message for invalid config', () async { final fixtureDir = _copyFixture(fixturesRoot, 'missing_config'); addTearDown(() => fixtureDir.deleteSync(recursive: true)); From e3c8da0fd43627dec16fa6b0cdaaac65e5d167a0 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:02:40 +0800 Subject: [PATCH 049/154] feat(runtime): support to-many relation where operators --- pub/orm/lib/src/client/client.dart | 24 ++++- pub/orm/test/client/client_test.dart | 136 +++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 5 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 0f28eff3..b3ce01c4 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -27,6 +27,8 @@ typedef IncludeExecutionStrategySelector = }); const int _defaultMaxIncludeDepth = 4; +const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; +const Set _relationWhereOperators = {'some', 'every', 'none'}; IncludeExecutionStrategy defaultIncludeExecutionStrategySelector({ required OrmContract contract, @@ -615,6 +617,10 @@ class ModelDelegate { required int includeDepth, }) async { final normalizedInclude = _normalizeInclude(include); + final normalizedWhere = await _normalizeWhereForExecution( + model: modelName, + where: where, + ); final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, @@ -623,7 +629,7 @@ class ModelDelegate { profileHash: _client.contract.profileHash, model: modelName, action: OrmAction.findMany, - where: where, + where: normalizedWhere, skip: skip, take: take, orderBy: orderBy, @@ -654,6 +660,10 @@ class ModelDelegate { required int includeDepth, }) async { final normalizedInclude = _normalizeInclude(include); + final normalizedWhere = await _normalizeWhereForExecution( + model: modelName, + where: where, + ); final response = await _client.execute( OrmPlan( contractHash: _client.contract.hash, @@ -662,7 +672,7 @@ class ModelDelegate { profileHash: _client.contract.profileHash, model: modelName, action: OrmAction.findUnique, - where: where, + where: normalizedWhere, select: _expandSelectForInclude( model: modelName, select: select, @@ -699,12 +709,16 @@ class ModelDelegate { required String responseAction, }) async { final normalizedInclude = _normalizeInclude(include); + final normalizedWhere = await _normalizeWhereForExecution( + model: modelName, + where: where, + ); JsonMap? preDeleteRow; if (action == OrmAction.delete && !(_client.contract.capabilities.mutationReturning)) { preDeleteRow = await _findUniqueInternal( action: OrmAction.findUnique, - where: where, + where: normalizedWhere, select: _expandSelectForInclude( model: modelName, select: select, @@ -723,7 +737,7 @@ class ModelDelegate { profileHash: _client.contract.profileHash, model: modelName, action: action, - where: where, + where: normalizedWhere, data: data, select: _expandSelectForInclude( model: modelName, @@ -740,7 +754,7 @@ class ModelDelegate { row = switch (action) { OrmAction.update => await _findUniqueInternal( action: OrmAction.findUnique, - where: where, + where: normalizedWhere, select: _expandSelectForInclude( model: modelName, select: select, diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 1d89bf21..c4646c58 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -715,6 +715,142 @@ void main() { }, ); + test( + 'supports relation where some/none/every for to-many relation', + () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final users = client.model('User'); + await users.create( + data: {'id': 'u3', 'email': 'u3@example.com'}, + ); + + final someRows = await users.findMany( + where: { + 'posts': { + 'some': { + 'title': {'contains': 'A'}, + }, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + someRows.map((row) => row['id']).toList(growable: false), + ['u1'], + ); + + final noneRows = await users.findMany( + where: { + 'posts': { + 'none': { + 'title': {'contains': 'A'}, + }, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + noneRows.map((row) => row['id']).toList(growable: false), + ['u2', 'u3'], + ); + + final everyRows = await users.findMany( + where: { + 'posts': { + 'every': { + 'title': {'contains': 'A'}, + }, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + everyRows.map((row) => row['id']).toList(growable: false), + ['u3'], + ); + await client.disconnect(); + }, + ); + + test('supports relation where with nested logical operators', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final users = client.model('User'); + await users.create( + data: {'id': 'u3', 'email': 'u3@example.com'}, + ); + + final rows = await users.findMany( + where: { + 'AND': [ + { + 'posts': { + 'some': { + 'title': {'contains': 'Post'}, + }, + }, + }, + { + 'OR': [ + {'id': 'u2'}, + {'id': 'u3'}, + ], + }, + { + 'NOT': { + 'posts': { + 'some': { + 'title': {'contains': 'A'}, + }, + }, + }, + }, + ], + }, + orderBy: const [OrmOrderBy('id')], + ); + + expect(rows, hasLength(1)); + expect(rows.single['id'], 'u2'); + await client.disconnect(); + }); + + test('supports relation where on mutation paths', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final users = client.model('User'); + + final updated = await users.update( + where: { + 'posts': { + 'some': {'title': 'Post C'}, + }, + }, + data: {'email': 'u2+updated@example.com'}, + ); + expect(updated?['id'], 'u2'); + expect(updated?['email'], 'u2+updated@example.com'); + + final persisted = await users.findUnique( + where: {'id': 'u2'}, + ); + expect(persisted?['email'], 'u2+updated@example.com'); + await client.disconnect(); + }); + test('supports include for one-to-many relation', () async { final client = OrmClient( contract: relationalContract, From 706e4b544c948fa866a0cb5b8c89215638439abc Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:05:04 +0800 Subject: [PATCH 050/154] fix(runtime): complete relation where rewrite implementation --- pub/orm/lib/src/client/client.dart | 341 +++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index b3ce01c4..35a0514c 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1364,6 +1364,315 @@ class ModelDelegate { return Map.from(data); } + Future _normalizeWhereForExecution({ + required String model, + required JsonMap where, + }) async { + if (where.isEmpty) { + return const {}; + } + return _rewriteRelationWhere(model: model, where: where); + } + + Future _rewriteRelationWhere({ + required String model, + required JsonMap where, + }) async { + if (where.isEmpty) { + return const {}; + } + + final modelContract = _client.contract.models[model]; + if (modelContract == null) { + throw ModelNotFoundException(model, _client.contract.models.keys); + } + + final normalizedWhere = {}; + final relationClauses = []; + for (final entry in where.entries) { + final key = entry.key; + if (_whereLogicalKeys.contains(key)) { + normalizedWhere[key] = await _normalizeWhereLogicalOperand( + model: model, + operand: entry.value, + ); + continue; + } + + final relation = modelContract.relations[key]; + if (relation == null || + relation.cardinality != RelationCardinality.many) { + normalizedWhere[key] = entry.value; + continue; + } + + final relationWhere = _coerceWhereMap(entry.value); + if (relationWhere == null) { + throw runtimeError( + 'PLAN.RELATION_WHERE_INVALID', + 'Relation where expects a map of operators.', + details: { + 'model': model, + 'relation': key, + 'expectedOperators': _relationWhereOperators.toList( + growable: false, + ), + }, + ); + } + + final clause = await _compileRelationWhereClause( + relationName: key, + relation: relation, + where: relationWhere, + ); + if (clause != null) { + relationClauses.add(clause); + } + } + + for (final clause in relationClauses) { + _appendAndWhereClause(where: normalizedWhere, clause: clause); + } + + return normalizedWhere; + } + + Future _normalizeWhereLogicalOperand({ + required String model, + required Object? operand, + }) async { + final nestedWhere = _coerceWhereMap(operand); + if (nestedWhere != null) { + return _rewriteRelationWhere(model: model, where: nestedWhere); + } + + final nestedWhereList = _coerceWhereList(operand); + if (nestedWhereList == null) { + return operand; + } + + final normalized = []; + for (final entry in nestedWhereList) { + normalized.add(await _rewriteRelationWhere(model: model, where: entry)); + } + return normalized; + } + + Future _compileRelationWhereClause({ + required String relationName, + required ModelRelationContract relation, + required JsonMap where, + }) async { + if (where.isEmpty) { + return null; + } + + final unknownOperators = where.keys + .where((key) => !_relationWhereOperators.contains(key)) + .toList(growable: false); + if (unknownOperators.isNotEmpty) { + throw runtimeError( + 'PLAN.RELATION_WHERE_OPERATOR_INVALID', + 'Relation where contains unknown operators.', + details: { + 'model': modelName, + 'relation': relationName, + 'unknownOperators': unknownOperators, + 'supportedOperators': _relationWhereOperators.toList(growable: false), + }, + ); + } + + final clauses = []; + + if (where.containsKey('some')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'some', + operand: where['some'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } + + if (where.containsKey('none')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'none', + operand: where['none'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } + + if (where.containsKey('every')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'every', + operand: where['every'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: {'NOT': relationWhere}, + include: false, + ), + ); + } + + if (clauses.isEmpty) { + return null; + } + if (clauses.length == 1) { + return clauses.single; + } + return {'AND': clauses}; + } + + Future _normalizeRelationOperatorWhere({ + required String relationName, + required ModelRelationContract relation, + required String operator, + required Object? operand, + }) async { + if (operand == null) { + return const {}; + } + + final nestedWhere = _coerceWhereMap(operand); + if (nestedWhere == null) { + throw runtimeError( + 'PLAN.RELATION_WHERE_VALUE_INVALID', + 'Relation where operator expects a nested where map.', + details: { + 'model': modelName, + 'relation': relationName, + 'operator': operator, + }, + ); + } + + return _rewriteRelationWhere( + model: relation.relatedModel, + where: nestedWhere, + ); + } + + Future _buildRelationMembershipClause({ + required ModelRelationContract relation, + required JsonMap relatedWhere, + required bool include, + }) async { + final relatedRows = await _client + .model(relation.relatedModel) + ._findManyInternal( + action: OrmAction.findMany, + where: relatedWhere, + select: relation.targetFields, + includeDepth: 0, + ); + + final keys = <_RelationMergeKey>{}; + for (final row in relatedRows) { + final key = _buildRelationMergeKeyFromRow( + row: row, + fields: relation.targetFields, + ); + if (key != null) { + keys.add(key); + } + } + + return _buildRelationTupleMembershipWhere( + sourceFields: relation.sourceFields, + keys: keys, + include: include, + ); + } + + JsonMap _buildRelationTupleMembershipWhere({ + required List sourceFields, + required Set<_RelationMergeKey> keys, + required bool include, + }) { + if (keys.isEmpty) { + return include + ? const {'OR': []} + : const {'AND': []}; + } + + if (sourceFields.length == 1) { + final field = sourceFields.single; + final values = keys + .map((key) => key.parts.single) + .toList(growable: false); + return { + field: {include ? 'in' : 'notIn': values}, + }; + } + + final tupleClauses = keys + .map((key) { + final where = {}; + for (var index = 0; index < sourceFields.length; index++) { + where[sourceFields[index]] = key.parts[index]; + } + return where; + }) + .toList(growable: false); + + if (include) { + return {'OR': tupleClauses}; + } + + return { + 'NOT': {'OR': tupleClauses}, + }; + } + + void _appendAndWhereClause({ + required JsonMap where, + required JsonMap clause, + }) { + if (clause.isEmpty) { + return; + } + + final existing = where['AND']; + if (existing == null) { + where['AND'] = [clause]; + return; + } + + final existingMap = _coerceWhereMap(existing); + if (existingMap != null) { + where['AND'] = [existingMap, clause]; + return; + } + + final existingList = _coerceWhereList(existing); + if (existingList != null) { + where['AND'] = [...existingList, clause]; + return; + } + + where['AND'] = [clause]; + } + Map _normalizeInclude(Map include) { if (include.isEmpty) { return const {}; @@ -1778,6 +2087,38 @@ JsonMap _coerceRow(Object? value, {required String action}) { ); } +JsonMap? _coerceWhereMap(Object? value) { + if (value is! Map) { + return null; + } + + final normalized = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) { + return null; + } + normalized[key] = entry.value; + } + return normalized; +} + +List? _coerceWhereList(Object? value) { + if (value is! List) { + return null; + } + + final whereList = []; + for (final item in value) { + final where = _coerceWhereMap(item); + if (where == null) { + return null; + } + whereList.add(where); + } + return whereList; +} + T? _firstOrNull(List values) { if (values.isEmpty) { return null; From e300b3645323b9b035dc377eb6ee429fc3bb4d3d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:11:26 +0800 Subject: [PATCH 051/154] feat(generator): add to-one relation where filter DSL --- pub/orm/lib/src/generator/client_emitter.dart | 2 +- pub/orm/lib/src/generator/writer.dart | 119 ++++++++++++------ pub/orm/test/generator/generate_test.dart | 33 ++++- 3 files changed, 111 insertions(+), 43 deletions(-) diff --git a/pub/orm/lib/src/generator/client_emitter.dart b/pub/orm/lib/src/generator/client_emitter.dart index dd37ced7..23ac9077 100644 --- a/pub/orm/lib/src/generator/client_emitter.dart +++ b/pub/orm/lib/src/generator/client_emitter.dart @@ -25,7 +25,7 @@ TypedField _toTypedField(SchemaFieldDefinition field) { model: relationModel, isNullable: parsed.isNullable, isList: parsed.isList, - includeInWhere: parsed.isList, + includeInWhere: true, includeInCreate: false, includeInUpdate: false, ); diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index c06da14d..0930d3f3 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1169,7 +1169,7 @@ final class TypedClientWriter { required Map lookup, }) { final relationFields = model.model.fields - .where((field) => field.isRelation && field.isList) + .where((field) => field.isRelation) .toList(growable: false); for (final relation in relationFields) { @@ -1186,43 +1186,82 @@ final class TypedClientWriter { relationFieldName: relation.name, ); buffer.writeln('class $className {'); - buffer.writeln(' final ${relationModel.whereInputClassName}? some;'); - buffer.writeln(' final ${relationModel.whereInputClassName}? every;'); - buffer.writeln(' final ${relationModel.whereInputClassName}? none;'); - buffer.writeln(); - buffer.writeln(' const $className({this.some, this.every, this.none});'); - buffer.writeln(); - buffer.writeln(' factory $className.fromJsonValue(Object? value) {'); - buffer.writeln(' if (value is Map) {'); - buffer.writeln(' return $className('); - buffer.writeln( - " some: _readRelation(value['some'], ${relationModel.whereInputClassName}.fromJson),", - ); - buffer.writeln( - " every: _readRelation(value['every'], ${relationModel.whereInputClassName}.fromJson),", - ); - buffer.writeln( - " none: _readRelation(value['none'], ${relationModel.whereInputClassName}.fromJson),", - ); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(' return const $className();'); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln(' Object? toJsonValue() {'); - buffer.writeln(' if (isEmpty) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return {'); - buffer.writeln(" if (some != null) 'some': some!.toJson(),"); - buffer.writeln(" if (every != null) 'every': every!.toJson(),"); - buffer.writeln(" if (none != null) 'none': none!.toJson(),"); - buffer.writeln(' };'); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( - ' bool get isEmpty => some == null && every == null && none == null;', - ); + if (relation.isList) { + buffer.writeln(' final ${relationModel.whereInputClassName}? some;'); + buffer.writeln(' final ${relationModel.whereInputClassName}? every;'); + buffer.writeln(' final ${relationModel.whereInputClassName}? none;'); + buffer.writeln(); + buffer.writeln( + ' const $className({this.some, this.every, this.none});', + ); + buffer.writeln(); + buffer.writeln(' factory $className.fromJsonValue(Object? value) {'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln(' return $className('); + buffer.writeln( + " some: _readRelation(value['some'], ${relationModel.whereInputClassName}.fromJson),", + ); + buffer.writeln( + " every: _readRelation(value['every'], ${relationModel.whereInputClassName}.fromJson),", + ); + buffer.writeln( + " none: _readRelation(value['none'], ${relationModel.whereInputClassName}.fromJson),", + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' return const $className();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return {'); + buffer.writeln(" if (some != null) 'some': some!.toJson(),"); + buffer.writeln(" if (every != null) 'every': every!.toJson(),"); + buffer.writeln(" if (none != null) 'none': none!.toJson(),"); + buffer.writeln(' };'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' bool get isEmpty => some == null && every == null && none == null;', + ); + } else { + buffer.writeln( + ' final ${relationModel.whereInputClassName}? isValue;', + ); + buffer.writeln(' final ${relationModel.whereInputClassName}? isNot;'); + buffer.writeln(); + buffer.writeln(' const $className({this.isValue, this.isNot});'); + buffer.writeln(); + buffer.writeln(' factory $className.fromJsonValue(Object? value) {'); + buffer.writeln(' if (value is Map) {'); + buffer.writeln(' return $className('); + buffer.writeln( + " isValue: _readRelation(value['is'], ${relationModel.whereInputClassName}.fromJson),", + ); + buffer.writeln( + " isNot: _readRelation(value['isNot'], ${relationModel.whereInputClassName}.fromJson),", + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' return const $className();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return {'); + buffer.writeln(" if (isValue != null) 'is': isValue!.toJson(),"); + buffer.writeln(" if (isNot != null) 'isNot': isNot!.toJson(),"); + buffer.writeln(' };'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' bool get isEmpty => isValue == null && isNot == null;', + ); + } buffer.writeln('}'); buffer.writeln(); } @@ -1861,9 +1900,7 @@ final class TypedClientWriter { required TypedField field, required _TemplateClassKind classKind, }) { - return classKind == _TemplateClassKind.where && - field.isRelation && - field.isList; + return classKind == _TemplateClassKind.where && field.isRelation; } bool _includeInWhereUnique(TypedField field) { diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index b9065e74..b80295b4 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -296,7 +296,7 @@ void main() { ); }); - test('generates relation where some/every/none filter classes', () async { + test('generates relation where some/every/none and is/isNot filter classes', () async { final fixtureDir = _copyFixture(fixturesRoot, 'relation_output'); addTearDown(() => fixtureDir.deleteSync(recursive: true)); @@ -348,6 +348,29 @@ void main() { reason: 'Expected UserWhereInput relation field to use relation where filter class.', ); + expect( + RegExp( + r'\bclass PostAuthorRelationWhereFilter\b', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected relation where filter class for Post.author.', + ); + expect( + RegExp( + r'class\s+PostAuthorRelationWhereFilter\s*\{[\s\S]*?final\s+UserWhereInput\?\s+isValue;[\s\S]*?final\s+UserWhereInput\?\s+isNot;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected to-one relation filter to expose is/isNot typed operands.', + ); + expect( + RegExp( + r'class\s+PostWhereInput\s*\{[\s\S]*?final\s+PostAuthorRelationWhereFilter\?\s+author;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected PostWhereInput relation field to use to-one relation where filter class.', + ); expect( RegExp( r"if\s*\(posts\s*!=\s*null\s*&&\s*!posts!\.isEmpty\)\s*'posts':\s*posts!\.toJsonValue\(\)", @@ -356,6 +379,14 @@ void main() { reason: 'Expected relation where filter serialization to skip empty filter.', ); + expect( + RegExp( + r"if\s*\(author\s*!=\s*null\s*&&\s*!author!\.isEmpty\)\s*'author':\s*author!\.toJsonValue\(\)", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected to-one relation where serialization to skip empty filter.', + ); }); test('prints actionable error message for invalid config', () async { From 9a9337eb266f53780b1b1f01ce46c9c8b6aa1421 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:11:31 +0800 Subject: [PATCH 052/154] feat(runtime): support to-one relation where operators --- pub/orm/lib/src/client/client.dart | 148 ++++++++++++++++++--------- pub/orm/test/client/client_test.dart | 80 +++++++++++++++ 2 files changed, 179 insertions(+), 49 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 35a0514c..7c8230d6 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -28,7 +28,12 @@ typedef IncludeExecutionStrategySelector = const int _defaultMaxIncludeDepth = 4; const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; -const Set _relationWhereOperators = {'some', 'every', 'none'}; +const Set _toManyRelationWhereOperators = { + 'some', + 'every', + 'none', +}; +const Set _toOneRelationWhereOperators = {'is', 'isNot'}; IncludeExecutionStrategy defaultIncludeExecutionStrategySelector({ required OrmContract contract, @@ -1400,23 +1405,23 @@ class ModelDelegate { } final relation = modelContract.relations[key]; - if (relation == null || - relation.cardinality != RelationCardinality.many) { + if (relation == null) { normalizedWhere[key] = entry.value; continue; } final relationWhere = _coerceWhereMap(entry.value); if (relationWhere == null) { + final supportedOperators = _relationWhereOperatorsFor( + cardinality: relation.cardinality, + ); throw runtimeError( 'PLAN.RELATION_WHERE_INVALID', 'Relation where expects a map of operators.', details: { 'model': model, 'relation': key, - 'expectedOperators': _relationWhereOperators.toList( - growable: false, - ), + 'expectedOperators': supportedOperators.toList(growable: false), }, ); } @@ -1468,8 +1473,11 @@ class ModelDelegate { return null; } + final supportedOperators = _relationWhereOperatorsFor( + cardinality: relation.cardinality, + ); final unknownOperators = where.keys - .where((key) => !_relationWhereOperators.contains(key)) + .where((key) => !supportedOperators.contains(key)) .toList(growable: false); if (unknownOperators.isNotEmpty) { throw runtimeError( @@ -1479,59 +1487,92 @@ class ModelDelegate { 'model': modelName, 'relation': relationName, 'unknownOperators': unknownOperators, - 'supportedOperators': _relationWhereOperators.toList(growable: false), + 'supportedOperators': supportedOperators.toList(growable: false), }, ); } final clauses = []; + if (relation.cardinality == RelationCardinality.many) { + if (where.containsKey('some')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'some', + operand: where['some'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } - if (where.containsKey('some')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'some', - operand: where['some'], - ); - clauses.add( - await _buildRelationMembershipClause( + if (where.containsKey('none')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, relation: relation, - relatedWhere: relationWhere, - include: true, - ), - ); - } + operator: 'none', + operand: where['none'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } - if (where.containsKey('none')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'none', - operand: where['none'], - ); - clauses.add( - await _buildRelationMembershipClause( + if (where.containsKey('every')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, relation: relation, - relatedWhere: relationWhere, - include: false, - ), - ); - } + operator: 'every', + operand: where['every'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: {'NOT': relationWhere}, + include: false, + ), + ); + } + } else { + if (where.containsKey('is')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'is', + operand: where['is'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } - if (where.containsKey('every')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'every', - operand: where['every'], - ); - clauses.add( - await _buildRelationMembershipClause( + if (where.containsKey('isNot')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, relation: relation, - relatedWhere: {'NOT': relationWhere}, - include: false, - ), - ); + operator: 'isNot', + operand: where['isNot'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } } if (clauses.isEmpty) { @@ -1673,6 +1714,15 @@ class ModelDelegate { where['AND'] = [clause]; } + Set _relationWhereOperatorsFor({ + required RelationCardinality cardinality, + }) { + return switch (cardinality) { + RelationCardinality.many => _toManyRelationWhereOperators, + RelationCardinality.one => _toOneRelationWhereOperators, + }; + } + Map _normalizeInclude(Map include) { if (include.isEmpty) { return const {}; diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index c4646c58..26a36538 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -777,6 +777,60 @@ void main() { }, ); + test('supports relation where is/isNot for to-one relation', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final posts = client.model('Post'); + + await posts.create( + data: {'id': 'p4', 'userId': 'ux', 'title': 'Post D'}, + ); + + final isRows = await posts.findMany( + where: { + 'author': { + 'is': { + 'email': {'contains': 'u1@'}, + }, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect(isRows.map((row) => row['id']).toList(growable: false), [ + 'p1', + 'p2', + ]); + + final isNotRows = await posts.findMany( + where: { + 'author': { + 'isNot': {'id': 'u1'}, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + isNotRows.map((row) => row['id']).toList(growable: false), + ['p3', 'p4'], + ); + + final relationMissingRows = await posts.findMany( + where: { + 'author': {'isNot': const {}}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + relationMissingRows.map((row) => row['id']).toList(growable: false), + ['p4'], + ); + await client.disconnect(); + }); + test('supports relation where with nested logical operators', () async { final client = OrmClient( contract: relationalContract, @@ -851,6 +905,32 @@ void main() { await client.disconnect(); }); + test('supports to-one relation where on mutation paths', () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final posts = client.model('Post'); + + final updated = await posts.update( + where: { + 'author': { + 'is': {'id': 'u2'}, + }, + }, + data: {'title': 'Post C updated'}, + ); + expect(updated?['id'], 'p3'); + + final persisted = await posts.findUnique( + where: {'id': 'p3'}, + ); + expect(persisted?['title'], 'Post C updated'); + await client.disconnect(); + }); + test('supports include for one-to-many relation', () async { final client = OrmClient( contract: relationalContract, From c665529a79a140fa82077cc4c67ac932e784689f Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:25:30 +0800 Subject: [PATCH 053/154] feat(generator): improve to-one relation where nullability dx --- pub/orm/lib/src/generator/writer.dart | 37 +++++++++++++++++------ pub/orm/test/generator/generate_test.dart | 10 +++++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 0930d3f3..4b105ba4 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1227,21 +1227,34 @@ final class TypedClientWriter { ' bool get isEmpty => some == null && every == null && none == null;', ); } else { - buffer.writeln( - ' final ${relationModel.whereInputClassName}? isValue;', - ); + buffer.writeln(' final ${relationModel.whereInputClassName}? is_;'); buffer.writeln(' final ${relationModel.whereInputClassName}? isNot;'); + buffer.writeln(' final bool isNull;'); + buffer.writeln(' final bool isNotNull;'); buffer.writeln(); - buffer.writeln(' const $className({this.isValue, this.isNot});'); + buffer.writeln(' const $className({'); + buffer.writeln(' this.is_,'); + buffer.writeln(' this.isNot,'); + buffer.writeln(' this.isNull = false,'); + buffer.writeln(' this.isNotNull = false,'); + buffer.writeln(' }) : assert(!((is_ != null) && isNull)),'); + buffer.writeln(' assert(!((isNot != null) && isNotNull)),'); + buffer.writeln(' assert(!(isNull && isNotNull));'); buffer.writeln(); buffer.writeln(' factory $className.fromJsonValue(Object? value) {'); buffer.writeln(' if (value is Map) {'); + buffer.writeln(" final hasIs = value.containsKey('is');"); + buffer.writeln(" final hasIsNot = value.containsKey('isNot');"); buffer.writeln(' return $className('); buffer.writeln( - " isValue: _readRelation(value['is'], ${relationModel.whereInputClassName}.fromJson),", + " is_: hasIs && value['is'] != null ? _readRelation(value['is'], ${relationModel.whereInputClassName}.fromJson) : null,", + ); + buffer.writeln( + " isNot: hasIsNot && value['isNot'] != null ? _readRelation(value['isNot'], ${relationModel.whereInputClassName}.fromJson) : null,", ); + buffer.writeln(" isNull: hasIs && value['is'] == null,"); buffer.writeln( - " isNot: _readRelation(value['isNot'], ${relationModel.whereInputClassName}.fromJson),", + " isNotNull: hasIsNot && value['isNot'] == null,", ); buffer.writeln(' );'); buffer.writeln(' }'); @@ -1253,14 +1266,18 @@ final class TypedClientWriter { buffer.writeln(' return null;'); buffer.writeln(' }'); buffer.writeln(' return {'); - buffer.writeln(" if (isValue != null) 'is': isValue!.toJson(),"); + buffer.writeln(" if (is_ != null) 'is': is_!.toJson(),"); + buffer.writeln(" if (isNull) 'is': null,"); buffer.writeln(" if (isNot != null) 'isNot': isNot!.toJson(),"); + buffer.writeln(" if (isNotNull) 'isNot': null,"); buffer.writeln(' };'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln( - ' bool get isEmpty => isValue == null && isNot == null;', - ); + buffer.writeln(' bool get isEmpty =>'); + buffer.writeln(' is_ == null &&'); + buffer.writeln(' isNot == null &&'); + buffer.writeln(' !isNull &&'); + buffer.writeln(' !isNotNull;'); } buffer.writeln('}'); buffer.writeln(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index b80295b4..3ce386b1 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -357,7 +357,7 @@ void main() { ); expect( RegExp( - r'class\s+PostAuthorRelationWhereFilter\s*\{[\s\S]*?final\s+UserWhereInput\?\s+isValue;[\s\S]*?final\s+UserWhereInput\?\s+isNot;', + r'class\s+PostAuthorRelationWhereFilter\s*\{[\s\S]*?final\s+UserWhereInput\?\s+is_;[\s\S]*?final\s+UserWhereInput\?\s+isNot;', ).hasMatch(generatedSource), isTrue, reason: @@ -387,6 +387,14 @@ void main() { reason: 'Expected to-one relation where serialization to skip empty filter.', ); + expect( + RegExp( + r'class\s+PostAuthorRelationWhereFilter\s*\{[\s\S]*?final\s+bool\s+isNull;[\s\S]*?final\s+bool\s+isNotNull;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected to-one relation filter to support is:null and isNot:null.', + ); }); test('prints actionable error message for invalid config', () async { From a5528452492ae589044d2a5d40d0325938c4c1c5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:25:34 +0800 Subject: [PATCH 054/154] feat(runtime): support relation is/isNot null semantics --- pub/orm/lib/src/client/client.dart | 70 ++++++++++++++++++---------- pub/orm/test/client/client_test.dart | 22 +++++++++ 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 7c8230d6..56c37dd0 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1543,35 +1543,57 @@ class ModelDelegate { } } else { if (where.containsKey('is')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'is', - operand: where['is'], - ); - clauses.add( - await _buildRelationMembershipClause( + final isOperand = where['is']; + if (isOperand == null) { + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: const {}, + include: false, + ), + ); + } else { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, relation: relation, - relatedWhere: relationWhere, - include: true, - ), - ); + operator: 'is', + operand: isOperand, + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } } if (where.containsKey('isNot')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'isNot', - operand: where['isNot'], - ); - clauses.add( - await _buildRelationMembershipClause( + final isNotOperand = where['isNot']; + if (isNotOperand == null) { + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: const {}, + include: true, + ), + ); + } else { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, relation: relation, - relatedWhere: relationWhere, - include: false, - ), - ); + operator: 'isNot', + operand: isNotOperand, + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } } } diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 26a36538..36fcaf18 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -828,6 +828,28 @@ void main() { relationMissingRows.map((row) => row['id']).toList(growable: false), ['p4'], ); + + final isNullRows = await posts.findMany( + where: { + 'author': {'is': null}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + isNullRows.map((row) => row['id']).toList(growable: false), + ['p4'], + ); + + final isNotNullRows = await posts.findMany( + where: { + 'author': {'isNot': null}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + isNotNullRows.map((row) => row['id']).toList(growable: false), + ['p1', 'p2', 'p3'], + ); await client.disconnect(); }); From 8628295f2ede96eb381ae08796667f117ae2d283 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:36:32 +0800 Subject: [PATCH 055/154] feat(sql): lower relation where to exists predicates --- pub/orm/lib/src/sql/adapter.dart | 288 ++++++++++++++++++++++++- pub/orm/test/sql/sql_adapter_test.dart | 94 ++++++++ 2 files changed, 374 insertions(+), 8 deletions(-) diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index b261eb58..18012395 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -36,6 +36,19 @@ const Set _whereOperators = { }; const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; +const List _toManyRelationWhereOperatorOrder = [ + 'some', + 'none', + 'every', +]; +const List _toOneRelationWhereOperatorOrder = ['is', 'isNot']; +const Set _toManyRelationWhereOperators = { + 'some', + 'every', + 'none', +}; +const Set _toOneRelationWhereOperators = {'is', 'isNot'}; +const String _relationWhereAlias = '_rel'; final class SqlAdapter implements TargetAdapter { final OrmContract contract; @@ -241,10 +254,16 @@ final class SqlAdapter implements TargetAdapter { return ''; } + final modelContract = contract.models[model]; + if (modelContract == null) { + throw ModelNotFoundException(model, contract.models.keys); + } final predicate = _buildWhereExpression( model: model, where: where, params: params, + fieldRefPrefix: null, + rowRef: _id(modelContract.table), ); return ' WHERE $predicate'; } @@ -253,7 +272,14 @@ final class SqlAdapter implements TargetAdapter { required String model, required JsonMap where, required List params, + required String rowRef, + required String? fieldRefPrefix, }) { + final modelContract = contract.models[model]; + if (modelContract == null) { + throw ModelNotFoundException(model, contract.models.keys); + } + final predicates = []; for (final entry in where.entries) { @@ -265,6 +291,21 @@ final class SqlAdapter implements TargetAdapter { key: key, operand: entry.value, params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ), + ); + continue; + } + + final relation = modelContract.relations[key]; + if (relation != null) { + predicates.addAll( + _buildWhereRelationPredicates( + relation: relation, + condition: entry.value, + params: params, + outerRowRef: rowRef, ), ); continue; @@ -276,6 +317,7 @@ final class SqlAdapter implements TargetAdapter { field: key, condition: entry.value, params: params, + fieldRefPrefix: fieldRefPrefix, ), ); } @@ -292,22 +334,30 @@ final class SqlAdapter implements TargetAdapter { required String key, required Object? operand, required List params, + required String rowRef, + required String? fieldRefPrefix, }) { return switch (key) { 'AND' => _buildWhereAndPredicate( model: model, operand: operand, params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, ), 'OR' => _buildWhereOrPredicate( model: model, operand: operand, params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, ), 'NOT' => _buildWhereNotPredicate( model: model, operand: operand, params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, ), _ => '1 = 0', }; @@ -317,6 +367,8 @@ final class SqlAdapter implements TargetAdapter { required String model, required Object? operand, required List params, + required String rowRef, + required String? fieldRefPrefix, }) { final where = _coerceWhereMap(operand); if (where != null) { @@ -324,6 +376,8 @@ final class SqlAdapter implements TargetAdapter { model: model, where: where, params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, ); return '($nested)'; } @@ -338,8 +392,13 @@ final class SqlAdapter implements TargetAdapter { final predicates = whereList .map( - (item) => - _buildWhereExpression(model: model, where: item, params: params), + (item) => _buildWhereExpression( + model: model, + where: item, + params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ), ) .toList(growable: false); return '(${predicates.join(' AND ')})'; @@ -349,6 +408,8 @@ final class SqlAdapter implements TargetAdapter { required String model, required Object? operand, required List params, + required String rowRef, + required String? fieldRefPrefix, }) { final where = _coerceWhereMap(operand); if (where != null) { @@ -356,6 +417,8 @@ final class SqlAdapter implements TargetAdapter { model: model, where: where, params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, ); return '($nested)'; } @@ -370,8 +433,13 @@ final class SqlAdapter implements TargetAdapter { final predicates = whereList .map( - (item) => - _buildWhereExpression(model: model, where: item, params: params), + (item) => _buildWhereExpression( + model: model, + where: item, + params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ), ) .toList(growable: false); return '(${predicates.join(' OR ')})'; @@ -381,6 +449,8 @@ final class SqlAdapter implements TargetAdapter { required String model, required Object? operand, required List params, + required String rowRef, + required String? fieldRefPrefix, }) { final where = _coerceWhereMap(operand); if (where != null) { @@ -388,6 +458,8 @@ final class SqlAdapter implements TargetAdapter { model: model, where: where, params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, ); return 'NOT ($nested)'; } @@ -402,8 +474,13 @@ final class SqlAdapter implements TargetAdapter { final predicates = whereList .map( - (item) => - _buildWhereExpression(model: model, where: item, params: params), + (item) => _buildWhereExpression( + model: model, + where: item, + params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ), ) .map((item) => 'NOT ($item)') .toList(growable: false); @@ -415,11 +492,14 @@ final class SqlAdapter implements TargetAdapter { required String field, required Object? condition, required List params, + required String? fieldRefPrefix, }) { final predicates = []; final operatorMap = _coerceOperatorMap(condition); if (operatorMap == null) { - predicates.add('${_id(field)} = ?'); + predicates.add( + '${_fieldReference(field: field, fieldRefPrefix: fieldRefPrefix)} = ?', + ); params.add( _encodeWhereValue(model: model, field: field, value: condition), ); @@ -437,12 +517,181 @@ final class SqlAdapter implements TargetAdapter { field: field, operator: operator, operand: operatorMap[operator], + fieldRefPrefix: fieldRefPrefix, ); } return predicates; } + List _buildWhereRelationPredicates({ + required ModelRelationContract relation, + required Object? condition, + required List params, + required String outerRowRef, + }) { + final relationWhere = _coerceWhereMap(condition); + if (relationWhere == null) { + return const ['1 = 0']; + } + if (relationWhere.isEmpty) { + return const ['1 = 1']; + } + + final supportedOperators = _relationWhereOperatorsFor( + cardinality: relation.cardinality, + ); + if (relationWhere.keys.any((key) => !supportedOperators.contains(key))) { + return const ['1 = 0']; + } + + final predicates = []; + if (relation.cardinality == RelationCardinality.many) { + for (final operator in _toManyRelationWhereOperatorOrder) { + if (!relationWhere.containsKey(operator)) { + continue; + } + final relatedWhere = _normalizeRelationWhereOperand( + relationWhere[operator], + ); + if (relatedWhere == null) { + return const ['1 = 0']; + } + switch (operator) { + case 'some': + predicates.add( + _buildRelationExistsPredicate( + relation: relation, + relatedWhere: relatedWhere, + params: params, + outerRowRef: outerRowRef, + negated: false, + ), + ); + case 'none': + predicates.add( + _buildRelationExistsPredicate( + relation: relation, + relatedWhere: relatedWhere, + params: params, + outerRowRef: outerRowRef, + negated: true, + ), + ); + case 'every': + predicates.add( + _buildRelationExistsPredicate( + relation: relation, + relatedWhere: {'NOT': relatedWhere}, + params: params, + outerRowRef: outerRowRef, + negated: true, + ), + ); + default: + return const ['1 = 0']; + } + } + return predicates; + } + + for (final operator in _toOneRelationWhereOperatorOrder) { + if (!relationWhere.containsKey(operator)) { + continue; + } + final operand = relationWhere[operator]; + if (operand == null) { + predicates.add( + _buildRelationExistsPredicate( + relation: relation, + relatedWhere: const {}, + params: params, + outerRowRef: outerRowRef, + negated: operator == 'is', + ), + ); + continue; + } + + final relatedWhere = _normalizeRelationWhereOperand(operand); + if (relatedWhere == null) { + return const ['1 = 0']; + } + predicates.add( + _buildRelationExistsPredicate( + relation: relation, + relatedWhere: relatedWhere, + params: params, + outerRowRef: outerRowRef, + negated: operator == 'isNot', + ), + ); + } + return predicates; + } + + JsonMap? _normalizeRelationWhereOperand(Object? operand) { + if (operand == null) { + return const {}; + } + return _coerceWhereMap(operand); + } + + String _buildRelationExistsPredicate({ + required ModelRelationContract relation, + required JsonMap relatedWhere, + required List params, + required String outerRowRef, + required bool negated, + }) { + final relatedModel = contract.models[relation.relatedModel]; + if (relatedModel == null) { + throw ModelNotFoundException(relation.relatedModel, contract.models.keys); + } + + final relationRowRef = _id(_relationWhereAlias); + final predicates = _buildRelationJoinPredicates( + relation: relation, + outerRowRef: outerRowRef, + relationRowRef: relationRowRef, + ); + if (relatedWhere.isNotEmpty) { + predicates.add( + _buildWhereExpression( + model: relation.relatedModel, + where: relatedWhere, + params: params, + rowRef: relationRowRef, + fieldRefPrefix: relationRowRef, + ), + ); + } + + final existsSql = + 'EXISTS (SELECT 1 FROM ${_id(relatedModel.table)} AS $relationRowRef ' + 'WHERE ${predicates.join(' AND ')})'; + if (negated) { + return 'NOT $existsSql'; + } + return existsSql; + } + + List _buildRelationJoinPredicates({ + required ModelRelationContract relation, + required String outerRowRef, + required String relationRowRef, + }) { + final predicates = []; + for (var index = 0; index < relation.sourceFields.length; index++) { + final sourceFieldRef = + '$outerRowRef.${_id(relation.sourceFields[index])}'; + final targetFieldRef = + '$relationRowRef.${_id(relation.targetFields[index])}'; + predicates.add('$targetFieldRef = $sourceFieldRef'); + } + return predicates; + } + Map? _coerceOperatorMap(Object? value) { if (value is! Map) { return null; @@ -504,8 +753,12 @@ final class SqlAdapter implements TargetAdapter { required String field, required String operator, required Object? operand, + required String? fieldRefPrefix, }) { - final idField = _id(field); + final idField = _fieldReference( + field: field, + fieldRefPrefix: fieldRefPrefix, + ); switch (operator) { case 'equals': @@ -571,6 +824,25 @@ final class SqlAdapter implements TargetAdapter { } } + Set _relationWhereOperatorsFor({ + required RelationCardinality cardinality, + }) { + return switch (cardinality) { + RelationCardinality.many => _toManyRelationWhereOperators, + RelationCardinality.one => _toOneRelationWhereOperators, + }; + } + + String _fieldReference({ + required String field, + required String? fieldRefPrefix, + }) { + if (fieldRefPrefix == null) { + return _id(field); + } + return '$fieldRefPrefix.${_id(field)}'; + } + List _coerceListOperand(Object? value) { if (value is List) { return value; diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 0e722a64..0e1e2b20 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -17,6 +17,44 @@ void main() { ); } + OrmContract buildRelationalContract() { + return OrmContract( + version: '1', + hash: 'rel-hash', + target: 'sql-family', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + relations: { + 'posts': ModelRelationContract( + name: 'posts', + relatedModel: 'Post', + sourceFields: ['id'], + targetFields: ['userId'], + cardinality: RelationCardinality.many, + ), + }, + ), + 'Post': ModelContract( + name: 'Post', + table: 'posts', + fields: {'id', 'userId', 'title'}, + relations: { + 'author': ModelRelationContract( + name: 'author', + relatedModel: 'User', + sourceFields: ['userId'], + targetFields: ['id'], + cardinality: RelationCardinality.one, + ), + }, + ), + }, + ); + } + final contract = buildContract(); test('lowers findMany with where/order/pagination/select', () { @@ -153,6 +191,62 @@ void main() { ]); }); + test('lowers to-many relation where using EXISTS predicates', () { + final contract = buildRelationalContract(); + final adapter = SqlAdapter(contract: contract); + final plan = OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: { + 'posts': { + 'some': { + 'title': {'contains': 'A'}, + }, + 'none': {'title': 'Z'}, + 'every': { + 'title': {'startsWith': 'Post'}, + }, + }, + }, + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT * FROM "users" WHERE ' + 'EXISTS (SELECT 1 FROM "posts" AS "_rel" WHERE "_rel"."userId" = "users"."id" AND "_rel"."title" LIKE ? ESCAPE \'\\\') AND ' + 'NOT EXISTS (SELECT 1 FROM "posts" AS "_rel" WHERE "_rel"."userId" = "users"."id" AND "_rel"."title" = ?) AND ' + 'NOT EXISTS (SELECT 1 FROM "posts" AS "_rel" WHERE "_rel"."userId" = "users"."id" AND NOT ("_rel"."title" LIKE ? ESCAPE \'\\\'))', + ); + expect(statement.parameters, ['%A%', 'Z', 'Post%']); + }); + + test('lowers to-one relation where including null semantics', () { + final contract = buildRelationalContract(); + final adapter = SqlAdapter(contract: contract); + final plan = OrmPlan( + contractHash: contract.hash, + model: 'Post', + action: OrmAction.findMany, + where: { + 'author': { + 'is': {'email': 'u1@example.com'}, + 'isNot': null, + }, + }, + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT * FROM "posts" WHERE ' + 'EXISTS (SELECT 1 FROM "users" AS "_rel" WHERE "_rel"."id" = "posts"."userId" AND "_rel"."email" = ?) AND ' + 'EXISTS (SELECT 1 FROM "users" AS "_rel" WHERE "_rel"."id" = "posts"."userId")', + ); + expect(statement.parameters, ['u1@example.com']); + }); + test( 'keeps scalar where compatibility and does not misclassify normal maps', () { From 911a0926f92aed416c7a6559454509250246e43d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:36:44 +0800 Subject: [PATCH 056/154] feat(runtime): validate relation where and bypass sql client rewrite --- pub/orm/lib/src/client/client.dart | 3 + pub/orm/lib/src/runtime/core.dart | 84 ++++++++++++++++++++++++++++ pub/orm/test/client/client_test.dart | 36 ++++++++++++ 3 files changed, 123 insertions(+) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 56c37dd0..84fe596f 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1376,6 +1376,9 @@ class ModelDelegate { if (where.isEmpty) { return const {}; } + if (_client.contract.target == 'sql-family') { + return where; + } return _rewriteRelationWhere(model: model, where: where); } diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index f7d5d563..d112119d 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -9,6 +9,12 @@ import 'types.dart'; typedef MarkerHashReader = Future Function(); const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; +const Set _toManyRelationWhereOperators = { + 'some', + 'every', + 'none', +}; +const Set _toOneRelationWhereOperators = {'is', 'isNot'}; abstract interface class ContractMarkerReader { Future readContractHash(); @@ -389,6 +395,15 @@ final class OrmRuntimeCore implements RuntimeCore { ); continue; } + final relation = model.relations[key]; + if (relation != null) { + _assertRelationWhereFields( + relation: relation, + operand: entry.value, + source: source, + ); + continue; + } _assertKnownFields(model: model, fields: [key], source: source); } } @@ -414,12 +429,81 @@ final class OrmRuntimeCore implements RuntimeCore { } } + void _assertRelationWhereFields({ + required ModelRelationContract relation, + required Object? operand, + required String source, + }) { + final relationWhere = _coerceWhereMap(operand); + if (relationWhere == null || relationWhere.isEmpty) { + return; + } + + final supportedOperators = _relationWhereOperatorsFor( + cardinality: relation.cardinality, + ); + final unknownOperators = relationWhere.keys + .where((key) => !supportedOperators.contains(key)) + .toList(growable: false); + if (unknownOperators.isNotEmpty) { + throw runtimeError( + 'PLAN.RELATION_WHERE_OPERATOR_INVALID', + 'Relation where contains unknown operators.', + details: { + 'relation': relation.name, + 'unknownOperators': unknownOperators, + 'supportedOperators': supportedOperators.toList(growable: false), + 'source': source, + }, + ); + } + + final relatedModel = contract.models[relation.relatedModel]; + if (relatedModel == null) { + throw ModelNotFoundException(relation.relatedModel, contract.models.keys); + } + + for (final entry in relationWhere.entries) { + final value = entry.value; + if (value == null) { + continue; + } + + final nestedWhere = _coerceWhereMap(value); + if (nestedWhere == null) { + throw runtimeError( + 'PLAN.RELATION_WHERE_VALUE_INVALID', + 'Relation where operator expects a nested where map.', + details: { + 'relation': relation.name, + 'operator': entry.key, + 'source': source, + }, + ); + } + _assertWhereFields( + model: relatedModel, + where: nestedWhere, + source: source, + ); + } + } + void _ensureConnected() { if (_connected) { return; } throw ClientNotConnectedException(); } + + Set _relationWhereOperatorsFor({ + required RelationCardinality cardinality, + }) { + return switch (cardinality) { + RelationCardinality.many => _toManyRelationWhereOperators, + RelationCardinality.one => _toOneRelationWhereOperators, + }; + } } JsonMap? _coerceWhereMap(Object? value) { diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 36fcaf18..93266c1d 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -953,6 +953,42 @@ void main() { await client.disconnect(); }); + test('skips relation where rewrite when target is sql-family', () async { + final sqlTargetContract = OrmContract( + version: relationalContract.version, + hash: 'contract-rel-sql-v1', + target: 'sql-family', + models: relationalContract.models, + aliases: relationalContract.aliases, + capabilities: relationalContract.capabilities, + ); + final countingEngine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient( + contract: sqlTargetContract, + engine: countingEngine, + ); + await client.connect(); + + await client + .model('User') + .findMany( + where: { + 'posts': { + 'some': {'title': 'Post A'}, + }, + }, + ); + + expect(countingEngine.executeCount, 1); + expect(countingEngine.executedPlans.single.model, 'User'); + expect(countingEngine.executedPlans.single.where, { + 'posts': { + 'some': {'title': 'Post A'}, + }, + }); + await client.disconnect(); + }); + test('supports include for one-to-many relation', () async { final client = OrmClient( contract: relationalContract, From 90ea1dfd8e27b0a3b8f912ab40f9da9a0cf74b9c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:56:07 +0800 Subject: [PATCH 057/154] feat(generator): add typed bulk mutations and generate path overrides --- pub/orm/bin/orm.dart | 143 ++++++++++++++++-- pub/orm/lib/src/generator/command.dart | 16 +- pub/orm/lib/src/generator/config_loader.dart | 75 +++++++-- pub/orm/lib/src/generator/writer.dart | 69 ++++++++- .../config/override.config.dart | 11 ++ .../schema_override/schema/from_cli.dart | 8 + .../schema_override/schema/from_config.dart | 8 + pub/orm/test/generator/generate_test.dart | 112 +++++++++++++- 8 files changed, 408 insertions(+), 34 deletions(-) create mode 100644 pub/orm/test/generator/fixtures/schema_override/config/override.config.dart create mode 100644 pub/orm/test/generator/fixtures/schema_override/schema/from_cli.dart create mode 100644 pub/orm/test/generator/fixtures/schema_override/schema/from_config.dart diff --git a/pub/orm/bin/orm.dart b/pub/orm/bin/orm.dart index 8550c0c3..91d067ba 100644 --- a/pub/orm/bin/orm.dart +++ b/pub/orm/bin/orm.dart @@ -14,25 +14,28 @@ void main(List args) { switch (command) { case 'generate': - if (commandArgs.isNotEmpty && _isHelp(commandArgs.first)) { + final parseResult = _parseGenerateArgs(commandArgs); + if (parseResult.helpRequested) { _printGenerateHelp(); exitCode = 0; return; } - if (commandArgs.isNotEmpty) { - stderr.writeln( - 'Unexpected arguments for generate: ${commandArgs.join(' ')}', - ); + if (parseResult.errorMessage != null) { + stderr.writeln(parseResult.errorMessage); _printGenerateHelp(stream: stderr); exitCode = 64; return; } + final options = parseResult.options!; exitCode = runGenerateCommand( cwd: Directory.current, out: stdout, err: stderr, + configPath: options.configPath, + schemaPath: options.schemaPath, + outputPath: options.outputPath, ); return; default: @@ -45,6 +48,83 @@ void main(List args) { bool _isHelp(String value) => value == '--help' || value == '-h' || value == 'help'; +_GenerateCliParseResult _parseGenerateArgs(List args) { + String? configPath; + String? schemaPath; + String? outputPath; + + for (var index = 0; index < args.length; index++) { + final argument = args[index]; + if (_isHelp(argument)) { + return const _GenerateCliParseResult.help(); + } + + if (!argument.startsWith('--')) { + return _GenerateCliParseResult.error( + 'Unexpected arguments for generate: ${args.join(' ')}', + ); + } + + final separatorIndex = argument.indexOf('='); + var optionName = argument; + String? optionValue; + + if (separatorIndex >= 0) { + optionName = argument.substring(0, separatorIndex); + optionValue = argument.substring(separatorIndex + 1); + } + + if (!_isGenerateOption(optionName)) { + return _GenerateCliParseResult.error( + 'Unexpected arguments for generate: ${args.join(' ')}', + ); + } + + if (optionValue == null) { + if (index + 1 >= args.length) { + return _GenerateCliParseResult.error('Missing value for $optionName.'); + } + + final next = args[index + 1]; + if (_isHelp(next) || next.startsWith('--')) { + return _GenerateCliParseResult.error('Missing value for $optionName.'); + } + + optionValue = next; + index++; + } + + if (optionValue.trim().isEmpty) { + return _GenerateCliParseResult.error( + 'Option $optionName requires a non-empty path.', + ); + } + + switch (optionName) { + case '--config': + configPath = optionValue; + break; + case '--schema': + schemaPath = optionValue; + break; + case '--output': + outputPath = optionValue; + break; + } + } + + return _GenerateCliParseResult.success( + _GenerateCliOptions( + configPath: configPath, + schemaPath: schemaPath, + outputPath: outputPath, + ), + ); +} + +bool _isGenerateOption(String value) => + value == '--config' || value == '--schema' || value == '--output'; + void _printUsage({IOSink? stream}) { final sink = stream ?? stdout; sink.writeln('ORM CLI'); @@ -61,14 +141,55 @@ void _printUsage({IOSink? stream}) { void _printGenerateHelp({IOSink? stream}) { final sink = stream ?? stdout; sink.writeln('Generate typed client.'); - sink.writeln('Usage: dart run orm generate'); + sink.writeln('Usage: dart run orm generate [options]'); sink.writeln(''); - sink.writeln('Input files from current working directory:'); - sink.writeln(' - orm.config.dart'); + sink.writeln('Options:'); sink.writeln( - ' - schema path from config.schema, or orm.schema.dart by default', + ' --config Override config file path (default: orm.config.dart)', ); + sink.writeln(' --schema Override schema path from config.schema'); + sink.writeln(' --output Override output path from config.output'); sink.writeln(''); - sink.writeln('Output:'); - sink.writeln(' - config.output, or lib/orm_client.g.dart by default'); + sink.writeln('Defaults from current working directory:'); + sink.writeln(' - config: orm.config.dart'); + sink.writeln( + ' - schema: config.schema, or orm.schema.dart when config.schema is not set', + ); + sink.writeln(''); + sink.writeln('Default output:'); + sink.writeln( + ' - config.output, or lib/orm_client.g.dart when output is empty', + ); +} + +final class _GenerateCliOptions { + final String? configPath; + final String? schemaPath; + final String? outputPath; + + const _GenerateCliOptions({ + this.configPath, + this.schemaPath, + this.outputPath, + }); +} + +final class _GenerateCliParseResult { + final _GenerateCliOptions? options; + final String? errorMessage; + final bool helpRequested; + + const _GenerateCliParseResult._({ + this.options, + this.errorMessage, + this.helpRequested = false, + }); + + const _GenerateCliParseResult.success(_GenerateCliOptions options) + : this._(options: options); + + const _GenerateCliParseResult.error(String errorMessage) + : this._(errorMessage: errorMessage); + + const _GenerateCliParseResult.help() : this._(helpRequested: true); } diff --git a/pub/orm/lib/src/generator/command.dart b/pub/orm/lib/src/generator/command.dart index 835b9828..d63212c4 100644 --- a/pub/orm/lib/src/generator/command.dart +++ b/pub/orm/lib/src/generator/command.dart @@ -5,13 +5,25 @@ import 'config_loader.dart'; import 'error.dart'; import 'schema_loader.dart'; -int runGenerateCommand({Directory? cwd, IOSink? out, IOSink? err}) { +int runGenerateCommand({ + Directory? cwd, + IOSink? out, + IOSink? err, + String? configPath, + String? schemaPath, + String? outputPath, +}) { final workingDirectory = cwd ?? Directory.current; final output = out ?? stdout; final error = err ?? stderr; try { - final config = loadGeneratorConfig(cwd: workingDirectory); + final config = loadGeneratorConfig( + cwd: workingDirectory, + configPath: configPath, + schemaOverridePath: schemaPath, + outputOverridePath: outputPath, + ); final schema = loadSchema(cwd: workingDirectory, config: config); final outputFile = _resolveOutputFile( cwd: workingDirectory, diff --git a/pub/orm/lib/src/generator/config_loader.dart b/pub/orm/lib/src/generator/config_loader.dart index 4e4136e6..c0c230e0 100644 --- a/pub/orm/lib/src/generator/config_loader.dart +++ b/pub/orm/lib/src/generator/config_loader.dart @@ -8,13 +8,26 @@ import 'snapshot.dart'; const _defaultOutputPath = 'lib/orm_client.g.dart'; -GeneratorConfigSnapshot loadGeneratorConfig({required Directory cwd}) { - final configFile = File(_join(cwd.path, 'orm.config.dart')); +GeneratorConfigSnapshot loadGeneratorConfig({ + required Directory cwd, + String? configPath, + String? schemaOverridePath, + String? outputOverridePath, +}) { + final configFile = _resolveConfigFile(cwd: cwd, configPath: configPath); if (!configFile.existsSync()) { + final configOverride = _readOverridePath( + configPath, + optionName: '--config', + ); throw GeneratorException( - 'Cannot find orm.config.dart in current working directory.', + configOverride == null + ? 'Cannot find orm.config.dart in current working directory.' + : 'Cannot find config file.', path: configFile.path, - hint: 'Create orm.config.dart with a top-level config declaration.', + hint: configOverride == null + ? 'Create orm.config.dart with a top-level config declaration.' + : 'Check the --config path and ensure it points to a Dart file.', ); } @@ -29,7 +42,7 @@ GeneratorConfigSnapshot loadGeneratorConfig({required Directory cwd}) { final diagnostic = parsed.errors.first; final location = parsed.lineInfo.getLocation(diagnostic.offset); throw GeneratorException( - 'orm.config.dart contains invalid Dart syntax.', + 'Config file contains invalid Dart syntax.', path: configFile.path, line: location.lineNumber, column: location.columnNumber, @@ -50,17 +63,17 @@ GeneratorConfigSnapshot loadGeneratorConfig({required Directory cwd}) { } final namedArguments = _readNamedArguments(configArguments); - final output = _readStringArg( - namedArguments, - key: 'output', - file: configFile, - defaultValue: _defaultOutputPath, - ); - final schema = _readNullableStringArg( - namedArguments, - key: 'schema', - file: configFile, - ); + final output = + _readOverridePath(outputOverridePath, optionName: '--output') ?? + _readStringArg( + namedArguments, + key: 'output', + file: configFile, + defaultValue: _defaultOutputPath, + ); + final schema = + _readOverridePath(schemaOverridePath, optionName: '--schema') ?? + _readNullableStringArg(namedArguments, key: 'schema', file: configFile); return GeneratorConfigSnapshot( configFile: configFile, @@ -69,6 +82,36 @@ GeneratorConfigSnapshot loadGeneratorConfig({required Directory cwd}) { ); } +File _resolveConfigFile({required Directory cwd, required String? configPath}) { + final configuredPath = _readOverridePath(configPath, optionName: '--config'); + if (configuredPath == null) { + return File(_join(cwd.path, 'orm.config.dart')); + } + + final configuredFile = File(configuredPath); + if (configuredFile.isAbsolute) { + return configuredFile; + } + + return File(_join(cwd.path, configuredPath)); +} + +String? _readOverridePath(String? value, {required String optionName}) { + if (value == null) { + return null; + } + + final normalized = value.trim(); + if (normalized.isEmpty) { + throw GeneratorException( + 'Generate option $optionName requires a non-empty path.', + hint: 'Pass a non-empty file path to $optionName.', + ); + } + + return normalized; +} + ArgumentList? _findConfigArguments( CompilationUnit unit, { required String configFilePath, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 4b105ba4..f43078a2 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -870,6 +870,30 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future> createMany({'); + buffer.writeln(' required List<${model.createInputClassName}> data,'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) async {'); + buffer.writeln( + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', + ); + buffer.writeln(' final rows = await _delegate.createMany('); + buffer.writeln( + ' data: data.map((entry) => entry.toJson()).toList(growable: false),', + ); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln( + ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> update({'); buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' required ${model.updateInputClassName} data,'); @@ -942,6 +966,15 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future deleteMany({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return _delegate.deleteMany(where: where.toJson());'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future count({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -1151,6 +1184,22 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' Future> createMany({required List<${model.createInputClassName}> data}) {', + ); + buffer.writeln(' return _delegate.createMany('); + buffer.writeln(' data: data,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future deleteMany() {'); + buffer.writeln(' return _delegate.deleteMany(where: _where);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future count() {'); buffer.writeln(' return _delegate.count(where: _where);'); buffer.writeln(' }'); @@ -1482,6 +1531,17 @@ final class TypedClientWriter { buffer.writeln(' return value;'); buffer.writeln('}'); buffer.writeln(); + buffer.writeln('Object? _readWhereUniqueEquals(Object? value) {'); + buffer.writeln(' final map = _readJsonMap(value);'); + buffer.writeln(' if (map == null) {'); + buffer.writeln(' return value;'); + buffer.writeln(' }'); + buffer.writeln(" if (!map.containsKey('equals')) {"); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(" return map['equals'];"); + buffer.writeln('}'); + buffer.writeln(); buffer.writeln('List? _readStringList(Object? value) {'); buffer.writeln(' if (value is! List) {'); buffer.writeln(' return null;'); @@ -1787,6 +1847,12 @@ final class TypedClientWriter { final filterClass = _whereFilterClassName(field.scalarType); return '$filterClass.fromJsonValue($accessor)'; } + if (classKind == _TemplateClassKind.whereUnique && field.isScalar) { + return _decodeScalar( + field, + accessor: '_readWhereUniqueEquals($accessor)', + ); + } if (_isRelationWhereFilterField(field: field, classKind: classKind)) { final filterClass = _relationWhereFilterClassName( owner: owner, @@ -1909,8 +1975,7 @@ final class TypedClientWriter { } bool _isWhereFilterClassKind(_TemplateClassKind classKind) { - return classKind == _TemplateClassKind.where || - classKind == _TemplateClassKind.whereUnique; + return classKind == _TemplateClassKind.where; } bool _isRelationWhereFilterField({ diff --git a/pub/orm/test/generator/fixtures/schema_override/config/override.config.dart b/pub/orm/test/generator/fixtures/schema_override/config/override.config.dart new file mode 100644 index 00000000..ca2cd5d0 --- /dev/null +++ b/pub/orm/test/generator/fixtures/schema_override/config/override.config.dart @@ -0,0 +1,11 @@ +class Config { + final String? output; + final String? schema; + + const Config({this.output, this.schema}); +} + +const config = Config( + output: 'generated/from_config.g.dart', + schema: 'schema/from_config.dart', +); diff --git a/pub/orm/test/generator/fixtures/schema_override/schema/from_cli.dart b/pub/orm/test/generator/fixtures/schema_override/schema/from_cli.dart new file mode 100644 index 00000000..f458435b --- /dev/null +++ b/pub/orm/test/generator/fixtures/schema_override/schema/from_cli.dart @@ -0,0 +1,8 @@ +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +@model +typedef CliOnlyUser = ({int id, String email}); diff --git a/pub/orm/test/generator/fixtures/schema_override/schema/from_config.dart b/pub/orm/test/generator/fixtures/schema_override/schema/from_config.dart new file mode 100644 index 00000000..ff6b45df --- /dev/null +++ b/pub/orm/test/generator/fixtures/schema_override/schema/from_config.dart @@ -0,0 +1,8 @@ +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +@model +typedef ConfigOnlyUser = ({int id, String email}); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 3ce386b1..cc0dafcd 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -59,6 +59,57 @@ void main() { ); }); + test('cli options override config, schema, and output paths', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'schema_override'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runGenerate( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + generateArgs: [ + '--config', + 'config/override.config.dart', + '--schema=schema/from_cli.dart', + '--output', + 'generated/from_cli.g.dart', + ], + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + final cliOutput = File( + _path([fixtureDir.path, 'generated', 'from_cli.g.dart']), + ); + expect( + cliOutput.existsSync(), + isTrue, + reason: + 'Expected CLI output at ${cliOutput.path}.\n${run.debugOutput}', + ); + + final configOutput = File( + _path([fixtureDir.path, 'generated', 'from_config.g.dart']), + ); + expect( + configOutput.existsSync(), + isFalse, + reason: + 'Did not expect config output when --output override is provided.', + ); + + final generatedSource = cliOutput.readAsStringSync(); + expect( + generatedSource.contains("_context.model('CliOnlyUser')"), + isTrue, + reason: 'Expected CLI schema model in generated output.', + ); + expect( + generatedSource.contains("_context.model('ConfigOnlyUser')"), + isFalse, + reason: 'Did not expect config schema model after --schema override.', + ); + }); + test('generated code contains typed delegate and typed input/data markers', () async { final fixtureDir = _copyFixture(fixturesRoot, 'config_output'); addTearDown(() => fixtureDir.deleteSync(recursive: true)); @@ -142,11 +193,34 @@ void main() { ); expect( RegExp( - r'class\s+UserWhereUniqueInput\s*\{[\s\S]*?final\s+IntWhereFilter\?\s+id;', + r'class\s+UserWhereUniqueInput\s*\{[\s\S]*?final\s+int\?\s+id;', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserWhereUniqueInput to expose scalar unique id.', + ); + expect( + RegExp( + r"class\s+UserWhereUniqueInput\s*\{[\s\S]*?_readInt\(_readWhereUniqueEquals\(json\['id'\]\)\)", ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserWhereUniqueInput to expose typed unique id filter.', + 'Expected UserWhereUniqueInput.fromJson to accept scalar or equals map input.', + ); + expect( + RegExp( + r"class\s+UserWhereUniqueInput\s*\{[\s\S]*?if\s*\(id\s*!=\s*null\)\s*'id':\s*id!", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserWhereUniqueInput.toJson to emit runtime-compatible scalar where value.', + ); + expect( + RegExp( + r'Object\?\s+_readWhereUniqueEquals\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include where unique equals compatibility helper.', ); expect( RegExp( @@ -187,6 +261,37 @@ void main() { isTrue, reason: 'Expected non-unique findMany to keep UserWhereInput.', ); + expect( + RegExp( + r'Future>\s+createMany\(\{\s*required\s+List\s+data,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.createMany(...) to accept typed input list.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+createMany\(\{\s*required\s+List\s+data\}\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.createMany(...) to exist with typed input list.', + ); + expect( + RegExp( + r'Future\s+deleteMany\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.deleteMany(...) to accept typed where input.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+deleteMany\s*\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.deleteMany() to exist.', + ); expect( RegExp(r'\bclass UserOrderBy\b').hasMatch(generatedSource), @@ -464,8 +569,9 @@ Directory _copyFixture(Directory fixturesRoot, String name) { Future<_GenerateRun> _runGenerate({ required String entryPath, required String workingDirectory, + List generateArgs = const [], }) async { - final args = [entryPath, 'generate']; + final args = [entryPath, 'generate', ...generateArgs]; final result = await Process.run( 'dart', args, From 087cb9d94bc8d40ac65a19d859fb16e13782067a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:56:10 +0800 Subject: [PATCH 058/154] test(runtime): add regression coverage for atomic createMany and sql logical operands --- pub/orm/test/client/client_test.dart | 29 +++++++++++++++++++++ pub/orm/test/sql/sql_adapter_test.dart | 36 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 93266c1d..67f99537 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -633,6 +633,35 @@ void main() { await client.disconnect(); }); + test('createMany_rolls_back_on_partial_failure', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'seed', 'email': 'seed@x.com'}, + ); + + await expectLater( + users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'b@x.com', 'bad': true}, + {'id': 'u3', 'email': 'c@x.com'}, + ], + ), + throwsA(isA()), + ); + + final rows = await users.findMany( + orderBy: const [OrmOrderBy('id')], + ); + expect(rows.map((row) => row['id']).toList(growable: false), [ + 'seed', + ]); + await client.disconnect(); + }); + test( 'falls back for create/update/delete when mutation returning is disabled', () async { diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 0e1e2b20..cd287add 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -191,6 +191,42 @@ void main() { ]); }); + test('sql_logical_operand_edge_semantics_are_deterministic', () { + final adapter = SqlAdapter(contract: contract); + + final emptyOperandStatement = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: { + 'AND': const [], + 'OR': const [], + 'NOT': const [], + }, + ), + ); + expect( + emptyOperandStatement.text, + 'SELECT * FROM "users" WHERE 1 = 1 AND 1 = 0 AND 1 = 1', + ); + expect(emptyOperandStatement.parameters, isEmpty); + + final invalidOperandStatement = adapter.lower( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.findMany, + where: {'AND': 'bad', 'OR': 1, 'NOT': true}, + ), + ); + expect( + invalidOperandStatement.text, + 'SELECT * FROM "users" WHERE 1 = 0 AND 1 = 0 AND 1 = 0', + ); + expect(invalidOperandStatement.parameters, isEmpty); + }); + test('lowers to-many relation where using EXISTS predicates', () { final contract = buildRelationalContract(); final adapter = SqlAdapter(contract: contract); From f1f03af1f7c3a8aba2b0e2ab526dacef3e359b93 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:09:47 +0800 Subject: [PATCH 059/154] feat(client): add typed distinct support across query runtime and generator --- pub/orm/lib/src/client/client.dart | 129 +++++++++++++++++++--- pub/orm/lib/src/generator/writer.dart | 89 +++++++++++++++ pub/orm/lib/src/runtime/core.dart | 1 + pub/orm/lib/src/runtime/plan.dart | 3 + pub/orm/test/client/client_test.dart | 54 +++++++++ pub/orm/test/generator/generate_test.dart | 45 ++++++++ 6 files changed, 308 insertions(+), 13 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 84fe596f..a4a577fb 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -329,6 +329,11 @@ class ModelDelegate { ModelQuery selectField(String field) => query().selectField(field); + ModelQuery distinct(List fields, {bool append = false}) => + query().distinct(fields, append: append); + + ModelQuery distinctField(String field) => query().distinctField(field); + ModelQuery include(Map include) => query().include(include); @@ -342,6 +347,7 @@ class ModelDelegate { int? skip, int? take, List orderBy = const [], + List distinct = const [], List select = const [], Map include = const {}, }) { @@ -351,6 +357,7 @@ class ModelDelegate { skip: skip, take: take, orderBy: orderBy, + distinct: distinct, select: select, include: include, includeDepth: 0, @@ -362,6 +369,7 @@ class ModelDelegate { int? skip, int? take, List orderBy = const [], + List distinct = const [], List select = const [], Map include = const {}, }) async* { @@ -370,6 +378,7 @@ class ModelDelegate { skip: skip, take: take, orderBy: orderBy, + distinct: distinct, select: select, include: include, ); @@ -397,6 +406,7 @@ class ModelDelegate { JsonMap where = const {}, int? skip, List orderBy = const [], + List distinct = const [], List select = const [], Map include = const {}, }) async { @@ -406,6 +416,7 @@ class ModelDelegate { skip: skip, take: 1, orderBy: orderBy, + distinct: distinct, select: select, include: include, includeDepth: 0, @@ -617,10 +628,18 @@ class ModelDelegate { int? skip, int? take, List orderBy = const [], + List distinct = const [], List select = const [], Map include = const {}, required int includeDepth, }) async { + if (skip case final offset? when offset < 0) { + throw PlanInvalidPaginationException(key: 'skip', value: offset); + } + if (take case final limit? when limit < 0) { + throw PlanInvalidPaginationException(key: 'take', value: limit); + } + final normalizedInclude = _normalizeInclude(include); final normalizedWhere = await _normalizeWhereForExecution( model: modelName, @@ -635,18 +654,24 @@ class ModelDelegate { model: modelName, action: OrmAction.findMany, where: normalizedWhere, - skip: skip, - take: take, + skip: distinct.isEmpty ? skip : null, + take: distinct.isEmpty ? take : null, orderBy: orderBy, - select: _expandSelectForInclude( + distinct: distinct, + select: _expandSelectForExecution( model: modelName, select: select, include: normalizedInclude, + distinct: distinct, ), ), ); - final rows = _readRows(response.data); + var rows = _readRows(response.data); + if (distinct.isNotEmpty) { + rows = _applyDistinctRows(rows: rows, distinct: distinct); + rows = _sliceRows(rows: rows, skip: skip, take: take); + } final hydratedRows = await _resolveIncludeRows( action: action, rows: rows, @@ -1270,27 +1295,47 @@ class ModelDelegate { return List.from(window, growable: false); } - List _expandSelectForInclude({ + List _expandSelectForExecution({ required String model, required List select, required Map include, + required List distinct, }) { - if (select.isEmpty || include.isEmpty) { + if (select.isEmpty) { return select; } - final expanded = {...select}; - for (final relationName in include.keys) { - final relation = _resolveRelation( - model: model, - relationName: relationName, - ); - expanded.addAll(relation.sourceFields); + if (include.isEmpty && distinct.isEmpty) { + return select; + } + + final expanded = {...select, ...distinct}; + if (include.isNotEmpty) { + for (final relationName in include.keys) { + final relation = _resolveRelation( + model: model, + relationName: relationName, + ); + expanded.addAll(relation.sourceFields); + } } return expanded.toList(growable: false); } + List _expandSelectForInclude({ + required String model, + required List select, + required Map include, + }) { + return _expandSelectForExecution( + model: model, + select: select, + include: include, + distinct: const [], + ); + } + List _expandSelectForNestedCreate({ required String model, required List select, @@ -1362,6 +1407,29 @@ class ModelDelegate { return next; } + List _applyDistinctRows({ + required List rows, + required List distinct, + }) { + if (rows.isEmpty || distinct.isEmpty) { + return rows; + } + + final seen = <_RelationMergeKey>{}; + final deduplicated = []; + for (final row in rows) { + final key = _RelationMergeKey( + distinct + .map((field) => row.containsKey(field) ? row[field] : null) + .toList(growable: false), + ); + if (seen.add(key)) { + deduplicated.add(row); + } + } + return deduplicated; + } + JsonMap? _fallbackCreateRow({required JsonMap data}) { if (_client.contract.capabilities.mutationReturning) { return null; @@ -1806,6 +1874,7 @@ final class ModelQueryState { final int? skip; final int? take; final List orderBy; + final List distinct; final List select; final Map include; @@ -1814,6 +1883,7 @@ final class ModelQueryState { this.skip, this.take, this.orderBy = const [], + this.distinct = const [], this.select = const [], this.include = const {}, }); @@ -1834,6 +1904,8 @@ final class ModelQuery { List get orderByValues => _state.orderBy; + List get distinctValues => _state.distinct; + List get selectedFields => _state.select; Map get includeValues => _state.include; @@ -1848,6 +1920,7 @@ final class ModelQuery { skip: _state.skip, take: _state.take, orderBy: _state.orderBy, + distinct: _state.distinct, select: _state.select, include: _state.include, ), @@ -1864,6 +1937,7 @@ final class ModelQuery { skip: _state.skip, take: _state.take, orderBy: nextOrderBy, + distinct: _state.distinct, select: _state.select, include: _state.include, ), @@ -1874,6 +1948,27 @@ final class ModelQuery { return orderBy([OrmOrderBy(field, order: order)]); } + ModelQuery distinct(List fields, {bool append = false}) { + final nextDistinct = append + ? [..._state.distinct, ...fields] + : [...fields]; + return _next( + ModelQueryState( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + distinct: nextDistinct, + select: _state.select, + include: _state.include, + ), + ); + } + + ModelQuery distinctField(String field) { + return distinct([field], append: true); + } + ModelQuery select(List fields, {bool append = false}) { final nextSelect = append ? [..._state.select, ...fields] @@ -1884,6 +1979,7 @@ final class ModelQuery { skip: _state.skip, take: _state.take, orderBy: _state.orderBy, + distinct: _state.distinct, select: nextSelect, include: _state.include, ), @@ -1905,6 +2001,7 @@ final class ModelQuery { skip: _state.skip, take: _state.take, orderBy: _state.orderBy, + distinct: _state.distinct, select: _state.select, include: nextInclude, ), @@ -1925,6 +2022,7 @@ final class ModelQuery { skip: value, take: _state.take, orderBy: _state.orderBy, + distinct: _state.distinct, select: _state.select, include: _state.include, ), @@ -1938,6 +2036,7 @@ final class ModelQuery { skip: _state.skip, take: value, orderBy: _state.orderBy, + distinct: _state.distinct, select: _state.select, include: _state.include, ), @@ -1951,6 +2050,7 @@ final class ModelQuery { skip: _state.skip, take: null, orderBy: _state.orderBy, + distinct: _state.distinct, select: _state.select, include: _state.include, ), @@ -1963,6 +2063,7 @@ final class ModelQuery { skip: _state.skip, take: _state.take, orderBy: _state.orderBy, + distinct: _state.distinct, select: _state.select, include: _state.include, ); @@ -1974,6 +2075,7 @@ final class ModelQuery { skip: _state.skip, take: _state.take, orderBy: _state.orderBy, + distinct: _state.distinct, select: _state.select, include: _state.include, ); @@ -1989,6 +2091,7 @@ final class ModelQuery { where: _state.where, skip: _state.skip, orderBy: _state.orderBy, + distinct: _state.distinct, select: _state.select, include: _state.include, ); diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index f43078a2..2bafe91c 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -562,6 +562,38 @@ final class TypedClientWriter { .where((field) => field.isRelation) .toList(growable: false); + buffer.writeln('class ${model.distinctClassName} {'); + buffer.writeln(' final String value;'); + buffer.writeln(); + buffer.writeln(' const ${model.distinctClassName}._(this.value);'); + if (scalarFields.isNotEmpty) { + for (final field in scalarFields) { + final memberName = _toLowerCamelIdentifier( + field.name, + fallback: 'field', + ); + buffer.writeln( + " static const ${model.distinctClassName} $memberName = ${model.distinctClassName}._('${_escapeString(field.name)}');", + ); + } + final fieldEntries = scalarFields + .map( + (field) => _toLowerCamelIdentifier(field.name, fallback: 'field'), + ) + .join(', '); + buffer.writeln(); + buffer.writeln( + ' static const List<${model.distinctClassName}> values = <${model.distinctClassName}>[$fieldEntries];', + ); + } else { + buffer.writeln(); + buffer.writeln( + ' static const List<${model.distinctClassName}> values = <${model.distinctClassName}>[];', + ); + } + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('class ${model.orderByClassName} {'); buffer.writeln(' final OrmOrderBy value;'); buffer.writeln(); @@ -743,6 +775,9 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) {'); @@ -752,6 +787,7 @@ final class TypedClientWriter { buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); buffer.writeln(' select: select,'); buffer.writeln(' include: include,'); buffer.writeln(' );'); @@ -767,12 +803,18 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) async {'); buffer.writeln( ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', ); + buffer.writeln( + ' final runtimeDistinct = distinct.map((entry) => entry.value).toList(growable: false);', + ); buffer.writeln( ' final runtimeSelect = select?.toFields() ?? const [];', ); @@ -784,6 +826,7 @@ final class TypedClientWriter { buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); buffer.writeln(' orderBy: runtimeOrderBy,'); + buffer.writeln(' distinct: runtimeDistinct,'); buffer.writeln(' select: runtimeSelect,'); buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); @@ -824,12 +867,18 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) async {'); buffer.writeln( ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', ); + buffer.writeln( + ' final runtimeDistinct = distinct.map((entry) => entry.value).toList(growable: false);', + ); buffer.writeln( ' final runtimeSelect = select?.toFields() ?? const [];', ); @@ -840,6 +889,7 @@ final class TypedClientWriter { buffer.writeln(' where: where.toJson(),'); buffer.writeln(' skip: skip,'); buffer.writeln(' orderBy: runtimeOrderBy,'); + buffer.writeln(' distinct: runtimeDistinct,'); buffer.writeln(' select: runtimeSelect,'); buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' );'); @@ -1002,12 +1052,18 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) async* {'); buffer.writeln( ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', ); + buffer.writeln( + ' final runtimeDistinct = distinct.map((entry) => entry.value).toList(growable: false);', + ); buffer.writeln( ' final runtimeSelect = select?.toFields() ?? const [];', ); @@ -1019,6 +1075,7 @@ final class TypedClientWriter { buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); buffer.writeln(' orderBy: runtimeOrderBy,'); + buffer.writeln(' distinct: runtimeDistinct,'); buffer.writeln(' select: runtimeSelect,'); buffer.writeln(' include: runtimeInclude,'); buffer.writeln(' )) {'); @@ -1041,6 +1098,7 @@ final class TypedClientWriter { buffer.writeln(' final int? _skip;'); buffer.writeln(' final int? _take;'); buffer.writeln(' final List<${model.orderByClassName}> _orderBy;'); + buffer.writeln(' final List<${model.distinctClassName}> _distinct;'); buffer.writeln(' final ${model.selectClassName}? _select;'); buffer.writeln(' final ${model.includeClassName}? _include;'); buffer.writeln(); @@ -1050,6 +1108,7 @@ final class TypedClientWriter { buffer.writeln(' required int? skip,'); buffer.writeln(' required int? take,'); buffer.writeln(' required List<${model.orderByClassName}> orderBy,'); + buffer.writeln(' required List<${model.distinctClassName}> distinct,'); buffer.writeln(' required ${model.selectClassName}? select,'); buffer.writeln(' required ${model.includeClassName}? include,'); buffer.writeln(' }) : _delegate = delegate,'); @@ -1059,6 +1118,9 @@ final class TypedClientWriter { buffer.writeln( ' _orderBy = List<${model.orderByClassName}>.unmodifiable(orderBy),', ); + buffer.writeln( + ' _distinct = List<${model.distinctClassName}>.unmodifiable(distinct),', + ); buffer.writeln(' _select = select,'); buffer.writeln(' _include = include;'); buffer.writeln(); @@ -1072,6 +1134,7 @@ final class TypedClientWriter { buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -1085,6 +1148,7 @@ final class TypedClientWriter { buffer.writeln(' skip: skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -1098,6 +1162,7 @@ final class TypedClientWriter { buffer.writeln(' skip: _skip,'); buffer.writeln(' take: take,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -1113,6 +1178,23 @@ final class TypedClientWriter { buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: _distinct,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} distinct(List<${model.distinctClassName}> distinct) {', + ); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -1128,6 +1210,7 @@ final class TypedClientWriter { buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -1143,6 +1226,7 @@ final class TypedClientWriter { buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: include,'); buffer.writeln(' );'); @@ -1155,6 +1239,7 @@ final class TypedClientWriter { buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -1166,6 +1251,7 @@ final class TypedClientWriter { buffer.writeln(' where: _where,'); buffer.writeln(' skip: _skip,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -1178,6 +1264,7 @@ final class TypedClientWriter { buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -2133,6 +2220,8 @@ final class _ResolvedModel { String get queryClassName => '${classBaseName}Query'; + String get distinctClassName => '${classBaseName}Distinct'; + String get dataClassName => '${classBaseName}Data'; String get whereInputClassName => '${classBaseName}WhereInput'; diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index d112119d..0be16ac3 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -352,6 +352,7 @@ final class OrmRuntimeCore implements RuntimeCore { fields: plan.orderBy.map((entry) => entry.field), source: 'orderBy', ); + _assertKnownFields(model: model, fields: plan.distinct, source: 'distinct'); _assertKnownFields(model: model, fields: plan.select, source: 'select'); if (plan.skip case final skip? when skip < 0) { diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index adb3a4a9..170089c7 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -26,6 +26,7 @@ final class OrmPlan { final int? skip; final int? take; final List orderBy; + final List distinct; final List select; OrmPlan({ @@ -40,9 +41,11 @@ final class OrmPlan { this.skip, this.take, List orderBy = const [], + List distinct = const [], List select = const [], }) : where = Map.unmodifiable(where), data = Map.unmodifiable(data), orderBy = List.unmodifiable(orderBy), + distinct = List.unmodifiable(distinct), select = List.unmodifiable(select); } diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 67f99537..83df876c 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -230,6 +230,56 @@ void main() { await client.disconnect(); }); + test( + 'supports distinct with order and pagination in memory engine', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 'u2', 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 'u3', 'email': 'b@x.com'}, + ); + + final distinctRows = await users.findMany( + orderBy: const [OrmOrderBy('id')], + distinct: const ['email'], + ); + expect( + distinctRows.map((row) => row['id']).toList(growable: false), + ['u1', 'u3'], + ); + + final pagedDistinctRows = await users.findMany( + orderBy: const [OrmOrderBy('id')], + distinct: const ['email'], + skip: 1, + take: 1, + ); + expect( + pagedDistinctRows.map((row) => row['id']).toList(growable: false), + ['u3'], + ); + + final distinctFromQuery = await users + .query() + .orderByField('id') + .distinctField('email') + .findMany(); + expect( + distinctFromQuery.map((row) => row['id']).toList(growable: false), + ['u1', 'u3'], + ); + await client.disconnect(); + }, + ); + test('supports where operators gt/in/notIn in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -2042,6 +2092,10 @@ void main() { client.model('User').findMany(select: const ['age']), throwsA(isA()), ); + await expectLater( + client.model('User').findMany(distinct: const ['age']), + throwsA(isA()), + ); await client.disconnect(); }); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index cc0dafcd..8cdecdc3 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -261,6 +261,51 @@ void main() { isTrue, reason: 'Expected non-unique findMany to keep UserWhereInput.', ); + expect( + RegExp( + r'Future>\s+findMany\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected findMany to expose typed distinct parameter in generated delegate.', + ); + expect( + RegExp( + r'Future\s+findFirst\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected findFirst to expose typed distinct parameter in generated delegate.', + ); + expect( + RegExp( + r'Stream\s+stream\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected stream to expose typed distinct parameter in generated delegate.', + ); + expect( + RegExp(r'\bclass UserDistinct\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed distinct DSL class in generated source.', + ); + expect( + RegExp( + r'class\s+UserDistinct\s*\{[\s\S]*?static\s+const\s+UserDistinct\s+id\s*=\s*UserDistinct\._\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDistinct class to expose static scalar field members.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+distinct\(List\s+distinct\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.distinct(...) to support typed distinct chaining.', + ); expect( RegExp( r'Future>\s+createMany\(\{\s*required\s+List\s+data,', From d475adfcfa3b687309770bf33a8cc137c25c9aaa Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:19:35 +0800 Subject: [PATCH 060/154] feat(client): add aggregate and groupBy query surfaces --- pub/orm/lib/src/client/client.dart | 394 +++++++++++++++++++++- pub/orm/lib/src/generator/writer.dart | 166 +++++++++ pub/orm/test/client/client_test.dart | 57 ++++ pub/orm/test/generator/generate_test.dart | 16 + 4 files changed, 632 insertions(+), 1 deletion(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index a4a577fb..29fb60fa 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -438,6 +438,147 @@ class ModelDelegate { return row != null; } + Future aggregate({ + JsonMap where = const {}, + bool countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) async { + _assertKnownAggregateFields(fields: count, source: 'aggregate.count'); + _assertKnownAggregateFields(fields: min, source: 'aggregate.min'); + _assertKnownAggregateFields(fields: max, source: 'aggregate.max'); + _assertKnownAggregateFields(fields: sum, source: 'aggregate.sum'); + _assertKnownAggregateFields(fields: avg, source: 'aggregate.avg'); + + final rows = await _findManyInternal( + action: OrmAction.findMany, + where: where, + select: _buildAggregateSelect( + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ), + includeDepth: 0, + ); + + return _buildAggregateResult( + rows: rows, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + } + + Future> groupBy({ + required List by, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + bool countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) async { + if (by.isEmpty) { + throw runtimeError( + 'PLAN.GROUP_BY_FIELDS_EMPTY', + 'GroupBy requires at least one field in by.', + details: {'model': modelName}, + ); + } + + _assertKnownAggregateFields(fields: by, source: 'groupBy.by'); + _assertKnownAggregateFields(fields: count, source: 'groupBy.count'); + _assertKnownAggregateFields(fields: min, source: 'groupBy.min'); + _assertKnownAggregateFields(fields: max, source: 'groupBy.max'); + _assertKnownAggregateFields(fields: sum, source: 'groupBy.sum'); + _assertKnownAggregateFields(fields: avg, source: 'groupBy.avg'); + + final bySet = by.toSet(); + for (final clause in orderBy) { + if (bySet.contains(clause.field)) { + continue; + } + throw runtimeError( + 'PLAN.GROUP_BY_ORDER_BY_FIELD_INVALID', + 'GroupBy orderBy fields must be included in by.', + details: { + 'model': modelName, + 'field': clause.field, + 'by': by.toList(growable: false), + }, + ); + } + + final rows = await _findManyInternal( + action: OrmAction.findMany, + where: where, + select: _buildAggregateSelect( + count: by.followedBy(count).toList(growable: false), + min: min, + max: max, + sum: sum, + avg: avg, + ), + includeDepth: 0, + ); + + final groupedRows = <_RelationMergeKey, List>{}; + for (final row in rows) { + final key = _RelationMergeKey( + by + .map((field) => row.containsKey(field) ? row[field] : null) + .toList(growable: false), + ); + groupedRows.putIfAbsent(key, () => []).add(row); + } + + final results = []; + for (final entry in groupedRows.entries) { + final groupRows = entry.value; + if (groupRows.isEmpty) { + continue; + } + + final groupResult = {}; + final first = groupRows.first; + for (final field in by) { + groupResult[field] = first[field]; + } + groupResult.addAll( + _buildAggregateResult( + rows: groupRows, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ), + ); + results.add(groupResult); + } + + if (orderBy.isNotEmpty) { + results.sort( + (left, right) => _compareRowsForOrderBy(left, right, orderBy), + ); + } + + return _sliceRows(rows: results, skip: skip, take: take); + } + Future create({ required JsonMap data, List select = const [], @@ -889,7 +1030,7 @@ class ModelDelegate { final normalizedCreate = _normalizeNestedCreate(create); final normalizedInclude = _normalizeInclude(include); - final updated = await this.update( + final updated = await update( where: where, data: data, select: _expandSelectForNestedCreate( @@ -1336,6 +1477,174 @@ class ModelDelegate { ); } + void _assertKnownAggregateFields({ + required List fields, + required String source, + }) { + if (fields.isEmpty) { + return; + } + + final model = _client.contract.models[modelName]; + if (model == null) { + throw ModelNotFoundException(modelName, _client.contract.models.keys); + } + + for (final field in fields) { + if (model.fields.contains(field)) { + continue; + } + throw PlanFieldNotFoundException( + model: modelName, + field: field, + source: source, + ); + } + } + + List _buildAggregateSelect({ + required List count, + required List min, + required List max, + required List sum, + required List avg, + }) { + final fields = {...count, ...min, ...max, ...sum, ...avg}; + if (fields.isEmpty) { + return const []; + } + return fields.toList(growable: false); + } + + JsonMap _buildAggregateResult({ + required List rows, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, + }) { + final result = {}; + + if (countAll || count.isNotEmpty) { + final countResult = {}; + if (countAll) { + countResult['all'] = rows.length; + } + for (final field in count) { + countResult[field] = rows.where((row) => row[field] != null).length; + } + result['count'] = countResult; + } + + if (min.isNotEmpty) { + final minResult = {}; + for (final field in min) { + minResult[field] = _aggregateMin(rows: rows, field: field); + } + result['min'] = minResult; + } + + if (max.isNotEmpty) { + final maxResult = {}; + for (final field in max) { + maxResult[field] = _aggregateMax(rows: rows, field: field); + } + result['max'] = maxResult; + } + + if (sum.isNotEmpty) { + final sumResult = {}; + for (final field in sum) { + sumResult[field] = _aggregateSum(rows: rows, field: field); + } + result['sum'] = sumResult; + } + + if (avg.isNotEmpty) { + final avgResult = {}; + for (final field in avg) { + avgResult[field] = _aggregateAvg(rows: rows, field: field); + } + result['avg'] = avgResult; + } + + return result; + } + + Object? _aggregateMin({required List rows, required String field}) { + Object? current; + for (final row in rows) { + final value = row[field]; + if (value == null) { + continue; + } + if (current == null || + _compareAggregateValues(left: value, right: current) < 0) { + current = value; + } + } + return current; + } + + Object? _aggregateMax({required List rows, required String field}) { + Object? current; + for (final row in rows) { + final value = row[field]; + if (value == null) { + continue; + } + if (current == null || + _compareAggregateValues(left: value, right: current) > 0) { + current = value; + } + } + return current; + } + + num? _aggregateSum({required List rows, required String field}) { + num? sum; + for (final row in rows) { + final value = row[field]; + if (value is! num) { + continue; + } + sum = (sum ?? 0) + value; + } + return sum; + } + + double? _aggregateAvg({required List rows, required String field}) { + var count = 0; + var sum = 0.0; + for (final row in rows) { + final value = row[field]; + if (value is! num) { + continue; + } + sum += value.toDouble(); + count += 1; + } + if (count == 0) { + return null; + } + return sum / count; + } + + int _compareAggregateValues({required Object left, required Object right}) { + if (left is num && right is num) { + return left.compareTo(right); + } + if (left is DateTime && right is DateTime) { + return left.compareTo(right); + } + if (left is Comparable && left.runtimeType == right.runtimeType) { + return left.compareTo(right); + } + return left.toString().compareTo(right.toString()); + } + List _expandSelectForNestedCreate({ required String model, required List select, @@ -1430,6 +1739,46 @@ class ModelDelegate { return deduplicated; } + int _compareRowsForOrderBy( + JsonMap left, + JsonMap right, + List orderBy, + ) { + for (final clause in orderBy) { + final compared = _compareOrderByValues( + left[clause.field], + right[clause.field], + ); + if (compared == 0) { + continue; + } + return clause.order == SortOrder.desc ? -compared : compared; + } + return 0; + } + + int _compareOrderByValues(Object? left, Object? right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + if (left is num && right is num) { + return left.compareTo(right); + } + if (left is DateTime && right is DateTime) { + return left.compareTo(right); + } + if (left is Comparable && left.runtimeType == right.runtimeType) { + return left.compareTo(right); + } + return left.toString().compareTo(right.toString()); + } + JsonMap? _fallbackCreateRow({required JsonMap data}) { if (_client.contract.capabilities.mutationReturning) { return null; @@ -2100,6 +2449,49 @@ final class ModelQuery { Future exists() => _delegate.exists(where: _state.where); + Future aggregate({ + bool countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) { + return _delegate.aggregate( + where: _state.where, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + } + + Future> groupBy({ + required List by, + bool countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) { + return _delegate.groupBy( + by: by, + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + } + Future create({required JsonMap data}) { return _delegate.create( data: data, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 2bafe91c..7d586ef3 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1043,6 +1043,107 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future> aggregate({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' bool countAll = false,'); + buffer.writeln( + ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return _delegate.aggregate('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' countAll: countAll,'); + buffer.writeln( + ' count: count.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' min: min.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' max: max.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' sum: sum.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' avg: avg.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future>> groupBy({'); + buffer.writeln(' required List<${model.distinctClassName}> by,'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln( + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', + ); + buffer.writeln(' bool countAll = false,'); + buffer.writeln( + ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' }) {'); + buffer.writeln( + ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln(' return _delegate.groupBy('); + buffer.writeln( + ' by: by.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: runtimeOrderBy,'); + buffer.writeln(' countAll: countAll,'); + buffer.writeln( + ' count: count.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' min: min.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' max: max.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' sum: sum.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' avg: avg.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Stream<${model.dataClassName}> stream({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -1271,6 +1372,71 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future> aggregate({'); + buffer.writeln(' bool countAll = false,'); + buffer.writeln( + ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return _delegate.aggregate('); + buffer.writeln(' where: _where,'); + buffer.writeln(' countAll: countAll,'); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future>> groupBy({'); + buffer.writeln(' required List<${model.distinctClassName}> by,'); + buffer.writeln(' bool countAll = false,'); + buffer.writeln( + ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return _delegate.groupBy('); + buffer.writeln(' by: by,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' countAll: countAll,'); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( ' Future> createMany({required List<${model.createInputClassName}> data}) {', ); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 83df876c..60952469 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -280,6 +280,63 @@ void main() { }, ); + test('supports aggregate helpers in memory engine', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': null}); + await users.create(data: {'id': 3, 'email': 'b@x.com'}); + + final aggregate = await users.aggregate( + countAll: true, + count: const ['email'], + min: const ['id'], + max: const ['id'], + sum: const ['id'], + avg: const ['id'], + ); + + expect(aggregate['count'], {'all': 3, 'email': 2}); + expect(aggregate['min'], {'id': 1}); + expect(aggregate['max'], {'id': 3}); + expect(aggregate['sum'], {'id': 6}); + expect(aggregate['avg'], {'id': 2.0}); + await client.disconnect(); + }); + + test('supports groupBy helpers in memory engine', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': 'a@x.com'}); + await users.create(data: {'id': 4, 'email': 'b@x.com'}); + + final grouped = await users + .query() + .orderByField('email') + .groupBy( + by: const ['email'], + countAll: true, + sum: const ['id'], + avg: const ['id'], + ); + + expect(grouped, hasLength(2)); + expect(grouped.first['email'], 'a@x.com'); + expect(grouped.first['count'], {'all': 2}); + expect(grouped.first['sum'], {'id': 3}); + expect(grouped.first['avg'], {'id': 1.5}); + expect(grouped.last['email'], 'b@x.com'); + expect(grouped.last['count'], {'all': 1}); + expect(grouped.last['sum'], {'id': 4}); + expect(grouped.last['avg'], {'id': 4.0}); + await client.disconnect(); + }); + test('supports where operators gt/in/notIn in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 8cdecdc3..0106ed61 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -306,6 +306,22 @@ void main() { reason: 'Expected UserQuery.distinct(...) to support typed distinct chaining.', ); + expect( + RegExp( + r'Future>\s+aggregate\(\{', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate/query to expose aggregate helper.', + ); + expect( + RegExp( + r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate/query to expose typed groupBy helper.', + ); expect( RegExp( r'Future>\s+createMany\(\{\s*required\s+List\s+data,', From 5b228c66425cecbd150f766fc29fec4a565b00b8 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:49:02 +0800 Subject: [PATCH 061/154] feat(client): add groupBy having and aggregate alias ordering --- pub/orm/lib/src/client/client.dart | 681 ++++++++++++++++++++-- pub/orm/lib/src/generator/writer.dart | 8 + pub/orm/test/client/client_test.dart | 105 ++++ pub/orm/test/generator/generate_test.dart | 23 + 4 files changed, 781 insertions(+), 36 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 29fb60fa..7b90c997 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -28,6 +28,46 @@ typedef IncludeExecutionStrategySelector = const int _defaultMaxIncludeDepth = 4; const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; +const List _filterOperatorOrder = [ + 'equals', + 'not', + 'in', + 'notIn', + 'contains', + 'startsWith', + 'endsWith', + 'gt', + 'gte', + 'lt', + 'lte', +]; +const Set _filterOperators = { + 'equals', + 'not', + 'in', + 'notIn', + 'contains', + 'startsWith', + 'endsWith', + 'gt', + 'gte', + 'lt', + 'lte', +}; +const Set _groupByAggregateBuckets = { + 'count', + 'min', + 'max', + 'sum', + 'avg', +}; +const Map _groupByAggregateBucketAliases = { + '_count': 'count', + '_min': 'min', + '_max': 'max', + '_sum': 'sum', + '_avg': 'avg', +}; const Set _toManyRelationWhereOperators = { 'some', 'every', @@ -480,6 +520,7 @@ class ModelDelegate { Future> groupBy({ required List by, JsonMap where = const {}, + JsonMap having = const {}, int? skip, int? take, List orderBy = const [], @@ -497,6 +538,12 @@ class ModelDelegate { details: {'model': modelName}, ); } + if (skip case final offset? when offset < 0) { + throw PlanInvalidPaginationException(key: 'skip', value: offset); + } + if (take case final limit? when limit < 0) { + throw PlanInvalidPaginationException(key: 'take', value: limit); + } _assertKnownAggregateFields(fields: by, source: 'groupBy.by'); _assertKnownAggregateFields(fields: count, source: 'groupBy.count'); @@ -504,22 +551,26 @@ class ModelDelegate { _assertKnownAggregateFields(fields: max, source: 'groupBy.max'); _assertKnownAggregateFields(fields: sum, source: 'groupBy.sum'); _assertKnownAggregateFields(fields: avg, source: 'groupBy.avg'); - - final bySet = by.toSet(); - for (final clause in orderBy) { - if (bySet.contains(clause.field)) { - continue; - } - throw runtimeError( - 'PLAN.GROUP_BY_ORDER_BY_FIELD_INVALID', - 'GroupBy orderBy fields must be included in by.', - details: { - 'model': modelName, - 'field': clause.field, - 'by': by.toList(growable: false), - }, - ); - } + _assertGroupByOrderByFields( + orderBy: orderBy, + by: by, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + _assertGroupByHavingFields( + having: having, + by: by, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); final rows = await _findManyInternal( action: OrmAction.findMany, @@ -544,7 +595,7 @@ class ModelDelegate { groupedRows.putIfAbsent(key, () => []).add(row); } - final results = []; + var results = []; for (final entry in groupedRows.entries) { final groupRows = entry.value; if (groupRows.isEmpty) { @@ -570,9 +621,19 @@ class ModelDelegate { results.add(groupResult); } + if (having.isNotEmpty) { + results = results + .where((row) => _matchesGroupByHaving(row: row, having: having)) + .toList(growable: false); + } + if (orderBy.isNotEmpty) { results.sort( - (left, right) => _compareRowsForOrderBy(left, right, orderBy), + (left, right) => _compareRowsForGroupByOrderBy( + left: left, + right: right, + orderBy: orderBy, + ), ); } @@ -1502,6 +1563,570 @@ class ModelDelegate { } } + void _assertGroupByOrderByFields({ + required List orderBy, + required List by, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, + }) { + if (orderBy.isEmpty) { + return; + } + + final allowedFields = _groupByOrderableFields( + by: by, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + for (final clause in orderBy) { + if (allowedFields.contains(clause.field)) { + continue; + } + throw runtimeError( + 'PLAN.GROUP_BY_ORDER_BY_INVALID', + 'GroupBy orderBy field is not available in grouped results.', + details: { + 'model': modelName, + 'field': clause.field, + 'allowedFields': allowedFields.toList(growable: false), + }, + ); + } + } + + void _assertGroupByHavingFields({ + required JsonMap having, + required List by, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, + }) { + if (having.isEmpty) { + return; + } + _assertGroupByHavingClause( + clause: having, + source: 'groupBy.having', + by: by, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + } + + void _assertGroupByHavingClause({ + required JsonMap clause, + required String source, + required List by, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, + }) { + for (final entry in clause.entries) { + final key = entry.key; + final value = entry.value; + if (_whereLogicalKeys.contains(key)) { + final nestedMap = _coerceWhereMap(value); + if (nestedMap != null) { + _assertGroupByHavingClause( + clause: nestedMap, + source: '$source.$key', + by: by, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + continue; + } + + final nestedList = _coerceWhereList(value); + if (nestedList == null) { + throw runtimeError( + 'PLAN.GROUP_BY_HAVING_INVALID', + 'GroupBy having logical operator expects a map or list of maps.', + details: { + 'model': modelName, + 'source': '$source.$key', + }, + ); + } + for (var index = 0; index < nestedList.length; index++) { + _assertGroupByHavingClause( + clause: nestedList[index], + source: '$source.$key[$index]', + by: by, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + } + continue; + } + + if (by.contains(key)) { + _assertGroupByHavingCondition(condition: value, source: '$source.$key'); + continue; + } + + final aggregateBucket = _normalizeGroupByAggregateBucket(key); + if (aggregateBucket != null) { + final aggregateFilters = _coerceWhereMap(value); + if (aggregateFilters == null) { + throw runtimeError( + 'PLAN.GROUP_BY_HAVING_INVALID', + 'GroupBy having aggregate bucket expects a map.', + details: { + 'model': modelName, + 'source': '$source.$key', + 'bucket': key, + }, + ); + } + final allowedFields = _groupByAggregateBucketFields( + bucket: aggregateBucket, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + for (final aggregateEntry in aggregateFilters.entries) { + final aggregateField = aggregateEntry.key; + if (!allowedFields.contains(aggregateField)) { + throw runtimeError( + 'PLAN.GROUP_BY_HAVING_FIELD_INVALID', + 'GroupBy having references an aggregate field that is not selected.', + details: { + 'model': modelName, + 'source': '$source.$key.$aggregateField', + 'bucket': key, + 'field': aggregateField, + 'allowedFields': allowedFields.toList(growable: false), + }, + ); + } + _assertGroupByHavingCondition( + condition: aggregateEntry.value, + source: '$source.$key.$aggregateField', + ); + } + continue; + } + + throw runtimeError( + 'PLAN.GROUP_BY_HAVING_FIELD_INVALID', + 'GroupBy having field is not groupable or aggregated.', + details: { + 'model': modelName, + 'source': '$source.$key', + 'field': key, + 'allowedFields': [ + ...by, + ..._groupByAggregateBuckets, + ..._groupByAggregateBucketAliases.keys, + ..._whereLogicalKeys, + ], + }, + ); + } + } + + void _assertGroupByHavingCondition({ + required Object? condition, + required String source, + }) { + final conditionMap = _coerceWhereMap(condition); + if (conditionMap == null || conditionMap.isEmpty) { + return; + } + + final unknownOperators = conditionMap.keys + .where((operator) => !_filterOperators.contains(operator)) + .toList(growable: false); + if (unknownOperators.isNotEmpty) { + throw runtimeError( + 'PLAN.GROUP_BY_HAVING_OPERATOR_INVALID', + 'GroupBy having contains unknown filter operators.', + details: { + 'model': modelName, + 'source': source, + 'unknownOperators': unknownOperators, + 'supportedOperators': _filterOperators.toList(growable: false), + }, + ); + } + + for (final entry in conditionMap.entries) { + final operator = entry.key; + final operand = entry.value; + if ((operator == 'in' || operator == 'notIn') && operand is! List) { + throw runtimeError( + 'PLAN.GROUP_BY_HAVING_OPERATOR_INVALID', + 'GroupBy having in/notIn expects a list operand.', + details: { + 'model': modelName, + 'source': '$source.$operator', + 'operator': operator, + }, + ); + } + if (operator == 'not') { + _assertGroupByHavingCondition( + condition: operand, + source: '$source.$operator', + ); + } + } + } + + Set _groupByOrderableFields({ + required List by, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, + }) { + final fields = {...by}; + for (final bucket in _groupByAggregateBuckets) { + final bucketFields = _groupByAggregateBucketFields( + bucket: bucket, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + for (final field in bucketFields) { + fields.add('$bucket.$field'); + fields.addAll(_groupByAggregateBucketAliasFieldPaths(bucket, field)); + } + } + return fields; + } + + Set _groupByAggregateBucketFields({ + required String bucket, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, + }) { + return switch (bucket) { + 'count' => {if (countAll) 'all', ...count}, + 'min' => {...min}, + 'max' => {...max}, + 'sum' => {...sum}, + 'avg' => {...avg}, + _ => const {}, + }; + } + + String? _normalizeGroupByAggregateBucket(String bucket) { + if (_groupByAggregateBuckets.contains(bucket)) { + return bucket; + } + return _groupByAggregateBucketAliases[bucket]; + } + + Set _groupByAggregateBucketAliasFieldPaths( + String bucket, + String field, + ) { + final paths = {}; + for (final alias in _groupByAggregateBucketAliases.entries) { + if (alias.value != bucket) { + continue; + } + paths.add('${alias.key}.$field'); + } + return paths; + } + + bool _matchesGroupByHaving({required JsonMap row, required JsonMap having}) { + for (final entry in having.entries) { + final key = entry.key; + final value = entry.value; + + if (_whereLogicalKeys.contains(key)) { + final logicalMatches = _matchesGroupByHavingLogical( + row: row, + operator: key, + operand: value, + ); + if (!logicalMatches) { + return false; + } + continue; + } + + final aggregateBucket = _normalizeGroupByAggregateBucket(key); + if (aggregateBucket != null) { + final aggregateFilters = _coerceWhereMap(value); + if (aggregateFilters == null) { + return false; + } + for (final aggregateEntry in aggregateFilters.entries) { + final aggregateValue = _readGroupByAggregateValue( + row: row, + bucket: aggregateBucket, + field: aggregateEntry.key, + ); + if (!_matchesGroupByHavingCondition( + actual: aggregateValue, + condition: aggregateEntry.value, + )) { + return false; + } + } + continue; + } + + if (!_matchesGroupByHavingCondition(actual: row[key], condition: value)) { + return false; + } + } + + return true; + } + + bool _matchesGroupByHavingLogical({ + required JsonMap row, + required String operator, + required Object? operand, + }) { + final nestedMap = _coerceWhereMap(operand); + if (nestedMap != null) { + final matched = _matchesGroupByHaving(row: row, having: nestedMap); + return operator == 'NOT' ? !matched : matched; + } + + final nestedList = _coerceWhereList(operand); + if (nestedList == null) { + return false; + } + + return switch (operator) { + 'AND' => nestedList.every( + (clause) => _matchesGroupByHaving(row: row, having: clause), + ), + 'OR' => nestedList.any( + (clause) => _matchesGroupByHaving(row: row, having: clause), + ), + 'NOT' => nestedList.every( + (clause) => !_matchesGroupByHaving(row: row, having: clause), + ), + _ => false, + }; + } + + bool _matchesGroupByHavingCondition({ + required Object? actual, + required Object? condition, + }) { + final conditionMap = _coerceWhereMap(condition); + if (conditionMap == null || conditionMap.isEmpty) { + return actual == condition; + } + + if (conditionMap.keys.any( + (operator) => !_filterOperators.contains(operator), + )) { + return false; + } + + for (final operator in _filterOperatorOrder) { + if (!conditionMap.containsKey(operator)) { + continue; + } + final operand = conditionMap[operator]; + if (!_matchesGroupByHavingOperator( + actual: actual, + operator: operator, + operand: operand, + )) { + return false; + } + } + return true; + } + + bool _matchesGroupByHavingOperator({ + required Object? actual, + required String operator, + required Object? operand, + }) { + return switch (operator) { + 'equals' => actual == operand, + 'not' => + operand is Map + ? !_matchesGroupByHavingCondition( + actual: actual, + condition: operand, + ) + : actual != operand, + 'in' => _matchInList(actual: actual, operand: operand), + 'notIn' => _matchNotInList(actual: actual, operand: operand), + 'contains' => + actual is String && operand is String && actual.contains(operand), + 'startsWith' => + actual is String && operand is String && actual.startsWith(operand), + 'endsWith' => + actual is String && operand is String && actual.endsWith(operand), + 'gt' => _matchesGroupByHavingComparison( + actual: actual, + operand: operand, + predicate: (comparison) => comparison > 0, + ), + 'gte' => _matchesGroupByHavingComparison( + actual: actual, + operand: operand, + predicate: (comparison) => comparison >= 0, + ), + 'lt' => _matchesGroupByHavingComparison( + actual: actual, + operand: operand, + predicate: (comparison) => comparison < 0, + ), + 'lte' => _matchesGroupByHavingComparison( + actual: actual, + operand: operand, + predicate: (comparison) => comparison <= 0, + ), + _ => false, + }; + } + + bool _matchInList({required Object? actual, required Object? operand}) { + if (operand is! List) { + return false; + } + return List.from(operand).contains(actual); + } + + bool _matchNotInList({required Object? actual, required Object? operand}) { + if (operand is! List) { + return false; + } + return !List.from(operand).contains(actual); + } + + bool _matchesGroupByHavingComparison({ + required Object? actual, + required Object? operand, + required bool Function(int comparison) predicate, + }) { + final comparison = _compareGroupByHavingValues(actual, operand); + if (comparison == null) { + return false; + } + return predicate(comparison); + } + + int? _compareGroupByHavingValues(Object? left, Object? right) { + if (left == null || right == null) { + return null; + } + if (left is num && right is num) { + return left.compareTo(right); + } + if (left is String && right is String) { + return left.compareTo(right); + } + if (left is DateTime && right is DateTime) { + return left.compareTo(right); + } + if (left is bool && right is bool) { + final leftValue = left ? 1 : 0; + final rightValue = right ? 1 : 0; + return leftValue.compareTo(rightValue); + } + if (left is Comparable && left.runtimeType == right.runtimeType) { + return left.compareTo(right); + } + return null; + } + + Object? _readGroupByAggregateValue({ + required JsonMap row, + required String bucket, + required String field, + }) { + final bucketValue = row[bucket]; + if (bucketValue is! Map) { + return null; + } + return bucketValue[field]; + } + + Object? _readGroupByOrderByValue({ + required JsonMap row, + required String field, + }) { + if (row.containsKey(field)) { + return row[field]; + } + final fieldPath = field.split('.'); + if (fieldPath.length != 2) { + return row[field]; + } + final normalizedBucket = _normalizeGroupByAggregateBucket(fieldPath[0]); + if (normalizedBucket == null) { + return row[field]; + } + return _readGroupByAggregateValue( + row: row, + bucket: normalizedBucket, + field: fieldPath[1], + ); + } + + int _compareRowsForGroupByOrderBy({ + required JsonMap left, + required JsonMap right, + required List orderBy, + }) { + for (final clause in orderBy) { + final compared = _compareOrderByValues( + _readGroupByOrderByValue(row: left, field: clause.field), + _readGroupByOrderByValue(row: right, field: clause.field), + ); + if (compared == 0) { + continue; + } + return clause.order == SortOrder.desc ? -compared : compared; + } + return 0; + } + List _buildAggregateSelect({ required List count, required List min, @@ -1739,24 +2364,6 @@ class ModelDelegate { return deduplicated; } - int _compareRowsForOrderBy( - JsonMap left, - JsonMap right, - List orderBy, - ) { - for (final clause in orderBy) { - final compared = _compareOrderByValues( - left[clause.field], - right[clause.field], - ); - if (compared == 0) { - continue; - } - return clause.order == SortOrder.desc ? -compared : compared; - } - return 0; - } - int _compareOrderByValues(Object? left, Object? right) { if (left == null && right == null) { return 0; @@ -2470,6 +3077,7 @@ final class ModelQuery { Future> groupBy({ required List by, + JsonMap having = const {}, bool countAll = false, List count = const [], List min = const [], @@ -2480,6 +3088,7 @@ final class ModelQuery { return _delegate.groupBy( by: by, where: _state.where, + having: having, skip: _state.skip, take: _state.take, orderBy: _state.orderBy, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 7d586ef3..8331ff38 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -770,6 +770,9 @@ final class TypedClientWriter { buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); + buffer.writeln( + ' ${model.whereInputClassName} having = const ${model.whereInputClassName}(),', + ); buffer.writeln(' int? skip,'); buffer.writeln(' int? take,'); buffer.writeln( @@ -1121,6 +1124,7 @@ final class TypedClientWriter { ' by: by.map((entry) => entry.value).toList(growable: false),', ); buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' having: having.toJson(),'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); buffer.writeln(' orderBy: runtimeOrderBy,'); @@ -1404,6 +1408,9 @@ final class TypedClientWriter { buffer.writeln(' Future>> groupBy({'); buffer.writeln(' required List<${model.distinctClassName}> by,'); + buffer.writeln( + ' ${model.whereInputClassName} having = const ${model.whereInputClassName}(),', + ); buffer.writeln(' bool countAll = false,'); buffer.writeln( ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', @@ -1424,6 +1431,7 @@ final class TypedClientWriter { buffer.writeln(' return _delegate.groupBy('); buffer.writeln(' by: by,'); buffer.writeln(' where: _where,'); + buffer.writeln(' having: having.toJson(),'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 60952469..a4684611 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -337,6 +337,111 @@ void main() { await client.disconnect(); }); + test( + 'supports groupBy having filters and aggregate orderBy in memory engine', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 1, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 2, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 10, 'email': 'b@x.com'}, + ); + await users.create( + data: {'id': 20, 'email': 'b@x.com'}, + ); + await users.create( + data: {'id': 5, 'email': 'c@x.com'}, + ); + + final grouped = await users + .query() + .orderByField('_sum.id', order: SortOrder.desc) + .groupBy( + by: const ['email'], + having: { + '_count': { + 'all': {'gte': 2}, + }, + }, + countAll: true, + sum: const ['id'], + ); + + expect(grouped, hasLength(2)); + expect( + grouped.map((row) => row['email']).toList(growable: false), + ['b@x.com', 'a@x.com'], + ); + expect(grouped.first['count'], {'all': 2}); + expect(grouped.first['sum'], {'id': 30}); + expect(grouped.last['count'], {'all': 2}); + expect(grouped.last['sum'], {'id': 3}); + await client.disconnect(); + }, + ); + + test('rejects invalid groupBy aggregate orderBy fields', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + + await expectLater( + users + .query() + .orderByField('sum.email') + .groupBy( + by: const ['email'], + countAll: true, + sum: const ['id'], + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.GROUP_BY_ORDER_BY_INVALID', + ), + ), + ); + await client.disconnect(); + }); + + test('rejects invalid groupBy having aggregate fields', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + + await expectLater( + users.groupBy( + by: const ['email'], + having: { + '_sum': { + 'email': {'gte': 1}, + }, + }, + sum: const ['id'], + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.GROUP_BY_HAVING_FIELD_INVALID', + ), + ), + ); + await client.disconnect(); + }); + test('supports where operators gt/in/notIn in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 0106ed61..c63bd94a 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -322,6 +322,29 @@ void main() { reason: 'Expected generated delegate/query to expose typed groupBy helper.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?UserWhereInput\s+having\s*=\s*const\s+UserWhereInput\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserDelegate.groupBy(...) to expose typed having.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?having:\s*having\.toJson\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.groupBy(...) to serialize having to runtime JSON.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?UserWhereInput\s+having\s*=\s*const\s+UserWhereInput\(\),[\s\S]*?having:\s*having\.toJson\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.groupBy(...) to expose and forward typed having.', + ); expect( RegExp( r'Future>\s+createMany\(\{\s*required\s+List\s+data,', From 2125a792c716b09f31736cc16144c8b299638716 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:52:30 +0800 Subject: [PATCH 062/154] fix(generator): place groupBy having in the correct signatures --- pub/orm/lib/src/generator/writer.dart | 6 +++--- pub/orm/test/generator/generate_test.dart | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 8331ff38..2e1b856c 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -770,9 +770,6 @@ final class TypedClientWriter { buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); - buffer.writeln( - ' ${model.whereInputClassName} having = const ${model.whereInputClassName}(),', - ); buffer.writeln(' int? skip,'); buffer.writeln(' int? take,'); buffer.writeln( @@ -1094,6 +1091,9 @@ final class TypedClientWriter { buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); + buffer.writeln( + ' ${model.whereInputClassName} having = const ${model.whereInputClassName}(),', + ); buffer.writeln(' int? skip,'); buffer.writeln(' int? take,'); buffer.writeln( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index c63bd94a..b23df832 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -324,10 +324,11 @@ void main() { ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?UserWhereInput\s+having\s*=\s*const\s+UserWhereInput\(\),', + r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),\s*UserWhereInput\s+having\s*=\s*const\s+UserWhereInput\(\),', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserDelegate.groupBy(...) to expose typed having.', + reason: + 'Expected UserDelegate.groupBy(...) to expose where + typed having in signature.', ); expect( RegExp( @@ -345,6 +346,14 @@ void main() { reason: 'Expected UserQuery.groupBy(...) to expose and forward typed having.', ); + expect( + RegExp( + r'UserQuery\s+query\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),\s*int\?\s+skip,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.query(...) to keep only row-level where (no having parameter).', + ); expect( RegExp( r'Future>\s+createMany\(\{\s*required\s+List\s+data,', From f3aaeff4064e707fc6fa065cba4a9c64c80ad4e7 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:58:54 +0800 Subject: [PATCH 063/154] feat(generator): add typed groupBy having and orderBy helpers --- pub/orm/lib/src/generator/writer.dart | 320 +++++++++++++++++++++- pub/orm/test/generator/generate_test.dart | 36 ++- 2 files changed, 345 insertions(+), 11 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 2e1b856c..07ec7c9c 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -613,6 +613,292 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); + buffer.writeln('class ${model.groupByHavingConditionClassName} {'); + buffer.writeln(' final Object? value;'); + buffer.writeln(); + buffer.writeln( + ' const ${model.groupByHavingConditionClassName}._(this.value);', + ); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} equals(Object? value) {', + ); + buffer.writeln( + ' return ${model.groupByHavingConditionClassName}._(value);', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} notEquals(Object? value) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'not': value});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} not(${model.groupByHavingConditionClassName} condition) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'not': condition.toJsonValue()});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} inList(List values) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'in': values});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} notInList(List values) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'notIn': values});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} contains(String value) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'contains': value});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} startsWith(String value) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'startsWith': value});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} endsWith(String value) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'endsWith': value});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} gt(Object? value) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'gt': value});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} gte(Object? value) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'gte': value});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} lt(Object? value) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'lt': value});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} lte(Object? value) {', + ); + buffer.writeln( + " return ${model.groupByHavingConditionClassName}._({'lte': value});", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() => value;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class ${model.groupByHavingClassName} {'); + buffer.writeln(' final Map value;'); + buffer.writeln(); + buffer.writeln( + ' const ${model.groupByHavingClassName}() : value = const {};', + ); + buffer.writeln(); + buffer.writeln(' const ${model.groupByHavingClassName}._(this.value);'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} raw(Map value) {', + ); + buffer.writeln( + ' return ${model.groupByHavingClassName}._(Map.from(value));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} and(List<${model.groupByHavingClassName}> clauses) {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln(' {'); + buffer.writeln( + " 'AND': clauses.map((clause) => clause.toJson()).toList(growable: false),", + ); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} or(List<${model.groupByHavingClassName}> clauses) {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln(' {'); + buffer.writeln( + " 'OR': clauses.map((clause) => clause.toJson()).toList(growable: false),", + ); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} not(List<${model.groupByHavingClassName}> clauses) {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln(' {'); + buffer.writeln( + " 'NOT': clauses.map((clause) => clause.toJson()).toList(growable: false),", + ); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} by(' + '${model.distinctClassName} field, ' + '${model.groupByHavingConditionClassName} condition' + ') {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln( + ' {field.value: condition.toJsonValue()},', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} count(' + '${model.distinctClassName} field, ' + '${model.groupByHavingConditionClassName} condition' + ') {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln(' {'); + buffer.writeln( + " '_count': {field.value: condition.toJsonValue()},", + ); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} countAll(' + '${model.groupByHavingConditionClassName} condition' + ') {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln(' {'); + buffer.writeln( + " '_count': {'all': condition.toJsonValue()},", + ); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + for (final bucket in const ['min', 'max', 'sum', 'avg']) { + buffer.writeln( + ' static ${model.groupByHavingClassName} $bucket(' + '${model.distinctClassName} field, ' + '${model.groupByHavingConditionClassName} condition' + ') {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln(' {'); + buffer.writeln( + " '_$bucket': {field.value: condition.toJsonValue()},", + ); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + } + buffer.writeln( + ' ${model.groupByHavingClassName} merge(${model.groupByHavingClassName} other) {', + ); + buffer.writeln( + ' return ${model.groupByHavingClassName}._({...value, ...other.value});', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Map toJson() {'); + buffer.writeln(' return Map.from(value);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' bool get isEmpty => value.isEmpty;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class ${model.groupByOrderByClassName} {'); + buffer.writeln(' final OrmOrderBy value;'); + buffer.writeln(); + buffer.writeln(' const ${model.groupByOrderByClassName}._(this.value);'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByOrderByClassName} by(' + '${model.distinctClassName} field, ' + '{SortOrder order = SortOrder.asc}' + ') {', + ); + buffer.writeln( + ' return ${model.groupByOrderByClassName}._(OrmOrderBy(field.value, order: order));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByOrderByClassName} count(' + '${model.distinctClassName} field, ' + '{SortOrder order = SortOrder.asc}' + ') {', + ); + buffer.writeln( + " return ${model.groupByOrderByClassName}._(OrmOrderBy('_count.\${field.value}', order: order));", + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByOrderByClassName} countAll({SortOrder order = SortOrder.asc}) {', + ); + buffer.writeln( + " return ${model.groupByOrderByClassName}._(OrmOrderBy('_count.all', order: order));", + ); + buffer.writeln(' }'); + buffer.writeln(); + for (final bucket in const ['min', 'max', 'sum', 'avg']) { + buffer.writeln( + ' static ${model.groupByOrderByClassName} $bucket(' + '${model.distinctClassName} field, ' + '{SortOrder order = SortOrder.asc}' + ') {', + ); + buffer.writeln( + " return ${model.groupByOrderByClassName}._(OrmOrderBy('_$bucket.\${field.value}', order: order));", + ); + buffer.writeln(' }'); + buffer.writeln(); + } + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('class ${model.selectClassName} {'); for (final field in scalarFields) { final memberName = _toLowerCamelIdentifier(field.name, fallback: 'field'); @@ -1099,6 +1385,12 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', ); + buffer.writeln( + ' List<${model.groupByOrderByClassName}> groupByOrderBy = const <${model.groupByOrderByClassName}>[],', + ); + buffer.writeln( + ' ${model.groupByHavingClassName} typedHaving = const ${model.groupByHavingClassName}(),', + ); buffer.writeln(' bool countAll = false,'); buffer.writeln( ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', @@ -1116,15 +1408,22 @@ final class TypedClientWriter { ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); buffer.writeln(' }) {'); + buffer.writeln(' final runtimeOrderBy = groupByOrderBy.isNotEmpty'); buffer.writeln( - ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', + ' ? groupByOrderBy.map((entry) => entry.value).toList(growable: false)', + ); + buffer.writeln( + ' : orderBy.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln( + ' final runtimeHaving = typedHaving.isEmpty ? having.toJson() : typedHaving.toJson();', ); buffer.writeln(' return _delegate.groupBy('); buffer.writeln( ' by: by.map((entry) => entry.value).toList(growable: false),', ); buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' having: having.toJson(),'); + buffer.writeln(' having: runtimeHaving,'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); buffer.writeln(' orderBy: runtimeOrderBy,'); @@ -1411,6 +1710,12 @@ final class TypedClientWriter { buffer.writeln( ' ${model.whereInputClassName} having = const ${model.whereInputClassName}(),', ); + buffer.writeln( + ' ${model.groupByHavingClassName} typedHaving = const ${model.groupByHavingClassName}(),', + ); + buffer.writeln( + ' List<${model.groupByOrderByClassName}> groupByOrderBy = const <${model.groupByOrderByClassName}>[],', + ); buffer.writeln(' bool countAll = false,'); buffer.writeln( ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', @@ -1431,10 +1736,12 @@ final class TypedClientWriter { buffer.writeln(' return _delegate.groupBy('); buffer.writeln(' by: by,'); buffer.writeln(' where: _where,'); - buffer.writeln(' having: having.toJson(),'); + buffer.writeln(' having: having,'); + buffer.writeln(' typedHaving: typedHaving,'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' groupByOrderBy: groupByOrderBy,'); buffer.writeln(' countAll: countAll,'); buffer.writeln(' count: count,'); buffer.writeln(' min: min,'); @@ -2408,6 +2715,13 @@ final class _ResolvedModel { String get orderByClassName => '${classBaseName}OrderBy'; + String get groupByOrderByClassName => '${classBaseName}GroupByOrderBy'; + + String get groupByHavingClassName => '${classBaseName}GroupByHaving'; + + String get groupByHavingConditionClassName => + '${classBaseName}GroupByHavingCondition'; + String get selectClassName => '${classBaseName}Select'; String get includeClassName => '${classBaseName}Include'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index b23df832..f3d5e31c 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -316,35 +316,35 @@ void main() { ); expect( RegExp( - r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,', + r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserWhereInput\s+having\s*=\s*const\s+UserWhereInput\(\),', ).hasMatch(generatedSource), isTrue, reason: - 'Expected generated delegate/query to expose typed groupBy helper.', + 'Expected generated delegate/query to expose groupBy helper with row-level having.', ); expect( RegExp( - r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),\s*UserWhereInput\s+having\s*=\s*const\s+UserWhereInput\(\),', + r'class\s+UserDelegate\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.groupBy(...) to expose where + typed having in signature.', + 'Expected UserDelegate.groupBy(...) to expose typedHaving parameter.', ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?having:\s*having\.toJson\(\),', + r'class\s+UserDelegate\s*\{[\s\S]*?final\s+runtimeHaving\s*=\s*typedHaving\.isEmpty\s*\?\s*having\.toJson\(\)\s*:\s*typedHaving\.toJson\(\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.groupBy(...) to serialize having to runtime JSON.', + 'Expected UserDelegate.groupBy(...) to resolve typedHaving before runtime call.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?UserWhereInput\s+having\s*=\s*const\s+UserWhereInput\(\),[\s\S]*?having:\s*having\.toJson\(\),', + r'class\s+UserQuery\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?List\s+groupByOrderBy\s*=\s*const\s+\[\],[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?groupByOrderBy:\s*groupByOrderBy,', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.groupBy(...) to expose and forward typed having.', + 'Expected UserQuery.groupBy(...) to expose and forward typed groupBy helpers.', ); expect( RegExp( @@ -354,6 +354,26 @@ void main() { reason: 'Expected UserDelegate.query(...) to keep only row-level where (no having parameter).', ); + expect( + RegExp( + r'\bclass UserGroupByHavingCondition\b', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed groupBy having condition helper.', + ); + expect( + RegExp(r'\bclass UserGroupByHaving\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed groupBy having helper.', + ); + expect( + RegExp(r'\bclass UserGroupByOrderBy\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed groupBy orderBy helper.', + ); expect( RegExp( r'Future>\s+createMany\(\{\s*required\s+List\s+data,', From a1a5e885320417e3b765f6b5f64a3a704aee0f9b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:01:36 +0800 Subject: [PATCH 064/154] feat(generator)!: make groupBy typed-only for having and ordering BREAKING CHANGE: generated groupBy APIs removed legacy having/orderBy parameters and now require typedHaving/groupByOrderBy. --- pub/orm/lib/src/generator/writer.dart | 21 ++------------------ pub/orm/test/generator/generate_test.dart | 24 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 07ec7c9c..44e09e33 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1377,14 +1377,8 @@ final class TypedClientWriter { buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); - buffer.writeln( - ' ${model.whereInputClassName} having = const ${model.whereInputClassName}(),', - ); buffer.writeln(' int? skip,'); buffer.writeln(' int? take,'); - buffer.writeln( - ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', - ); buffer.writeln( ' List<${model.groupByOrderByClassName}> groupByOrderBy = const <${model.groupByOrderByClassName}>[],', ); @@ -1408,16 +1402,10 @@ final class TypedClientWriter { ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); buffer.writeln(' }) {'); - buffer.writeln(' final runtimeOrderBy = groupByOrderBy.isNotEmpty'); - buffer.writeln( - ' ? groupByOrderBy.map((entry) => entry.value).toList(growable: false)', - ); - buffer.writeln( - ' : orderBy.map((entry) => entry.value).toList(growable: false);', - ); buffer.writeln( - ' final runtimeHaving = typedHaving.isEmpty ? having.toJson() : typedHaving.toJson();', + ' final runtimeOrderBy = groupByOrderBy.map((entry) => entry.value).toList(growable: false);', ); + buffer.writeln(' final runtimeHaving = typedHaving.toJson();'); buffer.writeln(' return _delegate.groupBy('); buffer.writeln( ' by: by.map((entry) => entry.value).toList(growable: false),', @@ -1707,9 +1695,6 @@ final class TypedClientWriter { buffer.writeln(' Future>> groupBy({'); buffer.writeln(' required List<${model.distinctClassName}> by,'); - buffer.writeln( - ' ${model.whereInputClassName} having = const ${model.whereInputClassName}(),', - ); buffer.writeln( ' ${model.groupByHavingClassName} typedHaving = const ${model.groupByHavingClassName}(),', ); @@ -1736,11 +1721,9 @@ final class TypedClientWriter { buffer.writeln(' return _delegate.groupBy('); buffer.writeln(' by: by,'); buffer.writeln(' where: _where,'); - buffer.writeln(' having: having,'); buffer.writeln(' typedHaving: typedHaving,'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _orderBy,'); buffer.writeln(' groupByOrderBy: groupByOrderBy,'); buffer.writeln(' countAll: countAll,'); buffer.writeln(' count: count,'); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index f3d5e31c..a8bad354 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -316,11 +316,19 @@ void main() { ); expect( RegExp( - r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserWhereInput\s+having\s*=\s*const\s+UserWhereInput\(\),', + r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', ).hasMatch(generatedSource), isTrue, reason: - 'Expected generated delegate/query to expose groupBy helper with row-level having.', + 'Expected generated delegate/query to expose typed groupBy helper.', + ); + expect( + generatedSource.contains( + 'UserWhereInput having = const UserWhereInput()', + ), + isFalse, + reason: + 'Expected generated groupBy surfaces to remove row-level having parameter.', ); expect( RegExp( @@ -332,11 +340,11 @@ void main() { ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?final\s+runtimeHaving\s*=\s*typedHaving\.isEmpty\s*\?\s*having\.toJson\(\)\s*:\s*typedHaving\.toJson\(\);', + r'class\s+UserDelegate\s*\{[\s\S]*?final\s+runtimeHaving\s*=\s*typedHaving\.toJson\(\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.groupBy(...) to resolve typedHaving before runtime call.', + 'Expected UserDelegate.groupBy(...) to resolve runtime having from typedHaving only.', ); expect( RegExp( @@ -346,6 +354,14 @@ void main() { reason: 'Expected UserQuery.groupBy(...) to expose and forward typed groupBy helpers.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?orderBy:\s*_orderBy,', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserQuery.groupBy(...) to stop forwarding query orderBy state.', + ); expect( RegExp( r'UserQuery\s+query\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),\s*int\?\s+skip,', From 09ba1452b57afe2f775195df1645f81d8fd0999f Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:09:43 +0800 Subject: [PATCH 065/154] feat(cli): add contract emit command and artifact emission --- pub/orm/bin/orm.dart | 82 +++++++++++++++- .../lib/src/generator/contract_command.dart | 72 ++++++++++++++ .../lib/src/generator/contract_emitter.dart | 94 +++++++++++++++++++ pub/orm/lib/src/generator/generator.dart | 1 + pub/orm/test/generator/generate_test.dart | 71 ++++++++++++++ 5 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 pub/orm/lib/src/generator/contract_command.dart create mode 100644 pub/orm/lib/src/generator/contract_emitter.dart diff --git a/pub/orm/bin/orm.dart b/pub/orm/bin/orm.dart index 91d067ba..1a712bb6 100644 --- a/pub/orm/bin/orm.dart +++ b/pub/orm/bin/orm.dart @@ -38,6 +38,31 @@ void main(List args) { outputPath: options.outputPath, ); return; + case 'contract': + final parseResult = _parseContractEmitArgs(commandArgs); + if (parseResult.helpRequested) { + _printContractHelp(); + exitCode = 0; + return; + } + + if (parseResult.errorMessage != null) { + stderr.writeln(parseResult.errorMessage); + _printContractHelp(stream: stderr); + exitCode = 64; + return; + } + + final options = parseResult.options!; + exitCode = runContractEmitCommand( + cwd: Directory.current, + out: stdout, + err: stderr, + configPath: options.configPath, + schemaPath: options.schemaPath, + outputPath: options.outputPath, + ); + return; default: stderr.writeln('Unknown command: $command'); _printUsage(stream: stderr); @@ -49,6 +74,33 @@ bool _isHelp(String value) => value == '--help' || value == '-h' || value == 'help'; _GenerateCliParseResult _parseGenerateArgs(List args) { + return _parsePathOptionsArgs(args, commandName: 'generate'); +} + +_GenerateCliParseResult _parseContractEmitArgs(List args) { + if (args.isEmpty) { + return const _GenerateCliParseResult.error( + 'Missing contract subcommand. Expected: emit', + ); + } + + final subcommand = args.first; + if (_isHelp(subcommand)) { + return const _GenerateCliParseResult.help(); + } + if (subcommand != 'emit') { + return _GenerateCliParseResult.error( + 'Unknown contract subcommand: $subcommand', + ); + } + + return _parsePathOptionsArgs(args.sublist(1), commandName: 'contract emit'); +} + +_GenerateCliParseResult _parsePathOptionsArgs( + List args, { + required String commandName, +}) { String? configPath; String? schemaPath; String? outputPath; @@ -61,7 +113,7 @@ _GenerateCliParseResult _parseGenerateArgs(List args) { if (!argument.startsWith('--')) { return _GenerateCliParseResult.error( - 'Unexpected arguments for generate: ${args.join(' ')}', + 'Unexpected arguments for $commandName: ${args.join(' ')}', ); } @@ -76,7 +128,7 @@ _GenerateCliParseResult _parseGenerateArgs(List args) { if (!_isGenerateOption(optionName)) { return _GenerateCliParseResult.error( - 'Unexpected arguments for generate: ${args.join(' ')}', + 'Unexpected arguments for $commandName: ${args.join(' ')}', ); } @@ -134,8 +186,12 @@ void _printUsage({IOSink? stream}) { sink.writeln( ' generate Generate typed client from orm.config.dart and schema', ); + sink.writeln( + ' contract Emit runtime contract artifact from orm.schema.dart', + ); sink.writeln(''); sink.writeln('Run `dart run orm generate --help` for generate details.'); + sink.writeln('Run `dart run orm contract --help` for contract details.'); } void _printGenerateHelp({IOSink? stream}) { @@ -162,6 +218,28 @@ void _printGenerateHelp({IOSink? stream}) { ); } +void _printContractHelp({IOSink? stream}) { + final sink = stream ?? stdout; + sink.writeln('Emit runtime contract artifact.'); + sink.writeln('Usage: dart run orm contract emit [options]'); + sink.writeln(''); + sink.writeln('Options:'); + sink.writeln( + ' --config Override config file path (default: orm.config.dart)', + ); + sink.writeln(' --schema Override schema path from config.schema'); + sink.writeln(' --output Override output artifact path'); + sink.writeln(''); + sink.writeln('Defaults from current working directory:'); + sink.writeln(' - config: orm.config.dart'); + sink.writeln( + ' - schema: config.schema, or orm.schema.dart when config.schema is not set', + ); + sink.writeln(''); + sink.writeln('Default output:'); + sink.writeln(' - orm.contract.json'); +} + final class _GenerateCliOptions { final String? configPath; final String? schemaPath; diff --git a/pub/orm/lib/src/generator/contract_command.dart b/pub/orm/lib/src/generator/contract_command.dart new file mode 100644 index 00000000..2ac00f8d --- /dev/null +++ b/pub/orm/lib/src/generator/contract_command.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'config_loader.dart'; +import 'contract_emitter.dart'; +import 'error.dart'; +import 'schema_loader.dart'; + +const _defaultContractOutputPath = 'orm.contract.json'; + +int runContractEmitCommand({ + Directory? cwd, + IOSink? out, + IOSink? err, + String? configPath, + String? schemaPath, + String? outputPath, +}) { + final workingDirectory = cwd ?? Directory.current; + final output = out ?? stdout; + final error = err ?? stderr; + + try { + final config = loadGeneratorConfig( + cwd: workingDirectory, + configPath: configPath, + schemaOverridePath: schemaPath, + ); + final schema = loadSchema(cwd: workingDirectory, config: config); + final outputFile = _resolveOutputFile( + cwd: workingDirectory, + outputPath: outputPath, + ); + + final emitted = emitContractArtifact(schema: schema); + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(emitted); + + output.writeln('Emitted contract artifact: ${outputFile.path}'); + return 0; + } on GeneratorException catch (exception) { + error.writeln(exception.formatForCli()); + return 1; + } catch (exception) { + error.writeln('Contract emit failed: Unexpected error.'); + error.writeln(exception); + return 1; + } +} + +File _resolveOutputFile({required Directory cwd, required String? outputPath}) { + final normalizedOutput = outputPath?.trim(); + if (normalizedOutput != null && normalizedOutput.isEmpty) { + throw GeneratorException( + 'Contract emit option --output requires a non-empty path.', + hint: 'Pass a non-empty file path to --output.', + ); + } + + final configuredOutput = normalizedOutput ?? _defaultContractOutputPath; + final configuredFile = File(configuredOutput); + if (configuredFile.isAbsolute) { + return configuredFile; + } + return File(_join(cwd.path, configuredOutput)); +} + +String _join(String base, String child) { + if (base.endsWith(Platform.pathSeparator)) { + return '$base$child'; + } + return '$base${Platform.pathSeparator}$child'; +} diff --git a/pub/orm/lib/src/generator/contract_emitter.dart b/pub/orm/lib/src/generator/contract_emitter.dart new file mode 100644 index 00000000..aabb5eda --- /dev/null +++ b/pub/orm/lib/src/generator/contract_emitter.dart @@ -0,0 +1,94 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'snapshot.dart'; + +String emitContractArtifact({required SchemaSnapshot schema}) { + final models = SplayTreeMap(); + for (final model in schema.models) { + final scalarFields = + model.fields + .where((field) => _isScalarType(field.typeSource)) + .map((field) => field.name) + .toList(growable: false) + ..sort(); + models[model.name] = { + 'name': model.name, + 'table': _defaultTableName(model.name), + 'fields': scalarFields, + 'relations': const {}, + }; + } + + final canonical = { + 'version': '1.0.0', + 'target': 'generic', + 'models': models, + }; + final canonicalJson = jsonEncode(canonical); + final hash = _stableHash(canonicalJson); + + final contract = { + 'version': '1.0.0', + 'hash': hash, + 'target': 'generic', + 'markerStorageHash': hash, + 'models': models, + 'aliases': const {}, + 'capabilities': const { + 'includeSingleQuery': false, + 'mutationReturning': true, + }, + }; + + final encoder = const JsonEncoder.withIndent(' '); + return '${encoder.convert(contract)}\n'; +} + +bool _isScalarType(String source) { + final parsed = _normalizeType(source); + return switch (parsed) { + 'String' || 'int' || 'double' || 'num' || 'bool' || 'DateTime' => true, + 'Object' || 'dynamic' => true, + _ when parsed.startsWith('Map<') => true, + _ => false, + }; +} + +String _normalizeType(String source) { + var value = source.trim().replaceAll(RegExp(r'\s+'), ''); + if (value.endsWith('?')) { + value = value.substring(0, value.length - 1); + } + final listMatch = RegExp(r'^List<(.+)>$').firstMatch(value); + if (listMatch != null) { + value = listMatch.group(1)!; + if (value.endsWith('?')) { + value = value.substring(0, value.length - 1); + } + } + return value; +} + +String _defaultTableName(String modelName) { + if (modelName.isEmpty) { + return modelName; + } + final lower = modelName[0].toLowerCase() + modelName.substring(1); + if (lower.endsWith('s')) { + return lower; + } + return '${lower}s'; +} + +String _stableHash(String value) { + var hash = 0xcbf29ce484222325; + const prime = 0x100000001b3; + const mask = 0xffffffffffffffff; + for (final byte in utf8.encode(value)) { + hash ^= byte; + hash = (hash * prime) & mask; + } + final hex = hash.toUnsigned(64).toRadixString(16).padLeft(16, '0'); + return 'c$hex'; +} diff --git a/pub/orm/lib/src/generator/generator.dart b/pub/orm/lib/src/generator/generator.dart index 280eea24..0f987871 100644 --- a/pub/orm/lib/src/generator/generator.dart +++ b/pub/orm/lib/src/generator/generator.dart @@ -1 +1,2 @@ export 'command.dart' show runGenerateCommand; +export 'contract_command.dart' show runContractEmitCommand; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index a8bad354..12d563ad 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:test/test.dart'; @@ -110,6 +111,57 @@ void main() { ); }); + test('contract emit writes default artifact path', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'default_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runContractEmit( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + final output = File( + _path([fixtureDir.path, 'orm.contract.json']), + ); + expect( + output.existsSync(), + isTrue, + reason: 'Expected default contract output.\n${run.debugOutput}', + ); + + final decoded = jsonDecode(output.readAsStringSync()); + expect(decoded is Map, isTrue); + expect((decoded as Map).containsKey('hash'), isTrue); + expect( + (decoded['models'] as Map).containsKey('User'), + isTrue, + ); + }); + + test('contract emit supports --output override path', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'default_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runContractEmit( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + emitArgs: ['--output', 'generated/contract.custom.json'], + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + final output = File( + _path([fixtureDir.path, 'generated', 'contract.custom.json']), + ); + expect( + output.existsSync(), + isTrue, + reason: 'Expected overridden contract output.\n${run.debugOutput}', + ); + }); + test('generated code contains typed delegate and typed input/data markers', () async { final fixtureDir = _copyFixture(fixturesRoot, 'config_output'); addTearDown(() => fixtureDir.deleteSync(recursive: true)); @@ -714,6 +766,25 @@ Future<_GenerateRun> _runGenerate({ ); } +Future<_GenerateRun> _runContractEmit({ + required String entryPath, + required String workingDirectory, + List emitArgs = const [], +}) async { + final args = [entryPath, 'contract', 'emit', ...emitArgs]; + final result = await Process.run( + 'dart', + args, + workingDirectory: workingDirectory, + ); + return _GenerateRun( + args: args, + exitCode: result.exitCode, + stdout: '${result.stdout}', + stderr: '${result.stderr}', + ); +} + bool _containsAny(String text, List markers) { for (final marker in markers) { if (text.contains(marker)) { From 2a51648cfcd3a3ad9ebd2450dd6c0cbe391169dd Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:37:35 +0800 Subject: [PATCH 066/154] feat(client): add db.sql builder API and generated db namespace --- pub/orm/lib/src/client/client.dart | 398 +++++++++++++++++++++- pub/orm/lib/src/generator/writer.dart | 34 ++ pub/orm/test/client/client_test.dart | 110 ++++++ pub/orm/test/generator/generate_test.dart | 23 ++ 4 files changed, 562 insertions(+), 3 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 7b90c997..0453bedc 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -113,6 +113,8 @@ final class IncludeSpec { abstract interface class OrmModelContext { OrmContract get contract; + OrmSqlApi get sql; + IncludeExecutionStrategySelector get includeStrategySelector; int get maxIncludeDepth; @@ -134,6 +136,7 @@ final class OrmClient implements OrmModelContext { final Map _delegates = {}; final Map _modelAliases; final Map _collectionRegistry; + late final OrmSqlApi _sql = OrmSqlApi(this); @override final IncludeExecutionStrategySelector includeStrategySelector; @override @@ -225,6 +228,9 @@ final class OrmClient implements OrmModelContext { RuntimeTelemetryEvent? telemetry() => _runtime.telemetry(); + @override + OrmSqlApi get sql => _sql; + @override ModelDelegate model(String modelKey) { final modelName = _resolveModelOrThrow(modelKey: modelKey); @@ -279,6 +285,7 @@ final class OrmScopedClient implements OrmModelContext { final Map _modelAliases; final Map _collectionRegistry; final Map _delegates = {}; + late final OrmSqlApi _sql = OrmSqlApi(this); @override final IncludeExecutionStrategySelector includeStrategySelector; @override @@ -310,6 +317,9 @@ final class OrmScopedClient implements OrmModelContext { @override ModelDelegate collection(String modelKey) => model(modelKey); + @override + OrmSqlApi get sql => _sql; + @override Future execute(OrmPlan plan) => _executePlan(plan); @@ -342,6 +352,388 @@ final class OrmScopedClient implements OrmModelContext { } } +@immutable +final class OrmSqlMutationResult { + final JsonMap? row; + final int affectedRows; + + const OrmSqlMutationResult({this.row, this.affectedRows = 0}); +} + +final class OrmSqlApi { + final OrmModelContext _client; + + const OrmSqlApi(this._client); + + OrmSqlSelectBuilder from(String modelKey) { + return OrmSqlSelectBuilder._( + client: _client, + modelName: _resolveSqlModelName(client: _client, modelKey: modelKey), + ); + } + + OrmSqlInsertBuilder insertInto(String modelKey) { + return OrmSqlInsertBuilder._( + client: _client, + modelName: _resolveSqlModelName(client: _client, modelKey: modelKey), + ); + } + + OrmSqlUpdateBuilder update(String modelKey) { + return OrmSqlUpdateBuilder._( + client: _client, + modelName: _resolveSqlModelName(client: _client, modelKey: modelKey), + ); + } + + OrmSqlDeleteBuilder deleteFrom(String modelKey) { + return OrmSqlDeleteBuilder._( + client: _client, + modelName: _resolveSqlModelName(client: _client, modelKey: modelKey), + ); + } +} + +@immutable +final class OrmSqlSelectBuilder { + final OrmModelContext _client; + final String _modelName; + final JsonMap _where; + final int? _skip; + final int? _take; + final List _orderBy; + final List _distinct; + final List _select; + + OrmSqlSelectBuilder._({ + required OrmModelContext client, + required String modelName, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + }) : _client = client, + _modelName = modelName, + _where = Map.unmodifiable( + Map.from(where), + ), + _skip = skip, + _take = take, + _orderBy = List.unmodifiable(orderBy), + _distinct = List.unmodifiable(distinct), + _select = List.unmodifiable(select); + + OrmSqlSelectBuilder where(JsonMap where) => _copy(where: where); + + OrmSqlSelectBuilder orderBy(List orderBy) => + _copy(orderBy: orderBy); + + OrmSqlSelectBuilder orderByField( + String field, { + SortOrder order = SortOrder.asc, + bool append = true, + }) { + final nextOrderBy = append + ? [..._orderBy, OrmOrderBy(field, order: order)] + : [OrmOrderBy(field, order: order)]; + return _copy(orderBy: nextOrderBy); + } + + OrmSqlSelectBuilder distinct(List distinct) => + _copy(distinct: distinct); + + OrmSqlSelectBuilder select(List fields) => _copy(select: fields); + + OrmSqlSelectBuilder selectField(String field, {bool append = true}) { + final nextSelect = append ? [..._select, field] : [field]; + return _copy(select: nextSelect); + } + + OrmSqlSelectBuilder skip(int? value) => _copy(skip: value); + + OrmSqlSelectBuilder take(int? value) => _copy(take: value); + + OrmPlan build() { + return _buildSqlPlan( + client: _client, + modelName: _modelName, + action: OrmAction.findMany, + where: _where, + skip: _skip, + take: _take, + orderBy: _orderBy, + distinct: _distinct, + select: _select, + ); + } + + Future> query() async { + final response = await _client.execute(build()); + return _readRows(response.data, action: 'sql.query'); + } + + Future first() async { + final response = await _client.execute(take(1).build()); + return _readRow(response.data, action: 'sql.first'); + } + + Stream stream() async* { + final rows = await query(); + for (final row in rows) { + yield row; + } + } + + OrmSqlSelectBuilder _copy({ + JsonMap? where, + Object? skip = _sqlKeepToken, + Object? take = _sqlKeepToken, + List? orderBy, + List? distinct, + List? select, + }) { + return OrmSqlSelectBuilder._( + client: _client, + modelName: _modelName, + where: where ?? _where, + skip: identical(skip, _sqlKeepToken) ? _skip : skip as int?, + take: identical(take, _sqlKeepToken) ? _take : take as int?, + orderBy: orderBy ?? _orderBy, + distinct: distinct ?? _distinct, + select: select ?? _select, + ); + } +} + +@immutable +final class OrmSqlInsertBuilder { + final OrmModelContext _client; + final String _modelName; + final JsonMap _data; + final List _select; + + OrmSqlInsertBuilder._({ + required OrmModelContext client, + required String modelName, + JsonMap data = const {}, + List select = const [], + }) : _client = client, + _modelName = modelName, + _data = Map.unmodifiable( + Map.from(data), + ), + _select = List.unmodifiable(select); + + OrmSqlInsertBuilder values(JsonMap data) => _copy(data: data); + + OrmSqlInsertBuilder returning(List fields) => _copy(select: fields); + + OrmSqlInsertBuilder returningField(String field, {bool append = true}) { + final nextSelect = append ? [..._select, field] : [field]; + return _copy(select: nextSelect); + } + + OrmPlan build() { + return _buildSqlPlan( + client: _client, + modelName: _modelName, + action: OrmAction.create, + data: _data, + select: _select, + ); + } + + Future execute() async { + final response = await _client.execute(build()); + return OrmSqlMutationResult( + row: _readRow(response.data, action: 'sql.insert'), + affectedRows: response.affectedRows, + ); + } + + Future one() async => (await execute()).row; + + OrmSqlInsertBuilder _copy({JsonMap? data, List? select}) { + return OrmSqlInsertBuilder._( + client: _client, + modelName: _modelName, + data: data ?? _data, + select: select ?? _select, + ); + } +} + +@immutable +final class OrmSqlUpdateBuilder { + final OrmModelContext _client; + final String _modelName; + final JsonMap _where; + final JsonMap _data; + final List _select; + + OrmSqlUpdateBuilder._({ + required OrmModelContext client, + required String modelName, + JsonMap where = const {}, + JsonMap data = const {}, + List select = const [], + }) : _client = client, + _modelName = modelName, + _where = Map.unmodifiable( + Map.from(where), + ), + _data = Map.unmodifiable( + Map.from(data), + ), + _select = List.unmodifiable(select); + + OrmSqlUpdateBuilder where(JsonMap where) => _copy(where: where); + + OrmSqlUpdateBuilder set(JsonMap data) => _copy(data: data); + + OrmSqlUpdateBuilder returning(List fields) => _copy(select: fields); + + OrmSqlUpdateBuilder returningField(String field, {bool append = true}) { + final nextSelect = append ? [..._select, field] : [field]; + return _copy(select: nextSelect); + } + + OrmPlan build() { + return _buildSqlPlan( + client: _client, + modelName: _modelName, + action: OrmAction.update, + where: _where, + data: _data, + select: _select, + ); + } + + Future execute() async { + final response = await _client.execute(build()); + return OrmSqlMutationResult( + row: _readRow(response.data, action: 'sql.update'), + affectedRows: response.affectedRows, + ); + } + + Future one() async => (await execute()).row; + + OrmSqlUpdateBuilder _copy({ + JsonMap? where, + JsonMap? data, + List? select, + }) { + return OrmSqlUpdateBuilder._( + client: _client, + modelName: _modelName, + where: where ?? _where, + data: data ?? _data, + select: select ?? _select, + ); + } +} + +@immutable +final class OrmSqlDeleteBuilder { + final OrmModelContext _client; + final String _modelName; + final JsonMap _where; + final List _select; + + OrmSqlDeleteBuilder._({ + required OrmModelContext client, + required String modelName, + JsonMap where = const {}, + List select = const [], + }) : _client = client, + _modelName = modelName, + _where = Map.unmodifiable( + Map.from(where), + ), + _select = List.unmodifiable(select); + + OrmSqlDeleteBuilder where(JsonMap where) => _copy(where: where); + + OrmSqlDeleteBuilder returning(List fields) => _copy(select: fields); + + OrmSqlDeleteBuilder returningField(String field, {bool append = true}) { + final nextSelect = append ? [..._select, field] : [field]; + return _copy(select: nextSelect); + } + + OrmPlan build() { + return _buildSqlPlan( + client: _client, + modelName: _modelName, + action: OrmAction.delete, + where: _where, + select: _select, + ); + } + + Future execute() async { + final response = await _client.execute(build()); + return OrmSqlMutationResult( + row: _readRow(response.data, action: 'sql.delete'), + affectedRows: response.affectedRows, + ); + } + + Future one() async => (await execute()).row; + + OrmSqlDeleteBuilder _copy({JsonMap? where, List? select}) { + return OrmSqlDeleteBuilder._( + client: _client, + modelName: _modelName, + where: where ?? _where, + select: select ?? _select, + ); + } +} + +const Object _sqlKeepToken = Object(); + +String _resolveSqlModelName({ + required OrmModelContext client, + required String modelKey, +}) { + final delegate = client.model(modelKey); + return delegate.modelName; +} + +OrmPlan _buildSqlPlan({ + required OrmModelContext client, + required String modelName, + required OrmAction action, + JsonMap where = const {}, + JsonMap data = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], +}) { + final contract = client.contract; + return OrmPlan( + contractHash: contract.hash, + target: contract.target, + storageHash: contract.markerStorageHash, + profileHash: contract.profileHash, + model: modelName, + action: action, + where: where, + data: data, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + ); +} + class ModelDelegate { final OrmModelContext _client; final String modelName; @@ -3229,19 +3621,19 @@ Map _createCollectionRegistry( return registry; } -List _readRows(Object? data) { +List _readRows(Object? data, {String action = 'findMany'}) { if (data == null) { return const []; } if (data is! List) { throw RuntimeResponseShapeException( - action: 'findMany', + action: action, expected: 'List>', actual: data, ); } return data - .map((value) => _coerceRow(value, action: 'findMany')) + .map((value) => _coerceRow(value, action: action)) .toList(growable: false); } diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 44e09e33..b7d6cbe5 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -535,6 +535,40 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' GeneratedOrmClient(this._context);'); buffer.writeln(); + buffer.writeln( + ' late final GeneratedOrmDb db = GeneratedOrmDb(_context);', + ); + buffer.writeln(); + + for (final model in models) { + buffer.writeln( + ' late final ${model.delegateClassName} ${model.getterName} =', + ); + buffer.writeln(' db.orm.${model.getterName};'); + buffer.writeln(); + } + + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class GeneratedOrmDb {'); + buffer.writeln(' final OrmModelContext _context;'); + buffer.writeln(); + buffer.writeln(' GeneratedOrmDb(this._context);'); + buffer.writeln(); + buffer.writeln( + ' late final GeneratedOrmCollections orm = GeneratedOrmCollections(_context);', + ); + buffer.writeln(); + buffer.writeln(' OrmSqlApi get sql => _context.sql;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class GeneratedOrmCollections {'); + buffer.writeln(' final OrmModelContext _context;'); + buffer.writeln(); + buffer.writeln(' GeneratedOrmCollections(this._context);'); + buffer.writeln(); for (final model in models) { buffer.writeln( diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index a4684611..a8c2e48c 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -121,6 +121,56 @@ void main() { ); }); + test('supports db.sql select and mutation builders', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + final insertResult = await client.sql + .insertInto('users') + .values({'id': 'u1', 'email': 'a@example.com'}) + .returning(const ['id', 'email']) + .execute(); + expect(insertResult.affectedRows, 1); + expect(insertResult.row?['id'], 'u1'); + + final selectedRows = await client.sql + .from('User') + .where({'id': 'u1'}) + .select(const ['email']) + .query(); + expect(selectedRows, hasLength(1)); + expect(selectedRows.single['email'], 'a@example.com'); + + final updated = await client.sql + .update('User') + .where({'id': 'u1'}) + .set({'email': 'b@example.com'}) + .returning(const ['email']) + .execute(); + expect(updated.affectedRows, 1); + expect(updated.row?['email'], 'b@example.com'); + + final deleted = await client.sql + .deleteFrom('User') + .where({'id': 'u1'}) + .returning(const ['id']) + .execute(); + expect(deleted.affectedRows, 1); + expect(deleted.row?['id'], 'u1'); + + final remaining = await client.sql.from('User').query(); + expect(remaining, isEmpty); + await client.disconnect(); + }); + + test('db.sql requires explicit connect', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await expectLater( + client.sql.from('User').query(), + throwsA(isA()), + ); + }); + test('rejects plan with mismatched contract hash', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -1883,6 +1933,23 @@ void main() { await client.disconnect(); }); + test('withConnection exposes scoped sql api', () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await client.withConnection((connection) async { + final rows = await connection.sql.from('User').take(1).query(); + expect(rows, isEmpty); + }); + + expect(engine.connectionCount, 1); + expect(engine.connectionExecutePlans, hasLength(1)); + expect(engine.connectionExecutePlans.single.action, OrmAction.findMany); + expect(engine.connectionExecutePlans.single.take, 1); + await client.disconnect(); + }); + test( 'withConnection executes callback and always releases connection', () async { @@ -1922,6 +1989,24 @@ void main() { await client.disconnect(); }); + test('withTransaction exposes scoped sql api', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await client.withTransaction((transaction) async { + await transaction.sql.insertInto('User').values({ + 'id': 'u1', + 'email': 'a@example.com', + }).execute(); + }); + + final row = await client + .model('User') + .findUnique(where: {'id': 'u1'}); + expect(row?['email'], 'a@example.com'); + await client.disconnect(); + }); + test( 'withTransaction success branch commits and releases connection', () async { @@ -2291,6 +2376,20 @@ void main() { await client.disconnect(); }); + test('db.sql invokes plugin hooks in order', () async { + final plugin = _TrackingPlugin(); + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: [plugin], + ); + await client.connect(); + await client.sql.from('User').query(); + + expect(plugin.events, ['before:findMany', 'after:findMany']); + await client.disconnect(); + }); + test('invokes onError when engine execution fails', () async { final plugin = _TrackingPlugin(); final client = OrmClient( @@ -2400,6 +2499,17 @@ void main() { ); await client.disconnect(); }); + + test('db.sql returns structured runtime response shape errors', () async { + final client = OrmClient(contract: contract, engine: _BadShapeEngine()); + await client.connect(); + + await expectLater( + client.sql.from('User').query(), + throwsA(isA()), + ); + await client.disconnect(); + }); } Future _seedRelationalData(OrmClient client) async { diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 12d563ad..cef2d0d5 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -199,6 +199,29 @@ void main() { isTrue, reason: 'Missing typed delegate marker in generated source.', ); + expect( + RegExp( + r'class\s+GeneratedOrmClient\s*\{[\s\S]*?late\s+final\s+GeneratedOrmDb\s+db\s*=\s*GeneratedOrmDb\(_context\);', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected GeneratedOrmClient to expose db entrypoint.', + ); + expect( + RegExp( + r'class\s+GeneratedOrmDb\s*\{[\s\S]*?late\s+final\s+GeneratedOrmCollections\s+orm\s*=\s*GeneratedOrmCollections\(_context\);[\s\S]*?OrmSqlApi\s+get\s+sql\s*=>\s*_context\.sql;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected GeneratedOrmDb to expose db.orm and db.sql namespaces.', + ); + expect( + RegExp( + r'class\s+GeneratedOrmCollections\s*\{[\s\S]*?_context\.model\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected GeneratedOrmCollections to materialize typed delegates.', + ); expect( RegExp( From f3d97997713ff1d1707fb4d1196be31690ae4aa6 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:40:45 +0800 Subject: [PATCH 067/154] feat(generator): add typed db.sql model delegates --- pub/orm/lib/src/generator/writer.dart | 209 +++++++++++++++++++++- pub/orm/test/generator/generate_test.dart | 36 +++- 2 files changed, 243 insertions(+), 2 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index b7d6cbe5..648e6f4a 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -34,6 +34,7 @@ final class TypedClientWriter { for (final model in resolvedModels) { _writeQueryDslClasses(buffer: buffer, model: model, lookup: modelLookup); _writeTypedDelegateClass(buffer: buffer, model: model); + _writeTypedSqlClass(buffer: buffer, model: model); } for (final model in resolvedModels) { @@ -560,7 +561,9 @@ final class TypedClientWriter { ' late final GeneratedOrmCollections orm = GeneratedOrmCollections(_context);', ); buffer.writeln(); - buffer.writeln(' OrmSqlApi get sql => _context.sql;'); + buffer.writeln( + ' late final GeneratedOrmSql sql = GeneratedOrmSql(_context);', + ); buffer.writeln('}'); buffer.writeln(); @@ -582,6 +585,22 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); + + buffer.writeln('class GeneratedOrmSql {'); + buffer.writeln(' final OrmModelContext _context;'); + buffer.writeln(' late final OrmSqlApi _api = _context.sql;'); + buffer.writeln(); + buffer.writeln(' GeneratedOrmSql(this._context);'); + buffer.writeln(); + for (final model in models) { + buffer.writeln( + ' late final ${model.sqlClassName} ${model.getterName} =', + ); + buffer.writeln(' ${model.sqlClassName}(_api);'); + buffer.writeln(); + } + buffer.writeln('}'); + buffer.writeln(); } void _writeQueryDslClasses({ @@ -1514,6 +1533,192 @@ final class TypedClientWriter { _writeTypedQueryClass(buffer: buffer, model: model); } + void _writeTypedSqlClass({ + required StringBuffer buffer, + required _ResolvedModel model, + }) { + final runtimeName = _escapeString(model.model.runtimeName); + buffer.writeln('class ${model.sqlClassName} {'); + buffer.writeln(' final OrmSqlApi _sql;'); + buffer.writeln(); + buffer.writeln(' const ${model.sqlClassName}(this._sql);'); + buffer.writeln(); + + buffer.writeln(' OrmSqlSelectBuilder selectPlan({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln( + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' }) {'); + buffer.writeln( + ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln( + ' final runtimeDistinct = distinct.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln( + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln(" return _sql.from('$runtimeName')"); + buffer.writeln(' .where(where.toJson())'); + buffer.writeln(' .skip(skip)'); + buffer.writeln(' .take(take)'); + buffer.writeln(' .orderBy(runtimeOrderBy)'); + buffer.writeln(' .distinct(runtimeDistinct)'); + buffer.writeln(' .select(runtimeSelect);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future> query({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln( + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' }) async {'); + buffer.writeln(' final rows = await selectPlan('); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' ).query();'); + buffer.writeln( + ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> first({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln( + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' }) async {'); + buffer.writeln(' final row = await selectPlan('); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: 1,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' ).first();'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' OrmSqlInsertBuilder insertPlan({'); + buffer.writeln(' required ${model.createInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) {'); + buffer.writeln( + ' final runtimeReturning = returning?.toFields() ?? const [];', + ); + buffer.writeln( + " return _sql.insertInto('$runtimeName').values(data.toJson()).returning(runtimeReturning);", + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> insert({'); + buffer.writeln(' required ${model.createInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) async {'); + buffer.writeln( + ' final row = await insertPlan(data: data, returning: returning).one();', + ); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' OrmSqlUpdateBuilder updatePlan({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) {'); + buffer.writeln( + ' final runtimeReturning = returning?.toFields() ?? const [];', + ); + buffer.writeln( + " return _sql.update('$runtimeName').where(where.toJson()).set(data.toJson()).returning(runtimeReturning);", + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> update({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) async {'); + buffer.writeln( + ' final row = await updatePlan(where: where, data: data, returning: returning).one();', + ); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' OrmSqlDeleteBuilder deletePlan({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) {'); + buffer.writeln( + ' final runtimeReturning = returning?.toFields() ?? const [];', + ); + buffer.writeln( + " return _sql.deleteFrom('$runtimeName').where(where.toJson()).returning(runtimeReturning);", + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> delete({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) async {'); + buffer.writeln( + ' final row = await deletePlan(where: where, returning: returning).one();', + ); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + + buffer.writeln('}'); + buffer.writeln(); + } + void _writeTypedQueryClass({ required StringBuffer buffer, required _ResolvedModel model, @@ -2716,6 +2921,8 @@ final class _ResolvedModel { String get delegateClassName => '${classBaseName}Delegate'; + String get sqlClassName => '${classBaseName}Sql'; + String get queryClassName => '${classBaseName}Query'; String get distinctClassName => '${classBaseName}Distinct'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index cef2d0d5..02f61494 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -208,12 +208,20 @@ void main() { ); expect( RegExp( - r'class\s+GeneratedOrmDb\s*\{[\s\S]*?late\s+final\s+GeneratedOrmCollections\s+orm\s*=\s*GeneratedOrmCollections\(_context\);[\s\S]*?OrmSqlApi\s+get\s+sql\s*=>\s*_context\.sql;', + r'class\s+GeneratedOrmDb\s*\{[\s\S]*?late\s+final\s+GeneratedOrmCollections\s+orm\s*=\s*GeneratedOrmCollections\(_context\);[\s\S]*?late\s+final\s+GeneratedOrmSql\s+sql\s*=\s*GeneratedOrmSql\(_context\);', ).hasMatch(generatedSource), isTrue, reason: 'Expected GeneratedOrmDb to expose db.orm and db.sql namespaces.', ); + expect( + RegExp( + r'class\s+GeneratedOrmSql\s*\{[\s\S]*?late\s+final\s+OrmSqlApi\s+_api\s*=\s*_context\.sql;[\s\S]*?UserSql\s+user\s*=', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected GeneratedOrmSql to expose typed model sql delegates.', + ); expect( RegExp( r'class\s+GeneratedOrmCollections\s*\{[\s\S]*?_context\.model\(', @@ -261,6 +269,32 @@ void main() { isTrue, reason: 'Expected UserQuery.first() in generated source.', ); + expect( + RegExp(r'\bclass UserSql\b').hasMatch(generatedSource), + isTrue, + reason: 'Expected generated source to include typed UserSql class.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?OrmSqlSelectBuilder\s+selectPlan\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose selectPlan builder.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future>\s+query\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose typed query helper.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+insert\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose typed insert helper.', + ); expect( RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), isTrue, From c02550a82854ce46ffbb2cc76f12c453f01fb48a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:45:05 +0800 Subject: [PATCH 068/154] feat(generator): expose raw sql api under db.sql namespace --- pub/orm/lib/src/generator/writer.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 648e6f4a..58452f3f 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -592,6 +592,8 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' GeneratedOrmSql(this._context);'); buffer.writeln(); + buffer.writeln(' OrmSqlApi get raw => _api;'); + buffer.writeln(); for (final model in models) { buffer.writeln( ' late final ${model.sqlClassName} ${model.getterName} =', From 29148cccfa40cfff8f935169ef98865806baf90b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:48:10 +0800 Subject: [PATCH 069/154] feat(generator)!: remove direct model getters from generated client BREAKING CHANGE: generated model delegates are now available only under db.orm.*. --- pub/orm/lib/src/generator/writer.dart | 9 --------- pub/orm/test/generator/generate_test.dart | 6 ++++++ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 58452f3f..0ff98007 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -539,15 +539,6 @@ final class TypedClientWriter { buffer.writeln( ' late final GeneratedOrmDb db = GeneratedOrmDb(_context);', ); - buffer.writeln(); - - for (final model in models) { - buffer.writeln( - ' late final ${model.delegateClassName} ${model.getterName} =', - ); - buffer.writeln(' db.orm.${model.getterName};'); - buffer.writeln(); - } buffer.writeln('}'); buffer.writeln(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 02f61494..3e8b8fb1 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -206,6 +206,12 @@ void main() { isTrue, reason: 'Expected GeneratedOrmClient to expose db entrypoint.', ); + expect( + generatedSource.contains('db.orm.user;'), + isFalse, + reason: + 'Expected GeneratedOrmClient to remove direct model delegate getters.', + ); expect( RegExp( r'class\s+GeneratedOrmDb\s*\{[\s\S]*?late\s+final\s+GeneratedOrmCollections\s+orm\s*=\s*GeneratedOrmCollections\(_context\);[\s\S]*?late\s+final\s+GeneratedOrmSql\s+sql\s*=\s*GeneratedOrmSql\(_context\);', From 95f2e2467c7260a2522edc6610cdc1e75a05e1d2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:04:21 +0800 Subject: [PATCH 070/154] feat(generator): add sql mutation result helpers --- pub/orm/lib/src/generator/writer.dart | 37 +++++++++++++++++++++-- pub/orm/test/generator/generate_test.dart | 21 +++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 0ff98007..7d47f067 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1639,12 +1639,22 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future insertResult({'); + buffer.writeln(' required ${model.createInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) {'); + buffer.writeln( + ' return insertPlan(data: data, returning: returning).execute();', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> insert({'); buffer.writeln(' required ${model.createInputClassName} data,'); buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) async {'); buffer.writeln( - ' final row = await insertPlan(data: data, returning: returning).one();', + ' final row = (await insertResult(data: data, returning: returning)).row;', ); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); @@ -1667,13 +1677,24 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future updateResult({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) {'); + buffer.writeln( + ' return updatePlan(where: where, data: data, returning: returning).execute();', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> update({'); buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' required ${model.updateInputClassName} data,'); buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) async {'); buffer.writeln( - ' final row = await updatePlan(where: where, data: data, returning: returning).one();', + ' final row = (await updateResult(where: where, data: data, returning: returning)).row;', ); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); @@ -1695,12 +1716,22 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future deleteResult({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' ${model.selectClassName}? returning,'); + buffer.writeln(' }) {'); + buffer.writeln( + ' return deletePlan(where: where, returning: returning).execute();', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> delete({'); buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) async {'); buffer.writeln( - ' final row = await deletePlan(where: where, returning: returning).one();', + ' final row = (await deleteResult(where: where, returning: returning)).row;', ); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 3e8b8fb1..c637c4a6 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -301,6 +301,27 @@ void main() { isTrue, reason: 'Expected UserSql to expose typed insert helper.', ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+insertResult\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose insertResult helper.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+updateResult\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose updateResult helper.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+deleteResult\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose deleteResult helper.', + ); expect( RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), isTrue, From 759c3ba41adc30b2d0d8eaecd4fc7b52cc32d98e Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:06:30 +0800 Subject: [PATCH 071/154] feat(client): add db namespace facade for orm and sql --- pub/orm/lib/src/client/client.dart | 30 +++++++++++++++++++++++++++- pub/orm/test/client/client_test.dart | 21 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 0453bedc..743617b4 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -137,6 +137,7 @@ final class OrmClient implements OrmModelContext { final Map _modelAliases; final Map _collectionRegistry; late final OrmSqlApi _sql = OrmSqlApi(this); + late final OrmDbNamespace _db = OrmDbNamespace(this); @override final IncludeExecutionStrategySelector includeStrategySelector; @override @@ -231,6 +232,8 @@ final class OrmClient implements OrmModelContext { @override OrmSqlApi get sql => _sql; + OrmDbNamespace get db => _db; + @override ModelDelegate model(String modelKey) { final modelName = _resolveModelOrThrow(modelKey: modelKey); @@ -286,6 +289,7 @@ final class OrmScopedClient implements OrmModelContext { final Map _collectionRegistry; final Map _delegates = {}; late final OrmSqlApi _sql = OrmSqlApi(this); + late final OrmDbNamespace _db = OrmDbNamespace(this); @override final IncludeExecutionStrategySelector includeStrategySelector; @override @@ -320,6 +324,8 @@ final class OrmScopedClient implements OrmModelContext { @override OrmSqlApi get sql => _sql; + OrmDbNamespace get db => _db; + @override Future execute(OrmPlan plan) => _executePlan(plan); @@ -360,6 +366,27 @@ final class OrmSqlMutationResult { const OrmSqlMutationResult({this.row, this.affectedRows = 0}); } +final class OrmDbNamespace { + final OrmModelContext _context; + late final OrmModelNamespace orm = OrmModelNamespace(_context); + + OrmDbNamespace(this._context); + + OrmSqlApi get sql => _context.sql; +} + +final class OrmModelNamespace { + final OrmModelContext _context; + + OrmModelNamespace(this._context); + + ModelDelegate model(String modelKey) => _context.model(modelKey); + + ModelDelegate collection(String modelKey) => _context.collection(modelKey); + + ModelDelegate operator [](String modelKey) => model(modelKey); +} + final class OrmSqlApi { final OrmModelContext _client; @@ -476,7 +503,8 @@ final class OrmSqlSelectBuilder { Future first() async { final response = await _client.execute(take(1).build()); - return _readRow(response.data, action: 'sql.first'); + final rows = _readRows(response.data, action: 'sql.first'); + return _firstOrNull(rows); } Stream stream() async* { diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index a8c2e48c..e7c7ba52 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -171,6 +171,27 @@ void main() { ); }); + test('supports db namespace for orm and sql access', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + final users = client.db.orm.collection('users'); + await users.create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + + final sqlRow = await client.db.sql.from('User').where({ + 'id': 'u1', + }).first(); + expect(sqlRow?['email'], 'a@example.com'); + + final ormRow = await client.db.orm['User'].findUnique( + where: {'id': 'u1'}, + ); + expect(ormRow?['id'], 'u1'); + await client.disconnect(); + }); + test('rejects plan with mismatched contract hash', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); From 26a1f7b88844c2fbd0737fff33bda7656df1f987 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:10:20 +0800 Subject: [PATCH 072/154] feat(generator): add typed sql stream API --- pub/orm/lib/src/generator/writer.dart | 27 +++++++++++++++++++++++ pub/orm/test/generator/generate_test.dart | 7 ++++++ 2 files changed, 34 insertions(+) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 7d47f067..094a1fb8 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1598,6 +1598,33 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Stream<${model.dataClassName}> stream({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln( + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' }) async* {'); + buffer.writeln(' await for (final row in selectPlan('); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' ).stream()) {'); + buffer.writeln(' yield ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> first({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index c637c4a6..2537f140 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -294,6 +294,13 @@ void main() { isTrue, reason: 'Expected UserSql to expose typed query helper.', ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Stream\s+stream\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose typed stream helper.', + ); expect( RegExp( r'class\s+UserSql\s*\{[\s\S]*?Future\s+insert\(', From f45f8f6cf1922d04bef245b1a733e66794b55747 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:23:04 +0800 Subject: [PATCH 073/154] feat(generator): return typed aggregate result objects --- pub/orm/lib/src/generator/writer.dart | 46 +++++++++++++++++++++-- pub/orm/test/generator/generate_test.dart | 8 +++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 094a1fb8..93acc5c3 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -945,6 +945,39 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); + buffer.writeln('class ${model.aggregateResultClassName} {'); + buffer.writeln(' final Map value;'); + buffer.writeln(); + buffer.writeln(' const ${model.aggregateResultClassName}._(this.value);'); + buffer.writeln(); + buffer.writeln( + ' factory ${model.aggregateResultClassName}.fromJson(Map value) {', + ); + buffer.writeln(' return ${model.aggregateResultClassName}._('); + buffer.writeln( + ' Map.unmodifiable(Map.from(value)),', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' int? get countAll {'); + buffer.writeln(" final count = _readJsonMap(value['count']);"); + buffer.writeln(" return _readInt(count?['all']);"); + buffer.writeln(' }'); + buffer.writeln(); + for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { + buffer.writeln(' Map get $bucket {'); + buffer.writeln(" return _readJsonMap(value['$bucket']) ??"); + buffer.writeln(' const {};'); + buffer.writeln(' }'); + buffer.writeln(); + } + buffer.writeln(' Map toJson() {'); + buffer.writeln(' return Map.from(value);'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('class ${model.selectClassName} {'); for (final field in scalarFields) { final memberName = _toLowerCamelIdentifier(field.name, fallback: 'field'); @@ -1375,7 +1408,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future> aggregate({'); + buffer.writeln(' Future<${model.aggregateResultClassName}> aggregate({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); @@ -1395,8 +1428,8 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); - buffer.writeln(' }) {'); - buffer.writeln(' return _delegate.aggregate('); + buffer.writeln(' }) async {'); + buffer.writeln(' final value = await _delegate.aggregate('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' countAll: countAll,'); buffer.writeln( @@ -1415,6 +1448,9 @@ final class TypedClientWriter { ' avg: avg.map((entry) => entry.value).toList(growable: false),', ); buffer.writeln(' );'); + buffer.writeln( + ' return ${model.aggregateResultClassName}.fromJson(value);', + ); buffer.writeln(' }'); buffer.writeln(); @@ -1953,7 +1989,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future> aggregate({'); + buffer.writeln(' Future<${model.aggregateResultClassName}> aggregate({'); buffer.writeln(' bool countAll = false,'); buffer.writeln( ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', @@ -2997,6 +3033,8 @@ final class _ResolvedModel { String get groupByHavingConditionClassName => '${classBaseName}GroupByHavingCondition'; + String get aggregateResultClassName => '${classBaseName}AggregateResult'; + String get selectClassName => '${classBaseName}Select'; String get includeClassName => '${classBaseName}Include'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 2537f140..7917eb6c 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -451,12 +451,18 @@ void main() { ); expect( RegExp( - r'Future>\s+aggregate\(\{', + r'Future\s+aggregate\(\{', ).hasMatch(generatedSource), isTrue, reason: 'Expected generated delegate/query to expose aggregate helper.', ); + expect( + RegExp(r'\bclass UserAggregateResult\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include aggregate result wrapper.', + ); expect( RegExp( r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', From 22e25a8be1a8c9d86579a427d592cc78c29a64aa Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:26:40 +0800 Subject: [PATCH 074/154] feat(generator)!: return typed groupBy result objects BREAKING CHANGE: generated groupBy APIs now return typed model-specific result wrappers instead of raw map rows. --- pub/orm/lib/src/generator/writer.dart | 60 +++++++++++++++++++++-- pub/orm/test/generator/generate_test.dart | 14 ++++-- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 93acc5c3..bfc10a9e 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -978,6 +978,53 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); + buffer.writeln('class ${model.groupByResultClassName} {'); + buffer.writeln(' final Map value;'); + buffer.writeln(); + buffer.writeln(' const ${model.groupByResultClassName}._(this.value);'); + buffer.writeln(); + buffer.writeln( + ' factory ${model.groupByResultClassName}.fromJson(Map value) {', + ); + buffer.writeln(' return ${model.groupByResultClassName}._('); + buffer.writeln( + ' Map.unmodifiable(Map.from(value)),', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' Object? field(${model.distinctClassName} field) => value[field.value];', + ); + buffer.writeln(); + buffer.writeln(' int? get countAll {'); + buffer.writeln(" final count = _readJsonMap(value['count']);"); + buffer.writeln(" return _readInt(count?['all']);"); + buffer.writeln(' }'); + buffer.writeln(); + for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { + buffer.writeln(' Map get $bucket {'); + buffer.writeln(" return _readJsonMap(value['$bucket']) ??"); + buffer.writeln(' const {};'); + buffer.writeln(' }'); + buffer.writeln(); + } + buffer.writeln(' ${model.aggregateResultClassName} get aggregate {'); + buffer.writeln(' return ${model.aggregateResultClassName}.fromJson('); + buffer.writeln(' {'); + for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { + buffer.writeln(" '$bucket': $bucket,"); + } + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Map toJson() {'); + buffer.writeln(' return Map.from(value);'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('class ${model.selectClassName} {'); for (final field in scalarFields) { final memberName = _toLowerCamelIdentifier(field.name, fallback: 'field'); @@ -1454,7 +1501,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future>> groupBy({'); + buffer.writeln(' Future> groupBy({'); buffer.writeln(' required List<${model.distinctClassName}> by,'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -1483,12 +1530,12 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); - buffer.writeln(' }) {'); + buffer.writeln(' }) async {'); buffer.writeln( ' final runtimeOrderBy = groupByOrderBy.map((entry) => entry.value).toList(growable: false);', ); buffer.writeln(' final runtimeHaving = typedHaving.toJson();'); - buffer.writeln(' return _delegate.groupBy('); + buffer.writeln(' final rows = await _delegate.groupBy('); buffer.writeln( ' by: by.map((entry) => entry.value).toList(growable: false),', ); @@ -1514,6 +1561,9 @@ final class TypedClientWriter { ' avg: avg.map((entry) => entry.value).toList(growable: false),', ); buffer.writeln(' );'); + buffer.writeln( + ' return rows.map(${model.groupByResultClassName}.fromJson).toList(growable: false);', + ); buffer.writeln(' }'); buffer.writeln(); @@ -2019,7 +2069,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future>> groupBy({'); + buffer.writeln(' Future> groupBy({'); buffer.writeln(' required List<${model.distinctClassName}> by,'); buffer.writeln( ' ${model.groupByHavingClassName} typedHaving = const ${model.groupByHavingClassName}(),', @@ -3035,6 +3085,8 @@ final class _ResolvedModel { String get aggregateResultClassName => '${classBaseName}AggregateResult'; + String get groupByResultClassName => '${classBaseName}GroupByResult'; + String get selectClassName => '${classBaseName}Select'; String get includeClassName => '${classBaseName}Include'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 7917eb6c..1cac03b1 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -465,12 +465,18 @@ void main() { ); expect( RegExp( - r'Future>>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', + r'Future>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', ).hasMatch(generatedSource), isTrue, reason: 'Expected generated delegate/query to expose typed groupBy helper.', ); + expect( + RegExp(r'\bclass UserGroupByResult\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed groupBy result wrapper.', + ); expect( generatedSource.contains( 'UserWhereInput having = const UserWhereInput()', @@ -481,7 +487,7 @@ void main() { ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', ).hasMatch(generatedSource), isTrue, reason: @@ -497,7 +503,7 @@ void main() { ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?List\s+groupByOrderBy\s*=\s*const\s+\[\],[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?groupByOrderBy:\s*groupByOrderBy,', + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?List\s+groupByOrderBy\s*=\s*const\s+\[\],[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?groupByOrderBy:\s*groupByOrderBy,', ).hasMatch(generatedSource), isTrue, reason: @@ -505,7 +511,7 @@ void main() { ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>>\s+groupBy\(\{[\s\S]*?orderBy:\s*_orderBy,', + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?orderBy:\s*_orderBy,', ).hasMatch(generatedSource), isFalse, reason: From f6bf4dbea4a6722ef156e681db0c179f6b027c54 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:39:07 +0800 Subject: [PATCH 075/154] feat(generator)!: enforce typed aggregate/groupBy result surfaces BREAKING CHANGE: generated aggregate/groupBy result wrappers no longer expose public map payload or dynamic field-style accessors. --- pub/orm/lib/src/generator/writer.dart | 198 +++++++++++++++++++--- pub/orm/test/generator/generate_test.dart | 120 +++++++++++++ 2 files changed, 290 insertions(+), 28 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index bfc10a9e..b7b09c99 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -607,6 +607,23 @@ final class TypedClientWriter { final relationFields = model.model.fields .where((field) => field.isRelation) .toList(growable: false); + final aggregateCountBucketClassName = + '${model.classBaseName}AggregateCountBucket'; + final aggregateMinBucketClassName = + '${model.classBaseName}AggregateMinBucket'; + final aggregateMaxBucketClassName = + '${model.classBaseName}AggregateMaxBucket'; + final aggregateSumBucketClassName = + '${model.classBaseName}AggregateSumBucket'; + final aggregateAvgBucketClassName = + '${model.classBaseName}AggregateAvgBucket'; + final aggregateBucketClassNames = { + 'count': aggregateCountBucketClassName, + 'min': aggregateMinBucketClassName, + 'max': aggregateMaxBucketClassName, + 'sum': aggregateSumBucketClassName, + 'avg': aggregateAvgBucketClassName, + }; buffer.writeln('class ${model.distinctClassName} {'); buffer.writeln(' final String value;'); @@ -945,10 +962,19 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); + for (final entry in aggregateBucketClassNames.entries) { + _writeAggregateBucketClass( + buffer: buffer, + scalarFields: scalarFields, + className: entry.value, + bucket: entry.key, + ); + } + buffer.writeln('class ${model.aggregateResultClassName} {'); - buffer.writeln(' final Map value;'); + buffer.writeln(' final Map _value;'); buffer.writeln(); - buffer.writeln(' const ${model.aggregateResultClassName}._(this.value);'); + buffer.writeln(' const ${model.aggregateResultClassName}._(this._value);'); buffer.writeln(); buffer.writeln( ' factory ${model.aggregateResultClassName}.fromJson(Map value) {', @@ -960,28 +986,28 @@ final class TypedClientWriter { buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' int? get countAll {'); - buffer.writeln(" final count = _readJsonMap(value['count']);"); - buffer.writeln(" return _readInt(count?['all']);"); - buffer.writeln(' }'); + buffer.writeln(' int? get countAll => count.all;'); buffer.writeln(); - for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { - buffer.writeln(' Map get $bucket {'); - buffer.writeln(" return _readJsonMap(value['$bucket']) ??"); - buffer.writeln(' const {};'); + for (final entry in aggregateBucketClassNames.entries) { + buffer.writeln(' ${entry.value} get ${entry.key} {'); + buffer.writeln(' return ${entry.value}._('); + buffer.writeln( + " _readJsonMap(_value['${entry.key}']) ?? const {},", + ); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); } buffer.writeln(' Map toJson() {'); - buffer.writeln(' return Map.from(value);'); + buffer.writeln(' return Map.from(_value);'); buffer.writeln(' }'); buffer.writeln('}'); buffer.writeln(); buffer.writeln('class ${model.groupByResultClassName} {'); - buffer.writeln(' final Map value;'); + buffer.writeln(' final Map _value;'); buffer.writeln(); - buffer.writeln(' const ${model.groupByResultClassName}._(this.value);'); + buffer.writeln(' const ${model.groupByResultClassName}._(this._value);'); buffer.writeln(); buffer.writeln( ' factory ${model.groupByResultClassName}.fromJson(Map value) {', @@ -992,35 +1018,39 @@ final class TypedClientWriter { ); buffer.writeln(' );'); buffer.writeln(' }'); + for (final field in scalarFields) { + final memberName = _toLowerCamelIdentifier(field.name, fallback: 'field'); + final fieldName = _escapeString(field.name); + final decode = _decodeScalar(field, accessor: "_value['$fieldName']"); + final fieldType = _scalarResultType(field); + buffer.writeln(); + buffer.writeln(' $fieldType get $memberName => $decode;'); + } buffer.writeln(); - buffer.writeln( - ' Object? field(${model.distinctClassName} field) => value[field.value];', - ); - buffer.writeln(); - buffer.writeln(' int? get countAll {'); - buffer.writeln(" final count = _readJsonMap(value['count']);"); - buffer.writeln(" return _readInt(count?['all']);"); - buffer.writeln(' }'); + buffer.writeln(' int? get countAll => count.all;'); buffer.writeln(); - for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { - buffer.writeln(' Map get $bucket {'); - buffer.writeln(" return _readJsonMap(value['$bucket']) ??"); - buffer.writeln(' const {};'); + for (final entry in aggregateBucketClassNames.entries) { + buffer.writeln(' ${entry.value} get ${entry.key} {'); + buffer.writeln(' return ${entry.value}._('); + buffer.writeln( + " _readJsonMap(_value['${entry.key}']) ?? const {},", + ); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); } buffer.writeln(' ${model.aggregateResultClassName} get aggregate {'); buffer.writeln(' return ${model.aggregateResultClassName}.fromJson('); buffer.writeln(' {'); - for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { - buffer.writeln(" '$bucket': $bucket,"); + for (final entry in aggregateBucketClassNames.entries) { + buffer.writeln(" '${entry.key}': ${entry.key}.toJson(),"); } buffer.writeln(' },'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Map toJson() {'); - buffer.writeln(' return Map.from(value);'); + buffer.writeln(' return Map.from(_value);'); buffer.writeln(' }'); buffer.writeln('}'); buffer.writeln(); @@ -1168,6 +1198,118 @@ final class TypedClientWriter { buffer.writeln(); } + void _writeAggregateBucketClass({ + required StringBuffer buffer, + required List scalarFields, + required String className, + required String bucket, + }) { + final fields = scalarFields + .where( + (field) => + _supportsAggregateBucketField(field: field, bucket: bucket), + ) + .toList(growable: false); + + buffer.writeln('class $className {'); + buffer.writeln(' final Map _value;'); + buffer.writeln(); + buffer.writeln(' const $className._(this._value);'); + buffer.writeln(); + + if (bucket == 'count') { + buffer.writeln(" int? get all => _readInt(_value['all']);"); + buffer.writeln(); + } + + for (final scalarField in fields) { + final memberName = _toLowerCamelIdentifier( + scalarField.name, + fallback: 'field', + ); + final fieldName = _escapeString(scalarField.name); + final accessor = "_value['$fieldName']"; + final decode = _aggregateBucketDecodeExpression( + field: scalarField, + bucket: bucket, + accessor: accessor, + ); + final fieldType = _aggregateBucketFieldType( + field: scalarField, + bucket: bucket, + ); + buffer.writeln(' $fieldType get $memberName => $decode;'); + buffer.writeln(); + } + + buffer.writeln(' Map toJson() {'); + buffer.writeln(' return Map.from(_value);'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + } + + bool _supportsAggregateBucketField({ + required TypedField field, + required String bucket, + }) { + if (!field.isScalar) { + return false; + } + if (bucket == 'sum' || bucket == 'avg') { + if (field.isList) { + return false; + } + return field.scalarType == TypedScalarType.integer || + field.scalarType == TypedScalarType.floating; + } + return true; + } + + String _aggregateBucketFieldType({ + required TypedField field, + required String bucket, + }) { + return switch (bucket) { + 'count' => 'int?', + 'sum' => field.scalarType == TypedScalarType.integer ? 'int?' : 'double?', + 'avg' => 'double?', + 'min' || 'max' => _scalarResultType(field), + _ => 'Object?', + }; + } + + String _aggregateBucketDecodeExpression({ + required TypedField field, + required String bucket, + required String accessor, + }) { + return switch (bucket) { + 'count' => '_readInt($accessor)', + 'sum' => + field.scalarType == TypedScalarType.integer + ? '_readInt($accessor)' + : '_readDouble($accessor)', + 'avg' => '_readDouble($accessor)', + 'min' || 'max' => _decodeScalar(field, accessor: accessor), + _ => '_readJsonValue($accessor)', + }; + } + + String _scalarResultType(TypedField field) { + final scalarType = field.scalarType; + if (scalarType == null) { + return 'Object?'; + } + if (field.isList) { + if (scalarType == TypedScalarType.json) { + return 'List?'; + } + return 'List<${scalarType.dartType}>?'; + } + return '${scalarType.dartType}?'; + } + void _writeTypedDelegateClass({ required StringBuffer buffer, required _ResolvedModel model, diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 1cac03b1..e208bffc 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -463,6 +463,94 @@ void main() { reason: 'Expected generated source to include aggregate result wrapper.', ); + expect( + RegExp( + r'\bclass UserAggregateCountBucket\b', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed aggregate count bucket.', + ); + expect( + RegExp(r'\bclass UserAggregateMinBucket\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed aggregate min bucket.', + ); + expect( + RegExp(r'\bclass UserAggregateMaxBucket\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed aggregate max bucket.', + ); + expect( + RegExp(r'\bclass UserAggregateSumBucket\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed aggregate sum bucket.', + ); + expect( + RegExp(r'\bclass UserAggregateAvgBucket\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed aggregate avg bucket.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateCountBucket\s+get\s+count', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose typed count bucket getter.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateMinBucket\s+get\s+min', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose typed min bucket getter.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateMaxBucket\s+get\s+max', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose typed max bucket getter.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateSumBucket\s+get\s+sum', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose typed sum bucket getter.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateAvgBucket\s+get\s+avg', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose typed avg bucket getter.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?final\s+Map\s+value;', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserAggregateResult to avoid exposing public map payload.', + ); + expect( + RegExp( + r'class\s+UserAggregateCountBucket\s*\{[\s\S]*?field\(UserDistinct\s+field\)', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected typed aggregate buckets to avoid dynamic field(...) map-style accessor.', + ); expect( RegExp( r'Future>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', @@ -477,6 +565,38 @@ void main() { reason: 'Expected generated source to include typed groupBy result wrapper.', ); + expect( + RegExp( + r'class\s+UserGroupByResult\s*\{[\s\S]*?int\?\s+get\s+id\s*=>', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupByResult to expose typed getter for scalar id.', + ); + expect( + RegExp( + r'class\s+UserGroupByResult\s*\{[\s\S]*?String\?\s+get\s+email\s*=>', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupByResult to expose typed getter for scalar email.', + ); + expect( + RegExp( + r'class\s+UserGroupByResult\s*\{[\s\S]*?Object\?\s+field\(UserDistinct\s+field\)', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupByResult to avoid dynamic field(...) map-style accessor.', + ); + expect( + RegExp( + r'class\s+UserGroupByResult\s*\{[\s\S]*?final\s+Map\s+value;', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupByResult to avoid exposing public map payload.', + ); expect( generatedSource.contains( 'UserWhereInput having = const UserWhereInput()', From d0acee35e7716973bcc85c9a2ccc09ac18f4372e Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:47:31 +0800 Subject: [PATCH 076/154] docs(roadmap): define dart-first orm v6 blueprint and execution plan --- docs/ai-team-workflow.md | 4 ++ docs/orm-v6-blueprint.md | 84 +++++++++++++++++++++++++++++++++++ docs/orm-v6-execution-plan.md | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 docs/orm-v6-blueprint.md create mode 100644 docs/orm-v6-execution-plan.md diff --git a/docs/ai-team-workflow.md b/docs/ai-team-workflow.md index 5bfe3e47..92e619b5 100644 --- a/docs/ai-team-workflow.md +++ b/docs/ai-team-workflow.md @@ -1,5 +1,9 @@ # AI 团队协作规范(ORM) +## 当前执行文档 +- 蓝图:`docs/orm-v6-blueprint.md` +- 路线图与门禁:`docs/orm-v6-execution-plan.md` + ## 1. 目标 - 以多 agents 并行协作推进 ORM 开发。 - 角色固定为:产品、测试、开发1(Core/Runtime)、开发2(Repository/Target)。 diff --git a/docs/orm-v6-blueprint.md b/docs/orm-v6-blueprint.md new file mode 100644 index 00000000..7823be69 --- /dev/null +++ b/docs/orm-v6-blueprint.md @@ -0,0 +1,84 @@ +# ORM v6 实施蓝图(Dart 化) + +## 1. 目标 +- 在 v6 未发布阶段完成破坏式收敛,建立统一 fluent API 与 contract-first 运行时边界。 +- 所有查询入口统一编译为不可变 Plan。 +- 执行层保持单 Plan 单语句;多语句编排仅允许在 repository 层显式表达。 + +## 2. 范围与非范围 +范围: +- 契约工件完整化:relations、capabilities、target、hash 校验链。 +- fluent 查询主路径:`where / select / include / orderBy / take / skip / list / stream / firstOrNull / oneOrNull / toPlan`。 +- 运行时校验与插件管线稳定化:`beforeExecute -> onRow -> afterExecute -> onError`。 +- repository 显式编排:nested mutation、能力差异回退、include 执行策略。 + +非范围: +- parser/source-provider 新系统。 +- 运行时内隐式多语句回退。 +- 过度重载的入口矩阵与动态扩展注册。 + +## 3. 设计原则 +- Contract-first:runtime 只执行已验证契约工件。 +- Immutable plan:链式查询每一步返回新状态对象。 +- Thin core / fat targets:核心负责校验与生命周期;目标层负责方言与驱动细节。 +- Explicit behavior:跨语句流程必须显式;不在 lane 中隐藏编排。 +- Capability-driven:能力差异由契约声明并驱动确定性策略。 + +## 4. 分层边界 +| 层 | 负责 | 不负责 | +|---|---|---| +| shared/core | contract/plan/error/capability 模型与校验 | include 策略、事务编排 | +| runtime-core | marker 校验、插件管线、连接/事务生命周期、telemetry | 业务编排与回退策略 | +| runtime-sql-family | lowering、codec、marker reader | 多语句工作流 | +| repository-client | include 策略、nested mutation、upsert fallback | 绕过 runtime 校验 | +| targets | adapter/driver 实现与能力声明 | 核心错误语义定义 | + +## 5. Fluent API(Dart 形态) +- 使用闭包 Builder + 强类型对象,不以 `Map` 作为对外主入口。 +- 默认不可变链式,终止符明确。 +- 示例: + +```dart +final users = await db.user + .where((w) => w.email.equals('a@x.com') & w.active.equals(true)) + .orderBy((o) => [o.createdAt.desc()]) + .take(20) + .select((s) => s.pick((f) => [f.id, f.email])) + .list(); +``` + +```dart +final result = await db.order + .where((w) => w.userId.equals(currentUserId)) + .include((i) => i.items((q) => q.take(10))) + .firstOrNull(); +``` + +## 6. P0 / P1 能力清单 +P0: +- 契约工件与 hash 校验闭环。 +- 基础 fluent 查询与关系过滤。 +- 基础写能力:`create / update / delete / upsert`,危险操作默认强约束。 +- stream-first 执行接口。 +- 稳定错误信封与能力门禁。 +- 插件管线与执行遥测。 + +P1: +- `cursor / distinct / groupBy / having / aggregate`。 +- include 多策略优化与组合能力。 +- 扩展 API(自定义集合能力)与互操作层。 + +## 7. 迁移策略(v6 未发布) +1. 收敛入口命名与模型访问规范,只保留一条主路径。 +2. 先冻结语义,再迁移实现:拆模块但保持行为不变。 +3. 将多语句逻辑迁移到 repository,并为旧入口提供短期兼容转发。 +4. 强化测试门禁后再移除兼容入口。 + +## 8. 多角色职责 +| 角色 | 主要职责 | 交付物 | +|---|---|---| +| 产品 | 需求边界、验收标准、能力矩阵 | PRD、验收清单、决策记录 | +| 测试 | 分层测试与回归门禁 | 测试计划、Gate 报告、缺陷分级 | +| 开发1 | shared/core + runtime-core | 核心校验链、生命周期、插件执行保障 | +| 开发2 | repository + targets | 策略编排、目标实现、能力映射 | + diff --git a/docs/orm-v6-execution-plan.md b/docs/orm-v6-execution-plan.md new file mode 100644 index 00000000..0e5fec4a --- /dev/null +++ b/docs/orm-v6-execution-plan.md @@ -0,0 +1,77 @@ +# ORM v6 执行计划(6 周) + +## 1. 基线(2026-03-05) +- `pub/orm` 全量测试:136 项通过。 +- 现状强项:runtime 校验链、插件机制、include/嵌套写基础能力。 +- 现状缺口:契约工件信息不足、fluent API 语义未完全收敛、跨层职责仍有耦合点。 + +## 2. 6 周路线图 +| 周次 | 目标 | 产品 | 开发1(Core/Runtime) | 开发2(Repository/Target) | 测试 | 交付物 | +|---|---|---|---|---|---|---| +| 第1周 | 契约工件闭环 | 冻结 contract 字段/兼容策略 | 实现 contract 校验与加载 API | 扩展 schema->contract(relations/capabilities) | round-trip 与反例测试 | 契约规范 v1.1、加载器、回归桶 | +| 第2周 | Stream-first 执行面 | 定义兼容期 API 策略 | 增加流式执行主接口与桥接 | 将查询流入口统一到流式执行 | 大结果流式与插件顺序压测 | 新执行接口与迁移说明 | +| 第3周 | 生命周期与能力一致性 | 冻结 capability 决策表 | runtime 启动期能力校验 | target 错误改为结构化信封 | 生命周期负例与能力负例 | 能力校验矩阵、错误信封统一 | +| 第4周 | Include 计划化 | 定义 include 策略矩阵 | 增加 include 遥测指标 | 抽取 IncludeExecutionPlan | 策略等价性与深度边界回归 | include 计划器与预算基线 | +| 第5周 | 嵌套写显式编排 | 定义状态机与失败语义 | 事务边界插件事件 | 抽取 nested orchestration 模块 | 原子性/回滚/幂等回归 | 显式编排器与回退策略 | +| 第6周 | 集成收敛 | 范围收口与验收 | 门禁自动化与稳定性修复 | 门禁自动化与稳定性修复 | 全量回归与波动治理 | 发布候选、验收报告、迁移文档 | + +## 3. 阶段 Gate +| 阶段 | 阻断门禁 | 阈值门禁 | +|---|---|---| +| Phase 1 | 契约与 plan 基础契约测试全绿 | flake rate <= 1% | +| Phase 2 | verify mode 与生命周期矩阵全绿 | runtime/client 关键失败路径覆盖率 >= 85% | +| Phase 3 | lowering/codec/marker 语义测试全绿 | lowering p95 < 2ms | +| Phase 4 | fluent 验收矩阵全绿(FA-01..FA-12) | include 查询放大系数受控 | +| Phase 5 | 嵌套写原子性与回滚矩阵全绿 | P0/P1 缺陷为 0 | + +## 4. Fluent 验收矩阵(摘录) +- `FA-01` 链式不可变:任意变换后旧状态不变。 +- `FA-02` where 合并/覆盖语义稳定。 +- `FA-03` orderBy append/replace 语义稳定。 +- `FA-04` select/distinct 追加语义稳定。 +- `FA-05` include 合并与关系错误码稳定。 +- `FA-06` skip/take 边界错误码稳定。 +- `FA-07` `unbounded()` 只影响 `take`。 +- `FA-08` 终止操作到 action 映射唯一。 +- `FA-09` `stream()` 与 `list()` 一致性。 +- `FA-10` include 单查/多查策略结果等价。 +- `FA-11` include 深度边界行为稳定。 +- `FA-12` 模型别名映射稳定。 + +## 5. 必增测试清单 +契约测试: +- `contract_hash_is_stable_under_model_and_field_permutation` +- `runtime_rejects_plan_target_mismatch` +- `runtime_rejects_plan_storage_hash_mismatch` +- `runtime_rejects_plan_profile_hash_mismatch` +- `contract_relation_definition_validation_matrix` +- `generated_client_public_api_snapshot_compat` +- `runtime_error_taxonomy_snapshot` + +回归测试: +- `plugin_onRow_called_per_row_and_in_plugin_order` +- `multi_plugin_hook_order_before_onRow_after_onError` +- `fluent_where_merge_replace_matrix` +- `fluent_select_distinct_append_matrix` +- `fluent_include_merge_replace_matrix` +- `verify_on_first_use_resets_after_reconnect` +- `adapter_driver_error_does_not_corrupt_connection_state` +- `nested_mutation_two_level_atomic_rollback` + +## 6. 度量目标 +| 维度 | 指标 | 目标 | +|---|---|---| +| 稳定性 | 测试通过率 | 100% | +| 稳定性 | flake rate | <= 0.5% | +| 稳定性 | 阻断缺陷数 | P0/P1 = 0 | +| 性能 | 全量测试时长 | <= 60s | +| 性能 | runtime 执行开销 p95 | 插件额外开销 <= 15% | +| DX | 首次成功查询时间 | <= 15 分钟 | +| DX | 生成产物可用率 | 100% | + +## 7. 本周可执行任务(立即开工) +1. 产品:冻结 `where/include/orderBy` 语义文档与命名收敛规则。 +2. 开发1:补 runtime contract 加载与结构化错误码映射。 +3. 开发2:补 schema relation 到 contract emitter 的完整链路。 +4. 测试:建立 `generated_client_public_api_snapshot_compat` 与 fluent 验收矩阵骨架。 + From 158d6c98302a6fb78ada3296e0fb60c193c9c2db Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:09:35 +0800 Subject: [PATCH 077/154] feat(generator): enrich contract artifact with provider and relation metadata --- pub/orm/lib/src/generator/config_loader.dart | 67 +++ .../lib/src/generator/contract_command.dart | 2 +- .../lib/src/generator/contract_emitter.dart | 484 +++++++++++++++++- pub/orm/lib/src/generator/schema_loader.dart | 85 +++ pub/orm/lib/src/generator/snapshot.dart | 16 + pub/orm/test/generator/generate_test.dart | 104 +++- 6 files changed, 727 insertions(+), 31 deletions(-) diff --git a/pub/orm/lib/src/generator/config_loader.dart b/pub/orm/lib/src/generator/config_loader.dart index c0c230e0..14c8b20d 100644 --- a/pub/orm/lib/src/generator/config_loader.dart +++ b/pub/orm/lib/src/generator/config_loader.dart @@ -74,9 +74,11 @@ GeneratorConfigSnapshot loadGeneratorConfig({ final schema = _readOverridePath(schemaOverridePath, optionName: '--schema') ?? _readNullableStringArg(namedArguments, key: 'schema', file: configFile); + final provider = _readProviderArg(namedArguments, file: configFile); return GeneratorConfigSnapshot( configFile: configFile, + provider: provider, outputPath: output, schemaPath: schema, ); @@ -229,6 +231,71 @@ String? _readNullableStringArg( return value; } +String? _readProviderArg( + Map namedArguments, { + required File file, +}) { + final expression = namedArguments['provider']; + if (expression == null || expression is NullLiteral) { + return null; + } + + final identifier = _readProviderIdentifier(expression); + final literal = _readSimpleStringLiteral(expression); + final value = identifier ?? literal; + if (value == null || value.trim().isEmpty) { + throw GeneratorException( + 'Config.provider must be an enum value or string literal.', + path: file.path, + hint: "Example: provider: DatabaseProvider.sqlite or provider: 'sqlite'", + ); + } + + return _normalizeProvider(value); +} + +String? _readProviderIdentifier(Expression expression) { + if (expression is SimpleIdentifier) { + return expression.name; + } + + if (expression is PrefixedIdentifier) { + return expression.identifier.name; + } + + if (expression is PropertyAccess) { + if (_isSimpleNameChain(expression.target)) { + return expression.propertyName.name; + } + } + + return null; +} + +bool _isSimpleNameChain(Expression? expression) { + if (expression == null) { + return true; + } + + if (expression is SimpleIdentifier || expression is PrefixedIdentifier) { + return true; + } + + if (expression is PropertyAccess) { + return _isSimpleNameChain(expression.target); + } + + return false; +} + +String _normalizeProvider(String provider) { + final normalized = provider.trim(); + final marker = normalized.contains('.') + ? normalized.split('.').last + : normalized; + return marker.toLowerCase(); +} + String? _readSimpleStringLiteral(Expression expression) { if (expression is SimpleStringLiteral) { return expression.value; diff --git a/pub/orm/lib/src/generator/contract_command.dart b/pub/orm/lib/src/generator/contract_command.dart index 2ac00f8d..79e041bc 100644 --- a/pub/orm/lib/src/generator/contract_command.dart +++ b/pub/orm/lib/src/generator/contract_command.dart @@ -31,7 +31,7 @@ int runContractEmitCommand({ outputPath: outputPath, ); - final emitted = emitContractArtifact(schema: schema); + final emitted = emitContractArtifact(schema: schema, config: config); outputFile.parent.createSync(recursive: true); outputFile.writeAsStringSync(emitted); diff --git a/pub/orm/lib/src/generator/contract_emitter.dart b/pub/orm/lib/src/generator/contract_emitter.dart index aabb5eda..b980afb5 100644 --- a/pub/orm/lib/src/generator/contract_emitter.dart +++ b/pub/orm/lib/src/generator/contract_emitter.dart @@ -3,48 +3,406 @@ import 'dart:convert'; import 'snapshot.dart'; -String emitContractArtifact({required SchemaSnapshot schema}) { +String emitContractArtifact({ + required SchemaSnapshot schema, + required GeneratorConfigSnapshot config, +}) { + final runtimeProfile = _runtimeProfileForProvider(config.provider); + final modelInfos = _indexModels(schema.models); + final models = SplayTreeMap(); - for (final model in schema.models) { - final scalarFields = - model.fields - .where((field) => _isScalarType(field.typeSource)) - .map((field) => field.name) - .toList(growable: false) - ..sort(); - models[model.name] = { - 'name': model.name, - 'table': _defaultTableName(model.name), - 'fields': scalarFields, - 'relations': const {}, + for (final modelInfo in modelInfos.values) { + models[modelInfo.model.name] = { + 'name': modelInfo.model.name, + 'table': _defaultTableName(modelInfo.model.name), + 'fields': modelInfo.scalarFieldNames, + 'relations': _buildRelations(owner: modelInfo, modelInfos: modelInfos), }; } + final aliases = _buildAliases(modelInfos.values); + final capabilities = runtimeProfile.capabilities; + final canonical = { 'version': '1.0.0', - 'target': 'generic', + 'target': runtimeProfile.target, 'models': models, + 'aliases': aliases, + 'capabilities': capabilities, }; - final canonicalJson = jsonEncode(canonical); - final hash = _stableHash(canonicalJson); + final hash = _stableHash(jsonEncode(canonical)); final contract = { 'version': '1.0.0', 'hash': hash, - 'target': 'generic', + 'target': runtimeProfile.target, 'markerStorageHash': hash, 'models': models, - 'aliases': const {}, - 'capabilities': const { - 'includeSingleQuery': false, - 'mutationReturning': true, - }, + 'aliases': aliases, + 'capabilities': capabilities, }; final encoder = const JsonEncoder.withIndent(' '); return '${encoder.convert(contract)}\n'; } +SplayTreeMap _indexModels( + List models, +) { + final lookup = SplayTreeMap(); + for (final model in models) { + final scalarFieldNames = + model.fields + .where((field) => _isScalarType(field.typeSource)) + .map((field) => field.name) + .toList(growable: false) + ..sort(); + final scalarFields = scalarFieldNames.toSet(); + final idFields = + model.fields + .where((field) => field.isId && scalarFields.contains(field.name)) + .map((field) => field.name) + .toList(growable: false) + ..sort(); + final inferredIdFields = idFields.isNotEmpty + ? idFields + : scalarFields.contains('id') + ? const ['id'] + : const []; + + lookup[model.name] = _SchemaModelInfo( + model: model, + scalarFields: scalarFields, + scalarFieldNames: scalarFieldNames, + idFields: inferredIdFields, + ); + } + return lookup; +} + +SplayTreeMap _buildAliases(Iterable<_SchemaModelInfo> models) { + final aliases = SplayTreeMap(); + for (final modelInfo in models) { + final modelName = modelInfo.model.name; + final lowerName = _lowercaseFirst(modelName); + final table = _defaultTableName(modelName); + final candidates = [ + lowerName, + _pluralize(lowerName), + table, + _pluralize(table), + ]; + + for (final candidate in candidates) { + if (candidate.isEmpty || candidate == modelName) { + continue; + } + if (aliases.containsKey(candidate)) { + continue; + } + aliases[candidate] = modelName; + } + } + return aliases; +} + +SplayTreeMap _buildRelations({ + required _SchemaModelInfo owner, + required Map modelInfos, +}) { + final relations = SplayTreeMap(); + final modelNames = modelInfos.keys.toSet(); + + for (final field in owner.model.fields) { + if (_isScalarType(field.typeSource)) { + continue; + } + + final relationType = _parseRelationType( + source: field.typeSource, + modelNames: modelNames, + ); + if (relationType == null) { + continue; + } + + final related = modelInfos[relationType.relatedModel]; + if (related == null) { + continue; + } + + final resolved = _resolveRelation( + owner: owner, + relationField: field, + relationType: relationType, + related: related, + ); + if (resolved == null) { + continue; + } + + relations[field.name] = { + 'name': _relationName(field), + 'relatedModel': relationType.relatedModel, + 'sourceFields': resolved.sourceFields, + 'targetFields': resolved.targetFields, + 'cardinality': resolved.cardinality, + }; + } + + return relations; +} + +_ResolvedRelation? _resolveRelation({ + required _SchemaModelInfo owner, + required SchemaFieldDefinition relationField, + required _RelationFieldType relationType, + required _SchemaModelInfo related, +}) { + final fromAnnotation = _resolveRelationFromAnnotation( + owner: owner, + relationField: relationField, + relationType: relationType, + related: related, + ); + if (fromAnnotation != null) { + return fromAnnotation; + } + + if (relationType.isMany) { + return _resolveToManyRelation(owner: owner, related: related); + } + + return _resolveToOneRelation( + owner: owner, + relationField: relationField, + related: related, + ); +} + +_ResolvedRelation? _resolveRelationFromAnnotation({ + required _SchemaModelInfo owner, + required SchemaFieldDefinition relationField, + required _RelationFieldType relationType, + required _SchemaModelInfo related, +}) { + final annotation = relationField.relation; + if (annotation == null) { + return null; + } + + final sourceFields = annotation.fields?.toList(growable: false); + sourceFields?.sort(); + final targetFields = annotation.references?.toList(growable: false); + targetFields?.sort(); + + if (sourceFields != null && targetFields != null) { + if (sourceFields.length != 1 || targetFields.length != 1) { + return null; + } + if (!owner.scalarFields.contains(sourceFields.single)) { + return null; + } + if (!related.scalarFields.contains(targetFields.single)) { + return null; + } + + return _ResolvedRelation( + sourceFields: sourceFields, + targetFields: targetFields, + cardinality: relationType.cardinality, + ); + } + + if (sourceFields != null && targetFields == null) { + if (sourceFields.length != 1 || + !owner.scalarFields.contains(sourceFields.single)) { + return null; + } + + final targetField = related.singleIdField; + if (targetField == null || !related.scalarFields.contains(targetField)) { + return null; + } + + return _ResolvedRelation( + sourceFields: sourceFields, + targetFields: [targetField], + cardinality: relationType.cardinality, + ); + } + + if (sourceFields == null && targetFields != null && relationType.isMany) { + if (targetFields.length != 1 || + !related.scalarFields.contains(targetFields.single)) { + return null; + } + + final sourceField = owner.singleIdField; + if (sourceField == null || !owner.scalarFields.contains(sourceField)) { + return null; + } + + return _ResolvedRelation( + sourceFields: [sourceField], + targetFields: targetFields, + cardinality: relationType.cardinality, + ); + } + + if (sourceFields == null && targetFields != null && !relationType.isMany) { + if (targetFields.length != 1 || + !related.scalarFields.contains(targetFields.single)) { + return null; + } + + return _resolveToOneRelation( + owner: owner, + relationField: relationField, + related: related, + targetField: targetFields.single, + ); + } + + return null; +} + +_ResolvedRelation? _resolveToOneRelation({ + required _SchemaModelInfo owner, + required SchemaFieldDefinition relationField, + required _SchemaModelInfo related, + String? targetField, +}) { + final resolvedTarget = targetField ?? related.singleIdField; + if (resolvedTarget == null || + !related.scalarFields.contains(resolvedTarget)) { + return null; + } + + final sourceSuffix = _uppercaseFirst(resolvedTarget); + final sourceFieldCandidates = [ + if (relationField.name.isNotEmpty) '${relationField.name}Id', + '${_lowercaseFirst(related.model.name)}Id', + '${related.model.name}Id', + if (resolvedTarget != 'id' && relationField.name.isNotEmpty) + '${relationField.name}$sourceSuffix', + if (resolvedTarget != 'id') + '${_lowercaseFirst(related.model.name)}$sourceSuffix', + if (resolvedTarget != 'id') '${related.model.name}$sourceSuffix', + ]; + + final sourceField = _findFirstExistingField( + candidates: sourceFieldCandidates, + available: owner.scalarFields, + ); + if (sourceField == null) { + return null; + } + + return _ResolvedRelation( + sourceFields: [sourceField], + targetFields: [resolvedTarget], + cardinality: 'one', + ); +} + +_ResolvedRelation? _resolveToManyRelation({ + required _SchemaModelInfo owner, + required _SchemaModelInfo related, +}) { + final sourceField = owner.singleIdField; + if (sourceField == null || !owner.scalarFields.contains(sourceField)) { + return null; + } + + final targetSuffix = _uppercaseFirst(sourceField); + final targetFieldCandidates = [ + '${_lowercaseFirst(owner.model.name)}Id', + '${owner.model.name}Id', + if (sourceField != 'id') + '${_lowercaseFirst(owner.model.name)}$targetSuffix', + if (sourceField != 'id') '${owner.model.name}$targetSuffix', + ]; + + final targetField = _findFirstExistingField( + candidates: targetFieldCandidates, + available: related.scalarFields, + ); + if (targetField == null) { + return null; + } + + return _ResolvedRelation( + sourceFields: [sourceField], + targetFields: [targetField], + cardinality: 'many', + ); +} + +String? _findFirstExistingField({ + required Iterable candidates, + required Set available, +}) { + final seen = {}; + for (final candidate in candidates) { + final normalized = candidate.trim(); + if (normalized.isEmpty || !seen.add(normalized)) { + continue; + } + if (available.contains(normalized)) { + return normalized; + } + } + return null; +} + +_RelationFieldType? _parseRelationType({ + required String source, + required Set modelNames, +}) { + var value = source.trim().replaceAll(RegExp(r'\s+'), ''); + if (value.endsWith('?')) { + value = value.substring(0, value.length - 1); + } + + var isMany = false; + final listMatch = RegExp(r'^List<(.+)>$').firstMatch(value); + if (listMatch != null) { + isMany = true; + value = listMatch.group(1)!; + if (value.endsWith('?')) { + value = value.substring(0, value.length - 1); + } + } + + if (!modelNames.contains(value)) { + return null; + } + + return _RelationFieldType(relatedModel: value, isMany: isMany); +} + +_RuntimeProfile _runtimeProfileForProvider(String? provider) { + switch (provider) { + case 'sqlite': + return const _RuntimeProfile( + target: 'sql-family', + capabilities: { + 'includeSingleQuery': false, + 'mutationReturning': false, + }, + ); + default: + return const _RuntimeProfile( + target: 'generic', + capabilities: { + 'includeSingleQuery': false, + 'mutationReturning': true, + }, + ); + } +} + bool _isScalarType(String source) { final parsed = _normalizeType(source); return switch (parsed) { @@ -70,15 +428,32 @@ String _normalizeType(String source) { return value; } +String _pluralize(String value) { + if (value.isEmpty || value.endsWith('s')) { + return value; + } + return '${value}s'; +} + String _defaultTableName(String modelName) { if (modelName.isEmpty) { return modelName; } - final lower = modelName[0].toLowerCase() + modelName.substring(1); - if (lower.endsWith('s')) { - return lower; + return _pluralize(_lowercaseFirst(modelName)); +} + +String _lowercaseFirst(String value) { + if (value.isEmpty) { + return value; + } + return value[0].toLowerCase() + value.substring(1); +} + +String _uppercaseFirst(String value) { + if (value.isEmpty) { + return value; } - return '${lower}s'; + return value[0].toUpperCase() + value.substring(1); } String _stableHash(String value) { @@ -92,3 +467,60 @@ String _stableHash(String value) { final hex = hash.toUnsigned(64).toRadixString(16).padLeft(16, '0'); return 'c$hex'; } + +String _relationName(SchemaFieldDefinition field) { + final configured = field.relation?.name?.trim(); + if (configured == null || configured.isEmpty) { + return field.name; + } + return configured; +} + +final class _RuntimeProfile { + final String target; + final Map capabilities; + + const _RuntimeProfile({required this.target, required this.capabilities}); +} + +final class _SchemaModelInfo { + final SchemaModelDefinition model; + final Set scalarFields; + final List scalarFieldNames; + final List idFields; + + const _SchemaModelInfo({ + required this.model, + required this.scalarFields, + required this.scalarFieldNames, + required this.idFields, + }); + + String? get singleIdField { + if (idFields.length != 1) { + return null; + } + return idFields.single; + } +} + +final class _RelationFieldType { + final String relatedModel; + final bool isMany; + + const _RelationFieldType({required this.relatedModel, required this.isMany}); + + String get cardinality => isMany ? 'many' : 'one'; +} + +final class _ResolvedRelation { + final List sourceFields; + final List targetFields; + final String cardinality; + + const _ResolvedRelation({ + required this.sourceFields, + required this.targetFields, + required this.cardinality, + }); +} diff --git a/pub/orm/lib/src/generator/schema_loader.dart b/pub/orm/lib/src/generator/schema_loader.dart index de6ddc80..97f0115a 100644 --- a/pub/orm/lib/src/generator/schema_loader.dart +++ b/pub/orm/lib/src/generator/schema_loader.dart @@ -135,6 +135,7 @@ List _readModels( final fieldName = field.name.lexeme; final fieldTypeSource = field.type.toSource().trim(); final isId = _hasAnnotationIgnoreCase(field.metadata, 'id'); + final relation = _readRelationAnnotation(field.metadata); if (fieldTypeSource.isEmpty) { throw GeneratorException( 'Model $modelName field $fieldName has invalid type.', @@ -148,6 +149,7 @@ List _readModels( name: fieldName, typeSource: fieldTypeSource, isId: isId, + relation: relation, ), ); } @@ -177,6 +179,89 @@ bool _hasAnnotationIgnoreCase(NodeList metadata, String name) { return false; } +SchemaRelationAnnotationDefinition? _readRelationAnnotation( + NodeList metadata, +) { + for (final annotation in metadata) { + if (annotation.name.name.toLowerCase() != 'relation') { + continue; + } + + final arguments = annotation.arguments?.arguments; + if (arguments == null || arguments.isEmpty) { + return const SchemaRelationAnnotationDefinition(); + } + + Set? fields; + Set? references; + String? name; + + for (final argument in arguments) { + if (argument is! NamedExpression) { + continue; + } + + final label = argument.name.label.name; + switch (label) { + case 'fields': + fields = _readStringCollection(argument.expression); + break; + case 'references': + references = _readStringCollection(argument.expression); + break; + case 'name': + name = _readStringLiteral(argument.expression); + break; + } + } + + return SchemaRelationAnnotationDefinition( + fields: fields, + references: references, + name: name, + ); + } + + return null; +} + +Set? _readStringCollection(Expression expression) { + if (expression is SetOrMapLiteral) { + if (expression.isMap) { + return null; + } + return _readCollectionElements(expression.elements); + } + + if (expression is ListLiteral) { + return _readCollectionElements(expression.elements); + } + + return null; +} + +Set? _readCollectionElements(NodeList elements) { + final values = {}; + for (final element in elements) { + if (element is! Expression) { + return null; + } + final literal = _readStringLiteral(element); + if (literal == null) { + return null; + } + values.add(literal); + } + return values; +} + +String? _readStringLiteral(Expression expression) { + if (expression is StringLiteral) { + return expression.stringValue; + } + return null; +} + String _join(String base, String child) { if (base.endsWith(Platform.pathSeparator)) { return '$base$child'; diff --git a/pub/orm/lib/src/generator/snapshot.dart b/pub/orm/lib/src/generator/snapshot.dart index cb4a4a42..3a9fe6b8 100644 --- a/pub/orm/lib/src/generator/snapshot.dart +++ b/pub/orm/lib/src/generator/snapshot.dart @@ -2,11 +2,13 @@ import 'dart:io'; final class GeneratorConfigSnapshot { final File configFile; + final String? provider; final String outputPath; final String? schemaPath; const GeneratorConfigSnapshot({ required this.configFile, + required this.provider, required this.outputPath, required this.schemaPath, }); @@ -16,11 +18,25 @@ final class SchemaFieldDefinition { final String name; final String typeSource; final bool isId; + final SchemaRelationAnnotationDefinition? relation; const SchemaFieldDefinition({ required this.name, required this.typeSource, this.isId = false, + this.relation, + }); +} + +final class SchemaRelationAnnotationDefinition { + final Set? fields; + final Set? references; + final String? name; + + const SchemaRelationAnnotationDefinition({ + this.fields, + this.references, + this.name, }); } diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index e208bffc..e732db78 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -133,13 +133,109 @@ void main() { final decoded = jsonDecode(output.readAsStringSync()); expect(decoded is Map, isTrue); - expect((decoded as Map).containsKey('hash'), isTrue); - expect( - (decoded['models'] as Map).containsKey('User'), - isTrue, + final contract = decoded as Map; + expect(contract.containsKey('hash'), isTrue); + expect(contract['target'], 'generic'); + + final capabilities = contract['capabilities']; + expect(capabilities is Map, isTrue); + final capabilityMap = capabilities as Map; + expect(capabilityMap.containsKey('includeSingleQuery'), isTrue); + expect(capabilityMap['includeSingleQuery'], isFalse); + expect(capabilityMap.containsKey('mutationReturning'), isTrue); + expect(capabilityMap['mutationReturning'], isTrue); + + final aliases = contract['aliases']; + expect(aliases is Map, isTrue); + final aliasMap = aliases as Map; + expect(aliasMap['user'], 'User'); + expect(aliasMap['users'], 'User'); + + final models = contract['models'] as Map; + expect(models.containsKey('User'), isTrue); + final user = models['User']; + expect(user is Map, isTrue); + final relations = (user as Map)['relations']; + expect(relations is Map, isTrue); + expect((relations as Map).isEmpty, isTrue); + }); + + test('contract emit maps provider to target and capabilities', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'default_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final configFile = File( + _path([fixtureDir.path, 'orm.config.dart']), + ); + configFile.writeAsStringSync(''' +class Config { + final String? output; + final String? schema; + final String? provider; + + const Config({this.output, this.schema, this.provider}); +} + +const config = Config(provider: 'sqlite'); +'''); + + final run = await _runContractEmit( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + final output = File( + _path([fixtureDir.path, 'orm.contract.json']), ); + final contract = + jsonDecode(output.readAsStringSync()) as Map; + expect(contract['target'], 'sql-family'); + + final capabilities = contract['capabilities'] as Map; + expect(capabilities['includeSingleQuery'], isFalse); + expect(capabilities['mutationReturning'], isFalse); }); + test( + 'contract emit infers relation metadata from schema fields', + () async { + final fixtureDir = _copyFixture(fixturesRoot, 'relation_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final run = await _runContractEmit( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + final output = File( + _path([fixtureDir.path, 'orm.contract.json']), + ); + final contract = + jsonDecode(output.readAsStringSync()) as Map; + final models = contract['models'] as Map; + + final userModel = models['User'] as Map; + final userRelations = userModel['relations'] as Map; + final posts = userRelations['posts'] as Map; + expect(posts['relatedModel'], 'Post'); + expect(posts['cardinality'], 'many'); + expect(posts['sourceFields'], ['id']); + expect(posts['targetFields'], ['userId']); + + final postModel = models['Post'] as Map; + final postRelations = postModel['relations'] as Map; + final author = postRelations['author'] as Map; + expect(author['relatedModel'], 'User'); + expect(author['cardinality'], 'one'); + expect(author['sourceFields'], ['userId']); + expect(author['targetFields'], ['id']); + }, + ); + test('contract emit supports --output override path', () async { final fixtureDir = _copyFixture(fixturesRoot, 'default_output'); addTearDown(() => fixtureDir.deleteSync(recursive: true)); From f43f1df4005d0b9783bdd91f71405d8b2c5fe15d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:02:31 +0800 Subject: [PATCH 078/154] test(generator): lock contract relation override fallback semantics --- pub/orm/test/generator/generate_test.dart | 164 ++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index e732db78..c465a570 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -236,6 +236,170 @@ const config = Config(provider: 'sqlite'); }, ); + test( + 'contract emit keeps hash stable when scalar field declaration order changes', + () async { + final fixtureDir = _copyFixture(fixturesRoot, 'default_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final firstRun = await _runContractEmit( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + expect(firstRun.exitCode, 0, reason: firstRun.debugOutput); + + final contractFile = File( + _path([fixtureDir.path, 'orm.contract.json']), + ); + final firstContract = + jsonDecode(contractFile.readAsStringSync()) + as Map; + final firstHash = firstContract['hash']; + + final schemaFile = File( + _path([fixtureDir.path, 'orm.schema.dart']), + ); + schemaFile.writeAsStringSync(''' +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +@model +typedef User = ({DateTime createdAt, String email, int id}); +'''); + + final secondRun = await _runContractEmit( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + expect(secondRun.exitCode, 0, reason: secondRun.debugOutput); + + final secondContract = + jsonDecode(contractFile.readAsStringSync()) + as Map; + expect(secondContract['hash'], firstHash); + expect(secondContract['markerStorageHash'], firstHash); + }, + ); + + test( + 'contract emit applies relation annotation overrides when valid', + () async { + final fixtureDir = _copyFixture(fixturesRoot, 'relation_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final schemaFile = File( + _path([fixtureDir.path, 'orm.schema.dart']), + ); + schemaFile.writeAsStringSync(''' +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +class Relation { + final Set? fields; + final Set? references; + final String? name; + + const Relation({this.fields, this.references, this.name}); +} + +@model +typedef User = ({String id, String email, List posts}); + +@model +typedef Post = ({ + String id, + String userId, + String title, + @Relation(fields: {'userId'}, references: {'id'}, name: 'postAuthor') + User? author +}); +'''); + + final run = await _runContractEmit( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + expect(run.exitCode, 0, reason: run.debugOutput); + + final output = File( + _path([fixtureDir.path, 'orm.contract.json']), + ); + final contract = + jsonDecode(output.readAsStringSync()) as Map; + final models = contract['models'] as Map; + final postModel = models['Post'] as Map; + final postRelations = postModel['relations'] as Map; + final author = postRelations['author'] as Map; + expect(author['name'], 'postAuthor'); + expect(author['sourceFields'], ['userId']); + expect(author['targetFields'], ['id']); + }, + ); + + test( + 'contract emit ignores invalid relation annotation and falls back to inference', + () async { + final fixtureDir = _copyFixture(fixturesRoot, 'relation_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final schemaFile = File( + _path([fixtureDir.path, 'orm.schema.dart']), + ); + schemaFile.writeAsStringSync(''' +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +class Relation { + final Set? fields; + final Set? references; + final String? name; + + const Relation({this.fields, this.references, this.name}); +} + +@model +typedef User = ({String id, String email, List posts}); + +@model +typedef Post = ({ + String id, + String userId, + String title, + @Relation(fields: {'missingField'}, references: {'id'}) + User? author +}); +'''); + + final run = await _runContractEmit( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + expect(run.exitCode, 0, reason: run.debugOutput); + + final output = File( + _path([fixtureDir.path, 'orm.contract.json']), + ); + final contract = + jsonDecode(output.readAsStringSync()) as Map; + final models = contract['models'] as Map; + final postModel = models['Post'] as Map; + final postRelations = postModel['relations'] as Map; + final author = postRelations['author'] as Map; + expect(author['name'], 'author'); + expect(author['sourceFields'], ['userId']); + expect(author['targetFields'], ['id']); + }, + ); + test('contract emit supports --output override path', () async { final fixtureDir = _copyFixture(fixturesRoot, 'default_output'); addTearDown(() => fixtureDir.deleteSync(recursive: true)); From 43c3f9f5f142ed9162e85b4e3636dacbcffd96ab Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:16:12 +0800 Subject: [PATCH 079/154] feat(generator)!: default query where/include to merge semantics BREAKING CHANGE: generated query where/include now default to merge=true. Use merge: false to keep replacement behavior. --- pub/orm/lib/src/generator/writer.dart | 104 +++++++++++++++++++++- pub/orm/test/generator/generate_test.dart | 69 ++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index b7b09c99..8a1b8ff1 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1076,6 +1076,24 @@ final class TypedClientWriter { buffer.writeln(' const ${model.selectClassName}();'); } buffer.writeln(); + buffer.writeln( + ' ${model.selectClassName} merge(${model.selectClassName} other) {', + ); + if (scalarFields.isEmpty) { + buffer.writeln(' return this;'); + } else { + buffer.writeln(' return ${model.selectClassName}('); + for (final field in scalarFields) { + final memberName = _toLowerCamelIdentifier( + field.name, + fallback: 'field', + ); + buffer.writeln(' $memberName: $memberName || other.$memberName,'); + } + buffer.writeln(' );'); + } + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' List toFields() {'); if (scalarFields.isEmpty) { buffer.writeln(' return const [];'); @@ -1131,6 +1149,27 @@ final class TypedClientWriter { buffer.writeln(' this.include,'); buffer.writeln(' });'); buffer.writeln(); + buffer.writeln(' $includeClassName merge($includeClassName other) {'); + buffer.writeln(' return $includeClassName('); + buffer.writeln(' where: where.andWith(other.where),'); + buffer.writeln(' skip: other.skip ?? skip,'); + buffer.writeln(' take: other.take ?? take,'); + buffer.writeln( + ' orderBy: <${relationModel.orderByClassName}>[...orderBy, ...other.orderBy],', + ); + buffer.writeln(' select: select == null'); + buffer.writeln(' ? other.select'); + buffer.writeln( + ' : (other.select == null ? select : select!.merge(other.select!)),', + ); + buffer.writeln(' include: include == null'); + buffer.writeln(' ? other.include'); + buffer.writeln( + ' : (other.include == null ? include : include!.merge(other.include!)),', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' IncludeSpec toIncludeSpec() {'); buffer.writeln(' return IncludeSpec('); buffer.writeln(' where: where.toJson(),'); @@ -1177,6 +1216,28 @@ final class TypedClientWriter { buffer.writeln(' const ${model.includeClassName}();'); } buffer.writeln(); + buffer.writeln( + ' ${model.includeClassName} merge(${model.includeClassName} other) {', + ); + if (relationFields.isEmpty) { + buffer.writeln(' return this;'); + } else { + buffer.writeln(' return ${model.includeClassName}('); + for (final relation in relationFields) { + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + buffer.writeln(' $memberName: $memberName == null'); + buffer.writeln(' ? other.$memberName'); + buffer.writeln( + ' : (other.$memberName == null ? $memberName : $memberName!.merge(other.$memberName!)),', + ); + } + buffer.writeln(' );'); + } + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' Map toIncludeMap() {'); if (relationFields.isEmpty) { buffer.writeln(' return const {};'); @@ -2036,11 +2097,11 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' ${model.queryClassName} where(${model.whereInputClassName} where) {', + ' ${model.queryClassName} where(${model.whereInputClassName} where, {bool merge = true}) {', ); buffer.writeln(' return ${model.queryClassName}._('); buffer.writeln(' delegate: _delegate,'); - buffer.writeln(' where: where,'); + buffer.writeln(' where: merge ? _where.andWith(where) : where,'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); @@ -2128,7 +2189,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' ${model.queryClassName} include(${model.includeClassName}? include) {', + ' ${model.queryClassName} include(${model.includeClassName}? include, {bool merge = true}) {', ); buffer.writeln(' return ${model.queryClassName}._('); buffer.writeln(' delegate: _delegate,'); @@ -2138,7 +2199,11 @@ final class TypedClientWriter { buffer.writeln(' orderBy: _orderBy,'); buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); - buffer.writeln(' include: include,'); + buffer.writeln(' include: merge'); + buffer.writeln( + ' ? (include == null ? _include : (_include?.merge(include) ?? include))', + ); + buffer.writeln(' : include,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2448,6 +2513,21 @@ final class TypedClientWriter { buffer.writeln(' const $className();'); } + if (includeLogicalWhere) { + buffer.writeln(); + buffer.writeln(' $className andWith($className other) {'); + buffer.writeln(' if (isEmpty) {'); + buffer.writeln(' return other;'); + buffer.writeln(' }'); + buffer.writeln(' if (other.isEmpty) {'); + buffer.writeln(' return this;'); + buffer.writeln(' }'); + buffer.writeln(' return $className('); + buffer.writeln(' and: <$className>[this, other],'); + buffer.writeln(' );'); + buffer.writeln(' }'); + } + buffer.writeln(); buffer.writeln( ' factory $className.fromJson(Map json) {', @@ -2534,6 +2614,22 @@ final class TypedClientWriter { } buffer.writeln(' };'); buffer.writeln(' }'); + if (includeLogicalWhere) { + buffer.writeln(); + buffer.writeln(' bool get isEmpty =>'); + for (final field in fields) { + buffer.writeln( + ' (${field.memberName} == null || ${field.memberName}!.isEmpty) &&', + ); + } + buffer.writeln( + ' (and == null || and!.every((value) => value.isEmpty)) &&', + ); + buffer.writeln( + ' (or == null || or!.every((value) => value.isEmpty)) &&', + ); + buffer.writeln(' (not == null || not!.isEmpty);'); + } buffer.writeln('}'); buffer.writeln(); } diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index c465a570..22f2d5a1 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -521,6 +521,54 @@ typedef Post = ({ isTrue, reason: 'Expected UserQuery.where(...) in generated source.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+where\(\s*UserWhereInput\s+where,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.where(...) to expose merge flag with default true.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+where\([\s\S]*?_where\.andWith\(where\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.where(...) merge path to combine with _where.andWith(where).', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+where\([\s\S]*?return\s+UserQuery\._\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.where(...) chaining to stay immutable by returning a new query object.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+include\(\s*UserInclude\?\s+include,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.include(...) to expose merge flag with default true.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+include\([\s\S]*?_include\?\.merge\(include\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.include(...) merge path to use _include?.merge(include).', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+include\([\s\S]*?return\s+UserQuery\._\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.include(...) chaining to stay immutable by returning a new query object.', + ); expect( RegExp( r'\bFuture>\s+all\s*\(\s*\)', @@ -972,6 +1020,13 @@ typedef Post = ({ isTrue, reason: 'Missing typed include DSL class in generated source.', ); + expect( + RegExp( + r'class\s+UserInclude\s*\{[\s\S]*?UserInclude\s+merge\(\s*UserInclude\s+other\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected model include class to generate merge helper.', + ); expect( RegExp(r'\bclass StringWhereFilter\b').hasMatch(generatedSource), isTrue, @@ -1058,6 +1113,13 @@ typedef Post = ({ reason: 'Expected UserWhereInput to include NOT logical field marker.', ); + expect( + RegExp( + r'class\s+UserWhereInput\s*\{[\s\S]*?UserWhereInput\s+andWith\(\s*UserWhereInput\s+other\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserWhereInput to generate andWith merge helper.', + ); expect( generatedSource.contains('List orderBy'), isTrue, @@ -1094,6 +1156,13 @@ typedef Post = ({ .map((file) => file.readAsStringSync()) .join('\n'); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+merge\(\s*UserPostsInclude\s+other\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected relation include class to generate merge helper.', + ); expect( RegExp( r'\bclass UserPostsRelationWhereFilter\b', From 28834db87631951b358199cf907f43477d754821 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:20:14 +0800 Subject: [PATCH 080/154] feat(generator)!: replace typed findMany/findFirst with all/first BREAKING CHANGE: generated typed delegates no longer expose findMany/findFirst. Use all/first instead. --- pub/orm/lib/src/generator/writer.dart | 8 ++++---- pub/orm/test/generator/generate_test.dart | 24 +++++++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 8a1b8ff1..02f729bf 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1409,7 +1409,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future> findMany({'); + buffer.writeln(' Future> all({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); @@ -1474,7 +1474,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> findFirst({'); + buffer.writeln(' Future<${model.dataClassName}?> first({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); @@ -2209,7 +2209,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Future> all() {'); - buffer.writeln(' return _delegate.findMany('); + buffer.writeln(' return _delegate.all('); buffer.writeln(' where: _where,'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); @@ -2222,7 +2222,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Future<${model.dataClassName}?> first() {'); - buffer.writeln(' return _delegate.findFirst('); + buffer.writeln(' return _delegate.first('); buffer.writeln(' where: _where,'); buffer.writeln(' skip: _skip,'); buffer.writeln(' orderBy: _orderBy,'); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 22f2d5a1..afb207a8 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -707,26 +707,38 @@ typedef Post = ({ ); expect( RegExp( - r'Future>\s+findMany\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),', + r'Future>\s+all\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),', ).hasMatch(generatedSource), isTrue, - reason: 'Expected non-unique findMany to keep UserWhereInput.', + reason: 'Expected non-unique all to keep UserWhereInput.', ); expect( RegExp( - r'Future>\s+findMany\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', + r'Future>\s+all\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', ).hasMatch(generatedSource), isTrue, reason: - 'Expected findMany to expose typed distinct parameter in generated delegate.', + 'Expected all to expose typed distinct parameter in generated delegate.', ); expect( RegExp( - r'Future\s+findFirst\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', + r'Future\s+first\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', ).hasMatch(generatedSource), isTrue, reason: - 'Expected findFirst to expose typed distinct parameter in generated delegate.', + 'Expected first to expose typed distinct parameter in generated delegate.', + ); + expect( + generatedSource.contains('Future> findMany('), + isFalse, + reason: + 'Expected generated typed delegate source to not expose findMany signature.', + ); + expect( + generatedSource.contains('Future findFirst('), + isFalse, + reason: + 'Expected generated typed delegate source to not expose findFirst signature.', ); expect( RegExp( From 8f4cda80bd316ea08a715a5602966b39f909fd25 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:30:28 +0800 Subject: [PATCH 081/154] feat(generator)!: add fluent relation include chaining helpers BREAKING CHANGE: generated relation include classes now use chain methods for where/skip/take/orderBy/select/include; previous direct field access on relation include state is no longer part of the public generated surface. --- pub/orm/lib/src/generator/writer.dart | 208 +++++++++++++++++++--- pub/orm/test/generator/generate_test.dart | 112 ++++++++++++ 2 files changed, 291 insertions(+), 29 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 02f729bf..a8764eef 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1127,60 +1127,151 @@ final class TypedClientWriter { relationFieldName: relation.name, ); buffer.writeln('class $includeClassName {'); - buffer.writeln(' final ${relationModel.whereInputClassName} where;'); - buffer.writeln(' final int? skip;'); - buffer.writeln(' final int? take;'); + buffer.writeln(' final ${relationModel.whereInputClassName} _where;'); + buffer.writeln(' final int? _skip;'); + buffer.writeln(' final int? _take;'); buffer.writeln( - ' final List<${relationModel.orderByClassName}> orderBy;', + ' final List<${relationModel.orderByClassName}> _orderBy;', ); - buffer.writeln(' final ${relationModel.selectClassName}? select;'); - buffer.writeln(' final ${relationModel.includeClassName}? include;'); + buffer.writeln(' final ${relationModel.selectClassName}? _select;'); + buffer.writeln(' final ${relationModel.includeClassName}? _include;'); buffer.writeln(); buffer.writeln(' const $includeClassName({'); buffer.writeln( - ' this.where = const ${relationModel.whereInputClassName}(),', + ' ${relationModel.whereInputClassName} where = const ${relationModel.whereInputClassName}(),', ); - buffer.writeln(' this.skip,'); - buffer.writeln(' this.take,'); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); buffer.writeln( - ' this.orderBy = const <${relationModel.orderByClassName}>[],', + ' List<${relationModel.orderByClassName}> orderBy = const <${relationModel.orderByClassName}>[],', ); - buffer.writeln(' this.select,'); - buffer.writeln(' this.include,'); - buffer.writeln(' });'); + buffer.writeln(' ${relationModel.selectClassName}? select,'); + buffer.writeln(' ${relationModel.includeClassName}? include,'); + buffer.writeln(' }) : _where = where,'); + buffer.writeln(' _skip = skip,'); + buffer.writeln(' _take = take,'); + buffer.writeln(' _orderBy = orderBy,'); + buffer.writeln(' _select = select,'); + buffer.writeln(' _include = include;'); buffer.writeln(); buffer.writeln(' $includeClassName merge($includeClassName other) {'); buffer.writeln(' return $includeClassName('); - buffer.writeln(' where: where.andWith(other.where),'); - buffer.writeln(' skip: other.skip ?? skip,'); - buffer.writeln(' take: other.take ?? take,'); + buffer.writeln(' where: _where.andWith(other._where),'); + buffer.writeln(' skip: other._skip ?? _skip,'); + buffer.writeln(' take: other._take ?? _take,'); buffer.writeln( - ' orderBy: <${relationModel.orderByClassName}>[...orderBy, ...other.orderBy],', + ' orderBy: <${relationModel.orderByClassName}>[..._orderBy, ...other._orderBy],', ); - buffer.writeln(' select: select == null'); - buffer.writeln(' ? other.select'); + buffer.writeln(' select: _select == null'); + buffer.writeln(' ? other._select'); buffer.writeln( - ' : (other.select == null ? select : select!.merge(other.select!)),', + ' : (other._select == null ? _select : _select!.merge(other._select!)),', ); - buffer.writeln(' include: include == null'); - buffer.writeln(' ? other.include'); + buffer.writeln(' include: _include == null'); + buffer.writeln(' ? other._include'); buffer.writeln( - ' : (other.include == null ? include : include!.merge(other.include!)),', + ' : (other._include == null ? _include : _include!.merge(other._include!)),', ); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' IncludeSpec toIncludeSpec() {'); - buffer.writeln(' return IncludeSpec('); - buffer.writeln(' where: where.toJson(),'); + buffer.writeln( + ' $includeClassName where(${relationModel.whereInputClassName} where, {bool merge = true}) {', + ); + buffer.writeln(' return $includeClassName('); + buffer.writeln(' where: merge ? _where.andWith(where) : where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' $includeClassName skip(int? skip) {'); + buffer.writeln(' return $includeClassName('); + buffer.writeln(' where: _where,'); buffer.writeln(' skip: skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' $includeClassName take(int? take) {'); + buffer.writeln(' return $includeClassName('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln( - ' orderBy: orderBy.map((entry) => entry.value).toList(growable: false),', + ' $includeClassName orderBy(List<${relationModel.orderByClassName}> orderBy, {bool append = true}) {', ); - buffer.writeln(' select: select?.toFields() ?? const [],'); + buffer.writeln(' return $includeClassName('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: append'); buffer.writeln( - ' include: include?.toIncludeMap() ?? const {},', + ' ? <${relationModel.orderByClassName}>[..._orderBy, ...orderBy]', + ); + buffer.writeln(' : orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' $includeClassName select(${relationModel.selectClassName}? select, {bool merge = true}) {', + ); + buffer.writeln(' return $includeClassName('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: merge'); + buffer.writeln( + ' ? (select == null ? _select : (_select?.merge(select) ?? select))', + ); + buffer.writeln(' : select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' $includeClassName include(${relationModel.includeClassName}? include, {bool merge = true}) {', + ); + buffer.writeln(' return $includeClassName('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: merge'); + buffer.writeln( + ' ? (include == null ? _include : (_include?.merge(include) ?? include))', + ); + buffer.writeln(' : include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' IncludeSpec toIncludeSpec() {'); + buffer.writeln(' return IncludeSpec('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln( + ' orderBy: _orderBy.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' select: _select?.toFields() ?? const [],'); + buffer.writeln( + ' include: _include?.toIncludeMap() ?? const {},', ); buffer.writeln(' );'); buffer.writeln(' }'); @@ -1238,6 +1329,34 @@ final class TypedClientWriter { } buffer.writeln(' }'); buffer.writeln(); + for (final relation in relationFields) { + final includeClassName = _relationIncludeClassName( + owner: model, + relationFieldName: relation.name, + ); + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + final methodSuffix = _toUpperCamelIdentifier( + relation.name, + fallback: 'Relation', + ); + buffer.writeln( + ' ${model.includeClassName} include$methodSuffix([$includeClassName Function($includeClassName current)? configure]) {', + ); + buffer.writeln( + ' final current = $memberName ?? const $includeClassName();', + ); + buffer.writeln( + ' final next = configure == null ? current : configure(current);', + ); + buffer.writeln( + ' return merge(${model.includeClassName}($memberName: next));', + ); + buffer.writeln(' }'); + buffer.writeln(); + } buffer.writeln(' Map toIncludeMap() {'); if (relationFields.isEmpty) { buffer.writeln(' return const {};'); @@ -2063,6 +2182,9 @@ final class TypedClientWriter { required StringBuffer buffer, required _ResolvedModel model, }) { + final relationFields = model.model.fields + .where((field) => field.isRelation) + .toList(growable: false); buffer.writeln('class ${model.queryClassName} {'); buffer.writeln(' final ${model.delegateClassName} _delegate;'); buffer.writeln(' final ${model.whereInputClassName} _where;'); @@ -2207,6 +2329,34 @@ final class TypedClientWriter { buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); + for (final relation in relationFields) { + final includeClassName = _relationIncludeClassName( + owner: model, + relationFieldName: relation.name, + ); + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + final methodSuffix = _toUpperCamelIdentifier( + relation.name, + fallback: 'Relation', + ); + buffer.writeln( + ' ${model.queryClassName} include$methodSuffix([$includeClassName Function($includeClassName current)? configure]) {', + ); + buffer.writeln( + ' final current = _include?.$memberName ?? const $includeClassName();', + ); + buffer.writeln( + ' final next = configure == null ? current : configure(current);', + ); + buffer.writeln( + ' return include(${model.includeClassName}($memberName: next));', + ); + buffer.writeln(' }'); + buffer.writeln(); + } buffer.writeln(' Future> all() {'); buffer.writeln(' return _delegate.all('); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index afb207a8..87f4b829 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -569,6 +569,22 @@ typedef Post = ({ reason: 'Expected UserQuery.include(...) chaining to stay immutable by returning a new query object.', ); + expect( + RegExp( + r'class\s+UserInclude\s*\{[\s\S]*?Map\s+toIncludeMap\(\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected model include class to expose include map conversion for convenience chaining pipeline.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+all\(\)\s*\{[\s\S]*?include:\s*_include,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery execution path to keep include state forwarding from query chain.', + ); expect( RegExp( r'\bFuture>\s+all\s*\(\s*\)', @@ -1175,6 +1191,102 @@ typedef Post = ({ isTrue, reason: 'Expected relation include class to generate merge helper.', ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+where\(\s*PostWhereInput\s+where,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include class to expose chainable where(...) helper.', + ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+skip\(\s*int\?\s+skip\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include class to expose chainable skip(...) helper.', + ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+take\(\s*int\?\s+take\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include class to expose chainable take(...) helper.', + ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+orderBy\(\s*List\s+orderBy,\s*\{\s*bool\s+append\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include class to expose chainable orderBy(...) helper.', + ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+select\(\s*PostSelect\?\s+select,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include class to expose chainable select(...) helper.', + ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+include\(\s*PostInclude\?\s+include,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include class to expose chainable include(...) helper.', + ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+where\([\s\S]*?return\s+UserPostsInclude\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include where(...) chaining to stay immutable by returning a new relation include object.', + ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+include\([\s\S]*?return\s+UserPostsInclude\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include include(...) chaining to stay immutable by returning a new relation include object.', + ); + expect( + RegExp( + r'class\s+UserInclude\s*\{[\s\S]*?UserInclude\s+includePosts\(\[\s*UserPostsInclude\s+Function\(\s*UserPostsInclude\s+\w+\s*\)\?\s+\w+\s*\]\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected model include class to expose includePosts([configure]) convenience helper.', + ); + expect( + RegExp( + r'class\s+PostInclude\s*\{[\s\S]*?PostInclude\s+includeAuthor\(\[\s*PostAuthorInclude\s+Function\(\s*PostAuthorInclude\s+\w+\s*\)\?\s+\w+\s*\]\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected model include class to expose includeAuthor([configure]) convenience helper.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+includePosts\(\[\s*UserPostsInclude\s+Function\(\s*UserPostsInclude\s+\w+\s*\)\?\s+\w+\s*\]\s*\)\s*\{[\s\S]*?return\s+include\(\s*UserInclude\(\s*posts:', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.includePosts([configure]) to route through include(...) with UserInclude(posts: ...).', + ); + expect( + RegExp( + r'class\s+PostQuery\s*\{[\s\S]*?PostQuery\s+includeAuthor\(\[\s*PostAuthorInclude\s+Function\(\s*PostAuthorInclude\s+\w+\s*\)\?\s+\w+\s*\]\s*\)\s*\{[\s\S]*?return\s+include\(\s*PostInclude\(\s*author:', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected PostQuery.includeAuthor([configure]) to route through include(...) with PostInclude(author: ...).', + ); expect( RegExp( r'\bclass UserPostsRelationWhereFilter\b', From 622bbdd5667bd0477ef73f8c86d6450705e20e4d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:57:32 +0800 Subject: [PATCH 082/154] feat(generator): add includeWith callback entrypoints --- pub/orm/lib/src/generator/writer.dart | 27 +++++++++++++++ pub/orm/test/generator/generate_test.dart | 40 +++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index a8764eef..41da88b7 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1261,6 +1261,16 @@ final class TypedClientWriter { buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' $includeClassName includeWith(${relationModel.includeClassName} Function(${relationModel.includeClassName} include) build, {bool merge = true}) {', + ); + buffer.writeln( + ' final current = _include ?? const ${relationModel.includeClassName}();', + ); + buffer.writeln(' final next = build(current);'); + buffer.writeln(' return include(next, merge: merge);'); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' IncludeSpec toIncludeSpec() {'); buffer.writeln(' return IncludeSpec('); buffer.writeln(' where: _where.toJson(),'); @@ -1329,6 +1339,13 @@ final class TypedClientWriter { } buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' ${model.includeClassName} includeWith(${model.includeClassName} Function(${model.includeClassName} include) build, {bool merge = true}) {', + ); + buffer.writeln(' final next = build(this);'); + buffer.writeln(' return merge ? this.merge(next) : next;'); + buffer.writeln(' }'); + buffer.writeln(); for (final relation in relationFields) { final includeClassName = _relationIncludeClassName( owner: model, @@ -2329,6 +2346,16 @@ final class TypedClientWriter { buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' ${model.queryClassName} includeWith(${model.includeClassName} Function(${model.includeClassName} include) build, {bool merge = true}) {', + ); + buffer.writeln( + ' final current = _include ?? const ${model.includeClassName}();', + ); + buffer.writeln(' final next = build(current);'); + buffer.writeln(' return include(next, merge: merge);'); + buffer.writeln(' }'); + buffer.writeln(); for (final relation in relationFields) { final includeClassName = _relationIncludeClassName( owner: model, diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 87f4b829..8c7c3dad 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -569,6 +569,30 @@ typedef Post = ({ reason: 'Expected UserQuery.include(...) chaining to stay immutable by returning a new query object.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+includeWith\(\s*UserInclude\s+Function\(\s*UserInclude\s+include\s*\)\s+build,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.includeWith(...) to expose typed include callback with merge flag.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+includeWith\([\s\S]*?(?:return\s+include\(\s*build\([\s\S]*?\)\s*,\s*merge:\s*merge\s*\);|final\s+\w+\s*=\s*build\([\s\S]*?\);[\s\S]*?return\s+include\(\s*\w+\s*,\s*merge:\s*merge\s*\);)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.includeWith(...) to route through include(..., merge: merge).', + ); + expect( + RegExp( + r'class\s+UserInclude\s*\{[\s\S]*?UserInclude\s+includeWith\(\s*UserInclude\s+Function\(\s*UserInclude\s+include\s*\)\s+build,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserInclude.includeWith(...) to expose typed include callback helper.', + ); expect( RegExp( r'class\s+UserInclude\s*\{[\s\S]*?Map\s+toIncludeMap\(\)', @@ -1239,6 +1263,22 @@ typedef Post = ({ reason: 'Expected relation include class to expose chainable include(...) helper.', ); + expect( + RegExp( + r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+includeWith\(\s*PostInclude\s+Function\(\s*PostInclude\s+include\s*\)\s+build,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected relation include class to expose includeWith(...) callback helper.', + ); + expect( + RegExp( + r'class\s+PostAuthorInclude\s*\{[\s\S]*?PostAuthorInclude\s+includeWith\(\s*UserInclude\s+Function\(\s*UserInclude\s+include\s*\)\s+build,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected to-one relation include class to expose includeWith(...) callback helper.', + ); expect( RegExp( r'class\s+UserPostsInclude\s*\{[\s\S]*?UserPostsInclude\s+where\([\s\S]*?return\s+UserPostsInclude\(', From 49b4becafc85ef618bb0c07304904f8d1c0886a2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:04:53 +0800 Subject: [PATCH 083/154] feat(client)!: add includeWith and deep include merges BREAKING CHANGE: ModelQuery.include now deep-merges IncludeSpec for identical relation keys when merge=true; previous shallow replacement behavior changed. --- pub/orm/lib/src/client/client.dart | 70 +++++++++++++++++++++- pub/orm/test/client/client_test.dart | 89 ++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 743617b4..587f3b6b 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -108,6 +108,60 @@ final class IncludeSpec { orderBy = orderBy, select = select, include = include; + + IncludeSpec merge(IncludeSpec other) { + return IncludeSpec( + where: {...where, ...other.where}, + skip: other.skip ?? skip, + take: other.take ?? take, + orderBy: [...orderBy, ...other.orderBy], + select: [...select, ...other.select], + include: _mergeIncludeSpecMap(include, other.include), + ); + } + + IncludeSpec includeWith( + Map Function(Map include) build, { + bool merge = true, + }) { + final current = {...include}; + final next = build(current); + return IncludeSpec( + where: {...where}, + skip: skip, + take: take, + orderBy: [...orderBy], + select: [...select], + include: merge + ? _mergeIncludeSpecMap(include, next) + : {...next}, + ); + } +} + +Map _mergeIncludeSpecMap( + Map current, + Map next, +) { + if (current.isEmpty) { + if (next.isEmpty) { + return const {}; + } + return {...next}; + } + if (next.isEmpty) { + return {...current}; + } + final merged = {...current}; + for (final entry in next.entries) { + final existing = merged[entry.key]; + if (existing == null) { + merged[entry.key] = entry.value; + continue; + } + merged[entry.key] = existing.merge(entry.value); + } + return merged; } abstract interface class OrmModelContext { @@ -797,6 +851,11 @@ class ModelDelegate { ModelQuery include(Map include) => query().include(include); + ModelQuery includeWith( + Map Function(Map include) build, { + bool merge = true, + }) => query().includeWith(build, merge: merge); + ModelQuery includeRelation( String relation, { IncludeSpec spec = const IncludeSpec(), @@ -3368,7 +3427,7 @@ final class ModelQuery { ModelQuery include(Map include, {bool merge = true}) { final nextInclude = merge - ? {..._state.include, ...include} + ? _mergeIncludeSpecMap(_state.include, include) : {...include}; return _next( @@ -3384,6 +3443,15 @@ final class ModelQuery { ); } + ModelQuery includeWith( + Map Function(Map include) build, { + bool merge = true, + }) { + final current = {..._state.include}; + final next = build(current); + return include(next, merge: merge); + } + ModelQuery includeRelation( String relation, { IncludeSpec spec = const IncludeSpec(), diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index e7c7ba52..8485ca67 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -1705,15 +1705,53 @@ void main() { take: 1, ), }); + final withIncludeWith = base.includeWith( + (include) => { + ...include, + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + take: 1, + ), + }, + ); + final deepMergedInclude = withIncludeWith.includeWith( + (include) => { + ...include, + 'posts': IncludeSpec( + include: { + 'author': IncludeSpec(select: const ['email']), + }, + ), + }, + ); expect(base.includeValues, isEmpty); expect(withInclude.includeValues.keys, ['posts']); + expect(withIncludeWith.includeValues.keys, ['posts']); + expect(withIncludeWith.includeValues['posts']?.include, isEmpty); + final deepMergedPostsSpec = deepMergedInclude.includeValues['posts']; + expect(deepMergedPostsSpec, isNotNull); + expect(deepMergedPostsSpec?.take, 1); + expect(deepMergedPostsSpec?.orderBy, hasLength(1)); + expect(deepMergedPostsSpec?.orderBy.single.field, 'id'); + expect(deepMergedPostsSpec?.include.keys, ['author']); + expect( + deepMergedPostsSpec?.include['author']?.select, + ['email'], + ); + expect(base.includeValues, isEmpty); final includeRow = await withInclude.findUnique(); final includePosts = _readRowsValue(includeRow?['posts']); expect(includePosts, hasLength(1)); expect(includePosts.single['id'], 'p1'); + final deepMergedRow = await deepMergedInclude.findUnique(); + final deepMergedPosts = _readRowsValue(deepMergedRow?['posts']); + expect(deepMergedPosts, hasLength(1)); + final deepMergedAuthor = _readRowValue(deepMergedPosts.single['author']); + expect(deepMergedAuthor?['email'], 'u1@example.com'); + final includeRelationRow = await users .where({'id': 'u1'}) .includeRelation( @@ -1735,6 +1773,57 @@ void main() { }, ); + test( + 'supports IncludeSpec.includeWith deep-merge and replace behaviors', + () { + final base = IncludeSpec( + include: { + 'author': IncludeSpec( + include: { + 'posts': IncludeSpec(select: const ['id']), + }, + ), + }, + ); + + final merged = base.includeWith( + (include) => { + ...include, + 'author': IncludeSpec( + include: { + 'profile': IncludeSpec(select: const ['email']), + }, + ), + }, + ); + + expect(base.include['author']?.include.keys, ['posts']); + final mergedAuthor = merged.include['author']; + expect(mergedAuthor, isNotNull); + expect( + mergedAuthor?.include.keys.toSet(), + {'posts', 'profile'}, + ); + expect(mergedAuthor?.include['profile']?.select, ['email']); + + final replaced = base.includeWith( + (_) => { + 'author': IncludeSpec( + include: { + 'profile': IncludeSpec(select: const ['email']), + }, + ), + }, + merge: false, + ); + + final replacedAuthor = replaced.include['author']; + expect(replacedAuthor, isNotNull); + expect(replacedAuthor?.include.keys, ['profile']); + expect(base.include['author']?.include.keys, ['posts']); + }, + ); + test('supports nested include for relation traversal', () async { final client = OrmClient( contract: relationalContract, From 362f84fbddd6d7bc7b81038b8558611f84d0774c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:47:26 +0800 Subject: [PATCH 084/154] feat(repository)!: align read query surface with plan-first naming --- README.md | 2 +- docs/orm-v6-blueprint.md | 5 +- pub/orm/lib/src/client/client.dart | 233 +++++++++++++---- pub/orm/lib/src/generator/writer.dart | 16 +- pub/orm/lib/src/runtime/plan.dart | 7 +- pub/orm/test/client/client_test.dart | 252 +++++++++++-------- pub/orm/test/generator/generate_test.dart | 24 +- pub/orm/test/sql/sql_marker_reader_test.dart | 4 +- 8 files changed, 361 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index 8bccc2f5..2ae3d7c6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ import 'package:orm/orm.dart'; final client = PrismaClient(); main() { - final users = await client.user.findMany(); + final users = await client.user.all(); } ``` diff --git a/docs/orm-v6-blueprint.md b/docs/orm-v6-blueprint.md index 7823be69..cd50d136 100644 --- a/docs/orm-v6-blueprint.md +++ b/docs/orm-v6-blueprint.md @@ -8,7 +8,7 @@ ## 2. 范围与非范围 范围: - 契约工件完整化:relations、capabilities、target、hash 校验链。 -- fluent 查询主路径:`where / select / include / orderBy / take / skip / list / stream / firstOrNull / oneOrNull / toPlan`。 +- fluent 查询主路径:`where / select / include / orderBy / take / skip / all / stream / firstOrNull / oneOrNull / toPlan`。 - 运行时校验与插件管线稳定化:`beforeExecute -> onRow -> afterExecute -> onError`。 - repository 显式编排:nested mutation、能力差异回退、include 执行策略。 @@ -44,7 +44,7 @@ final users = await db.user .orderBy((o) => [o.createdAt.desc()]) .take(20) .select((s) => s.pick((f) => [f.id, f.email])) - .list(); + .all(); ``` ```dart @@ -81,4 +81,3 @@ P1: | 测试 | 分层测试与回归门禁 | 测试计划、Gate 报告、缺陷分级 | | 开发1 | shared/core + runtime-core | 核心校验链、生命周期、插件执行保障 | | 开发2 | repository + targets | 策略编排、目标实现、能力映射 | - diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 587f3b6b..f2b9ea64 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -98,16 +98,13 @@ final class IncludeSpec { final Map include; const IncludeSpec({ - JsonMap where = const {}, + this.where = const {}, this.skip, this.take, - List orderBy = const [], - List select = const [], - Map include = const {}, - }) : where = where, - orderBy = orderBy, - select = select, - include = include; + this.orderBy = const [], + this.select = const [], + this.include = const {}, + }); IncludeSpec merge(IncludeSpec other) { return IncludeSpec( @@ -164,6 +161,61 @@ Map _mergeIncludeSpecMap( return merged; } +JsonMap _serializeIncludeSpec(IncludeSpec spec) { + final encoded = {}; + if (spec.where.isNotEmpty) { + encoded['where'] = Map.from(spec.where); + } + if (spec.skip case final skip?) { + encoded['skip'] = skip; + } + if (spec.take case final take?) { + encoded['take'] = take; + } + if (spec.orderBy.isNotEmpty) { + encoded['orderBy'] = spec.orderBy + .map( + (entry) => { + 'field': entry.field, + 'order': entry.order.name, + }, + ) + .toList(growable: false); + } + if (spec.select.isNotEmpty) { + encoded['select'] = List.from(spec.select, growable: false); + } + if (spec.include.isNotEmpty) { + encoded['include'] = _serializeIncludeSpecMap(spec.include); + } + return encoded; +} + +JsonMap _serializeIncludeSpecMap(Map include) { + if (include.isEmpty) { + return const {}; + } + return { + for (final entry in include.entries) + entry.key: _serializeIncludeSpec(entry.value), + }; +} + +JsonMap _buildOrmReadAnnotations({ + required String resultMode, + required Map include, + List distinct = const [], +}) { + final annotations = {'resultMode': resultMode}; + if (include.isNotEmpty) { + annotations['include'] = _serializeIncludeSpecMap(include); + } + if (distinct.isNotEmpty) { + annotations['distinct'] = List.from(distinct, growable: false); + } + return annotations; +} + abstract interface class OrmModelContext { OrmContract get contract; @@ -804,6 +856,7 @@ OrmPlan _buildSqlPlan({ target: contract.target, storageHash: contract.markerStorageHash, profileHash: contract.profileHash, + lane: 'sql', model: modelName, action: action, where: where, @@ -816,6 +869,14 @@ OrmPlan _buildSqlPlan({ ); } +@immutable +final class _PreparedReadPlan { + final OrmPlan plan; + final Map include; + + const _PreparedReadPlan({required this.plan, required this.include}); +} + class ModelDelegate { final OrmModelContext _client; final String modelName; @@ -861,7 +922,29 @@ class ModelDelegate { IncludeSpec spec = const IncludeSpec(), }) => query().includeRelation(relation, spec: spec); - Future> findMany({ + Future toPlan({ + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + }) async { + final prepared = await _buildReadPlan( + action: OrmAction.findMany, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + ); + return prepared.plan; + } + + Future> all({ JsonMap where = const {}, int? skip, int? take, @@ -883,7 +966,7 @@ class ModelDelegate { ); } - Stream streamMany({ + Stream stream({ JsonMap where = const {}, int? skip, int? take, @@ -892,7 +975,7 @@ class ModelDelegate { List select = const [], Map include = const {}, }) async* { - final rows = await findMany( + final rows = await all( where: where, skip: skip, take: take, @@ -907,7 +990,7 @@ class ModelDelegate { } } - Future findUnique({ + Future oneOrNull({ JsonMap where = const {}, List select = const [], Map include = const {}, @@ -921,7 +1004,7 @@ class ModelDelegate { ); } - Future findFirst({ + Future firstOrNull({ JsonMap where = const {}, int? skip, List orderBy = const [], @@ -953,7 +1036,7 @@ class ModelDelegate { } Future exists({JsonMap where = const {}}) async { - final row = await findFirst(where: where, select: const []); + final row = await firstOrNull(where: where, select: const []); return row != null; } @@ -1249,7 +1332,7 @@ class ModelDelegate { }) { return _client.transaction((tx) async { final scoped = tx.model(modelName); - final existing = await scoped.findUnique(where: where); + final existing = await scoped.oneOrNull(where: where); if (existing == null) { return scoped.create(data: create, select: select, include: include); } @@ -1303,7 +1386,7 @@ class ModelDelegate { ); } - Future> _findManyInternal({ + Future<_PreparedReadPlan> _buildReadPlan({ required OrmAction action, JsonMap where = const {}, int? skip, @@ -1312,7 +1395,6 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, - required int includeDepth, }) async { if (skip case final offset? when offset < 0) { throw PlanInvalidPaginationException(key: 'skip', value: offset); @@ -1326,27 +1408,73 @@ class ModelDelegate { model: modelName, where: where, ); - final response = await _client.execute( - OrmPlan( + final resultMode = switch (action) { + OrmAction.findMany => take == 1 ? 'firstOrNull' : 'all', + OrmAction.findUnique => 'oneOrNull', + _ => action.name, + }; + + return _PreparedReadPlan( + include: normalizedInclude, + plan: OrmPlan( contractHash: _client.contract.hash, target: _client.contract.target, storageHash: _client.contract.markerStorageHash, profileHash: _client.contract.profileHash, - model: modelName, - action: OrmAction.findMany, - where: normalizedWhere, - skip: distinct.isEmpty ? skip : null, - take: distinct.isEmpty ? take : null, - orderBy: orderBy, - distinct: distinct, - select: _expandSelectForExecution( - model: modelName, - select: select, + lane: 'orm', + annotations: _buildOrmReadAnnotations( + resultMode: resultMode, include: normalizedInclude, distinct: distinct, ), + model: modelName, + action: action, + where: normalizedWhere, + skip: action == OrmAction.findMany && distinct.isEmpty ? skip : null, + take: action == OrmAction.findMany && distinct.isEmpty ? take : null, + orderBy: action == OrmAction.findMany ? orderBy : const [], + distinct: action == OrmAction.findMany ? distinct : const [], + select: switch (action) { + OrmAction.findMany => _expandSelectForExecution( + model: modelName, + select: select, + include: normalizedInclude, + distinct: distinct, + ), + OrmAction.findUnique => _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), + _ => select, + }, ), ); + } + + Future> _findManyInternal({ + required OrmAction action, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + required int includeDepth, + }) async { + final prepared = await _buildReadPlan( + action: OrmAction.findMany, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + ); + final normalizedInclude = prepared.include; + final response = await _client.execute(prepared.plan); var rows = _readRows(response.data); if (distinct.isNotEmpty) { @@ -1370,27 +1498,14 @@ class ModelDelegate { Map include = const {}, required int includeDepth, }) async { - final normalizedInclude = _normalizeInclude(include); - final normalizedWhere = await _normalizeWhereForExecution( - model: modelName, + final prepared = await _buildReadPlan( + action: OrmAction.findUnique, where: where, + select: select, + include: include, ); - final response = await _client.execute( - OrmPlan( - contractHash: _client.contract.hash, - target: _client.contract.target, - storageHash: _client.contract.markerStorageHash, - profileHash: _client.contract.profileHash, - model: modelName, - action: OrmAction.findUnique, - where: normalizedWhere, - select: _expandSelectForInclude( - model: modelName, - select: select, - include: normalizedInclude, - ), - ), - ); + final normalizedInclude = prepared.include; + final response = await _client.execute(prepared.plan); final row = _readRow(response.data, action: 'findUnique'); if (row == null) { @@ -3501,8 +3616,20 @@ final class ModelQuery { ); } - Future> findMany() { - return _delegate.findMany( + Future toPlan() { + return _delegate.toPlan( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + distinct: _state.distinct, + select: _state.select, + include: _state.include, + ); + } + + Future> all() { + return _delegate.all( where: _state.where, skip: _state.skip, take: _state.take, @@ -3514,7 +3641,7 @@ final class ModelQuery { } Stream stream() { - return _delegate.streamMany( + return _delegate.stream( where: _state.where, skip: _state.skip, take: _state.take, @@ -3525,13 +3652,13 @@ final class ModelQuery { ); } - Future findUnique() => _delegate.findUnique( + Future oneOrNull() => _delegate.oneOrNull( where: _state.where, select: _state.select, include: _state.include, ); - Future findFirst() => _delegate.findFirst( + Future firstOrNull() => _delegate.firstOrNull( where: _state.where, skip: _state.skip, orderBy: _state.orderBy, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 41da88b7..155cd576 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1572,7 +1572,7 @@ final class TypedClientWriter { buffer.writeln( ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' final rows = await _delegate.findMany('); + buffer.writeln(' final rows = await _delegate.all('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); @@ -1587,7 +1587,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> findUnique({'); + buffer.writeln(' Future<${model.dataClassName}?> oneOrNull({'); buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); @@ -1598,7 +1598,7 @@ final class TypedClientWriter { buffer.writeln( ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' final row = await _delegate.findUnique('); + buffer.writeln(' final row = await _delegate.oneOrNull('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' select: runtimeSelect,'); buffer.writeln(' include: runtimeInclude,'); @@ -1610,7 +1610,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> first({'); + buffer.writeln(' Future<${model.dataClassName}?> firstOrNull({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); @@ -1636,7 +1636,7 @@ final class TypedClientWriter { buffer.writeln( ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' final row = await _delegate.findFirst('); + buffer.writeln(' final row = await _delegate.firstOrNull('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' skip: skip,'); buffer.writeln(' orderBy: runtimeOrderBy,'); @@ -1933,7 +1933,7 @@ final class TypedClientWriter { buffer.writeln( ' final runtimeInclude = include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' await for (final row in _delegate.streamMany('); + buffer.writeln(' await for (final row in _delegate.stream('); buffer.writeln(' where: where.toJson(),'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); @@ -2398,8 +2398,8 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> first() {'); - buffer.writeln(' return _delegate.first('); + buffer.writeln(' Future<${model.dataClassName}?> firstOrNull() {'); + buffer.writeln(' return _delegate.firstOrNull('); buffer.writeln(' where: _where,'); buffer.writeln(' skip: _skip,'); buffer.writeln(' orderBy: _orderBy,'); diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index 170089c7..a57b1bf7 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -19,6 +19,8 @@ final class OrmPlan { final String? target; final String? storageHash; final String? profileHash; + final String? lane; + final JsonMap annotations; final String model; final OrmAction action; final JsonMap where; @@ -34,6 +36,8 @@ final class OrmPlan { this.target, this.storageHash, this.profileHash, + this.lane, + JsonMap annotations = const {}, required this.model, required this.action, JsonMap where = const {}, @@ -43,7 +47,8 @@ final class OrmPlan { List orderBy = const [], List distinct = const [], List select = const [], - }) : where = Map.unmodifiable(where), + }) : annotations = Map.unmodifiable(annotations), + where = Map.unmodifiable(where), data = Map.unmodifiable(data), orderBy = List.unmodifiable(orderBy), distinct = List.unmodifiable(distinct), diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 8485ca67..f68b633e 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -88,11 +88,11 @@ void main() { ); expect(created['id'], 'u1'); - final allRows = await users.findMany(); + final allRows = await users.all(); expect(allRows, hasLength(1)); expect(allRows.first['email'], 'a@example.com'); - final unique = await users.findUnique( + final unique = await users.oneOrNull( where: {'id': 'u1'}, ); expect(unique?['id'], 'u1'); @@ -106,7 +106,7 @@ void main() { final removed = await users.delete(where: {'id': 'u1'}); expect(removed?['id'], 'u1'); - final remaining = await users.findMany(); + final remaining = await users.all(); expect(remaining, isEmpty); await client.disconnect(); }); @@ -116,7 +116,7 @@ void main() { final users = client.model('User'); await expectLater( - users.findMany(), + users.all(), throwsA(isA()), ); }); @@ -185,7 +185,7 @@ void main() { }).first(); expect(sqlRow?['email'], 'a@example.com'); - final ormRow = await client.db.orm['User'].findUnique( + final ormRow = await client.db.orm['User'].oneOrNull( where: {'id': 'u1'}, ); expect(ormRow?['id'], 'u1'); @@ -291,7 +291,7 @@ void main() { data: {'id': '3', 'email': 'b@x.com'}, ); - final rows = await users.findMany( + final rows = await users.all( orderBy: const [OrmOrderBy('email')], skip: 1, take: 1, @@ -318,7 +318,7 @@ void main() { data: {'id': 'u3', 'email': 'b@x.com'}, ); - final distinctRows = await users.findMany( + final distinctRows = await users.all( orderBy: const [OrmOrderBy('id')], distinct: const ['email'], ); @@ -327,7 +327,7 @@ void main() { ['u1', 'u3'], ); - final pagedDistinctRows = await users.findMany( + final pagedDistinctRows = await users.all( orderBy: const [OrmOrderBy('id')], distinct: const ['email'], skip: 1, @@ -342,7 +342,7 @@ void main() { .query() .orderByField('id') .distinctField('email') - .findMany(); + .all(); expect( distinctFromQuery.map((row) => row['id']).toList(growable: false), ['u1', 'u3'], @@ -523,7 +523,7 @@ void main() { await users.create(data: {'id': 3, 'email': 'c@x.com'}); await users.create(data: {'id': 4, 'email': 'd@x.com'}); - final gtRows = await users.findMany( + final gtRows = await users.all( where: { 'id': {'gt': 2}, }, @@ -534,7 +534,7 @@ void main() { 4, ]); - final inRows = await users.findMany( + final inRows = await users.all( where: { 'email': { 'in': ['a@x.com', 'c@x.com'], @@ -547,7 +547,7 @@ void main() { 3, ]); - final notInRows = await users.findMany( + final notInRows = await users.all( where: { 'id': { 'notIn': [2, 3], @@ -582,7 +582,7 @@ void main() { data: {'id': 'u4', 'email': 'gamma@sample.com'}, ); - final containsRows = await users.findMany( + final containsRows = await users.all( where: { 'email': {'contains': 'example.com'}, }, @@ -593,7 +593,7 @@ void main() { ['u1', 'u2', 'u3'], ); - final startsWithRows = await users.findMany( + final startsWithRows = await users.all( where: { 'email': {'startsWith': 'alph'}, }, @@ -604,7 +604,7 @@ void main() { ['u1', 'u3'], ); - final endsWithRows = await users.findMany( + final endsWithRows = await users.all( where: { 'email': {'endsWith': 'sample.com'}, }, @@ -638,7 +638,7 @@ void main() { data: {'id': 4, 'email': 'z@sample.com'}, ); - final andRows = await users.findMany( + final andRows = await users.all( where: { 'AND': [ { @@ -656,7 +656,7 @@ void main() { [2], ); - final orRows = await users.findMany( + final orRows = await users.all( where: { 'OR': [ {'id': 1}, @@ -670,7 +670,7 @@ void main() { [1, 4], ); - final notRows = await users.findMany( + final notRows = await users.all( where: { 'NOT': [ { @@ -702,7 +702,7 @@ void main() { expect(created.keys, ['id']); expect(created['id'], 'u1'); - final unique = await users.findUnique( + final unique = await users.oneOrNull( where: {'id': 'u1'}, select: const ['email'], ); @@ -745,8 +745,8 @@ void main() { final base = users.orderByField('email'); final narrowed = base.skip(1).take(1); - final all = await base.findMany(); - final page = await narrowed.findMany(); + final all = await base.all(); + final page = await narrowed.all(); expect(all, hasLength(3)); expect(page, hasLength(1)); @@ -754,6 +754,46 @@ void main() { await client.disconnect(); }); + test( + 'query toPlan emits orm lane metadata and include annotations', + () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + final users = client.model('User'); + + final plan = await users + .query() + .where({'id': 'u1'}) + .include({ + 'posts': const IncludeSpec( + take: 3, + include: { + 'author': IncludeSpec(select: ['email']), + }, + ), + }) + .take(5) + .toPlan(); + + expect(plan.lane, 'orm'); + expect(plan.action, OrmAction.findMany); + expect(plan.take, 5); + expect(plan.annotations['resultMode'], 'all'); + expect(plan.annotations['include'], { + 'posts': { + 'take': 3, + 'include': { + 'author': { + 'select': ['email'], + }, + }, + }, + }); + }, + ); + test('supports select projection through chained query state', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -766,7 +806,7 @@ void main() { final readQuery = users.where({'id': '1'}).select( const ['email'], ); - final selected = await readQuery.findUnique(); + final selected = await readQuery.oneOrNull(); expect(selected?.keys, ['email']); expect(selected?['email'], 'a@x.com'); @@ -797,7 +837,7 @@ void main() { final unique = await users.where({ 'id': 'u1', - }).findUnique(); + }).oneOrNull(); expect(unique?['email'], 'b@example.com'); final removed = await users.where({'id': 'u1'}).delete(); @@ -805,12 +845,12 @@ void main() { final remaining = await users.where({ 'id': 'u1', - }).findUnique(); + }).oneOrNull(); expect(remaining, isNull); await client.disconnect(); }); - test('supports findFirst, count and exists helpers', () async { + test('supports firstOrNull, count and exists helpers', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); final users = client.model('User'); @@ -825,7 +865,7 @@ void main() { data: {'id': 'u3', 'email': 'c@x.com'}, ); - final first = await users.findFirst( + final first = await users.firstOrNull( orderBy: const [OrmOrderBy('email')], ); expect(first?['id'], 'u2'); @@ -856,7 +896,7 @@ void main() { ); final delegateRows = await users - .streamMany(orderBy: const [OrmOrderBy('email')]) + .stream(orderBy: const [OrmOrderBy('email')]) .toList(); expect(delegateRows, hasLength(3)); expect(delegateRows.first['id'], 'u2'); @@ -936,7 +976,7 @@ void main() { throwsA(isA()), ); - final rows = await users.findMany( + final rows = await users.all( orderBy: const [OrmOrderBy('id')], ); expect(rows.map((row) => row['id']).toList(growable: false), [ @@ -984,7 +1024,7 @@ void main() { expect(removed?['id'], 'u1'); expect(removed?['email'], 'b@x.com'); - final remaining = await users.findUnique( + final remaining = await users.oneOrNull( where: {'id': 'u1'}, ); expect(remaining, isNull); @@ -1007,7 +1047,7 @@ void main() { ); final query = users.where({'email': 'a@x.com'}); - final first = await query.orderByField('id').findFirst(); + final first = await query.orderByField('id').firstOrNull(); expect(first?['id'], 'u1'); expect(await query.count(), 2); expect(await query.exists(), isTrue); @@ -1041,7 +1081,7 @@ void main() { data: {'id': 'u3', 'email': 'u3@example.com'}, ); - final someRows = await users.findMany( + final someRows = await users.all( where: { 'posts': { 'some': { @@ -1056,7 +1096,7 @@ void main() { ['u1'], ); - final noneRows = await users.findMany( + final noneRows = await users.all( where: { 'posts': { 'none': { @@ -1071,7 +1111,7 @@ void main() { ['u2', 'u3'], ); - final everyRows = await users.findMany( + final everyRows = await users.all( where: { 'posts': { 'every': { @@ -1102,7 +1142,7 @@ void main() { data: {'id': 'p4', 'userId': 'ux', 'title': 'Post D'}, ); - final isRows = await posts.findMany( + final isRows = await posts.all( where: { 'author': { 'is': { @@ -1117,7 +1157,7 @@ void main() { 'p2', ]); - final isNotRows = await posts.findMany( + final isNotRows = await posts.all( where: { 'author': { 'isNot': {'id': 'u1'}, @@ -1130,7 +1170,7 @@ void main() { ['p3', 'p4'], ); - final relationMissingRows = await posts.findMany( + final relationMissingRows = await posts.all( where: { 'author': {'isNot': const {}}, }, @@ -1141,7 +1181,7 @@ void main() { ['p4'], ); - final isNullRows = await posts.findMany( + final isNullRows = await posts.all( where: { 'author': {'is': null}, }, @@ -1152,7 +1192,7 @@ void main() { ['p4'], ); - final isNotNullRows = await posts.findMany( + final isNotNullRows = await posts.all( where: { 'author': {'isNot': null}, }, @@ -1177,7 +1217,7 @@ void main() { data: {'id': 'u3', 'email': 'u3@example.com'}, ); - final rows = await users.findMany( + final rows = await users.all( where: { 'AND': [ { @@ -1232,7 +1272,7 @@ void main() { expect(updated?['id'], 'u2'); expect(updated?['email'], 'u2+updated@example.com'); - final persisted = await users.findUnique( + final persisted = await users.oneOrNull( where: {'id': 'u2'}, ); expect(persisted?['email'], 'u2+updated@example.com'); @@ -1258,7 +1298,7 @@ void main() { ); expect(updated?['id'], 'p3'); - final persisted = await posts.findUnique( + final persisted = await posts.oneOrNull( where: {'id': 'p3'}, ); expect(persisted?['title'], 'Post C updated'); @@ -1283,7 +1323,7 @@ void main() { await client .model('User') - .findMany( + .all( where: { 'posts': { 'some': {'title': 'Post A'}, @@ -1311,7 +1351,7 @@ void main() { final rows = await client .model('User') - .findMany( + .all( orderBy: const [OrmOrderBy('id')], include: { 'posts': IncludeSpec( @@ -1356,7 +1396,7 @@ void main() { await _seedRelationalData(client); final rows = await client .model('User') - .findMany( + .all( orderBy: const [OrmOrderBy('id')], include: { 'posts': IncludeSpec( @@ -1406,7 +1446,7 @@ void main() { final rows = await client .model('User') - .findMany( + .all( orderBy: const [OrmOrderBy('id')], include: { 'posts': IncludeSpec( @@ -1452,7 +1492,7 @@ void main() { await expectLater( client .model('User') - .findMany( + .all( include: {'posts': const IncludeSpec()}, ), throwsA(isA()), @@ -1530,7 +1570,7 @@ void main() { final persistedPosts = await client .model('Post') - .findMany(where: {'userId': 'u3'}); + .all(where: {'userId': 'u3'}); expect(persistedPosts, hasLength(2)); await client.disconnect(); }, @@ -1559,7 +1599,7 @@ void main() { final rolledBackUser = await client .model('User') - .findUnique(where: {'id': 'u4'}); + .oneOrNull(where: {'id': 'u4'}); expect(rolledBackUser, isNull); await client.disconnect(); }); @@ -1600,12 +1640,12 @@ void main() { final persistedUser = await client .model('User') - .findUnique(where: {'id': 'u1'}); + .oneOrNull(where: {'id': 'u1'}); expect(persistedUser?['email'], 'u1+updated@example.com'); final persistedChild = await client .model('Post') - .findUnique(where: {'id': 'p4'}); + .oneOrNull(where: {'id': 'p4'}); expect(persistedChild?['userId'], 'u1'); await client.disconnect(); }, @@ -1634,7 +1674,7 @@ void main() { expect(updated, isNull); final createdChild = await client .model('Post') - .findUnique(where: {'id': 'p9'}); + .oneOrNull(where: {'id': 'p9'}); expect(createdChild, isNull); await client.disconnect(); }); @@ -1668,12 +1708,12 @@ void main() { final rolledBackUser = await client .model('User') - .findUnique(where: {'id': 'u1'}); + .oneOrNull(where: {'id': 'u1'}); expect(rolledBackUser?['email'], 'u1@example.com'); final rolledBackChild = await client .model('Post') - .findUnique(where: {'id': 'p10'}); + .oneOrNull(where: {'id': 'p10'}); expect(rolledBackChild, isNull); await client.disconnect(); }); @@ -1694,7 +1734,7 @@ void main() { orderBy: const [OrmOrderBy('id')], take: 1, ), - }).findMany(); + }).all(); expect(delegatedRows, hasLength(2)); expect(_readRowsValue(delegatedRows.first['posts']), hasLength(1)); @@ -1735,21 +1775,22 @@ void main() { expect(deepMergedPostsSpec?.orderBy, hasLength(1)); expect(deepMergedPostsSpec?.orderBy.single.field, 'id'); expect(deepMergedPostsSpec?.include.keys, ['author']); - expect( - deepMergedPostsSpec?.include['author']?.select, - ['email'], - ); + expect(deepMergedPostsSpec?.include['author']?.select, [ + 'email', + ]); expect(base.includeValues, isEmpty); - final includeRow = await withInclude.findUnique(); + final includeRow = await withInclude.oneOrNull(); final includePosts = _readRowsValue(includeRow?['posts']); expect(includePosts, hasLength(1)); expect(includePosts.single['id'], 'p1'); - final deepMergedRow = await deepMergedInclude.findUnique(); + final deepMergedRow = await deepMergedInclude.oneOrNull(); final deepMergedPosts = _readRowsValue(deepMergedRow?['posts']); expect(deepMergedPosts, hasLength(1)); - final deepMergedAuthor = _readRowValue(deepMergedPosts.single['author']); + final deepMergedAuthor = _readRowValue( + deepMergedPosts.single['author'], + ); expect(deepMergedAuthor?['email'], 'u1@example.com'); final includeRelationRow = await users @@ -1763,7 +1804,7 @@ void main() { }, ), ) - .findUnique(); + .oneOrNull(); final relationPosts = _readRowsValue(includeRelationRow?['posts']); expect(relationPosts, hasLength(2)); @@ -1800,10 +1841,10 @@ void main() { expect(base.include['author']?.include.keys, ['posts']); final mergedAuthor = merged.include['author']; expect(mergedAuthor, isNotNull); - expect( - mergedAuthor?.include.keys.toSet(), - {'posts', 'profile'}, - ); + expect(mergedAuthor?.include.keys.toSet(), { + 'posts', + 'profile', + }); expect(mergedAuthor?.include['profile']?.select, ['email']); final replaced = base.includeWith( @@ -1834,7 +1875,7 @@ void main() { final row = await client .model('Post') - .findUnique( + .oneOrNull( where: {'id': 'p1'}, include: { 'author': IncludeSpec( @@ -1871,7 +1912,7 @@ void main() { await expectLater( client .model('User') - .findMany( + .all( include: {'unknown': const IncludeSpec()}, ), throwsA(isA()), @@ -1891,7 +1932,7 @@ void main() { await expectLater( client .model('User') - .findMany( + .all( include: { 'posts': IncludeSpec( include: {'author': const IncludeSpec()}, @@ -1913,7 +1954,7 @@ void main() { final row = await client .model('User') - .findUnique( + .oneOrNull( where: {'id': 'u1'}, select: const ['email'], include: { @@ -1959,9 +2000,7 @@ void main() { await client .model('User') - .findMany( - include: {'posts': const IncludeSpec()}, - ); + .all(include: {'posts': const IncludeSpec()}); expect(callCount, greaterThan(0)); expect(callModels.first, 'User'); @@ -2019,7 +2058,7 @@ void main() { final row = await client .model('User') - .findUnique(where: {'id': 'u1'}); + .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'b@example.com'); await client.disconnect(); }); @@ -2038,7 +2077,7 @@ void main() { final row = await client .model('User') - .findUnique(where: {'id': 'u1'}); + .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); }); @@ -2068,7 +2107,7 @@ void main() { await client.connect(); await client.withConnection((connection) async { - final rows = await connection.model('User').findMany(); + final rows = await connection.model('User').all(); expect(rows, isEmpty); }); @@ -2094,7 +2133,7 @@ void main() { final row = await client .model('User') - .findUnique(where: {'id': 'u1'}); + .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); }); @@ -2112,7 +2151,7 @@ void main() { final row = await client .model('User') - .findUnique(where: {'id': 'u1'}); + .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); }); @@ -2125,7 +2164,7 @@ void main() { await client.connect(); await client.withTransaction((transaction) async { - final rows = await transaction.model('User').findMany(); + final rows = await transaction.model('User').all(); expect(rows, isEmpty); }); @@ -2161,7 +2200,7 @@ void main() { final row = await client .model('User') - .findUnique(where: {'id': 'u1'}); + .oneOrNull(where: {'id': 'u1'}); expect(row, isNull); await client.disconnect(); }); @@ -2175,7 +2214,7 @@ void main() { await expectLater( () => client.withTransaction((transaction) async { - await transaction.model('User').findMany(); + await transaction.model('User').all(); throw StateError('stop'); }), throwsA(isA()), @@ -2236,7 +2275,7 @@ void main() { await transaction.rollback(); await connection.release(); - final row = await users.findUnique(where: {'id': 'u1'}); + final row = await users.oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); }); @@ -2278,7 +2317,7 @@ void main() { test('records telemetry for successful execution', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - await client.model('User').findMany(); + await client.model('User').all(); final telemetry = client.telemetry(); expect(telemetry, isNotNull); @@ -2306,8 +2345,8 @@ void main() { await client.connect(); expect(readCount, 1); - await client.model('User').findMany(); - await client.model('User').findMany(); + await client.model('User').all(); + await client.model('User').all(); expect(readCount, 1); await client.disconnect(); }); @@ -2332,9 +2371,9 @@ void main() { await client.connect(); expect(readCount, 0); - await client.model('User').findMany(); + await client.model('User').all(); expect(readCount, 1); - await client.model('User').findMany(); + await client.model('User').all(); expect(readCount, 1); await client.disconnect(); }, @@ -2356,8 +2395,8 @@ void main() { ); await client.connect(); - await client.model('User').findMany(); - await client.model('User').findMany(); + await client.model('User').all(); + await client.model('User').all(); expect(readCount, 2); await client.disconnect(); @@ -2376,7 +2415,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').findMany(), + client.model('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2395,7 +2434,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').findMany(), + client.model('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2408,7 +2447,7 @@ void main() { await expectLater( client .model('User') - .findMany( + .all( where: { 'OR': [ {'id': 'u1'}, @@ -2419,13 +2458,13 @@ void main() { completes, ); await expectLater( - client.model('User').findMany(where: {'age': 1}), + client.model('User').all(where: {'age': 1}), throwsA(isA()), ); await expectLater( client .model('User') - .findMany( + .all( where: { 'AND': [ {'id': 'u1'}, @@ -2442,15 +2481,15 @@ void main() { await expectLater( client .model('User') - .findMany(orderBy: const [OrmOrderBy('age')]), + .all(orderBy: const [OrmOrderBy('age')]), throwsA(isA()), ); await expectLater( - client.model('User').findMany(select: const ['age']), + client.model('User').all(select: const ['age']), throwsA(isA()), ); await expectLater( - client.model('User').findMany(distinct: const ['age']), + client.model('User').all(distinct: const ['age']), throwsA(isA()), ); await client.disconnect(); @@ -2461,11 +2500,11 @@ void main() { await client.connect(); await expectLater( - client.model('User').findMany(skip: -1), + client.model('User').all(skip: -1), throwsA(isA()), ); await expectLater( - client.model('User').findMany(take: -1), + client.model('User').all(take: -1), throwsA(isA()), ); await client.disconnect(); @@ -2480,7 +2519,7 @@ void main() { plugins: [plugin], ); await client.connect(); - await client.model('User').findMany(); + await client.model('User').all(); expect(plugin.events, ['before:findMany', 'after:findMany']); await client.disconnect(); @@ -2509,10 +2548,7 @@ void main() { ); await client.connect(); - await expectLater( - client.model('User').findMany(), - throwsA(isA()), - ); + await expectLater(client.model('User').all(), throwsA(isA())); expect(plugin.events, [ 'before:findMany', 'error:findMany', @@ -2532,7 +2568,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').findMany(), + client.model('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2557,7 +2593,7 @@ void main() { ); await client.connect(); - await client.model('User').findMany(); + await client.model('User').all(); expect(logs.warnEvents, isNotEmpty); await client.disconnect(); }); @@ -2571,7 +2607,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').findMany(take: 2), + client.model('User').all(take: 2), throwsA(isA()), ); await client.disconnect(); @@ -2604,7 +2640,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').findMany(), + client.model('User').all(), throwsA(isA()), ); await client.disconnect(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 8c7c3dad..af9bebf5 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -618,10 +618,10 @@ typedef Post = ({ ); expect( RegExp( - r'\bFuture\s+first\s*\(\s*\)', + r'\bFuture\s+firstOrNull\s*\(\s*\)', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserQuery.first() in generated source.', + reason: 'Expected UserQuery.firstOrNull() in generated source.', ); expect( RegExp(r'\bclass UserSql\b').hasMatch(generatedSource), @@ -715,11 +715,11 @@ typedef Post = ({ ); expect( RegExp( - r'Future\s+findUnique\(\{\s*required\s+UserWhereUniqueInput\s+where,', + r'Future\s+oneOrNull\(\{\s*required\s+UserWhereUniqueInput\s+where,', ).hasMatch(generatedSource), isTrue, reason: - 'Expected findUnique where parameter to use UserWhereUniqueInput.', + 'Expected oneOrNull where parameter to use UserWhereUniqueInput.', ); expect( RegExp( @@ -762,11 +762,11 @@ typedef Post = ({ ); expect( RegExp( - r'Future\s+first\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', + r'Future\s+firstOrNull\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', ).hasMatch(generatedSource), isTrue, reason: - 'Expected first to expose typed distinct parameter in generated delegate.', + 'Expected firstOrNull to expose typed distinct parameter in generated delegate.', ); expect( generatedSource.contains('Future> findMany('), @@ -780,6 +780,18 @@ typedef Post = ({ reason: 'Expected generated typed delegate source to not expose findFirst signature.', ); + expect( + generatedSource.contains('Future findUnique('), + isFalse, + reason: + 'Expected generated typed delegate source to not expose findUnique signature.', + ); + expect( + generatedSource.contains('Future first()'), + isFalse, + reason: + 'Expected generated typed query source to not expose first() signature.', + ); expect( RegExp( r'Stream\s+stream\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', diff --git a/pub/orm/test/sql/sql_marker_reader_test.dart b/pub/orm/test/sql/sql_marker_reader_test.dart index bcd6698c..413975b3 100644 --- a/pub/orm/test/sql/sql_marker_reader_test.dart +++ b/pub/orm/test/sql/sql_marker_reader_test.dart @@ -250,7 +250,7 @@ void main() { ); await client.connect(); - await expectLater(client.model('User').findMany(), completes); + await expectLater(client.model('User').all(), completes); await client.disconnect(); }); @@ -268,7 +268,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').findMany(), + client.model('User').all(), throwsA(isA()), ); await client.disconnect(); From eae2915080217ef251a3b5723faf348a0dbe216a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:54:49 +0800 Subject: [PATCH 085/154] feat(repository)!: extract include planner and structure read plans --- pub/orm/lib/src/client/client.dart | 265 +++----------------- pub/orm/lib/src/client/include_planner.dart | 213 ++++++++++++++++ pub/orm/lib/src/generator/writer.dart | 37 ++- pub/orm/lib/src/runtime/plan.dart | 31 ++- pub/orm/test/client/client_test.dart | 20 +- pub/orm/test/generator/generate_test.dart | 27 +- 6 files changed, 337 insertions(+), 256 deletions(-) create mode 100644 pub/orm/lib/src/client/include_planner.dart diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index f2b9ea64..e6852c82 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -9,6 +9,8 @@ import '../runtime/plan.dart'; import '../runtime/plugin.dart'; import '../runtime/types.dart'; +part 'include_planner.dart'; + typedef CollectionFactory = ModelDelegate Function({ required OrmModelContext client, @@ -161,61 +163,29 @@ Map _mergeIncludeSpecMap( return merged; } -JsonMap _serializeIncludeSpec(IncludeSpec spec) { - final encoded = {}; - if (spec.where.isNotEmpty) { - encoded['where'] = Map.from(spec.where); - } - if (spec.skip case final skip?) { - encoded['skip'] = skip; - } - if (spec.take case final take?) { - encoded['take'] = take; - } - if (spec.orderBy.isNotEmpty) { - encoded['orderBy'] = spec.orderBy - .map( - (entry) => { - 'field': entry.field, - 'order': entry.order.name, - }, - ) - .toList(growable: false); - } - if (spec.select.isNotEmpty) { - encoded['select'] = List.from(spec.select, growable: false); - } - if (spec.include.isNotEmpty) { - encoded['include'] = _serializeIncludeSpecMap(spec.include); - } - return encoded; +OrmIncludePlan _buildOrmIncludePlan(IncludeSpec spec) { + return OrmIncludePlan( + where: spec.where, + skip: spec.skip, + take: spec.take, + orderBy: spec.orderBy, + select: spec.select, + include: _buildOrmIncludePlanMap(spec.include), + ); } -JsonMap _serializeIncludeSpecMap(Map include) { +Map _buildOrmIncludePlanMap( + Map include, +) { if (include.isEmpty) { - return const {}; + return const {}; } - return { + return { for (final entry in include.entries) - entry.key: _serializeIncludeSpec(entry.value), + entry.key: _buildOrmIncludePlan(entry.value), }; } -JsonMap _buildOrmReadAnnotations({ - required String resultMode, - required Map include, - List distinct = const [], -}) { - final annotations = {'resultMode': resultMode}; - if (include.isNotEmpty) { - annotations['include'] = _serializeIncludeSpecMap(include); - } - if (distinct.isNotEmpty) { - annotations['distinct'] = List.from(distinct, growable: false); - } - return annotations; -} - abstract interface class OrmModelContext { OrmContract get contract; @@ -1408,10 +1378,11 @@ class ModelDelegate { model: modelName, where: where, ); - final resultMode = switch (action) { - OrmAction.findMany => take == 1 ? 'firstOrNull' : 'all', - OrmAction.findUnique => 'oneOrNull', - _ => action.name, + final OrmReadResultMode? resultMode = switch (action) { + OrmAction.findMany => + take == 1 ? OrmReadResultMode.firstOrNull : OrmReadResultMode.all, + OrmAction.findUnique => OrmReadResultMode.oneOrNull, + _ => null, }; return _PreparedReadPlan( @@ -1422,11 +1393,13 @@ class ModelDelegate { storageHash: _client.contract.markerStorageHash, profileHash: _client.contract.profileHash, lane: 'orm', - annotations: _buildOrmReadAnnotations( - resultMode: resultMode, - include: normalizedInclude, - distinct: distinct, - ), + resultMode: resultMode, + include: _buildOrmIncludePlanMap(normalizedInclude), + annotations: distinct.isEmpty + ? const {} + : { + 'distinct': List.from(distinct, growable: false), + }, model: modelName, action: action, where: normalizedWhere, @@ -1750,159 +1723,9 @@ class ModelDelegate { required Map include, required int depth, }) { - if (rows.isEmpty || include.isEmpty) { - return Future>.value(rows); - } - - if (depth >= _client.maxIncludeDepth) { - throw IncludeDepthExceededException(maxDepth: _client.maxIncludeDepth); - } - - final strategy = _client.includeStrategySelector( - contract: _client.contract, - modelName: modelName, - action: action, - include: include, - depth: depth, - ); - - return switch (strategy) { - IncludeExecutionStrategy.singleQuery => _resolveIncludeRowsSingleQuery( - rows: rows, - include: include, - depth: depth, - ), - IncludeExecutionStrategy.multiQuery => _resolveIncludeRowsMultiQuery( - rows: rows, - include: include, - depth: depth, - ), - }; - } - - Future> _resolveIncludeRowsSingleQuery({ - required List rows, - required Map include, - required int depth, - }) async { - var hydrated = rows; - - for (final entry in include.entries) { - final relationName = entry.key; - final relationInclude = entry.value; - final relation = _resolveRelation( - model: modelName, - relationName: relationName, - ); - final relatedDelegate = _client.model(relation.relatedModel); - _validateIncludePagination(include: relationInclude); - - final relatedRows = await _loadRelationRowsSingleQuery( - relatedDelegate: relatedDelegate, - relation: relation, - relationInclude: relationInclude, - depth: depth, - ); - final rowsByRelationKey = _groupRowsByRelationFields( - rows: relatedRows, - fields: relation.targetFields, - ); - - final nextRows = []; - for (final row in hydrated) { - final relationWhere = _buildRelationWhere(row: row, relation: relation); - if (relationWhere == null) { - final emptyValue = relation.cardinality == RelationCardinality.one - ? null - : const []; - nextRows.add(_attachInclude(row, relationName, emptyValue)); - continue; - } - - final relationKey = _buildRelationMergeKeyFromRow( - row: relationWhere, - fields: relation.targetFields, - ); - final matchedRows = relationKey == null - ? const [] - : (rowsByRelationKey[relationKey] ?? const []); - final windowRows = _sliceRows( - rows: matchedRows, - skip: relationInclude.skip, - take: relationInclude.take, - ); - final shapedRows = relatedDelegate._shapeRows( - windowRows, - select: relationInclude.select, - include: relationInclude.include, - ); - final relationValue = relation.cardinality == RelationCardinality.one - ? _firstOrNull(shapedRows) - : shapedRows; - - nextRows.add(_attachInclude(row, relationName, relationValue)); - } - - hydrated = nextRows; - } - - return hydrated; - } - - Future> _resolveIncludeRowsMultiQuery({ - required List rows, - required Map include, - required int depth, - }) async { - var hydrated = rows; - - for (final entry in include.entries) { - final relationName = entry.key; - final relationInclude = entry.value; - final relation = _resolveRelation( - model: modelName, - relationName: relationName, - ); - final relatedDelegate = _client.model(relation.relatedModel); - - final nextRows = []; - for (final row in hydrated) { - final relationWhere = _buildRelationWhere(row: row, relation: relation); - if (relationWhere == null) { - final emptyValue = relation.cardinality == RelationCardinality.one - ? null - : const []; - nextRows.add(_attachInclude(row, relationName, emptyValue)); - continue; - } - - final relatedWhere = { - ...relationInclude.where, - ...relationWhere, - }; - - final relatedRows = await relatedDelegate._findManyInternal( - action: OrmAction.findMany, - where: relatedWhere, - skip: relationInclude.skip, - take: relationInclude.take, - orderBy: relationInclude.orderBy, - select: relationInclude.select, - include: relationInclude.include, - includeDepth: depth + 1, - ); - - final relationValue = relation.cardinality == RelationCardinality.one - ? _firstOrNull(relatedRows) - : relatedRows; - - nextRows.add(_attachInclude(row, relationName, relationValue)); - } - - hydrated = nextRows; - } - - return hydrated; + return _RepositoryIncludePlanner( + this, + ).resolve(action: action, rows: rows, include: include, depth: depth); } ModelRelationContract _resolveRelation({ @@ -1950,30 +1773,6 @@ class ModelDelegate { return where; } - Future> _loadRelationRowsSingleQuery({ - required ModelDelegate relatedDelegate, - required ModelRelationContract relation, - required IncludeSpec relationInclude, - required int depth, - }) { - final baseWhere = _buildSingleQueryRelationBaseWhere( - includeWhere: relationInclude.where, - relation: relation, - ); - - return relatedDelegate._findManyInternal( - action: OrmAction.findMany, - where: baseWhere, - orderBy: relationInclude.orderBy, - select: _buildSingleQueryRelationSelect( - include: relationInclude, - relation: relation, - ), - include: relationInclude.include, - includeDepth: depth + 1, - ); - } - JsonMap _buildSingleQueryRelationBaseWhere({ required JsonMap includeWhere, required ModelRelationContract relation, diff --git a/pub/orm/lib/src/client/include_planner.dart b/pub/orm/lib/src/client/include_planner.dart new file mode 100644 index 00000000..1f2e69cc --- /dev/null +++ b/pub/orm/lib/src/client/include_planner.dart @@ -0,0 +1,213 @@ +part of 'client.dart'; + +final class _RepositoryIncludePlanner { + final ModelDelegate _delegate; + + const _RepositoryIncludePlanner(this._delegate); + + Future> resolve({ + required OrmAction action, + required List rows, + required Map include, + required int depth, + }) { + if (rows.isEmpty || include.isEmpty) { + return Future>.value(rows); + } + + if (depth >= _delegate._client.maxIncludeDepth) { + throw IncludeDepthExceededException( + maxDepth: _delegate._client.maxIncludeDepth, + ); + } + + final strategy = _delegate._client.includeStrategySelector( + contract: _delegate._client.contract, + modelName: _delegate.modelName, + action: action, + include: include, + depth: depth, + ); + + return switch (strategy) { + IncludeExecutionStrategy.singleQuery => _resolveSingleQuery( + rows: rows, + include: include, + depth: depth, + ), + IncludeExecutionStrategy.multiQuery => _resolveMultiQuery( + rows: rows, + include: include, + depth: depth, + ), + }; + } + + Future> _resolveSingleQuery({ + required List rows, + required Map include, + required int depth, + }) async { + var hydrated = rows; + + for (final entry in include.entries) { + final relationName = entry.key; + final relationInclude = entry.value; + final relation = _delegate._resolveRelation( + model: _delegate.modelName, + relationName: relationName, + ); + final relatedDelegate = _delegate._client.model(relation.relatedModel); + _delegate._validateIncludePagination(include: relationInclude); + + final relatedRows = await _loadRelationRowsSingleQuery( + relatedDelegate: relatedDelegate, + relation: relation, + relationInclude: relationInclude, + depth: depth, + ); + final rowsByRelationKey = _delegate._groupRowsByRelationFields( + rows: relatedRows, + fields: relation.targetFields, + ); + + final nextRows = []; + for (final row in hydrated) { + final relationWhere = _delegate._buildRelationWhere( + row: row, + relation: relation, + ); + if (relationWhere == null) { + nextRows.add( + _delegate._attachInclude( + row, + relationName, + relation.cardinality == RelationCardinality.one + ? null + : const [], + ), + ); + continue; + } + + final relationKey = _delegate._buildRelationMergeKeyFromRow( + row: relationWhere, + fields: relation.targetFields, + ); + final matchedRows = relationKey == null + ? const [] + : (rowsByRelationKey[relationKey] ?? const []); + final windowRows = _delegate._sliceRows( + rows: matchedRows, + skip: relationInclude.skip, + take: relationInclude.take, + ); + final shapedRows = relatedDelegate._shapeRows( + windowRows, + select: relationInclude.select, + include: relationInclude.include, + ); + nextRows.add( + _delegate._attachInclude( + row, + relationName, + relation.cardinality == RelationCardinality.one + ? _firstOrNull(shapedRows) + : shapedRows, + ), + ); + } + + hydrated = nextRows; + } + + return hydrated; + } + + Future> _resolveMultiQuery({ + required List rows, + required Map include, + required int depth, + }) async { + var hydrated = rows; + + for (final entry in include.entries) { + final relationName = entry.key; + final relationInclude = entry.value; + final relation = _delegate._resolveRelation( + model: _delegate.modelName, + relationName: relationName, + ); + final relatedDelegate = _delegate._client.model(relation.relatedModel); + + final nextRows = []; + for (final row in hydrated) { + final relationWhere = _delegate._buildRelationWhere( + row: row, + relation: relation, + ); + if (relationWhere == null) { + nextRows.add( + _delegate._attachInclude( + row, + relationName, + relation.cardinality == RelationCardinality.one + ? null + : const [], + ), + ); + continue; + } + + final relatedRows = await relatedDelegate._findManyInternal( + action: OrmAction.findMany, + where: {...relationInclude.where, ...relationWhere}, + skip: relationInclude.skip, + take: relationInclude.take, + orderBy: relationInclude.orderBy, + select: relationInclude.select, + include: relationInclude.include, + includeDepth: depth + 1, + ); + + nextRows.add( + _delegate._attachInclude( + row, + relationName, + relation.cardinality == RelationCardinality.one + ? _firstOrNull(relatedRows) + : relatedRows, + ), + ); + } + + hydrated = nextRows; + } + + return hydrated; + } + + Future> _loadRelationRowsSingleQuery({ + required ModelDelegate relatedDelegate, + required ModelRelationContract relation, + required IncludeSpec relationInclude, + required int depth, + }) { + final baseWhere = _delegate._buildSingleQueryRelationBaseWhere( + includeWhere: relationInclude.where, + relation: relation, + ); + + return relatedDelegate._findManyInternal( + action: OrmAction.findMany, + where: baseWhere, + orderBy: relationInclude.orderBy, + select: _delegate._buildSingleQueryRelationSelect( + include: relationInclude, + relation: relation, + ), + include: relationInclude.include, + includeDepth: depth + 1, + ); + } +} diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 155cd576..b8601d6e 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1962,7 +1962,7 @@ final class TypedClientWriter { buffer.writeln(' const ${model.sqlClassName}(this._sql);'); buffer.writeln(); - buffer.writeln(' OrmSqlSelectBuilder selectPlan({'); + buffer.writeln(' OrmSqlSelectBuilder _selectBuilder({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); @@ -1995,7 +1995,32 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future> query({'); + buffer.writeln(' OrmPlan toPlan({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln( + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' }) {'); + buffer.writeln(' return _selectBuilder('); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' ).build();'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future> all({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); @@ -2009,7 +2034,7 @@ final class TypedClientWriter { ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' }) async {'); - buffer.writeln(' final rows = await selectPlan('); + buffer.writeln(' final rows = await _selectBuilder('); buffer.writeln(' where: where,'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); @@ -2037,7 +2062,7 @@ final class TypedClientWriter { ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' }) async* {'); - buffer.writeln(' await for (final row in selectPlan('); + buffer.writeln(' await for (final row in _selectBuilder('); buffer.writeln(' where: where,'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); @@ -2050,7 +2075,7 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> first({'); + buffer.writeln(' Future<${model.dataClassName}?> firstOrNull({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); @@ -2063,7 +2088,7 @@ final class TypedClientWriter { ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' }) async {'); - buffer.writeln(' final row = await selectPlan('); + buffer.writeln(' final row = await _selectBuilder('); buffer.writeln(' where: where,'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: 1,'); diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index a57b1bf7..4ae4acd6 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -5,6 +5,8 @@ import 'types.dart'; enum OrmAction { findMany, findUnique, create, update, delete } +enum OrmReadResultMode { all, firstOrNull, oneOrNull } + @immutable final class OrmOrderBy { final String field; @@ -13,6 +15,28 @@ final class OrmOrderBy { const OrmOrderBy(this.field, {this.order = SortOrder.asc}); } +@immutable +final class OrmIncludePlan { + final JsonMap where; + final int? skip; + final int? take; + final List orderBy; + final List select; + final Map include; + + OrmIncludePlan({ + JsonMap where = const {}, + this.skip, + this.take, + List orderBy = const [], + List select = const [], + Map include = const {}, + }) : where = Map.unmodifiable(where), + orderBy = List.unmodifiable(orderBy), + select = List.unmodifiable(select), + include = Map.unmodifiable(include); +} + @immutable final class OrmPlan { final String contractHash; @@ -20,6 +44,8 @@ final class OrmPlan { final String? storageHash; final String? profileHash; final String? lane; + final OrmReadResultMode? resultMode; + final Map include; final JsonMap annotations; final String model; final OrmAction action; @@ -37,6 +63,8 @@ final class OrmPlan { this.storageHash, this.profileHash, this.lane, + this.resultMode, + Map include = const {}, JsonMap annotations = const {}, required this.model, required this.action, @@ -47,7 +75,8 @@ final class OrmPlan { List orderBy = const [], List distinct = const [], List select = const [], - }) : annotations = Map.unmodifiable(annotations), + }) : include = Map.unmodifiable(include), + annotations = Map.unmodifiable(annotations), where = Map.unmodifiable(where), data = Map.unmodifiable(data), orderBy = List.unmodifiable(orderBy), diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index f68b633e..a57ced36 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -755,7 +755,7 @@ void main() { }); test( - 'query toPlan emits orm lane metadata and include annotations', + 'query toPlan emits orm lane metadata and structured include plan', () async { final client = OrmClient( contract: relationalContract, @@ -780,17 +780,13 @@ void main() { expect(plan.lane, 'orm'); expect(plan.action, OrmAction.findMany); expect(plan.take, 5); - expect(plan.annotations['resultMode'], 'all'); - expect(plan.annotations['include'], { - 'posts': { - 'take': 3, - 'include': { - 'author': { - 'select': ['email'], - }, - }, - }, - }); + expect(plan.resultMode, OrmReadResultMode.all); + expect(plan.include.keys, ['posts']); + final posts = plan.include['posts']; + expect(posts, isNotNull); + expect(posts?.take, 3); + expect(posts?.include.keys, ['author']); + expect(posts?.include['author']?.select, ['email']); }, ); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index af9bebf5..b879494e 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -630,17 +630,17 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserSql\s*\{[\s\S]*?OrmSqlSelectBuilder\s+selectPlan\(', + r'class\s+UserSql\s*\{[\s\S]*?OrmPlan\s+toPlan\(', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserSql to expose selectPlan builder.', + reason: 'Expected UserSql to expose typed toPlan helper.', ); expect( RegExp( - r'class\s+UserSql\s*\{[\s\S]*?Future>\s+query\(', + r'class\s+UserSql\s*\{[\s\S]*?Future>\s+all\(', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserSql to expose typed query helper.', + reason: 'Expected UserSql to expose typed all helper.', ); expect( RegExp( @@ -649,6 +649,13 @@ typedef Post = ({ isTrue, reason: 'Expected UserSql to expose typed stream helper.', ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+firstOrNull\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose typed firstOrNull helper.', + ); expect( RegExp( r'class\s+UserSql\s*\{[\s\S]*?Future\s+insert\(', @@ -792,6 +799,18 @@ typedef Post = ({ reason: 'Expected generated typed query source to not expose first() signature.', ); + expect( + generatedSource.contains('OrmSqlSelectBuilder selectPlan('), + isFalse, + reason: + 'Expected generated typed sql source to not expose selectPlan signature.', + ); + expect( + generatedSource.contains('Future> query('), + isFalse, + reason: + 'Expected generated typed sql source to not expose query() signature.', + ); expect( RegExp( r'Stream\s+stream\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', From 745d96f36e5e25945cde9b5f59c19b2e310d399a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:09:12 +0800 Subject: [PATCH 086/154] feat(runtime)!: collapse read plans to action plus result mode BREAKING CHANGE: read plans now use OrmAction.read with explicit resultMode, and SQL select builders use toPlan/all/firstOrNull. --- pub/orm/lib/src/client/client.dart | 168 +++++++++++------- pub/orm/lib/src/client/include_planner.dart | 8 +- pub/orm/lib/src/engine/memory_engine.dart | 34 ++-- pub/orm/lib/src/generator/writer.dart | 6 +- pub/orm/lib/src/runtime/plan.dart | 2 +- pub/orm/lib/src/runtime/plugins/budgets.dart | 2 +- pub/orm/lib/src/runtime/plugins/lints.dart | 14 +- pub/orm/lib/src/sql/adapter.dart | 51 +++--- pub/orm/test/client/client_test.dart | 62 +++---- pub/orm/test/sql/sql_adapter_test.dart | 40 +++-- .../target/adapter_driver_engine_test.dart | 18 +- 11 files changed, 224 insertions(+), 181 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index e6852c82..c6830453 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -558,11 +558,11 @@ final class OrmSqlSelectBuilder { OrmSqlSelectBuilder take(int? value) => _copy(take: value); - OrmPlan build() { + OrmPlan toPlan() { return _buildSqlPlan( client: _client, modelName: _modelName, - action: OrmAction.findMany, + action: OrmAction.read, where: _where, skip: _skip, take: _take, @@ -572,19 +572,19 @@ final class OrmSqlSelectBuilder { ); } - Future> query() async { - final response = await _client.execute(build()); - return _readRows(response.data, action: 'sql.query'); + Future> all() async { + final response = await _client.execute(toPlan()); + return _readRows(response.data, action: 'sql.all'); } - Future first() async { - final response = await _client.execute(take(1).build()); - final rows = _readRows(response.data, action: 'sql.first'); + Future firstOrNull() async { + final response = await _client.execute(take(1).toPlan()); + final rows = _readRows(response.data, action: 'sql.firstOrNull'); return _firstOrNull(rows); } Stream stream() async* { - final rows = await query(); + final rows = await all(); for (final row in rows) { yield row; } @@ -639,7 +639,7 @@ final class OrmSqlInsertBuilder { return _copy(select: nextSelect); } - OrmPlan build() { + OrmPlan toPlan() { return _buildSqlPlan( client: _client, modelName: _modelName, @@ -650,7 +650,7 @@ final class OrmSqlInsertBuilder { } Future execute() async { - final response = await _client.execute(build()); + final response = await _client.execute(toPlan()); return OrmSqlMutationResult( row: _readRow(response.data, action: 'sql.insert'), affectedRows: response.affectedRows, @@ -704,7 +704,7 @@ final class OrmSqlUpdateBuilder { return _copy(select: nextSelect); } - OrmPlan build() { + OrmPlan toPlan() { return _buildSqlPlan( client: _client, modelName: _modelName, @@ -716,7 +716,7 @@ final class OrmSqlUpdateBuilder { } Future execute() async { - final response = await _client.execute(build()); + final response = await _client.execute(toPlan()); return OrmSqlMutationResult( row: _readRow(response.data, action: 'sql.update'), affectedRows: response.affectedRows, @@ -768,7 +768,7 @@ final class OrmSqlDeleteBuilder { return _copy(select: nextSelect); } - OrmPlan build() { + OrmPlan toPlan() { return _buildSqlPlan( client: _client, modelName: _modelName, @@ -779,7 +779,7 @@ final class OrmSqlDeleteBuilder { } Future execute() async { - final response = await _client.execute(build()); + final response = await _client.execute(toPlan()); return OrmSqlMutationResult( row: _readRow(response.data, action: 'sql.delete'), affectedRows: response.affectedRows, @@ -902,7 +902,7 @@ class ModelDelegate { Map include = const {}, }) async { final prepared = await _buildReadPlan( - action: OrmAction.findMany, + resultMode: OrmReadResultMode.all, where: where, skip: skip, take: take, @@ -923,8 +923,8 @@ class ModelDelegate { List select = const [], Map include = const {}, }) { - return _findManyInternal( - action: OrmAction.findMany, + return _readAllInternal( + action: OrmAction.read, where: where, skip: skip, take: take, @@ -965,8 +965,8 @@ class ModelDelegate { List select = const [], Map include = const {}, }) { - return _findUniqueInternal( - action: OrmAction.findUnique, + return _readOneInternal( + action: OrmAction.read, where: where, select: select, include: include, @@ -982,23 +982,21 @@ class ModelDelegate { List select = const [], Map include = const {}, }) async { - final rows = await _findManyInternal( - action: OrmAction.findMany, + return _readFirstInternal( + action: OrmAction.read, where: where, skip: skip, - take: 1, orderBy: orderBy, distinct: distinct, select: select, include: include, includeDepth: 0, ); - return _firstOrNull(rows); } Future count({JsonMap where = const {}}) async { - final rows = await _findManyInternal( - action: OrmAction.findMany, + final rows = await _readAllInternal( + action: OrmAction.read, where: where, includeDepth: 0, ); @@ -1025,8 +1023,8 @@ class ModelDelegate { _assertKnownAggregateFields(fields: sum, source: 'aggregate.sum'); _assertKnownAggregateFields(fields: avg, source: 'aggregate.avg'); - final rows = await _findManyInternal( - action: OrmAction.findMany, + final rows = await _readAllInternal( + action: OrmAction.read, where: where, select: _buildAggregateSelect( count: count, @@ -1104,8 +1102,8 @@ class ModelDelegate { avg: avg, ); - final rows = await _findManyInternal( - action: OrmAction.findMany, + final rows = await _readAllInternal( + action: OrmAction.read, where: where, select: _buildAggregateSelect( count: by.followedBy(count).toList(growable: false), @@ -1357,7 +1355,7 @@ class ModelDelegate { } Future<_PreparedReadPlan> _buildReadPlan({ - required OrmAction action, + required OrmReadResultMode resultMode, JsonMap where = const {}, int? skip, int? take, @@ -1378,11 +1376,21 @@ class ModelDelegate { model: modelName, where: where, ); - final OrmReadResultMode? resultMode = switch (action) { - OrmAction.findMany => - take == 1 ? OrmReadResultMode.firstOrNull : OrmReadResultMode.all, - OrmAction.findUnique => OrmReadResultMode.oneOrNull, - _ => null, + final isCollectionRead = resultMode != OrmReadResultMode.oneOrNull; + final resolvedTake = resultMode == OrmReadResultMode.firstOrNull ? 1 : take; + final readSelect = switch (resultMode) { + OrmReadResultMode.oneOrNull => _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), + OrmReadResultMode.all || OrmReadResultMode.firstOrNull => + _expandSelectForExecution( + model: modelName, + select: select, + include: normalizedInclude, + distinct: distinct, + ), }; return _PreparedReadPlan( @@ -1401,31 +1409,18 @@ class ModelDelegate { 'distinct': List.from(distinct, growable: false), }, model: modelName, - action: action, + action: OrmAction.read, where: normalizedWhere, - skip: action == OrmAction.findMany && distinct.isEmpty ? skip : null, - take: action == OrmAction.findMany && distinct.isEmpty ? take : null, - orderBy: action == OrmAction.findMany ? orderBy : const [], - distinct: action == OrmAction.findMany ? distinct : const [], - select: switch (action) { - OrmAction.findMany => _expandSelectForExecution( - model: modelName, - select: select, - include: normalizedInclude, - distinct: distinct, - ), - OrmAction.findUnique => _expandSelectForInclude( - model: modelName, - select: select, - include: normalizedInclude, - ), - _ => select, - }, + skip: isCollectionRead && distinct.isEmpty ? skip : null, + take: isCollectionRead && distinct.isEmpty ? resolvedTake : null, + orderBy: isCollectionRead ? orderBy : const [], + distinct: isCollectionRead ? distinct : const [], + select: readSelect, ), ); } - Future> _findManyInternal({ + Future> _readAllInternal({ required OrmAction action, JsonMap where = const {}, int? skip, @@ -1437,7 +1432,7 @@ class ModelDelegate { required int includeDepth, }) async { final prepared = await _buildReadPlan( - action: OrmAction.findMany, + resultMode: OrmReadResultMode.all, where: where, skip: skip, take: take, @@ -1464,7 +1459,48 @@ class ModelDelegate { return _shapeRows(hydratedRows, select: select, include: normalizedInclude); } - Future _findUniqueInternal({ + Future _readFirstInternal({ + required OrmAction action, + JsonMap where = const {}, + int? skip, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + required int includeDepth, + }) async { + final prepared = await _buildReadPlan( + resultMode: OrmReadResultMode.firstOrNull, + where: where, + skip: skip, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + ); + final normalizedInclude = prepared.include; + final response = await _client.execute(prepared.plan); + + final row = _readRow(response.data, action: 'firstOrNull'); + if (row == null) { + return null; + } + + final hydratedRows = await _resolveIncludeRows( + action: action, + rows: [row], + include: normalizedInclude, + depth: includeDepth, + ); + + return _shapeRows( + hydratedRows, + select: select, + include: normalizedInclude, + ).single; + } + + Future _readOneInternal({ required OrmAction action, JsonMap where = const {}, List select = const [], @@ -1472,7 +1508,7 @@ class ModelDelegate { required int includeDepth, }) async { final prepared = await _buildReadPlan( - action: OrmAction.findUnique, + resultMode: OrmReadResultMode.oneOrNull, where: where, select: select, include: include, @@ -1480,7 +1516,7 @@ class ModelDelegate { final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); - final row = _readRow(response.data, action: 'findUnique'); + final row = _readRow(response.data, action: 'oneOrNull'); if (row == null) { return null; } @@ -1515,8 +1551,8 @@ class ModelDelegate { JsonMap? preDeleteRow; if (action == OrmAction.delete && !(_client.contract.capabilities.mutationReturning)) { - preDeleteRow = await _findUniqueInternal( - action: OrmAction.findUnique, + preDeleteRow = await _readOneInternal( + action: OrmAction.read, where: normalizedWhere, select: _expandSelectForInclude( model: modelName, @@ -1551,8 +1587,8 @@ class ModelDelegate { response.affectedRows > 0 && !(_client.contract.capabilities.mutationReturning)) { row = switch (action) { - OrmAction.update => await _findUniqueInternal( - action: OrmAction.findUnique, + OrmAction.update => await _readOneInternal( + action: OrmAction.read, where: normalizedWhere, select: _expandSelectForInclude( model: modelName, @@ -3062,8 +3098,8 @@ class ModelDelegate { }) async { final relatedRows = await _client .model(relation.relatedModel) - ._findManyInternal( - action: OrmAction.findMany, + ._readAllInternal( + action: OrmAction.read, where: relatedWhere, select: relation.targetFields, includeDepth: 0, diff --git a/pub/orm/lib/src/client/include_planner.dart b/pub/orm/lib/src/client/include_planner.dart index 1f2e69cc..ff1e1b07 100644 --- a/pub/orm/lib/src/client/include_planner.dart +++ b/pub/orm/lib/src/client/include_planner.dart @@ -159,8 +159,8 @@ final class _RepositoryIncludePlanner { continue; } - final relatedRows = await relatedDelegate._findManyInternal( - action: OrmAction.findMany, + final relatedRows = await relatedDelegate._readAllInternal( + action: OrmAction.read, where: {...relationInclude.where, ...relationWhere}, skip: relationInclude.skip, take: relationInclude.take, @@ -198,8 +198,8 @@ final class _RepositoryIncludePlanner { relation: relation, ); - return relatedDelegate._findManyInternal( - action: OrmAction.findMany, + return relatedDelegate._readAllInternal( + action: OrmAction.read, where: baseWhere, orderBy: relationInclude.orderBy, select: _delegate._buildSingleQueryRelationSelect( diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index 780ba4d1..b594d7bd 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -61,8 +61,7 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { final bucket = store.putIfAbsent(plan.model, () => []); return switch (plan.action) { - OrmAction.findMany => _findMany(bucket, plan), - OrmAction.findUnique => _findUnique(bucket, plan), + OrmAction.read => _read(bucket, plan), OrmAction.create => _create(bucket, plan), OrmAction.update => _update(bucket, plan), OrmAction.delete => _delete(bucket, plan), @@ -91,7 +90,7 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { throw StateError('MemoryEngine is closed. Call open() before execute().'); } - EngineResponse _findMany(List bucket, OrmPlan plan) { + EngineResponse _read(List bucket, OrmPlan plan) { var rows = bucket.where((row) => _matches(row, plan.where)).toList(); if (plan.orderBy.isNotEmpty) { @@ -106,21 +105,15 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { rows = take >= rows.length ? rows : rows.sublist(0, take); } - return EngineResponse( - data: rows - .map((row) => _projectRow(row, plan.select)) - .toList(growable: false), - ); - } + final projected = rows + .map((row) => _projectRow(row, plan.select)) + .toList(growable: false); - EngineResponse _findUnique(List bucket, OrmPlan plan) { - final row = bucket.cast().firstWhere( - (candidate) => candidate != null && _matches(candidate, plan.where), - orElse: () => null, - ); - return EngineResponse( - data: row == null ? null : _projectRow(row, plan.select), - ); + return switch (plan.resultMode) { + OrmReadResultMode.firstOrNull || OrmReadResultMode.oneOrNull => + EngineResponse(data: _firstOrNull(projected)), + _ => EngineResponse(data: projected), + }; } EngineResponse _create(List bucket, OrmPlan plan) { @@ -537,3 +530,10 @@ Map> _cloneStore(Map> source) { entry.key: List.from(entry.value.map(_cloneRow)), }; } + +T? _firstOrNull(List values) { + if (values.isEmpty) { + return null; + } + return values.first; +} diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index b8601d6e..6ca9f977 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2016,7 +2016,7 @@ final class TypedClientWriter { buffer.writeln(' orderBy: orderBy,'); buffer.writeln(' distinct: distinct,'); buffer.writeln(' select: select,'); - buffer.writeln(' ).build();'); + buffer.writeln(' ).toPlan();'); buffer.writeln(' }'); buffer.writeln(); @@ -2041,7 +2041,7 @@ final class TypedClientWriter { buffer.writeln(' orderBy: orderBy,'); buffer.writeln(' distinct: distinct,'); buffer.writeln(' select: select,'); - buffer.writeln(' ).query();'); + buffer.writeln(' ).all();'); buffer.writeln( ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', ); @@ -2095,7 +2095,7 @@ final class TypedClientWriter { buffer.writeln(' orderBy: orderBy,'); buffer.writeln(' distinct: distinct,'); buffer.writeln(' select: select,'); - buffer.writeln(' ).first();'); + buffer.writeln(' ).firstOrNull();'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); buffer.writeln(' }'); diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index 4ae4acd6..c88c371a 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -3,7 +3,7 @@ import 'package:meta/meta.dart'; import '../core/sort_order.dart'; import 'types.dart'; -enum OrmAction { findMany, findUnique, create, update, delete } +enum OrmAction { read, create, update, delete } enum OrmReadResultMode { all, firstOrNull, oneOrNull } diff --git a/pub/orm/lib/src/runtime/plugins/budgets.dart b/pub/orm/lib/src/runtime/plugins/budgets.dart index 92f552f2..5d2dbb0f 100644 --- a/pub/orm/lib/src/runtime/plugins/budgets.dart +++ b/pub/orm/lib/src/runtime/plugins/budgets.dart @@ -32,7 +32,7 @@ final class _BudgetsPlugin extends OrmPlugin { @override void beforeExecute(OrmPlan plan, PluginContext ctx) { - if (plan.action == OrmAction.findMany && + if (plan.action == OrmAction.read && plan.take != null && plan.take! > options.maxRows) { _handle( diff --git a/pub/orm/lib/src/runtime/plugins/lints.dart b/pub/orm/lib/src/runtime/plugins/lints.dart index 0cc45bd5..e925d878 100644 --- a/pub/orm/lib/src/runtime/plugins/lints.dart +++ b/pub/orm/lib/src/runtime/plugins/lints.dart @@ -44,22 +44,26 @@ final class _LintsPlugin extends OrmPlugin { ); } - if (plan.action == OrmAction.findUnique && plan.where.isEmpty) { + if (plan.action == OrmAction.read && + plan.resultMode == OrmReadResultMode.oneOrNull && + plan.where.isEmpty) { _handle( ctx: ctx, severity: options.uniqueWithoutWhere, code: 'LINT.UNIQUE_WITHOUT_WHERE', - message: 'findUnique requires a non-empty where clause.', + message: 'oneOrNull requires a non-empty where clause.', details: {'model': plan.model}, ); } - if (plan.action == OrmAction.findMany && plan.take == null) { + if (plan.action == OrmAction.read && + plan.resultMode == OrmReadResultMode.all && + plan.take == null) { _handle( ctx: ctx, severity: options.unboundedRead, code: 'LINT.UNBOUNDED_READ', - message: 'Unbounded findMany may return very large result sets.', + message: 'Unbounded read may return very large result sets.', details: {'model': plan.model}, ); } @@ -69,7 +73,7 @@ final class _LintsPlugin extends OrmPlugin { bool _isMutation(OrmPlan plan) { return switch (plan.action) { OrmAction.create || OrmAction.update || OrmAction.delete => true, - OrmAction.findMany || OrmAction.findUnique => false, + OrmAction.read => false, }; } diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 18012395..f13b652a 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -77,18 +77,11 @@ final class SqlAdapter implements TargetAdapter { final orderByClause = _buildOrderByClause(plan.orderBy); return switch (plan.action) { - OrmAction.findMany => SqlStatement( + OrmAction.read => SqlStatement( action: plan.action, text: 'SELECT ${_buildSelectColumns(plan.select)} FROM ${_id(model.table)}' - '$whereClause$orderByClause${_buildLimitOffsetClause(plan, params)}', - parameters: params, - ), - OrmAction.findUnique => SqlStatement( - action: plan.action, - text: - 'SELECT ${_buildSelectColumns(plan.select)} FROM ${_id(model.table)}' - '$whereClause$orderByClause LIMIT 1', + '$whereClause$orderByClause${_buildReadLimitOffsetClause(plan, params)}', parameters: params, ), OrmAction.create => _lowerCreate( @@ -114,13 +107,10 @@ final class SqlAdapter implements TargetAdapter { final resolver = codecResolver; if (resolver == null) { return switch (plan.action) { - OrmAction.findMany => EngineResponse( - data: response.rows, - affectedRows: response.affectedRows, - ), - OrmAction.findUnique => EngineResponse( - data: _firstOrNull(response.rows), + OrmAction.read => _decodeReadResult( + rows: response.rows, affectedRows: response.affectedRows, + plan: plan, ), OrmAction.create || OrmAction.update || @@ -133,13 +123,10 @@ final class SqlAdapter implements TargetAdapter { final decodedRows = _decodeRows(model: plan.model, rows: response.rows); return switch (plan.action) { - OrmAction.findMany => EngineResponse( - data: decodedRows, - affectedRows: response.affectedRows, - ), - OrmAction.findUnique => EngineResponse( - data: _firstOrNull(decodedRows), + OrmAction.read => _decodeReadResult( + rows: decodedRows, affectedRows: response.affectedRows, + plan: plan, ), OrmAction.create || OrmAction.update || @@ -209,6 +196,18 @@ final class SqlAdapter implements TargetAdapter { ); } + EngineResponse _decodeReadResult({ + required List rows, + required int affectedRows, + required OrmPlan plan, + }) { + return switch (plan.resultMode) { + OrmReadResultMode.firstOrNull || OrmReadResultMode.oneOrNull => + EngineResponse(data: _firstOrNull(rows), affectedRows: affectedRows), + _ => EngineResponse(data: rows, affectedRows: affectedRows), + }; + } + SqlStatement _lowerDelete({ required OrmPlan plan, required String table, @@ -905,16 +904,20 @@ final class SqlAdapter implements TargetAdapter { return ' ORDER BY ${clauses.join(', ')}'; } - String _buildLimitOffsetClause(OrmPlan plan, List params) { + String _buildReadLimitOffsetClause(OrmPlan plan, List params) { final clauses = []; + final effectiveTake = switch (plan.resultMode) { + OrmReadResultMode.oneOrNull => 1, + _ => plan.take, + }; - if (plan.take case final take?) { + if (effectiveTake case final take?) { clauses.add(' LIMIT ?'); params.add(take); } if (plan.skip case final skip?) { - if (plan.take == null) { + if (effectiveTake == null) { clauses.add(' LIMIT -1'); } clauses.add(' OFFSET ?'); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index a57ced36..5e096607 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -55,7 +55,7 @@ void main() { final multi = defaultIncludeExecutionStrategySelector( contract: contract, modelName: 'User', - action: OrmAction.findMany, + action: OrmAction.read, include: const {'posts': IncludeSpec()}, depth: 0, ); @@ -71,7 +71,7 @@ void main() { final single = defaultIncludeExecutionStrategySelector( contract: singleContract, modelName: 'User', - action: OrmAction.findMany, + action: OrmAction.read, include: const {'posts': IncludeSpec()}, depth: 0, ); @@ -137,7 +137,7 @@ void main() { .from('User') .where({'id': 'u1'}) .select(const ['email']) - .query(); + .all(); expect(selectedRows, hasLength(1)); expect(selectedRows.single['email'], 'a@example.com'); @@ -158,7 +158,7 @@ void main() { expect(deleted.affectedRows, 1); expect(deleted.row?['id'], 'u1'); - final remaining = await client.sql.from('User').query(); + final remaining = await client.sql.from('User').all(); expect(remaining, isEmpty); await client.disconnect(); }); @@ -166,7 +166,7 @@ void main() { test('db.sql requires explicit connect', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await expectLater( - client.sql.from('User').query(), + client.sql.from('User').all(), throwsA(isA()), ); }); @@ -182,7 +182,7 @@ void main() { final sqlRow = await client.db.sql.from('User').where({ 'id': 'u1', - }).first(); + }).firstOrNull(); expect(sqlRow?['email'], 'a@example.com'); final ormRow = await client.db.orm['User'].oneOrNull( @@ -201,7 +201,7 @@ void main() { OrmPlan( contractHash: 'mismatch', model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, ), ), throwsA(isA()), @@ -239,7 +239,7 @@ void main() { storageHash: profileContract.markerStorageHash, profileHash: profileContract.profileHash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, ), ), throwsA(isA()), @@ -253,7 +253,7 @@ void main() { storageHash: 'other-storage', profileHash: profileContract.profileHash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, ), ), throwsA(isA()), @@ -267,7 +267,7 @@ void main() { storageHash: profileContract.markerStorageHash, profileHash: 'other-profile', model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, ), ), throwsA(isA()), @@ -778,7 +778,7 @@ void main() { .toPlan(); expect(plan.lane, 'orm'); - expect(plan.action, OrmAction.findMany); + expect(plan.action, OrmAction.read); expect(plan.take, 5); expect(plan.resultMode, OrmReadResultMode.all); expect(plan.include.keys, ['posts']); @@ -1453,7 +1453,7 @@ void main() { expect(rows, hasLength(2)); final findManyPlans = engine.executedPlans - .where((plan) => plan.action == OrmAction.findMany) + .where((plan) => plan.action == OrmAction.read) .toList(growable: false); expect( findManyPlans.length, @@ -2084,13 +2084,13 @@ void main() { await client.connect(); await client.withConnection((connection) async { - final rows = await connection.sql.from('User').take(1).query(); + final rows = await connection.sql.from('User').take(1).all(); expect(rows, isEmpty); }); expect(engine.connectionCount, 1); expect(engine.connectionExecutePlans, hasLength(1)); - expect(engine.connectionExecutePlans.single.action, OrmAction.findMany); + expect(engine.connectionExecutePlans.single.action, OrmAction.read); expect(engine.connectionExecutePlans.single.take, 1); await client.disconnect(); }); @@ -2109,7 +2109,7 @@ void main() { expect(engine.connectionCount, 1); expect(engine.connectionExecutePlans, hasLength(1)); - expect(engine.connectionExecutePlans.single.action, OrmAction.findMany); + expect(engine.connectionExecutePlans.single.action, OrmAction.read); expect(engine.releaseCount, 1); await client.disconnect(); }, @@ -2169,7 +2169,7 @@ void main() { expect(engine.transactionExecutePlans, hasLength(1)); expect( engine.transactionExecutePlans.single.action, - OrmAction.findMany, + OrmAction.read, ); expect(engine.commitCount, 1); expect(engine.rollbackCount, 0); @@ -2221,7 +2221,7 @@ void main() { expect(engine.transactionExecutePlans, hasLength(1)); expect( engine.transactionExecutePlans.single.action, - OrmAction.findMany, + OrmAction.read, ); expect(engine.commitCount, 0); expect(engine.rollbackCount, 1); @@ -2287,7 +2287,7 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, ), ), throwsA(isA()), @@ -2301,7 +2301,7 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, ), ), throwsA(isA()), @@ -2318,7 +2318,7 @@ void main() { final telemetry = client.telemetry(); expect(telemetry, isNotNull); expect(telemetry?.model, 'User'); - expect(telemetry?.action, OrmAction.findMany); + expect(telemetry?.action, OrmAction.read); expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); await client.disconnect(); }); @@ -2517,7 +2517,7 @@ void main() { await client.connect(); await client.model('User').all(); - expect(plugin.events, ['before:findMany', 'after:findMany']); + expect(plugin.events, ['before:read', 'after:read']); await client.disconnect(); }); @@ -2529,9 +2529,9 @@ void main() { plugins: [plugin], ); await client.connect(); - await client.sql.from('User').query(); + await client.sql.from('User').all(); - expect(plugin.events, ['before:findMany', 'after:findMany']); + expect(plugin.events, ['before:read', 'after:read']); await client.disconnect(); }); @@ -2546,9 +2546,9 @@ void main() { await expectLater(client.model('User').all(), throwsA(isA())); expect(plugin.events, [ - 'before:findMany', - 'error:findMany', - 'after:findMany', + 'before:read', + 'error:read', + 'after:read', ]); expect(client.telemetry()?.outcome, RuntimeTelemetryOutcome.runtimeError); await client.disconnect(); @@ -2647,7 +2647,7 @@ void main() { await client.connect(); await expectLater( - client.sql.from('User').query(), + client.sql.from('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2818,19 +2818,15 @@ final class _CountingEngine implements OrmEngine { final class _BadRelatedFindManyShapeEngine implements OrmEngine { final OrmEngine inner; - final String relatedModel; - _BadRelatedFindManyShapeEngine({ - required this.inner, - this.relatedModel = 'Post', - }); + _BadRelatedFindManyShapeEngine({required this.inner}); @override Future close() => inner.close(); @override Future execute(OrmPlan plan) async { - if (plan.model == relatedModel && plan.action == OrmAction.findMany) { + if (plan.model == 'Post' && plan.action == OrmAction.read) { return const EngineResponse(data: 'bad-shape'); } return inner.execute(plan); diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index cd287add..706afed6 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -62,7 +62,7 @@ void main() { final plan = OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: {'email': 'a@example.com'}, orderBy: const [OrmOrderBy('id')], take: 10, @@ -84,7 +84,7 @@ void main() { final plan = OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'email': { 'lt': 'z@example.com', @@ -128,7 +128,7 @@ void main() { final plan = OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'email': { 'contains': 'a%b', @@ -154,7 +154,7 @@ void main() { final plan = OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'id': 'u1', 'AND': [ @@ -198,7 +198,7 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'AND': const [], 'OR': const [], @@ -216,7 +216,7 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: {'AND': 'bad', 'OR': 1, 'NOT': true}, ), ); @@ -233,7 +233,7 @@ void main() { final plan = OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'posts': { 'some': { @@ -264,7 +264,7 @@ void main() { final plan = OrmPlan( contractHash: contract.hash, model: 'Post', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'author': { 'is': {'email': 'u1@example.com'}, @@ -294,7 +294,7 @@ void main() { final plan = OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: {'id': 'u1', 'email': jsonPayload}, ); @@ -312,7 +312,7 @@ void main() { final plan = OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'id': {'in': const []}, 'email': {'notIn': const []}, @@ -431,7 +431,7 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, ), ); expect(findMany.data, isA>()); @@ -445,7 +445,8 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findUnique, + action: OrmAction.read, + resultMode: OrmReadResultMode.oneOrNull, ), ); if (findUnique.data case final Map row) { @@ -514,7 +515,8 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findUnique, + action: OrmAction.read, + resultMode: OrmReadResultMode.oneOrNull, ), ); if (decoded.data case final Map row) { @@ -545,7 +547,7 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'email': { 'in': ['a@example.com', 'b@example.com'], @@ -586,7 +588,7 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: { 'email': { 'contains': 'example', @@ -647,7 +649,8 @@ void main() { final decodePlan = OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findUnique, + action: OrmAction.read, + resultMode: OrmReadResultMode.oneOrNull, ); final decodedWithoutCodec = adapterWithoutCodec.decode( @@ -725,7 +728,8 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'User', - action: OrmAction.findUnique, + action: OrmAction.read, + resultMode: OrmReadResultMode.oneOrNull, ), ); if (decoded.data case final Map row) { @@ -747,7 +751,7 @@ void main() { OrmPlan( contractHash: contract.hash, model: 'Missing', - action: OrmAction.findMany, + action: OrmAction.read, ), ), throwsA(isA()), diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index 56103272..70cd356f 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -82,15 +82,15 @@ void main() { ); expect(adapter.loweredPlans, hasLength(1)); - expect(adapter.decodedRaw, ['driver:User:findMany']); - expect(driver.requests, ['User:findMany']); + expect(adapter.decodedRaw, ['driver:User:read']); + expect(driver.requests, ['User:read']); expect(response.affectedRows, 1); final row = response.data; expect(row, isA>()); if (row case final Map map) { - expect(map['request'], 'User:findMany'); - expect(map['action'], 'findMany'); + expect(map['request'], 'User:read'); + expect(map['action'], 'read'); expect(map['whereId'], 'u1'); } else { fail('Expected map response data.'); @@ -114,8 +114,8 @@ void main() { ); expect(driver.connectionCount, 1); - expect(driver.connections.single.requests, ['User:findMany']); - expect(adapter.decodedRaw, ['connection:User:findMany']); + expect(driver.connections.single.requests, ['User:read']); + expect(adapter.decodedRaw, ['connection:User:read']); expect(response.affectedRows, 1); await connection.release(); @@ -140,8 +140,8 @@ void main() { await transaction.commit(); final inner = driver.connections.single.transactions.single; - expect(inner.requests, ['User:findMany']); - expect(adapter.decodedRaw, ['transaction:User:findMany']); + expect(inner.requests, ['User:read']); + expect(adapter.decodedRaw, ['transaction:User:read']); expect(inner.commitCount, 1); expect(inner.rollbackCount, 0); @@ -187,7 +187,7 @@ OrmPlan _plan({JsonMap where = const {}}) { return OrmPlan( contractHash: 'hash', model: 'User', - action: OrmAction.findMany, + action: OrmAction.read, where: where, ); } From c1a4644c94d0ee08e713be57db43b11cc684adcd Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:11:19 +0800 Subject: [PATCH 087/154] feat(repository)!: reject invalid query state on mutations BREAKING CHANGE: mutation terminals now throw when query-only state such as skip/take/orderBy/distinct is present, and create/createMany also reject where. --- pub/orm/lib/src/client/client.dart | 48 +++++++++++++++++++++--- pub/orm/test/client/client_test.dart | 55 ++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index c6830453..0b2e9c92 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3551,7 +3551,33 @@ final class ModelQuery { ); } + void _assertMutationQueryState({ + required String action, + bool allowWhere = true, + }) { + final invalidKeys = [ + if (!allowWhere && _state.where.isNotEmpty) 'where', + if (_state.skip != null) 'skip', + if (_state.take != null) 'take', + if (_state.orderBy.isNotEmpty) 'orderBy', + if (_state.distinct.isNotEmpty) 'distinct', + ]; + if (invalidKeys.isEmpty) { + return; + } + + throw runtimeError( + 'PLAN.MUTATION_QUERY_STATE_INVALID', + '$action does not allow query state keys: ${invalidKeys.join(', ')}.', + details: { + 'action': action, + 'invalidKeys': invalidKeys, + }, + ); + } + Future create({required JsonMap data}) { + _assertMutationQueryState(action: 'create', allowWhere: false); return _delegate.create( data: data, select: _state.select, @@ -3560,6 +3586,7 @@ final class ModelQuery { } Future> createMany({required List data}) { + _assertMutationQueryState(action: 'createMany', allowWhere: false); return _delegate.createMany( data: data, select: _state.select, @@ -3567,9 +3594,13 @@ final class ModelQuery { ); } - Future deleteMany() => _delegate.deleteMany(where: _state.where); + Future deleteMany() { + _assertMutationQueryState(action: 'deleteMany'); + return _delegate.deleteMany(where: _state.where); + } Future upsert({required JsonMap create, required JsonMap update}) { + _assertMutationQueryState(action: 'upsert'); return _delegate.upsert( where: _state.where, create: create, @@ -3580,6 +3611,7 @@ final class ModelQuery { } Future update({required JsonMap data}) { + _assertMutationQueryState(action: 'update'); return _delegate.update( where: _state.where, data: data, @@ -3592,6 +3624,7 @@ final class ModelQuery { required JsonMap data, Map> create = const >{}, }) { + _assertMutationQueryState(action: 'updateNested'); return _delegate.updateNested( where: _state.where, data: data, @@ -3601,11 +3634,14 @@ final class ModelQuery { ); } - Future delete() => _delegate.delete( - where: _state.where, - select: _state.select, - include: _state.include, - ); + Future delete() { + _assertMutationQueryState(action: 'delete'); + return _delegate.delete( + where: _state.where, + select: _state.select, + include: _state.include, + ); + } ModelQuery _next(ModelQueryState nextState) => ModelQuery._(_delegate, nextState); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 5e096607..52b7b7a4 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -907,6 +907,61 @@ void main() { await client.disconnect(); }); + test('rejects unsupported query state on mutation terminals', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.model('User'); + + expect( + () => users + .where({'id': 'u1'}) + .create(data: {'id': 'u1', 'email': 'a@x.com'}), + throwsA( + isA() + .having((error) => error.code, 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID') + .having( + (error) => error.details['invalidKeys'], + 'invalidKeys', + ['where'], + ), + ), + ); + + expect( + () => users + .where({'id': 'u1'}) + .orderByField('email') + .update(data: {'email': 'b@x.com'}), + throwsA( + isA() + .having((error) => error.code, 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID') + .having( + (error) => error.details['invalidKeys'], + 'invalidKeys', + ['orderBy'], + ), + ), + ); + + expect( + () => users.take(1).deleteMany(), + throwsA( + isA() + .having((error) => error.code, 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID') + .having( + (error) => error.details['invalidKeys'], + 'invalidKeys', + ['take'], + ), + ), + ); + + await client.disconnect(); + }); + test('supports upsert create and update branches', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); From 55a0ec45b88f0013a253a10d762b58c171472186 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:20:41 +0800 Subject: [PATCH 088/154] feat(runtime)!: structure mutation plans and fallback semantics BREAKING CHANGE: orm and sql mutation plans now expose mutationResultMode, and repository fallback paths are covered by explicit mutation plan builders and regression tests. --- pub/orm/lib/src/client/client.dart | 104 +++++++++++++------- pub/orm/lib/src/runtime/plan.dart | 4 + pub/orm/test/client/client_test.dart | 137 +++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 36 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 0b2e9c92..c6414999 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -644,6 +644,7 @@ final class OrmSqlInsertBuilder { client: _client, modelName: _modelName, action: OrmAction.create, + mutationResultMode: OrmMutationResultMode.rowOrNull, data: _data, select: _select, ); @@ -709,6 +710,7 @@ final class OrmSqlUpdateBuilder { client: _client, modelName: _modelName, action: OrmAction.update, + mutationResultMode: OrmMutationResultMode.rowOrNull, where: _where, data: _data, select: _select, @@ -773,6 +775,7 @@ final class OrmSqlDeleteBuilder { client: _client, modelName: _modelName, action: OrmAction.delete, + mutationResultMode: OrmMutationResultMode.rowOrNull, where: _where, select: _select, ); @@ -812,6 +815,7 @@ OrmPlan _buildSqlPlan({ required OrmModelContext client, required String modelName, required OrmAction action, + OrmMutationResultMode? mutationResultMode, JsonMap where = const {}, JsonMap data = const {}, int? skip, @@ -827,6 +831,7 @@ OrmPlan _buildSqlPlan({ storageHash: contract.markerStorageHash, profileHash: contract.profileHash, lane: 'sql', + mutationResultMode: mutationResultMode, model: modelName, action: action, where: where, @@ -847,6 +852,14 @@ final class _PreparedReadPlan { const _PreparedReadPlan({required this.plan, required this.include}); } +@immutable +final class _PreparedMutationPlan { + final OrmPlan plan; + final Map include; + + const _PreparedMutationPlan({required this.plan, required this.include}); +} + class ModelDelegate { final OrmModelContext _client; final String modelName; @@ -1175,23 +1188,15 @@ class ModelDelegate { List select = const [], Map include = const {}, }) async { - final normalizedInclude = _normalizeInclude(include); - final response = await _client.execute( - OrmPlan( - contractHash: _client.contract.hash, - target: _client.contract.target, - storageHash: _client.contract.markerStorageHash, - profileHash: _client.contract.profileHash, - model: modelName, - action: OrmAction.create, - data: data, - select: _expandSelectForInclude( - model: modelName, - select: select, - include: normalizedInclude, - ), - ), + final prepared = await _buildMutationPlan( + action: OrmAction.create, + mutationResultMode: OrmMutationResultMode.row, + data: data, + select: select, + include: include, ); + final normalizedInclude = prepared.include; + final response = await _client.execute(prepared.plan); var row = _readRow(response.data, action: 'create'); if (row == null) { @@ -1331,6 +1336,7 @@ class ModelDelegate { }) { return _runNullableMutation( action: OrmAction.update, + mutationResultMode: OrmMutationResultMode.rowOrNull, where: where, data: data, select: select, @@ -1346,6 +1352,7 @@ class ModelDelegate { }) { return _runNullableMutation( action: OrmAction.delete, + mutationResultMode: OrmMutationResultMode.rowOrNull, where: where, data: const {}, select: select, @@ -1537,17 +1544,23 @@ class ModelDelegate { Future _runNullableMutation({ required OrmAction action, + required OrmMutationResultMode mutationResultMode, required JsonMap where, required JsonMap data, required List select, required Map include, required String responseAction, }) async { - final normalizedInclude = _normalizeInclude(include); - final normalizedWhere = await _normalizeWhereForExecution( - model: modelName, + final prepared = await _buildMutationPlan( + action: action, + mutationResultMode: mutationResultMode, where: where, + data: data, + select: select, + include: include, ); + final normalizedInclude = prepared.include; + final normalizedWhere = prepared.plan.where; JsonMap? preDeleteRow; if (action == OrmAction.delete && !(_client.contract.capabilities.mutationReturning)) { @@ -1564,23 +1577,7 @@ class ModelDelegate { ); } - final response = await _client.execute( - OrmPlan( - contractHash: _client.contract.hash, - target: _client.contract.target, - storageHash: _client.contract.markerStorageHash, - profileHash: _client.contract.profileHash, - model: modelName, - action: action, - where: normalizedWhere, - data: data, - select: _expandSelectForInclude( - model: modelName, - select: select, - include: normalizedInclude, - ), - ), - ); + final response = await _client.execute(prepared.plan); var row = _readRow(response.data, action: responseAction); if (row == null && @@ -1621,6 +1618,41 @@ class ModelDelegate { ).single; } + Future<_PreparedMutationPlan> _buildMutationPlan({ + required OrmAction action, + required OrmMutationResultMode mutationResultMode, + JsonMap where = const {}, + JsonMap data = const {}, + List select = const [], + Map include = const {}, + }) async { + final normalizedInclude = _normalizeInclude(include); + final normalizedWhere = where.isEmpty + ? const {} + : await _normalizeWhereForExecution(model: modelName, where: where); + + return _PreparedMutationPlan( + include: normalizedInclude, + plan: OrmPlan( + contractHash: _client.contract.hash, + target: _client.contract.target, + storageHash: _client.contract.markerStorageHash, + profileHash: _client.contract.profileHash, + lane: 'orm', + mutationResultMode: mutationResultMode, + model: modelName, + action: action, + where: normalizedWhere, + data: data, + select: _expandSelectForInclude( + model: modelName, + select: select, + include: normalizedInclude, + ), + ), + ); + } + Future _createNestedInScope({ required JsonMap data, required Map> create, diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index c88c371a..7bac1244 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -7,6 +7,8 @@ enum OrmAction { read, create, update, delete } enum OrmReadResultMode { all, firstOrNull, oneOrNull } +enum OrmMutationResultMode { row, rowOrNull } + @immutable final class OrmOrderBy { final String field; @@ -45,6 +47,7 @@ final class OrmPlan { final String? profileHash; final String? lane; final OrmReadResultMode? resultMode; + final OrmMutationResultMode? mutationResultMode; final Map include; final JsonMap annotations; final String model; @@ -64,6 +67,7 @@ final class OrmPlan { this.profileHash, this.lane, this.resultMode, + this.mutationResultMode, Map include = const {}, JsonMap annotations = const {}, required this.model, diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 52b7b7a4..b27c59be 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -790,6 +790,44 @@ void main() { }, ); + test('emits structured mutation plans for orm and sql writes', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await client.model('User').create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + final createPlan = engine.executedPlans.single; + expect(createPlan.lane, 'orm'); + expect(createPlan.action, OrmAction.create); + expect(createPlan.mutationResultMode, OrmMutationResultMode.row); + + engine.reset(); + await client.model('User').update( + where: {'id': 'u1'}, + data: {'email': 'b@x.com'}, + ); + final updatePlan = engine.executedPlans.single; + expect(updatePlan.lane, 'orm'); + expect(updatePlan.action, OrmAction.update); + expect( + updatePlan.mutationResultMode, + OrmMutationResultMode.rowOrNull, + ); + + final sqlPlan = client.sql + .update('User') + .where({'id': 'u1'}) + .set({'email': 'c@x.com'}) + .toPlan(); + expect(sqlPlan.lane, 'sql'); + expect(sqlPlan.action, OrmAction.update); + expect(sqlPlan.mutationResultMode, OrmMutationResultMode.rowOrNull); + + await client.disconnect(); + }); + test('supports select projection through chained query state', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -1083,6 +1121,105 @@ void main() { }, ); + test( + 'throws when create result is missing while mutation returning is enabled', + () async { + final client = OrmClient( + contract: contract, + engine: _NoMutationReturnEngine(inner: MemoryEngine()), + ); + await client.connect(); + + await expectLater( + client.model('User').create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ), + throwsA(isA()), + ); + + await client.disconnect(); + }, + ); + + test( + 'reads back updated row after write when mutation returning is disabled', + () async { + final noReturningContract = OrmContract( + version: contract.version, + hash: contract.hash, + models: contract.models, + aliases: contract.aliases, + capabilities: const ContractCapabilities(mutationReturning: false), + ); + final engine = _CountingEngine( + inner: _NoMutationReturnEngine(inner: MemoryEngine()), + ); + final client = OrmClient(contract: noReturningContract, engine: engine); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + engine.reset(); + + final updated = await users.update( + where: {'id': 'u1'}, + data: {'email': 'b@x.com'}, + select: const ['id', 'email'], + ); + + expect(updated, {'id': 'u1', 'email': 'b@x.com'}); + expect( + engine.executedPlans.map((plan) => plan.action).toList(), + [OrmAction.update, OrmAction.read], + ); + + await client.disconnect(); + }, + ); + + test( + 'prefetches row before delete when mutation returning is disabled', + () async { + final noReturningContract = OrmContract( + version: contract.version, + hash: contract.hash, + models: contract.models, + aliases: contract.aliases, + capabilities: const ContractCapabilities(mutationReturning: false), + ); + final engine = _CountingEngine( + inner: _NoMutationReturnEngine(inner: MemoryEngine()), + ); + final client = OrmClient(contract: noReturningContract, engine: engine); + await client.connect(); + final users = client.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + engine.reset(); + + final deleted = await users.delete( + where: {'id': 'u1'}, + select: const ['id', 'email'], + ); + + expect(deleted, {'id': 'u1', 'email': 'a@x.com'}); + expect( + engine.executedPlans.map((plan) => plan.action).toList(), + [OrmAction.read, OrmAction.delete], + ); + + final remaining = await users.oneOrNull( + where: {'id': 'u1'}, + ); + expect(remaining, isNull); + await client.disconnect(); + }, + ); + test( 'supports query state helpers for first/count/exists/upsert/deleteMany', () async { From ef1f03ebaaba54029c22979e7b188102a6dadee6 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:26:57 +0800 Subject: [PATCH 089/154] feat(client)!: remove redundant model root aliases BREAKING CHANGE: model access now uses model(...) as the single alias-free root; collection(...) and db.orm[...] were removed. --- pub/orm/lib/src/client/client.dart | 12 ------------ pub/orm/test/client/client_test.dart | 8 ++++---- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index c6414999..453097eb 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -199,8 +199,6 @@ abstract interface class OrmModelContext { ModelDelegate model(String modelKey); - ModelDelegate collection(String modelKey); - Future transaction(Future Function(OrmModelContext tx) run); } @@ -322,9 +320,6 @@ final class OrmClient implements OrmModelContext { }); } - @override - ModelDelegate collection(String modelKey) => model(modelKey); - @override Future execute(OrmPlan plan) => _runtime.execute(plan); @@ -394,9 +389,6 @@ final class OrmScopedClient implements OrmModelContext { }); } - @override - ModelDelegate collection(String modelKey) => model(modelKey); - @override OrmSqlApi get sql => _sql; @@ -457,10 +449,6 @@ final class OrmModelNamespace { OrmModelNamespace(this._context); ModelDelegate model(String modelKey) => _context.model(modelKey); - - ModelDelegate collection(String modelKey) => _context.collection(modelKey); - - ModelDelegate operator [](String modelKey) => model(modelKey); } final class OrmSqlApi { diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index b27c59be..e2c4a6c4 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -82,7 +82,7 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.collection('users'); + final users = client.model('users'); final created = await users.create( data: {'id': 'u1', 'email': 'a@example.com'}, ); @@ -175,7 +175,7 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.db.orm.collection('users'); + final users = client.db.orm.model('users'); await users.create( data: {'id': 'u1', 'email': 'a@example.com'}, ); @@ -185,7 +185,7 @@ void main() { }).firstOrNull(); expect(sqlRow?['email'], 'a@example.com'); - final ormRow = await client.db.orm['User'].oneOrNull( + final ormRow = await client.db.orm.model('User').oneOrNull( where: {'id': 'u1'}, ); expect(ormRow?['id'], 'u1'); @@ -2209,7 +2209,7 @@ void main() { ); await client.connect(); - final first = client.collection('users'); + final first = client.model('users'); final second = client.model('User'); expect(first, same(second)); From 57b077bcad5fbf34a8464f080f9eeed8668c8271 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:35:49 +0800 Subject: [PATCH 090/154] refactor(repository)!: extract mutation executor and harden transaction state BREAKING CHANGE: repository mutation orchestration now flows through a dedicated mutation executor, and transaction state allows rollback after commit failure before final completion. --- pub/orm/lib/src/client/client.dart | 463 +++-------------- .../lib/src/client/mutation_repository.dart | 489 ++++++++++++++++++ pub/orm/lib/src/runtime/core.dart | 9 +- pub/orm/test/client/client_test.dart | 95 ++++ 4 files changed, 652 insertions(+), 404 deletions(-) create mode 100644 pub/orm/lib/src/client/mutation_repository.dart diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 453097eb..79844bd9 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -10,6 +10,7 @@ import '../runtime/plugin.dart'; import '../runtime/types.dart'; part 'include_planner.dart'; +part 'mutation_repository.dart'; typedef CollectionFactory = ModelDelegate Function({ @@ -273,25 +274,29 @@ final class OrmClient implements OrmModelContext { Future Function(OrmScopedClient transaction) run, ) async { final connection = await _runtime.connection(); - final transaction = await connection.transaction(); - final scoped = OrmScopedClient._( - contract: contract, - executePlan: transaction.execute, - modelAliases: _modelAliases, - collectionRegistry: _collectionRegistry, - includeStrategySelector: includeStrategySelector, - maxIncludeDepth: maxIncludeDepth, - ); + OrmRuntimeTransaction? transaction; try { + final openedTransaction = await connection.transaction(); + transaction = openedTransaction; + final scoped = OrmScopedClient._( + contract: contract, + executePlan: openedTransaction.execute, + modelAliases: _modelAliases, + collectionRegistry: _collectionRegistry, + includeStrategySelector: includeStrategySelector, + maxIncludeDepth: maxIncludeDepth, + ); final value = await run(scoped); - await transaction.commit(); + await openedTransaction.commit(); return value; } catch (_) { - try { - await transaction.rollback(); - } catch (_) { - // Keep the original exception when rollback fails. + if (transaction != null) { + try { + await transaction.rollback(); + } catch (_) { + // Keep the original exception when rollback fails. + } } rethrow; } finally { @@ -840,14 +845,6 @@ final class _PreparedReadPlan { const _PreparedReadPlan({required this.plan, required this.include}); } -@immutable -final class _PreparedMutationPlan { - final OrmPlan plan; - final Map include; - - const _PreparedMutationPlan({required this.plan, required this.include}); -} - class ModelDelegate { final OrmModelContext _client; final String modelName; @@ -1175,60 +1172,23 @@ class ModelDelegate { required JsonMap data, List select = const [], Map include = const {}, - }) async { - final prepared = await _buildMutationPlan( - action: OrmAction.create, - mutationResultMode: OrmMutationResultMode.row, - data: data, - select: select, - include: include, - ); - final normalizedInclude = prepared.include; - final response = await _client.execute(prepared.plan); - - var row = _readRow(response.data, action: 'create'); - if (row == null) { - if (_client.contract.capabilities.mutationReturning && - response.affectedRows > 0) { - throw RuntimeCreateResultMissingException(model: modelName); - } - row = _fallbackCreateRow(data: data); - } - - if (row == null) { - throw RuntimeCreateResultMissingException(model: modelName); - } - - final hydratedRows = await _resolveIncludeRows( - action: OrmAction.create, - rows: [row], - include: normalizedInclude, - depth: 0, - ); - - return _shapeRows( - hydratedRows, - select: select, - include: normalizedInclude, - ).single; - } + }) => _RepositoryMutationExecutor(this).create( + data: data, + select: select, + include: include, + ); Future createNested({ required JsonMap data, Map> create = const >{}, List select = const [], Map include = const {}, - }) { - return _client.transaction((tx) async { - final scoped = tx.model(modelName); - return scoped._createNestedInScope( - data: data, - create: create, - select: select, - include: include, - ); - }); - } + }) => _RepositoryMutationExecutor(this).createNested( + data: data, + nestedCreate: create, + select: select, + include: include, + ); Future updateNested({ JsonMap where = const {}, @@ -1236,53 +1196,26 @@ class ModelDelegate { Map> create = const >{}, List select = const [], Map include = const {}, - }) { - return _client.transaction((tx) async { - final scoped = tx.model(modelName); - return scoped._updateNestedInScope( - where: where, - data: data, - create: create, - select: select, - include: include, - ); - }); - } + }) => _RepositoryMutationExecutor(this).updateNested( + where: where, + data: data, + nestedCreate: create, + select: select, + include: include, + ); Future> createMany({ required List data, List select = const [], Map include = const {}, - }) { - return _client.transaction((tx) async { - final scoped = tx.model(modelName); - final rows = []; - for (final item in data) { - final created = await scoped.create( - data: item, - select: select, - include: include, - ); - rows.add(created); - } - return rows; - }); - } + }) => _RepositoryMutationExecutor(this).createMany( + data: data, + select: select, + include: include, + ); - Future deleteMany({JsonMap where = const {}}) { - return _client.transaction((tx) async { - final scoped = tx.model(modelName); - var deleted = 0; - while (true) { - final row = await scoped.delete(where: where); - if (row == null) { - break; - } - deleted += 1; - } - return deleted; - }); - } + Future deleteMany({JsonMap where = const {}}) => + _RepositoryMutationExecutor(this).deleteMany(where: where); Future upsert({ required JsonMap where, @@ -1290,64 +1223,35 @@ class ModelDelegate { required JsonMap update, List select = const [], Map include = const {}, - }) { - return _client.transaction((tx) async { - final scoped = tx.model(modelName); - final existing = await scoped.oneOrNull(where: where); - if (existing == null) { - return scoped.create(data: create, select: select, include: include); - } - - final updated = await scoped.update( - where: where, - data: update, - select: select, - include: include, - ); - if (updated != null) { - return updated; - } - - throw runtimeError( - 'RUNTIME.UPSERT_UPDATE_MISSING', - 'Upsert update branch did not return a row.', - details: {'model': modelName, 'where': where}, - ); - }); - } + }) => _RepositoryMutationExecutor(this).upsert( + where: where, + create: create, + update: update, + select: select, + include: include, + ); Future update({ JsonMap where = const {}, required JsonMap data, List select = const [], Map include = const {}, - }) { - return _runNullableMutation( - action: OrmAction.update, - mutationResultMode: OrmMutationResultMode.rowOrNull, - where: where, - data: data, - select: select, - include: include, - responseAction: 'update', - ); - } + }) => _RepositoryMutationExecutor(this).update( + where: where, + data: data, + select: select, + include: include, + ); Future delete({ JsonMap where = const {}, List select = const [], Map include = const {}, - }) { - return _runNullableMutation( - action: OrmAction.delete, - mutationResultMode: OrmMutationResultMode.rowOrNull, - where: where, - data: const {}, - select: select, - include: include, - responseAction: 'delete', - ); - } + }) => _RepositoryMutationExecutor(this).delete( + where: where, + select: select, + include: include, + ); Future<_PreparedReadPlan> _buildReadPlan({ required OrmReadResultMode resultMode, @@ -1530,249 +1434,6 @@ class ModelDelegate { ).single; } - Future _runNullableMutation({ - required OrmAction action, - required OrmMutationResultMode mutationResultMode, - required JsonMap where, - required JsonMap data, - required List select, - required Map include, - required String responseAction, - }) async { - final prepared = await _buildMutationPlan( - action: action, - mutationResultMode: mutationResultMode, - where: where, - data: data, - select: select, - include: include, - ); - final normalizedInclude = prepared.include; - final normalizedWhere = prepared.plan.where; - JsonMap? preDeleteRow; - if (action == OrmAction.delete && - !(_client.contract.capabilities.mutationReturning)) { - preDeleteRow = await _readOneInternal( - action: OrmAction.read, - where: normalizedWhere, - select: _expandSelectForInclude( - model: modelName, - select: select, - include: normalizedInclude, - ), - include: const {}, - includeDepth: 0, - ); - } - - final response = await _client.execute(prepared.plan); - - var row = _readRow(response.data, action: responseAction); - if (row == null && - response.affectedRows > 0 && - !(_client.contract.capabilities.mutationReturning)) { - row = switch (action) { - OrmAction.update => await _readOneInternal( - action: OrmAction.read, - where: normalizedWhere, - select: _expandSelectForInclude( - model: modelName, - select: select, - include: normalizedInclude, - ), - include: const {}, - includeDepth: 0, - ), - OrmAction.delete => preDeleteRow, - _ => row, - }; - } - - if (row == null) { - return null; - } - - final hydratedRows = await _resolveIncludeRows( - action: action, - rows: [row], - include: normalizedInclude, - depth: 0, - ); - - return _shapeRows( - hydratedRows, - select: select, - include: normalizedInclude, - ).single; - } - - Future<_PreparedMutationPlan> _buildMutationPlan({ - required OrmAction action, - required OrmMutationResultMode mutationResultMode, - JsonMap where = const {}, - JsonMap data = const {}, - List select = const [], - Map include = const {}, - }) async { - final normalizedInclude = _normalizeInclude(include); - final normalizedWhere = where.isEmpty - ? const {} - : await _normalizeWhereForExecution(model: modelName, where: where); - - return _PreparedMutationPlan( - include: normalizedInclude, - plan: OrmPlan( - contractHash: _client.contract.hash, - target: _client.contract.target, - storageHash: _client.contract.markerStorageHash, - profileHash: _client.contract.profileHash, - lane: 'orm', - mutationResultMode: mutationResultMode, - model: modelName, - action: action, - where: normalizedWhere, - data: data, - select: _expandSelectForInclude( - model: modelName, - select: select, - include: normalizedInclude, - ), - ), - ); - } - - Future _createNestedInScope({ - required JsonMap data, - required Map> create, - required List select, - required Map include, - }) async { - final normalizedCreate = _normalizeNestedCreate(create); - final normalizedInclude = _normalizeInclude(include); - - final created = await this.create( - data: data, - select: _expandSelectForNestedCreate( - model: modelName, - select: select, - create: normalizedCreate, - ), - ); - - for (final entry in normalizedCreate.entries) { - final relation = _resolveRelation( - model: modelName, - relationName: entry.key, - ); - final related = _client.model(relation.relatedModel); - for (final child in entry.value) { - final linkedData = _linkNestedData( - parent: created, - relationName: entry.key, - relation: relation, - data: child, - ); - await related.create(data: linkedData); - } - } - - final includeForReturn = { - for (final relationName in normalizedCreate.keys) - relationName: const IncludeSpec(), - ...normalizedInclude, - }; - - if (includeForReturn.isEmpty) { - return _shapeRows( - [created], - select: select, - include: const {}, - ).single; - } - - final hydratedRows = await _resolveIncludeRows( - action: OrmAction.create, - rows: [created], - include: includeForReturn, - depth: 0, - ); - - return _shapeRows( - hydratedRows, - select: select, - include: includeForReturn, - ).single; - } - - Future _updateNestedInScope({ - required JsonMap where, - required JsonMap data, - required Map> create, - required List select, - required Map include, - }) async { - final normalizedCreate = _normalizeNestedCreate(create); - final normalizedInclude = _normalizeInclude(include); - - final updated = await update( - where: where, - data: data, - select: _expandSelectForNestedCreate( - model: modelName, - select: select, - create: normalizedCreate, - ), - ); - - if (updated == null) { - return null; - } - - for (final entry in normalizedCreate.entries) { - final relation = _resolveRelation( - model: modelName, - relationName: entry.key, - ); - final related = _client.model(relation.relatedModel); - for (final child in entry.value) { - final linkedData = _linkNestedData( - parent: updated, - relationName: entry.key, - relation: relation, - data: child, - ); - await related.create(data: linkedData); - } - } - - final includeForReturn = { - for (final relationName in normalizedCreate.keys) - relationName: const IncludeSpec(), - ...normalizedInclude, - }; - - if (includeForReturn.isEmpty) { - return _shapeRows( - [updated], - select: select, - include: const {}, - ).single; - } - - final hydratedRows = await _resolveIncludeRows( - action: OrmAction.update, - rows: [updated], - include: includeForReturn, - depth: 0, - ); - - return _shapeRows( - hydratedRows, - select: select, - include: includeForReturn, - ).single; - } - Future> _resolveIncludeRows({ required OrmAction action, required List rows, diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart new file mode 100644 index 00000000..15a0266d --- /dev/null +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -0,0 +1,489 @@ +part of 'client.dart'; + +@immutable +final class _PreparedMutationPlan { + final OrmPlan plan; + final Map include; + + const _PreparedMutationPlan({required this.plan, required this.include}); +} + +final class _RepositoryMutationExecutor { + final ModelDelegate _delegate; + + const _RepositoryMutationExecutor(this._delegate); + + Future create({ + required JsonMap data, + required List select, + required Map include, + }) async { + final prepared = await _buildMutationPlan( + action: OrmAction.create, + mutationResultMode: OrmMutationResultMode.row, + data: data, + select: select, + include: include, + ); + final normalizedInclude = prepared.include; + final response = await _delegate._client.execute(prepared.plan); + + var row = _readRow(response.data, action: 'create'); + if (row == null) { + if (_delegate._client.contract.capabilities.mutationReturning && + response.affectedRows > 0) { + throw RuntimeCreateResultMissingException(model: _delegate.modelName); + } + row = _delegate._fallbackCreateRow(data: data); + } + + if (row == null) { + throw RuntimeCreateResultMissingException(model: _delegate.modelName); + } + + return _shapeMutationRow( + action: OrmAction.create, + row: row, + select: select, + include: normalizedInclude, + ); + } + + Future update({ + required JsonMap where, + required JsonMap data, + required List select, + required Map include, + }) { + return _runNullableMutation( + action: OrmAction.update, + mutationResultMode: OrmMutationResultMode.rowOrNull, + where: where, + data: data, + select: select, + include: include, + responseAction: 'update', + ); + } + + Future delete({ + required JsonMap where, + required List select, + required Map include, + }) { + return _runNullableMutation( + action: OrmAction.delete, + mutationResultMode: OrmMutationResultMode.rowOrNull, + where: where, + data: const {}, + select: select, + include: include, + responseAction: 'delete', + ); + } + + Future createNested({ + required JsonMap data, + required Map> nestedCreate, + required List select, + required Map include, + }) { + return _delegate._client.transaction((tx) async { + final scoped = tx.model(_delegate.modelName); + return _RepositoryMutationExecutor(scoped)._createNestedInScope( + data: data, + nestedCreate: nestedCreate, + select: select, + include: include, + ); + }); + } + + Future updateNested({ + required JsonMap where, + required JsonMap data, + required Map> nestedCreate, + required List select, + required Map include, + }) { + return _delegate._client.transaction((tx) async { + final scoped = tx.model(_delegate.modelName); + return _RepositoryMutationExecutor(scoped)._updateNestedInScope( + where: where, + data: data, + nestedCreate: nestedCreate, + select: select, + include: include, + ); + }); + } + + Future> createMany({ + required List data, + required List select, + required Map include, + }) { + return _delegate._client.transaction((tx) async { + final scoped = tx.model(_delegate.modelName); + final executor = _RepositoryMutationExecutor(scoped); + final rows = []; + for (final item in data) { + rows.add( + await executor.create(data: item, select: select, include: include), + ); + } + return rows; + }); + } + + Future deleteMany({required JsonMap where}) { + return _delegate._client.transaction((tx) async { + final scoped = tx.model(_delegate.modelName); + final executor = _RepositoryMutationExecutor(scoped); + var deleted = 0; + while (true) { + final row = await executor.delete( + where: where, + select: const [], + include: const {}, + ); + if (row == null) { + break; + } + deleted += 1; + } + return deleted; + }); + } + + Future upsert({ + required JsonMap where, + required JsonMap create, + required JsonMap update, + required List select, + required Map include, + }) { + return _delegate._client.transaction((tx) async { + final scoped = tx.model(_delegate.modelName); + final executor = _RepositoryMutationExecutor(scoped); + final existing = await scoped.oneOrNull(where: where); + if (existing == null) { + return executor.create(data: create, select: select, include: include); + } + + final updatedRow = await executor.update( + where: where, + data: update, + select: select, + include: include, + ); + if (updatedRow != null) { + return updatedRow; + } + + throw runtimeError( + 'RUNTIME.UPSERT_UPDATE_MISSING', + 'Upsert update branch did not return a row.', + details: { + 'model': _delegate.modelName, + 'where': where, + }, + ); + }); + } + + Future _runNullableMutation({ + required OrmAction action, + required OrmMutationResultMode mutationResultMode, + required JsonMap where, + required JsonMap data, + required List select, + required Map include, + required String responseAction, + }) async { + final prepared = await _buildMutationPlan( + action: action, + mutationResultMode: mutationResultMode, + where: where, + data: data, + select: select, + include: include, + ); + final normalizedInclude = prepared.include; + final normalizedWhere = prepared.plan.where; + final preDeleteRow = await _preloadDeleteRow( + action: action, + where: normalizedWhere, + select: select, + include: normalizedInclude, + ); + + final response = await _delegate._client.execute(prepared.plan); + final row = await _resolveNullableMutationRow( + action: action, + response: response, + responseAction: responseAction, + where: normalizedWhere, + select: select, + include: normalizedInclude, + preDeleteRow: preDeleteRow, + ); + if (row == null) { + return null; + } + + return _shapeMutationRow( + action: action, + row: row, + select: select, + include: normalizedInclude, + ); + } + + Future<_PreparedMutationPlan> _buildMutationPlan({ + required OrmAction action, + required OrmMutationResultMode mutationResultMode, + JsonMap where = const {}, + JsonMap data = const {}, + List select = const [], + Map include = const {}, + }) async { + final normalizedInclude = _delegate._normalizeInclude(include); + final normalizedWhere = where.isEmpty + ? const {} + : await _delegate._normalizeWhereForExecution( + model: _delegate.modelName, + where: where, + ); + + return _PreparedMutationPlan( + include: normalizedInclude, + plan: OrmPlan( + contractHash: _delegate._client.contract.hash, + target: _delegate._client.contract.target, + storageHash: _delegate._client.contract.markerStorageHash, + profileHash: _delegate._client.contract.profileHash, + lane: 'orm', + mutationResultMode: mutationResultMode, + model: _delegate.modelName, + action: action, + where: normalizedWhere, + data: data, + select: _delegate._expandSelectForInclude( + model: _delegate.modelName, + select: select, + include: normalizedInclude, + ), + ), + ); + } + + Future _preloadDeleteRow({ + required OrmAction action, + required JsonMap where, + required List select, + required Map include, + }) { + if (action != OrmAction.delete || + _delegate._client.contract.capabilities.mutationReturning) { + return Future.value(null); + } + + return _delegate._readOneInternal( + action: OrmAction.read, + where: where, + select: _delegate._expandSelectForInclude( + model: _delegate.modelName, + select: select, + include: include, + ), + include: const {}, + includeDepth: 0, + ); + } + + Future _resolveNullableMutationRow({ + required OrmAction action, + required EngineResponse response, + required String responseAction, + required JsonMap where, + required List select, + required Map include, + required JsonMap? preDeleteRow, + }) async { + var row = _readRow(response.data, action: responseAction); + if (row == null && + response.affectedRows > 0 && + !(_delegate._client.contract.capabilities.mutationReturning)) { + row = switch (action) { + OrmAction.update => await _delegate._readOneInternal( + action: OrmAction.read, + where: where, + select: _delegate._expandSelectForInclude( + model: _delegate.modelName, + select: select, + include: include, + ), + include: const {}, + includeDepth: 0, + ), + OrmAction.delete => preDeleteRow, + _ => row, + }; + } + return row; + } + + Future _createNestedInScope({ + required JsonMap data, + required Map> nestedCreate, + required List select, + required Map include, + }) async { + final normalizedCreate = _delegate._normalizeNestedCreate(nestedCreate); + final normalizedInclude = _delegate._normalizeInclude(include); + + final created = await create( + data: data, + select: _delegate._expandSelectForNestedCreate( + model: _delegate.modelName, + select: select, + create: normalizedCreate, + ), + include: const {}, + ); + + for (final entry in normalizedCreate.entries) { + final relation = _delegate._resolveRelation( + model: _delegate.modelName, + relationName: entry.key, + ); + final related = _delegate._client.model(relation.relatedModel); + final relatedExecutor = _RepositoryMutationExecutor(related); + for (final child in entry.value) { + final linkedData = _delegate._linkNestedData( + parent: created, + relationName: entry.key, + relation: relation, + data: child, + ); + await relatedExecutor.create( + data: linkedData, + select: const [], + include: const {}, + ); + } + } + + return _shapeNestedMutationRow( + action: OrmAction.create, + row: created, + select: select, + include: normalizedInclude, + nestedCreate: normalizedCreate, + ); + } + + Future _updateNestedInScope({ + required JsonMap where, + required JsonMap data, + required Map> nestedCreate, + required List select, + required Map include, + }) async { + final normalizedCreate = _delegate._normalizeNestedCreate(nestedCreate); + final normalizedInclude = _delegate._normalizeInclude(include); + + final updated = await update( + where: where, + data: data, + select: _delegate._expandSelectForNestedCreate( + model: _delegate.modelName, + select: select, + create: normalizedCreate, + ), + include: const {}, + ); + if (updated == null) { + return null; + } + + for (final entry in normalizedCreate.entries) { + final relation = _delegate._resolveRelation( + model: _delegate.modelName, + relationName: entry.key, + ); + final related = _delegate._client.model(relation.relatedModel); + final relatedExecutor = _RepositoryMutationExecutor(related); + for (final child in entry.value) { + final linkedData = _delegate._linkNestedData( + parent: updated, + relationName: entry.key, + relation: relation, + data: child, + ); + await relatedExecutor.create( + data: linkedData, + select: const [], + include: const {}, + ); + } + } + + return _shapeNestedMutationRow( + action: OrmAction.update, + row: updated, + select: select, + include: normalizedInclude, + nestedCreate: normalizedCreate, + ); + } + + Future _shapeMutationRow({ + required OrmAction action, + required JsonMap row, + required List select, + required Map include, + }) async { + final hydratedRows = await _delegate._resolveIncludeRows( + action: action, + rows: [row], + include: include, + depth: 0, + ); + return _delegate._shapeRows( + hydratedRows, + select: select, + include: include, + ).single; + } + + Future _shapeNestedMutationRow({ + required OrmAction action, + required JsonMap row, + required List select, + required Map include, + required Map> nestedCreate, + }) async { + final includeForReturn = { + for (final relationName in nestedCreate.keys) + relationName: const IncludeSpec(), + ...include, + }; + + if (includeForReturn.isEmpty) { + return _delegate._shapeRows( + [row], + select: select, + include: const {}, + ).single; + } + + return _shapeMutationRow( + action: action, + row: row, + select: select, + include: includeForReturn, + ); + } +} diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 0be16ac3..f2793632 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -610,15 +610,18 @@ final class _RuntimeTransaction implements OrmRuntimeTransaction { @override Future commit() async { _ensureActive(); - _completed = true; await _inner.commit(); + _completed = true; } @override Future rollback() async { _ensureActive(); - _completed = true; - await _inner.rollback(); + try { + await _inner.rollback(); + } finally { + _completed = true; + } } @override diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index e2c4a6c4..23abbacc 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -2370,6 +2370,27 @@ void main() { }, ); + test( + 'withTransaction releases connection when opening transaction fails', + () async { + final engine = _TrackingConnectionEngine(failOnTransactionStart: true); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await expectLater( + () => client.withTransaction((_) async => null), + throwsA(isA()), + ); + + expect(engine.connectionCount, 1); + expect(engine.transactionCount, 1); + expect(engine.commitCount, 0); + expect(engine.rollbackCount, 0); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); + test('withTransaction rolls back on error', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -2422,6 +2443,62 @@ void main() { }, ); + test( + 'withTransaction commit failure rolls back and releases connection', + () async { + final engine = _TrackingConnectionEngine(failOnCommit: true); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await expectLater( + () => client.withTransaction((transaction) async { + final rows = await transaction.model('User').all(); + expect(rows, isEmpty); + }), + throwsA(isA()), + ); + + expect(engine.connectionCount, 1); + expect(engine.transactionCount, 1); + expect(engine.transactionExecutePlans, hasLength(1)); + expect(engine.transactionExecutePlans.single.action, OrmAction.read); + expect(engine.commitCount, 1); + expect(engine.rollbackCount, 1); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); + + test( + 'withTransaction preserves original error when rollback fails', + () async { + final engine = _TrackingConnectionEngine(failOnRollback: true); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await expectLater( + () => client.withTransaction((transaction) async { + await transaction.model('User').all(); + throw StateError('stop'); + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'stop', + ), + ), + ); + + expect(engine.connectionCount, 1); + expect(engine.transactionCount, 1); + expect(engine.commitCount, 0); + expect(engine.rollbackCount, 1); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); + test( 'throws RuntimeConnectionNotSupportedException when engine has no connection support', () async { @@ -3030,6 +3107,9 @@ final class _BadRelatedFindManyShapeEngine implements OrmEngine { final class _TrackingConnectionEngine implements OrmEngine, ConnectionCapableEngine { + final bool failOnTransactionStart; + final bool failOnCommit; + final bool failOnRollback; var connectionCount = 0; var transactionCount = 0; var releaseCount = 0; @@ -3038,6 +3118,12 @@ final class _TrackingConnectionEngine final List connectionExecutePlans = []; final List transactionExecutePlans = []; + _TrackingConnectionEngine({ + this.failOnTransactionStart = false, + this.failOnCommit = false, + this.failOnRollback = false, + }); + @override Future close() async {} @@ -3075,6 +3161,9 @@ final class _TrackingEngineConnection implements EngineConnection { @override Future transaction() async { _engine.transactionCount += 1; + if (_engine.failOnTransactionStart) { + throw StateError('transaction start failed'); + } return _TrackingEngineTransaction(_engine); } } @@ -3087,6 +3176,9 @@ final class _TrackingEngineTransaction implements EngineTransaction { @override Future commit() async { _engine.commitCount += 1; + if (_engine.failOnCommit) { + throw StateError('commit failed'); + } } @override @@ -3098,6 +3190,9 @@ final class _TrackingEngineTransaction implements EngineTransaction { @override Future rollback() async { _engine.rollbackCount += 1; + if (_engine.failOnRollback) { + throw StateError('rollback failed'); + } } } From 4da243f61a5f1ca2908a63d280b96e02e7d58985 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:44:21 +0800 Subject: [PATCH 091/154] feat(client)!: collapse public authoring to db root BREAKING CHANGE: direct root authoring entrypoints were removed; use db.orm.model(...) and db.sql instead of client.model(...), client.sql, and scoped root aliases. --- pub/orm/lib/src/client/client.dart | 99 +++---- pub/orm/lib/src/client/include_planner.dart | 8 +- .../lib/src/client/mutation_repository.dart | 24 +- pub/orm/lib/src/generator/writer.dart | 23 +- pub/orm/test/client/client_test.dart | 259 ++++++++---------- pub/orm/test/generator/generate_test.dart | 12 +- pub/orm/test/sql/sql_marker_reader_test.dart | 4 +- 7 files changed, 201 insertions(+), 228 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 79844bd9..a7621ff7 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -14,7 +14,7 @@ part 'mutation_repository.dart'; typedef CollectionFactory = ModelDelegate Function({ - required OrmModelContext client, + required OrmDelegateContext client, required String modelName, }); @@ -187,10 +187,12 @@ Map _buildOrmIncludePlanMap( }; } -abstract interface class OrmModelContext { - OrmContract get contract; +abstract interface class OrmDbContext { + OrmDbNamespace get db; +} - OrmSqlApi get sql; +abstract interface class OrmDelegateContext implements OrmDbContext { + OrmContract get contract; IncludeExecutionStrategySelector get includeStrategySelector; @@ -198,12 +200,10 @@ abstract interface class OrmModelContext { Future execute(OrmPlan plan); - ModelDelegate model(String modelKey); - - Future transaction(Future Function(OrmModelContext tx) run); + Future transaction(Future Function(OrmDbNamespace txDb) run); } -final class OrmClient implements OrmModelContext { +final class OrmClient implements OrmDelegateContext { @override final OrmContract contract; final OrmEngine engine; @@ -211,8 +211,10 @@ final class OrmClient implements OrmModelContext { final Map _delegates = {}; final Map _modelAliases; final Map _collectionRegistry; - late final OrmSqlApi _sql = OrmSqlApi(this); - late final OrmDbNamespace _db = OrmDbNamespace(this); + late final OrmDbNamespace _db = OrmDbNamespace( + context: this, + resolveModel: _model, + ); @override final IncludeExecutionStrategySelector includeStrategySelector; @override @@ -309,12 +311,9 @@ final class OrmClient implements OrmModelContext { RuntimeTelemetryEvent? telemetry() => _runtime.telemetry(); @override - OrmSqlApi get sql => _sql; - OrmDbNamespace get db => _db; - @override - ModelDelegate model(String modelKey) { + ModelDelegate _model(String modelKey) { final modelName = _resolveModelOrThrow(modelKey: modelKey); return _delegates.putIfAbsent(modelName, () { final factory = _collectionRegistry[modelName]; @@ -329,8 +328,8 @@ final class OrmClient implements OrmModelContext { Future execute(OrmPlan plan) => _runtime.execute(plan); @override - Future transaction(Future Function(OrmModelContext tx) run) { - return withTransaction((scoped) => run(scoped)); + Future transaction(Future Function(OrmDbNamespace txDb) run) { + return withTransaction((scoped) => run(scoped.db)); } String _resolveModelOrThrow({required String modelKey}) { @@ -357,15 +356,17 @@ final class OrmClient implements OrmModelContext { } } -final class OrmScopedClient implements OrmModelContext { +final class OrmScopedClient implements OrmDelegateContext { @override final OrmContract contract; final Future Function(OrmPlan plan) _executePlan; final Map _modelAliases; final Map _collectionRegistry; final Map _delegates = {}; - late final OrmSqlApi _sql = OrmSqlApi(this); - late final OrmDbNamespace _db = OrmDbNamespace(this); + late final OrmDbNamespace _db = OrmDbNamespace( + context: this, + resolveModel: _model, + ); @override final IncludeExecutionStrategySelector includeStrategySelector; @override @@ -382,8 +383,7 @@ final class OrmScopedClient implements OrmModelContext { _modelAliases = modelAliases, _collectionRegistry = collectionRegistry; - @override - ModelDelegate model(String modelKey) { + ModelDelegate _model(String modelKey) { final modelName = _resolveModelOrThrow(modelKey: modelKey); return _delegates.putIfAbsent(modelName, () { final factory = _collectionRegistry[modelName]; @@ -395,16 +395,14 @@ final class OrmScopedClient implements OrmModelContext { } @override - OrmSqlApi get sql => _sql; - OrmDbNamespace get db => _db; @override Future execute(OrmPlan plan) => _executePlan(plan); @override - Future transaction(Future Function(OrmModelContext tx) run) { - return run(this); + Future transaction(Future Function(OrmDbNamespace txDb) run) { + return run(db); } String _resolveModelOrThrow({required String modelKey}) { @@ -440,24 +438,29 @@ final class OrmSqlMutationResult { } final class OrmDbNamespace { - final OrmModelContext _context; - late final OrmModelNamespace orm = OrmModelNamespace(_context); + final OrmDelegateContext _context; + final ModelDelegate Function(String modelKey) _resolveModel; - OrmDbNamespace(this._context); + late final OrmModelNamespace orm = OrmModelNamespace(_resolveModel); + late final OrmSqlApi sql = OrmSqlApi(_context); - OrmSqlApi get sql => _context.sql; + OrmDbNamespace({ + required OrmDelegateContext context, + required ModelDelegate Function(String modelKey) resolveModel, + }) : _context = context, + _resolveModel = resolveModel; } final class OrmModelNamespace { - final OrmModelContext _context; + final ModelDelegate Function(String modelKey) _resolveModel; - OrmModelNamespace(this._context); + OrmModelNamespace(this._resolveModel); - ModelDelegate model(String modelKey) => _context.model(modelKey); + ModelDelegate model(String modelKey) => _resolveModel(modelKey); } final class OrmSqlApi { - final OrmModelContext _client; + final OrmDelegateContext _client; const OrmSqlApi(this._client); @@ -492,7 +495,7 @@ final class OrmSqlApi { @immutable final class OrmSqlSelectBuilder { - final OrmModelContext _client; + final OrmDelegateContext _client; final String _modelName; final JsonMap _where; final int? _skip; @@ -502,7 +505,7 @@ final class OrmSqlSelectBuilder { final List _select; OrmSqlSelectBuilder._({ - required OrmModelContext client, + required OrmDelegateContext client, required String modelName, JsonMap where = const {}, int? skip, @@ -606,13 +609,13 @@ final class OrmSqlSelectBuilder { @immutable final class OrmSqlInsertBuilder { - final OrmModelContext _client; + final OrmDelegateContext _client; final String _modelName; final JsonMap _data; final List _select; OrmSqlInsertBuilder._({ - required OrmModelContext client, + required OrmDelegateContext client, required String modelName, JsonMap data = const {}, List select = const [], @@ -665,14 +668,14 @@ final class OrmSqlInsertBuilder { @immutable final class OrmSqlUpdateBuilder { - final OrmModelContext _client; + final OrmDelegateContext _client; final String _modelName; final JsonMap _where; final JsonMap _data; final List _select; OrmSqlUpdateBuilder._({ - required OrmModelContext client, + required OrmDelegateContext client, required String modelName, JsonMap where = const {}, JsonMap data = const {}, @@ -737,13 +740,13 @@ final class OrmSqlUpdateBuilder { @immutable final class OrmSqlDeleteBuilder { - final OrmModelContext _client; + final OrmDelegateContext _client; final String _modelName; final JsonMap _where; final List _select; OrmSqlDeleteBuilder._({ - required OrmModelContext client, + required OrmDelegateContext client, required String modelName, JsonMap where = const {}, List select = const [], @@ -797,15 +800,15 @@ final class OrmSqlDeleteBuilder { const Object _sqlKeepToken = Object(); String _resolveSqlModelName({ - required OrmModelContext client, + required OrmDelegateContext client, required String modelKey, }) { - final delegate = client.model(modelKey); + final delegate = client.db.orm.model(modelKey); return delegate.modelName; } OrmPlan _buildSqlPlan({ - required OrmModelContext client, + required OrmDelegateContext client, required String modelName, required OrmAction action, OrmMutationResultMode? mutationResultMode, @@ -846,14 +849,14 @@ final class _PreparedReadPlan { } class ModelDelegate { - final OrmModelContext _client; + final OrmDelegateContext _client; final String modelName; - ModelDelegate({required OrmModelContext client, required this.modelName}) + ModelDelegate({required OrmDelegateContext client, required this.modelName}) : _client = client; @protected - OrmModelContext get client => _client; + OrmDelegateContext get client => _client; ModelQuery query() => ModelQuery._(this, const ModelQueryState()); @@ -2777,7 +2780,7 @@ class ModelDelegate { required JsonMap relatedWhere, required bool include, }) async { - final relatedRows = await _client + final relatedRows = await _client.db.orm .model(relation.relatedModel) ._readAllInternal( action: OrmAction.read, diff --git a/pub/orm/lib/src/client/include_planner.dart b/pub/orm/lib/src/client/include_planner.dart index ff1e1b07..2bc154b3 100644 --- a/pub/orm/lib/src/client/include_planner.dart +++ b/pub/orm/lib/src/client/include_planner.dart @@ -57,7 +57,9 @@ final class _RepositoryIncludePlanner { model: _delegate.modelName, relationName: relationName, ); - final relatedDelegate = _delegate._client.model(relation.relatedModel); + final relatedDelegate = _delegate._client.db.orm.model( + relation.relatedModel, + ); _delegate._validateIncludePagination(include: relationInclude); final relatedRows = await _loadRelationRowsSingleQuery( @@ -138,7 +140,9 @@ final class _RepositoryIncludePlanner { model: _delegate.modelName, relationName: relationName, ); - final relatedDelegate = _delegate._client.model(relation.relatedModel); + final relatedDelegate = _delegate._client.db.orm.model( + relation.relatedModel, + ); final nextRows = []; for (final row in hydrated) { diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index 15a0266d..a153415f 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -88,8 +88,8 @@ final class _RepositoryMutationExecutor { required List select, required Map include, }) { - return _delegate._client.transaction((tx) async { - final scoped = tx.model(_delegate.modelName); + return _delegate._client.transaction((txDb) async { + final scoped = txDb.orm.model(_delegate.modelName); return _RepositoryMutationExecutor(scoped)._createNestedInScope( data: data, nestedCreate: nestedCreate, @@ -106,8 +106,8 @@ final class _RepositoryMutationExecutor { required List select, required Map include, }) { - return _delegate._client.transaction((tx) async { - final scoped = tx.model(_delegate.modelName); + return _delegate._client.transaction((txDb) async { + final scoped = txDb.orm.model(_delegate.modelName); return _RepositoryMutationExecutor(scoped)._updateNestedInScope( where: where, data: data, @@ -123,8 +123,8 @@ final class _RepositoryMutationExecutor { required List select, required Map include, }) { - return _delegate._client.transaction((tx) async { - final scoped = tx.model(_delegate.modelName); + return _delegate._client.transaction((txDb) async { + final scoped = txDb.orm.model(_delegate.modelName); final executor = _RepositoryMutationExecutor(scoped); final rows = []; for (final item in data) { @@ -137,8 +137,8 @@ final class _RepositoryMutationExecutor { } Future deleteMany({required JsonMap where}) { - return _delegate._client.transaction((tx) async { - final scoped = tx.model(_delegate.modelName); + return _delegate._client.transaction((txDb) async { + final scoped = txDb.orm.model(_delegate.modelName); final executor = _RepositoryMutationExecutor(scoped); var deleted = 0; while (true) { @@ -163,8 +163,8 @@ final class _RepositoryMutationExecutor { required List select, required Map include, }) { - return _delegate._client.transaction((tx) async { - final scoped = tx.model(_delegate.modelName); + return _delegate._client.transaction((txDb) async { + final scoped = txDb.orm.model(_delegate.modelName); final executor = _RepositoryMutationExecutor(scoped); final existing = await scoped.oneOrNull(where: where); if (existing == null) { @@ -358,7 +358,7 @@ final class _RepositoryMutationExecutor { model: _delegate.modelName, relationName: entry.key, ); - final related = _delegate._client.model(relation.relatedModel); + final related = _delegate._client.db.orm.model(relation.relatedModel); final relatedExecutor = _RepositoryMutationExecutor(related); for (final child in entry.value) { final linkedData = _delegate._linkNestedData( @@ -413,7 +413,7 @@ final class _RepositoryMutationExecutor { model: _delegate.modelName, relationName: entry.key, ); - final related = _delegate._client.model(relation.relatedModel); + final related = _delegate._client.db.orm.model(relation.relatedModel); final relatedExecutor = _RepositoryMutationExecutor(related); for (final child in entry.value) { final linkedData = _delegate._linkNestedData( diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 6ca9f977..acb482f0 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -532,36 +532,36 @@ final class TypedClientWriter { required List<_ResolvedModel> models, }) { buffer.writeln('class GeneratedOrmClient {'); - buffer.writeln(' final OrmModelContext _context;'); + buffer.writeln(' final OrmDbContext _context;'); buffer.writeln(); buffer.writeln(' GeneratedOrmClient(this._context);'); buffer.writeln(); buffer.writeln( - ' late final GeneratedOrmDb db = GeneratedOrmDb(_context);', + ' late final GeneratedOrmDb db = GeneratedOrmDb(_context.db);', ); buffer.writeln('}'); buffer.writeln(); buffer.writeln('class GeneratedOrmDb {'); - buffer.writeln(' final OrmModelContext _context;'); + buffer.writeln(' final OrmDbNamespace _db;'); buffer.writeln(); - buffer.writeln(' GeneratedOrmDb(this._context);'); + buffer.writeln(' GeneratedOrmDb(this._db);'); buffer.writeln(); buffer.writeln( - ' late final GeneratedOrmCollections orm = GeneratedOrmCollections(_context);', + ' late final GeneratedOrmCollections orm = GeneratedOrmCollections(_db.orm);', ); buffer.writeln(); buffer.writeln( - ' late final GeneratedOrmSql sql = GeneratedOrmSql(_context);', + ' late final GeneratedOrmSql sql = GeneratedOrmSql(_db.sql);', ); buffer.writeln('}'); buffer.writeln(); buffer.writeln('class GeneratedOrmCollections {'); - buffer.writeln(' final OrmModelContext _context;'); + buffer.writeln(' final OrmModelNamespace _orm;'); buffer.writeln(); - buffer.writeln(' GeneratedOrmCollections(this._context);'); + buffer.writeln(' GeneratedOrmCollections(this._orm);'); buffer.writeln(); for (final model in models) { @@ -569,7 +569,7 @@ final class TypedClientWriter { ' late final ${model.delegateClassName} ${model.getterName} =', ); buffer.writeln( - " ${model.delegateClassName}(_context.model('${_escapeString(model.model.runtimeName)}'));", + " ${model.delegateClassName}(_orm.model('${_escapeString(model.model.runtimeName)}'));", ); buffer.writeln(); } @@ -578,10 +578,9 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln('class GeneratedOrmSql {'); - buffer.writeln(' final OrmModelContext _context;'); - buffer.writeln(' late final OrmSqlApi _api = _context.sql;'); + buffer.writeln(' final OrmSqlApi _api;'); buffer.writeln(); - buffer.writeln(' GeneratedOrmSql(this._context);'); + buffer.writeln(' GeneratedOrmSql(this._api);'); buffer.writeln(); buffer.writeln(' OrmSqlApi get raw => _api;'); buffer.writeln(); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 23abbacc..c3374a5b 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -82,7 +82,7 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('users'); + final users = client.db.orm.model('users'); final created = await users.create( data: {'id': 'u1', 'email': 'a@example.com'}, ); @@ -113,7 +113,7 @@ void main() { test('requires explicit connect', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); - final users = client.model('User'); + final users = client.db.orm.model('User'); await expectLater( users.all(), @@ -125,7 +125,7 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final insertResult = await client.sql + final insertResult = await client.db.sql .insertInto('users') .values({'id': 'u1', 'email': 'a@example.com'}) .returning(const ['id', 'email']) @@ -133,7 +133,7 @@ void main() { expect(insertResult.affectedRows, 1); expect(insertResult.row?['id'], 'u1'); - final selectedRows = await client.sql + final selectedRows = await client.db.sql .from('User') .where({'id': 'u1'}) .select(const ['email']) @@ -141,7 +141,7 @@ void main() { expect(selectedRows, hasLength(1)); expect(selectedRows.single['email'], 'a@example.com'); - final updated = await client.sql + final updated = await client.db.sql .update('User') .where({'id': 'u1'}) .set({'email': 'b@example.com'}) @@ -150,7 +150,7 @@ void main() { expect(updated.affectedRows, 1); expect(updated.row?['email'], 'b@example.com'); - final deleted = await client.sql + final deleted = await client.db.sql .deleteFrom('User') .where({'id': 'u1'}) .returning(const ['id']) @@ -158,7 +158,7 @@ void main() { expect(deleted.affectedRows, 1); expect(deleted.row?['id'], 'u1'); - final remaining = await client.sql.from('User').all(); + final remaining = await client.db.sql.from('User').all(); expect(remaining, isEmpty); await client.disconnect(); }); @@ -166,7 +166,7 @@ void main() { test('db.sql requires explicit connect', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await expectLater( - client.sql.from('User').all(), + client.db.sql.from('User').all(), throwsA(isA()), ); }); @@ -279,7 +279,7 @@ void main() { test('supports ordering and pagination in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': '1', 'email': 'c@x.com'}, @@ -306,7 +306,7 @@ void main() { () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'a@x.com'}, @@ -354,7 +354,7 @@ void main() { test('supports aggregate helpers in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create(data: {'id': 1, 'email': 'a@x.com'}); await users.create(data: {'id': 2, 'email': null}); @@ -380,7 +380,7 @@ void main() { test('supports groupBy helpers in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create(data: {'id': 1, 'email': 'a@x.com'}); await users.create(data: {'id': 2, 'email': 'a@x.com'}); @@ -413,7 +413,7 @@ void main() { () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 1, 'email': 'a@x.com'}, @@ -461,7 +461,7 @@ void main() { test('rejects invalid groupBy aggregate orderBy fields', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create(data: {'id': 1, 'email': 'a@x.com'}); @@ -488,7 +488,7 @@ void main() { test('rejects invalid groupBy having aggregate fields', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create(data: {'id': 1, 'email': 'a@x.com'}); @@ -516,7 +516,7 @@ void main() { test('supports where operators gt/in/notIn in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create(data: {'id': 1, 'email': 'a@x.com'}); await users.create(data: {'id': 2, 'email': 'b@x.com'}); @@ -567,7 +567,7 @@ void main() { () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'alpha@example.com'}, @@ -623,7 +623,7 @@ void main() { () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 1, 'email': 'a@example.com'}, @@ -693,7 +693,7 @@ void main() { () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); final created = await users.create( data: {'id': 'u1', 'email': 'a@example.com'}, @@ -730,7 +730,7 @@ void main() { test('supports immutable chained query state for reads', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': '1', 'email': 'c@x.com'}, @@ -761,7 +761,7 @@ void main() { contract: relationalContract, engine: MemoryEngine(), ); - final users = client.model('User'); + final users = client.db.orm.model('User'); final plan = await users .query() @@ -795,7 +795,7 @@ void main() { final client = OrmClient(contract: contract, engine: engine); await client.connect(); - await client.model('User').create( + await client.db.orm.model('User').create( data: {'id': 'u1', 'email': 'a@x.com'}, ); final createPlan = engine.executedPlans.single; @@ -804,7 +804,7 @@ void main() { expect(createPlan.mutationResultMode, OrmMutationResultMode.row); engine.reset(); - await client.model('User').update( + await client.db.orm.model('User').update( where: {'id': 'u1'}, data: {'email': 'b@x.com'}, ); @@ -816,7 +816,7 @@ void main() { OrmMutationResultMode.rowOrNull, ); - final sqlPlan = client.sql + final sqlPlan = client.db.sql .update('User') .where({'id': 'u1'}) .set({'email': 'c@x.com'}) @@ -831,7 +831,7 @@ void main() { test('supports select projection through chained query state', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': '1', 'email': 'a@x.com'}, @@ -858,7 +858,7 @@ void main() { test('supports chained query state for unique/update/delete', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'a@example.com'}, @@ -887,7 +887,7 @@ void main() { test('supports firstOrNull, count and exists helpers', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'b@x.com'}, @@ -917,7 +917,7 @@ void main() { test('supports stream-first reads on delegate and query', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'c@x.com'}, @@ -948,7 +948,7 @@ void main() { test('rejects unsupported query state on mutation terminals', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); expect( () => users @@ -1003,7 +1003,7 @@ void main() { test('supports upsert create and update branches', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); final created = await users.upsert( where: {'id': 'u1'}, @@ -1024,7 +1024,7 @@ void main() { test('supports createMany and deleteMany helpers', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); final createdRows = await users.createMany( data: [ @@ -1048,7 +1048,7 @@ void main() { test('createMany_rolls_back_on_partial_failure', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'seed', 'email': 'seed@x.com'}, @@ -1089,7 +1089,7 @@ void main() { engine: _NoMutationReturnEngine(inner: MemoryEngine()), ); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); final created = await users.create( data: {'id': 'u1', 'email': 'a@x.com'}, @@ -1131,7 +1131,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').create( + client.db.orm.model('User').create( data: {'id': 'u1', 'email': 'a@x.com'}, ), throwsA(isA()), @@ -1156,7 +1156,7 @@ void main() { ); final client = OrmClient(contract: noReturningContract, engine: engine); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'a@x.com'}, @@ -1194,7 +1194,7 @@ void main() { ); final client = OrmClient(contract: noReturningContract, engine: engine); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'a@x.com'}, @@ -1226,7 +1226,7 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'a@x.com'}, ); @@ -1264,7 +1264,7 @@ void main() { ); await client.connect(); await _seedRelationalData(client); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u3', 'email': 'u3@example.com'}, ); @@ -1324,7 +1324,7 @@ void main() { ); await client.connect(); await _seedRelationalData(client); - final posts = client.model('Post'); + final posts = client.db.orm.model('Post'); await posts.create( data: {'id': 'p4', 'userId': 'ux', 'title': 'Post D'}, @@ -1400,7 +1400,7 @@ void main() { ); await client.connect(); await _seedRelationalData(client); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u3', 'email': 'u3@example.com'}, ); @@ -1447,7 +1447,7 @@ void main() { ); await client.connect(); await _seedRelationalData(client); - final users = client.model('User'); + final users = client.db.orm.model('User'); final updated = await users.update( where: { @@ -1474,7 +1474,7 @@ void main() { ); await client.connect(); await _seedRelationalData(client); - final posts = client.model('Post'); + final posts = client.db.orm.model('Post'); final updated = await posts.update( where: { @@ -1509,8 +1509,7 @@ void main() { ); await client.connect(); - await client - .model('User') + await client.db.orm.model('User') .all( where: { 'posts': { @@ -1537,8 +1536,7 @@ void main() { await client.connect(); await _seedRelationalData(client); - final rows = await client - .model('User') + final rows = await client.db.orm.model('User') .all( orderBy: const [OrmOrderBy('id')], include: { @@ -1582,8 +1580,7 @@ void main() { await client.connect(); try { await _seedRelationalData(client); - final rows = await client - .model('User') + final rows = await client.db.orm.model('User') .all( orderBy: const [OrmOrderBy('id')], include: { @@ -1632,8 +1629,7 @@ void main() { await _seedRelationalData(client); engine.reset(); - final rows = await client - .model('User') + final rows = await client.db.orm.model('User') .all( orderBy: const [OrmOrderBy('id')], include: { @@ -1678,8 +1674,7 @@ void main() { try { await _seedRelationalData(client); await expectLater( - client - .model('User') + client.db.orm.model('User') .all( include: {'posts': const IncludeSpec()}, ), @@ -1698,7 +1693,7 @@ void main() { ); await client.connect(); await _seedRelationalData(client); - final posts = client.model('Post'); + final posts = client.db.orm.model('Post'); final created = await posts.create( data: {'id': 'p4', 'userId': 'u1', 'title': 'Post D'}, @@ -1739,8 +1734,7 @@ void main() { ); await client.connect(); - final created = await client - .model('User') + final created = await client.db.orm.model('User') .createNested( data: {'id': 'u3', 'email': 'u3@example.com'}, create: >{ @@ -1756,8 +1750,7 @@ void main() { expect(createdPosts, hasLength(2)); expect(createdPosts.first['userId'], 'u3'); - final persistedPosts = await client - .model('Post') + final persistedPosts = await client.db.orm.model('Post') .all(where: {'userId': 'u3'}); expect(persistedPosts, hasLength(2)); await client.disconnect(); @@ -1772,8 +1765,7 @@ void main() { await client.connect(); await expectLater( - client - .model('User') + client.db.orm.model('User') .createNested( data: {'id': 'u4', 'email': 'u4@example.com'}, create: >{ @@ -1785,8 +1777,7 @@ void main() { throwsA(isA()), ); - final rolledBackUser = await client - .model('User') + final rolledBackUser = await client.db.orm.model('User') .oneOrNull(where: {'id': 'u4'}); expect(rolledBackUser, isNull); await client.disconnect(); @@ -1802,8 +1793,7 @@ void main() { await client.connect(); await _seedRelationalData(client); - final updated = await client - .model('User') + final updated = await client.db.orm.model('User') .updateNested( where: {'id': 'u1'}, data: {'email': 'u1+updated@example.com'}, @@ -1826,13 +1816,11 @@ void main() { expect(includedPosts.last['id'], 'p4'); expect(includedPosts.last['userId'], 'u1'); - final persistedUser = await client - .model('User') + final persistedUser = await client.db.orm.model('User') .oneOrNull(where: {'id': 'u1'}); expect(persistedUser?['email'], 'u1+updated@example.com'); - final persistedChild = await client - .model('Post') + final persistedChild = await client.db.orm.model('Post') .oneOrNull(where: {'id': 'p4'}); expect(persistedChild?['userId'], 'u1'); await client.disconnect(); @@ -1847,8 +1835,7 @@ void main() { await client.connect(); await _seedRelationalData(client); - final updated = await client - .model('User') + final updated = await client.db.orm.model('User') .updateNested( where: {'id': 'ux'}, data: {'email': 'missing@example.com'}, @@ -1860,8 +1847,7 @@ void main() { ); expect(updated, isNull); - final createdChild = await client - .model('Post') + final createdChild = await client.db.orm.model('Post') .oneOrNull(where: {'id': 'p9'}); expect(createdChild, isNull); await client.disconnect(); @@ -1876,8 +1862,7 @@ void main() { await _seedRelationalData(client); await expectLater( - client - .model('User') + client.db.orm.model('User') .updateNested( where: {'id': 'u1'}, data: {'email': 'u1+rollback@example.com'}, @@ -1894,13 +1879,11 @@ void main() { throwsA(isA()), ); - final rolledBackUser = await client - .model('User') + final rolledBackUser = await client.db.orm.model('User') .oneOrNull(where: {'id': 'u1'}); expect(rolledBackUser?['email'], 'u1@example.com'); - final rolledBackChild = await client - .model('Post') + final rolledBackChild = await client.db.orm.model('Post') .oneOrNull(where: {'id': 'p10'}); expect(rolledBackChild, isNull); await client.disconnect(); @@ -1915,7 +1898,7 @@ void main() { ); await client.connect(); await _seedRelationalData(client); - final users = client.model('User'); + final users = client.db.orm.model('User'); final delegatedRows = await users.include({ 'posts': IncludeSpec( @@ -2061,8 +2044,7 @@ void main() { await client.connect(); await _seedRelationalData(client); - final row = await client - .model('Post') + final row = await client.db.orm.model('Post') .oneOrNull( where: {'id': 'p1'}, include: { @@ -2098,8 +2080,7 @@ void main() { await _seedRelationalData(client); await expectLater( - client - .model('User') + client.db.orm.model('User') .all( include: {'unknown': const IncludeSpec()}, ), @@ -2118,8 +2099,7 @@ void main() { await _seedRelationalData(client); await expectLater( - client - .model('User') + client.db.orm.model('User') .all( include: { 'posts': IncludeSpec( @@ -2140,8 +2120,7 @@ void main() { await client.connect(); await _seedRelationalData(client); - final row = await client - .model('User') + final row = await client.db.orm.model('User') .oneOrNull( where: {'id': 'u1'}, select: const ['email'], @@ -2186,8 +2165,7 @@ void main() { await client.connect(); await _seedRelationalData(client); - await client - .model('User') + await client.db.orm.model('User') .all(include: {'posts': const IncludeSpec()}); expect(callCount, greaterThan(0)); @@ -2202,15 +2180,15 @@ void main() { engine: MemoryEngine(), collections: { 'users': - ({required OrmModelContext client, required String modelName}) { + ({required OrmDelegateContext client, required String modelName}) { return _UsersCollection(client: client, modelName: modelName); }, }, ); await client.connect(); - final first = client.model('users'); - final second = client.model('User'); + final first = client.db.orm.model('users'); + final second = client.db.orm.model('User'); expect(first, same(second)); expect(first, isA<_UsersCollection>()); @@ -2244,8 +2222,7 @@ void main() { await transaction.commit(); await connection.release(); - final row = await client - .model('User') + final row = await client.db.orm.model('User') .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'b@example.com'); await client.disconnect(); @@ -2256,15 +2233,13 @@ void main() { await client.connect(); await client.withConnection((connection) async { - await connection - .model('User') + await connection.db.orm.model('User') .create( data: {'id': 'u1', 'email': 'a@example.com'}, ); }); - final row = await client - .model('User') + final row = await client.db.orm.model('User') .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); @@ -2276,7 +2251,7 @@ void main() { await client.connect(); await client.withConnection((connection) async { - final rows = await connection.sql.from('User').take(1).all(); + final rows = await connection.db.sql.from('User').take(1).all(); expect(rows, isEmpty); }); @@ -2295,7 +2270,7 @@ void main() { await client.connect(); await client.withConnection((connection) async { - final rows = await connection.model('User').all(); + final rows = await connection.db.orm.model('User').all(); expect(rows, isEmpty); }); @@ -2312,15 +2287,13 @@ void main() { await client.connect(); await client.withTransaction((transaction) async { - await transaction - .model('User') + await transaction.db.orm.model('User') .create( data: {'id': 'u1', 'email': 'a@example.com'}, ); }); - final row = await client - .model('User') + final row = await client.db.orm.model('User') .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); @@ -2331,14 +2304,13 @@ void main() { await client.connect(); await client.withTransaction((transaction) async { - await transaction.sql.insertInto('User').values({ + await transaction.db.sql.insertInto('User').values({ 'id': 'u1', 'email': 'a@example.com', }).execute(); }); - final row = await client - .model('User') + final row = await client.db.orm.model('User') .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); @@ -2352,7 +2324,7 @@ void main() { await client.connect(); await client.withTransaction((transaction) async { - final rows = await transaction.model('User').all(); + final rows = await transaction.db.orm.model('User').all(); expect(rows, isEmpty); }); @@ -2397,8 +2369,7 @@ void main() { await expectLater( () => client.withTransaction((transaction) async { - await transaction - .model('User') + await transaction.db.orm.model('User') .create( data: {'id': 'u1', 'email': 'a@example.com'}, ); @@ -2407,8 +2378,7 @@ void main() { throwsA(isA()), ); - final row = await client - .model('User') + final row = await client.db.orm.model('User') .oneOrNull(where: {'id': 'u1'}); expect(row, isNull); await client.disconnect(); @@ -2423,7 +2393,7 @@ void main() { await expectLater( () => client.withTransaction((transaction) async { - await transaction.model('User').all(); + await transaction.db.orm.model('User').all(); throw StateError('stop'); }), throwsA(isA()), @@ -2452,7 +2422,7 @@ void main() { await expectLater( () => client.withTransaction((transaction) async { - final rows = await transaction.model('User').all(); + final rows = await transaction.db.orm.model('User').all(); expect(rows, isEmpty); }), throwsA(isA()), @@ -2478,7 +2448,7 @@ void main() { await expectLater( () => client.withTransaction((transaction) async { - await transaction.model('User').all(); + await transaction.db.orm.model('User').all(); throw StateError('stop'); }), throwsA( @@ -2520,7 +2490,7 @@ void main() { test('rollback keeps original data in transaction API', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.model('User'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'a@example.com'}, @@ -2582,7 +2552,7 @@ void main() { test('records telemetry for successful execution', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - await client.model('User').all(); + await client.db.orm.model('User').all(); final telemetry = client.telemetry(); expect(telemetry, isNotNull); @@ -2610,8 +2580,8 @@ void main() { await client.connect(); expect(readCount, 1); - await client.model('User').all(); - await client.model('User').all(); + await client.db.orm.model('User').all(); + await client.db.orm.model('User').all(); expect(readCount, 1); await client.disconnect(); }); @@ -2636,9 +2606,9 @@ void main() { await client.connect(); expect(readCount, 0); - await client.model('User').all(); + await client.db.orm.model('User').all(); expect(readCount, 1); - await client.model('User').all(); + await client.db.orm.model('User').all(); expect(readCount, 1); await client.disconnect(); }, @@ -2660,8 +2630,8 @@ void main() { ); await client.connect(); - await client.model('User').all(); - await client.model('User').all(); + await client.db.orm.model('User').all(); + await client.db.orm.model('User').all(); expect(readCount, 2); await client.disconnect(); @@ -2680,7 +2650,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').all(), + client.db.orm.model('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2699,7 +2669,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').all(), + client.db.orm.model('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2710,8 +2680,7 @@ void main() { await client.connect(); await expectLater( - client - .model('User') + client.db.orm.model('User') .all( where: { 'OR': [ @@ -2723,12 +2692,11 @@ void main() { completes, ); await expectLater( - client.model('User').all(where: {'age': 1}), + client.db.orm.model('User').all(where: {'age': 1}), throwsA(isA()), ); await expectLater( - client - .model('User') + client.db.orm.model('User') .all( where: { 'AND': [ @@ -2740,21 +2708,20 @@ void main() { throwsA(isA()), ); await expectLater( - client.model('User').create(data: {'age': 1}), + client.db.orm.model('User').create(data: {'age': 1}), throwsA(isA()), ); await expectLater( - client - .model('User') + client.db.orm.model('User') .all(orderBy: const [OrmOrderBy('age')]), throwsA(isA()), ); await expectLater( - client.model('User').all(select: const ['age']), + client.db.orm.model('User').all(select: const ['age']), throwsA(isA()), ); await expectLater( - client.model('User').all(distinct: const ['age']), + client.db.orm.model('User').all(distinct: const ['age']), throwsA(isA()), ); await client.disconnect(); @@ -2765,11 +2732,11 @@ void main() { await client.connect(); await expectLater( - client.model('User').all(skip: -1), + client.db.orm.model('User').all(skip: -1), throwsA(isA()), ); await expectLater( - client.model('User').all(take: -1), + client.db.orm.model('User').all(take: -1), throwsA(isA()), ); await client.disconnect(); @@ -2784,7 +2751,7 @@ void main() { plugins: [plugin], ); await client.connect(); - await client.model('User').all(); + await client.db.orm.model('User').all(); expect(plugin.events, ['before:read', 'after:read']); await client.disconnect(); @@ -2798,7 +2765,7 @@ void main() { plugins: [plugin], ); await client.connect(); - await client.sql.from('User').all(); + await client.db.sql.from('User').all(); expect(plugin.events, ['before:read', 'after:read']); await client.disconnect(); @@ -2813,7 +2780,7 @@ void main() { ); await client.connect(); - await expectLater(client.model('User').all(), throwsA(isA())); + await expectLater(client.db.orm.model('User').all(), throwsA(isA())); expect(plugin.events, [ 'before:read', 'error:read', @@ -2833,7 +2800,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').all(), + client.db.orm.model('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2858,7 +2825,7 @@ void main() { ); await client.connect(); - await client.model('User').all(); + await client.db.orm.model('User').all(); expect(logs.warnEvents, isNotEmpty); await client.disconnect(); }); @@ -2872,7 +2839,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').all(take: 2), + client.db.orm.model('User').all(take: 2), throwsA(isA()), ); await client.disconnect(); @@ -2905,7 +2872,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').all(), + client.db.orm.model('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2916,7 +2883,7 @@ void main() { await client.connect(); await expectLater( - client.sql.from('User').all(), + client.db.sql.from('User').all(), throwsA(isA()), ); await client.disconnect(); @@ -2924,8 +2891,8 @@ void main() { } Future _seedRelationalData(OrmClient client) async { - final users = client.model('User'); - final posts = client.model('Post'); + final users = client.db.orm.model('User'); + final posts = client.db.orm.model('Post'); await users.create( data: {'id': 'u1', 'email': 'u1@example.com'}, diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index b879494e..0d056df2 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -100,12 +100,12 @@ void main() { final generatedSource = cliOutput.readAsStringSync(); expect( - generatedSource.contains("_context.model('CliOnlyUser')"), + generatedSource.contains("_orm.model('CliOnlyUser')"), isTrue, reason: 'Expected CLI schema model in generated output.', ); expect( - generatedSource.contains("_context.model('ConfigOnlyUser')"), + generatedSource.contains("_orm.model('ConfigOnlyUser')"), isFalse, reason: 'Did not expect config schema model after --schema override.', ); @@ -461,7 +461,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+GeneratedOrmClient\s*\{[\s\S]*?late\s+final\s+GeneratedOrmDb\s+db\s*=\s*GeneratedOrmDb\(_context\);', + r'class\s+GeneratedOrmClient\s*\{[\s\S]*?late\s+final\s+GeneratedOrmDb\s+db\s*=\s*GeneratedOrmDb\(_context\.db\);', ).hasMatch(generatedSource), isTrue, reason: 'Expected GeneratedOrmClient to expose db entrypoint.', @@ -474,7 +474,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+GeneratedOrmDb\s*\{[\s\S]*?late\s+final\s+GeneratedOrmCollections\s+orm\s*=\s*GeneratedOrmCollections\(_context\);[\s\S]*?late\s+final\s+GeneratedOrmSql\s+sql\s*=\s*GeneratedOrmSql\(_context\);', + r'class\s+GeneratedOrmDb\s*\{[\s\S]*?late\s+final\s+GeneratedOrmCollections\s+orm\s*=\s*GeneratedOrmCollections\(_db\.orm\);[\s\S]*?late\s+final\s+GeneratedOrmSql\s+sql\s*=\s*GeneratedOrmSql\(_db\.sql\);', ).hasMatch(generatedSource), isTrue, reason: @@ -482,7 +482,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+GeneratedOrmSql\s*\{[\s\S]*?late\s+final\s+OrmSqlApi\s+_api\s*=\s*_context\.sql;[\s\S]*?UserSql\s+user\s*=', + r'class\s+GeneratedOrmSql\s*\{[\s\S]*?final\s+OrmSqlApi\s+_api;[\s\S]*?UserSql\s+user\s*=', ).hasMatch(generatedSource), isTrue, reason: @@ -490,7 +490,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+GeneratedOrmCollections\s*\{[\s\S]*?_context\.model\(', + r'class\s+GeneratedOrmCollections\s*\{[\s\S]*?_orm\.model\(', ).hasMatch(generatedSource), isTrue, reason: diff --git a/pub/orm/test/sql/sql_marker_reader_test.dart b/pub/orm/test/sql/sql_marker_reader_test.dart index 413975b3..7846cb7f 100644 --- a/pub/orm/test/sql/sql_marker_reader_test.dart +++ b/pub/orm/test/sql/sql_marker_reader_test.dart @@ -250,7 +250,7 @@ void main() { ); await client.connect(); - await expectLater(client.model('User').all(), completes); + await expectLater(client.db.orm.model('User').all(), completes); await client.disconnect(); }); @@ -268,7 +268,7 @@ void main() { await client.connect(); await expectLater( - client.model('User').all(), + client.db.orm.model('User').all(), throwsA(isA()), ); await client.disconnect(); From fe5da2bfc30e4559f71e58a205161e3bd6182826 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:49:07 +0800 Subject: [PATCH 092/154] feat(runtime)!: reject invalid plan result mode combinations BREAKING CHANGE: runtime now rejects plans whose resultMode or mutationResultMode do not match the requested action semantics. --- pub/orm/lib/src/runtime/core.dart | 23 +++++++++++++ pub/orm/lib/src/runtime/errors.dart | 24 +++++++++++++ pub/orm/test/client/client_test.dart | 51 ++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index f2793632..15104e47 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -362,6 +362,29 @@ final class OrmRuntimeCore implements RuntimeCore { if (plan.take case final take? when take < 0) { throw PlanInvalidPaginationException(key: 'take', value: take); } + + _assertPlanModes(plan); + } + + void _assertPlanModes(OrmPlan plan) { + switch (plan.action) { + case OrmAction.read: + if (plan.mutationResultMode != null) { + throw PlanResultModeActionInvalidException( + action: plan.action, + resultMode: plan.resultMode, + mutationResultMode: plan.mutationResultMode, + ); + } + case OrmAction.create || OrmAction.update || OrmAction.delete: + if (plan.resultMode != null) { + throw PlanResultModeActionInvalidException( + action: plan.action, + resultMode: plan.resultMode, + mutationResultMode: plan.mutationResultMode, + ); + } + } } void _assertKnownFields({ diff --git a/pub/orm/lib/src/runtime/errors.dart b/pub/orm/lib/src/runtime/errors.dart index 48a325a1..5e19b9ba 100644 --- a/pub/orm/lib/src/runtime/errors.dart +++ b/pub/orm/lib/src/runtime/errors.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'plan.dart'; + enum RuntimeErrorCategory { runtime, contract, plan, plugin } enum RuntimeErrorSeverity { error, warn } @@ -243,6 +245,28 @@ final class PlanInvalidPaginationException extends OrmRuntimeError { ); } +final class PlanResultModeActionInvalidException extends OrmRuntimeError { + final OrmAction action; + final OrmReadResultMode? resultMode; + final OrmMutationResultMode? mutationResultMode; + + PlanResultModeActionInvalidException({ + required this.action, + required this.resultMode, + required this.mutationResultMode, + }) : super( + code: 'PLAN.RESULT_MODE_ACTION_INVALID', + category: RuntimeErrorCategory.plan, + message: + 'Plan result modes do not match the requested action semantics.', + details: { + 'action': action.name, + 'resultMode': resultMode?.name, + 'mutationResultMode': mutationResultMode?.name, + }, + ); +} + final class RuntimeCreateResultMissingException extends OrmRuntimeError { RuntimeCreateResultMissingException({required String model}) : super( diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index c3374a5b..fd1cf542 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -276,6 +276,57 @@ void main() { }, ); + test('rejects invalid plan result mode and action combinations', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await expectLater( + client.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.read, + resultMode: OrmReadResultMode.all, + mutationResultMode: OrmMutationResultMode.rowOrNull, + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.RESULT_MODE_ACTION_INVALID', + ), + ), + ); + + for (final action in [ + OrmAction.create, + OrmAction.update, + OrmAction.delete, + ]) { + await expectLater( + client.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: action, + resultMode: OrmReadResultMode.oneOrNull, + mutationResultMode: OrmMutationResultMode.rowOrNull, + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.RESULT_MODE_ACTION_INVALID', + ), + ), + ); + } + + await client.disconnect(); + }); + test('supports ordering and pagination in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); From 40ea172574daca3316a1b30b0907827986146d9e Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:57:57 +0800 Subject: [PATCH 093/154] refactor(client)!: narrow collection and sql runtime contexts BREAKING CHANGE: collection factories and model delegates now depend on OrmCollectionContext and OrmExecutionContext instead of the broader OrmDelegateContext surface. --- pub/orm/lib/src/client/client.dart | 113 +++++++++++------- pub/orm/lib/src/client/include_planner.dart | 4 +- .../lib/src/client/mutation_repository.dart | 8 +- pub/orm/test/client/client_test.dart | 2 +- 4 files changed, 80 insertions(+), 47 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index a7621ff7..6715afd6 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -14,7 +14,7 @@ part 'mutation_repository.dart'; typedef CollectionFactory = ModelDelegate Function({ - required OrmDelegateContext client, + required OrmCollectionContext client, required String modelName, }); @@ -191,19 +191,25 @@ abstract interface class OrmDbContext { OrmDbNamespace get db; } -abstract interface class OrmDelegateContext implements OrmDbContext { +abstract interface class OrmExecutionContext { OrmContract get contract; + Future execute(OrmPlan plan); +} + +abstract interface class OrmCollectionContext implements OrmExecutionContext { IncludeExecutionStrategySelector get includeStrategySelector; int get maxIncludeDepth; - Future execute(OrmPlan plan); - Future transaction(Future Function(OrmDbNamespace txDb) run); } -final class OrmClient implements OrmDelegateContext { +abstract interface class _OrmDelegateRuntime implements OrmCollectionContext { + ModelDelegate _resolveDelegate(String modelKey); +} + +final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { @override final OrmContract contract; final OrmEngine engine; @@ -212,8 +218,8 @@ final class OrmClient implements OrmDelegateContext { final Map _modelAliases; final Map _collectionRegistry; late final OrmDbNamespace _db = OrmDbNamespace( - context: this, - resolveModel: _model, + sqlContext: this, + resolveDelegate: _resolveDelegate, ); @override final IncludeExecutionStrategySelector includeStrategySelector; @@ -313,7 +319,8 @@ final class OrmClient implements OrmDelegateContext { @override OrmDbNamespace get db => _db; - ModelDelegate _model(String modelKey) { + @override + ModelDelegate _resolveDelegate(String modelKey) { final modelName = _resolveModelOrThrow(modelKey: modelKey); return _delegates.putIfAbsent(modelName, () { final factory = _collectionRegistry[modelName]; @@ -356,7 +363,7 @@ final class OrmClient implements OrmDelegateContext { } } -final class OrmScopedClient implements OrmDelegateContext { +final class OrmScopedClient implements OrmDbContext, _OrmDelegateRuntime { @override final OrmContract contract; final Future Function(OrmPlan plan) _executePlan; @@ -364,8 +371,8 @@ final class OrmScopedClient implements OrmDelegateContext { final Map _collectionRegistry; final Map _delegates = {}; late final OrmDbNamespace _db = OrmDbNamespace( - context: this, - resolveModel: _model, + sqlContext: this, + resolveDelegate: _resolveDelegate, ); @override final IncludeExecutionStrategySelector includeStrategySelector; @@ -383,7 +390,8 @@ final class OrmScopedClient implements OrmDelegateContext { _modelAliases = modelAliases, _collectionRegistry = collectionRegistry; - ModelDelegate _model(String modelKey) { + @override + ModelDelegate _resolveDelegate(String modelKey) { final modelName = _resolveModelOrThrow(modelKey: modelKey); return _delegates.putIfAbsent(modelName, () { final factory = _collectionRegistry[modelName]; @@ -438,17 +446,20 @@ final class OrmSqlMutationResult { } final class OrmDbNamespace { - final OrmDelegateContext _context; - final ModelDelegate Function(String modelKey) _resolveModel; + final OrmExecutionContext _sqlContext; + final ModelDelegate Function(String modelKey) _resolveDelegate; - late final OrmModelNamespace orm = OrmModelNamespace(_resolveModel); - late final OrmSqlApi sql = OrmSqlApi(_context); + late final OrmModelNamespace orm = OrmModelNamespace(_resolveDelegate); + late final OrmSqlApi sql = OrmSqlApi( + _sqlContext, + resolveDelegate: _resolveDelegate, + ); OrmDbNamespace({ - required OrmDelegateContext context, - required ModelDelegate Function(String modelKey) resolveModel, - }) : _context = context, - _resolveModel = resolveModel; + required OrmExecutionContext sqlContext, + required ModelDelegate Function(String modelKey) resolveDelegate, + }) : _sqlContext = sqlContext, + _resolveDelegate = resolveDelegate; } final class OrmModelNamespace { @@ -460,42 +471,58 @@ final class OrmModelNamespace { } final class OrmSqlApi { - final OrmDelegateContext _client; + final OrmExecutionContext _client; + final ModelDelegate Function(String modelKey) _resolveDelegate; - const OrmSqlApi(this._client); + const OrmSqlApi( + this._client, { + required ModelDelegate Function(String modelKey) resolveDelegate, + }) : _resolveDelegate = resolveDelegate; OrmSqlSelectBuilder from(String modelKey) { return OrmSqlSelectBuilder._( client: _client, - modelName: _resolveSqlModelName(client: _client, modelKey: modelKey), + modelName: _resolveSqlModelName( + resolveDelegate: _resolveDelegate, + modelKey: modelKey, + ), ); } OrmSqlInsertBuilder insertInto(String modelKey) { return OrmSqlInsertBuilder._( client: _client, - modelName: _resolveSqlModelName(client: _client, modelKey: modelKey), + modelName: _resolveSqlModelName( + resolveDelegate: _resolveDelegate, + modelKey: modelKey, + ), ); } OrmSqlUpdateBuilder update(String modelKey) { return OrmSqlUpdateBuilder._( client: _client, - modelName: _resolveSqlModelName(client: _client, modelKey: modelKey), + modelName: _resolveSqlModelName( + resolveDelegate: _resolveDelegate, + modelKey: modelKey, + ), ); } OrmSqlDeleteBuilder deleteFrom(String modelKey) { return OrmSqlDeleteBuilder._( client: _client, - modelName: _resolveSqlModelName(client: _client, modelKey: modelKey), + modelName: _resolveSqlModelName( + resolveDelegate: _resolveDelegate, + modelKey: modelKey, + ), ); } } @immutable final class OrmSqlSelectBuilder { - final OrmDelegateContext _client; + final OrmExecutionContext _client; final String _modelName; final JsonMap _where; final int? _skip; @@ -505,7 +532,7 @@ final class OrmSqlSelectBuilder { final List _select; OrmSqlSelectBuilder._({ - required OrmDelegateContext client, + required OrmExecutionContext client, required String modelName, JsonMap where = const {}, int? skip, @@ -609,13 +636,13 @@ final class OrmSqlSelectBuilder { @immutable final class OrmSqlInsertBuilder { - final OrmDelegateContext _client; + final OrmExecutionContext _client; final String _modelName; final JsonMap _data; final List _select; OrmSqlInsertBuilder._({ - required OrmDelegateContext client, + required OrmExecutionContext client, required String modelName, JsonMap data = const {}, List select = const [], @@ -668,14 +695,14 @@ final class OrmSqlInsertBuilder { @immutable final class OrmSqlUpdateBuilder { - final OrmDelegateContext _client; + final OrmExecutionContext _client; final String _modelName; final JsonMap _where; final JsonMap _data; final List _select; OrmSqlUpdateBuilder._({ - required OrmDelegateContext client, + required OrmExecutionContext client, required String modelName, JsonMap where = const {}, JsonMap data = const {}, @@ -740,13 +767,13 @@ final class OrmSqlUpdateBuilder { @immutable final class OrmSqlDeleteBuilder { - final OrmDelegateContext _client; + final OrmExecutionContext _client; final String _modelName; final JsonMap _where; final List _select; OrmSqlDeleteBuilder._({ - required OrmDelegateContext client, + required OrmExecutionContext client, required String modelName, JsonMap where = const {}, List select = const [], @@ -800,15 +827,15 @@ final class OrmSqlDeleteBuilder { const Object _sqlKeepToken = Object(); String _resolveSqlModelName({ - required OrmDelegateContext client, + required ModelDelegate Function(String modelKey) resolveDelegate, required String modelKey, }) { - final delegate = client.db.orm.model(modelKey); + final delegate = resolveDelegate(modelKey); return delegate.modelName; } OrmPlan _buildSqlPlan({ - required OrmDelegateContext client, + required OrmExecutionContext client, required String modelName, required OrmAction action, OrmMutationResultMode? mutationResultMode, @@ -849,14 +876,16 @@ final class _PreparedReadPlan { } class ModelDelegate { - final OrmDelegateContext _client; + final OrmCollectionContext _client; final String modelName; - ModelDelegate({required OrmDelegateContext client, required this.modelName}) + ModelDelegate({required OrmCollectionContext client, required this.modelName}) : _client = client; @protected - OrmDelegateContext get client => _client; + OrmCollectionContext get client => _client; + + _OrmDelegateRuntime get _runtime => _client as _OrmDelegateRuntime; ModelQuery query() => ModelQuery._(this, const ModelQueryState()); @@ -2780,8 +2809,8 @@ class ModelDelegate { required JsonMap relatedWhere, required bool include, }) async { - final relatedRows = await _client.db.orm - .model(relation.relatedModel) + final relatedRows = await _runtime + ._resolveDelegate(relation.relatedModel) ._readAllInternal( action: OrmAction.read, where: relatedWhere, diff --git a/pub/orm/lib/src/client/include_planner.dart b/pub/orm/lib/src/client/include_planner.dart index 2bc154b3..3f5c3211 100644 --- a/pub/orm/lib/src/client/include_planner.dart +++ b/pub/orm/lib/src/client/include_planner.dart @@ -57,7 +57,7 @@ final class _RepositoryIncludePlanner { model: _delegate.modelName, relationName: relationName, ); - final relatedDelegate = _delegate._client.db.orm.model( + final relatedDelegate = _delegate._runtime._resolveDelegate( relation.relatedModel, ); _delegate._validateIncludePagination(include: relationInclude); @@ -140,7 +140,7 @@ final class _RepositoryIncludePlanner { model: _delegate.modelName, relationName: relationName, ); - final relatedDelegate = _delegate._client.db.orm.model( + final relatedDelegate = _delegate._runtime._resolveDelegate( relation.relatedModel, ); diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index a153415f..707f8965 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -358,7 +358,9 @@ final class _RepositoryMutationExecutor { model: _delegate.modelName, relationName: entry.key, ); - final related = _delegate._client.db.orm.model(relation.relatedModel); + final related = _delegate._runtime._resolveDelegate( + relation.relatedModel, + ); final relatedExecutor = _RepositoryMutationExecutor(related); for (final child in entry.value) { final linkedData = _delegate._linkNestedData( @@ -413,7 +415,9 @@ final class _RepositoryMutationExecutor { model: _delegate.modelName, relationName: entry.key, ); - final related = _delegate._client.db.orm.model(relation.relatedModel); + final related = _delegate._runtime._resolveDelegate( + relation.relatedModel, + ); final relatedExecutor = _RepositoryMutationExecutor(related); for (final child in entry.value) { final linkedData = _delegate._linkNestedData( diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index fd1cf542..bbc29a5c 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -2231,7 +2231,7 @@ void main() { engine: MemoryEngine(), collections: { 'users': - ({required OrmDelegateContext client, required String modelName}) { + ({required OrmCollectionContext client, required String modelName}) { return _UsersCollection(client: client, modelName: modelName); }, }, From bfb4f2feb406ed55602612e88f992b9e48ca18f2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:05:41 +0800 Subject: [PATCH 094/154] refactor(runtime)!: split plans into read and mutation branches BREAKING CHANGE: OrmPlan now uses explicit read and mutation branches, and production plan construction goes through OrmPlan.read and OrmPlan.mutation. --- pub/orm/lib/src/client/client.dart | 53 +++-- .../lib/src/client/mutation_repository.dart | 6 +- pub/orm/lib/src/engine/memory_engine.dart | 35 ++-- pub/orm/lib/src/runtime/core.dart | 64 +++--- pub/orm/lib/src/runtime/errors.dart | 15 +- pub/orm/lib/src/runtime/plan.dart | 139 ++++++++++--- pub/orm/lib/src/runtime/plugins/budgets.dart | 8 +- pub/orm/lib/src/runtime/plugins/lints.dart | 15 +- pub/orm/lib/src/sql/adapter.dart | 64 +++--- pub/orm/test/client/client_test.dart | 77 ++++++-- pub/orm/test/sql/sql_adapter_test.dart | 183 ++++++++++-------- .../target/adapter_driver_engine_test.dart | 4 +- 12 files changed, 440 insertions(+), 223 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 6715afd6..9f011be3 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -848,23 +848,35 @@ OrmPlan _buildSqlPlan({ List select = const [], }) { final contract = client.contract; - return OrmPlan( - contractHash: contract.hash, - target: contract.target, - storageHash: contract.markerStorageHash, - profileHash: contract.profileHash, - lane: 'sql', - mutationResultMode: mutationResultMode, - model: modelName, - action: action, - where: where, - data: data, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - ); + return action == OrmAction.read + ? OrmPlan.read( + contractHash: contract.hash, + target: contract.target, + storageHash: contract.markerStorageHash, + profileHash: contract.profileHash, + lane: 'sql', + model: modelName, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + resultMode: OrmReadResultMode.all, + ) + : OrmPlan.mutation( + contractHash: contract.hash, + target: contract.target, + storageHash: contract.markerStorageHash, + profileHash: contract.profileHash, + lane: 'sql', + model: modelName, + action: action, + where: where, + data: data, + select: select, + resultMode: mutationResultMode ?? OrmMutationResultMode.rowOrNull, + ); } @immutable @@ -1326,27 +1338,26 @@ class ModelDelegate { return _PreparedReadPlan( include: normalizedInclude, - plan: OrmPlan( + plan: OrmPlan.read( contractHash: _client.contract.hash, target: _client.contract.target, storageHash: _client.contract.markerStorageHash, profileHash: _client.contract.profileHash, lane: 'orm', - resultMode: resultMode, - include: _buildOrmIncludePlanMap(normalizedInclude), annotations: distinct.isEmpty ? const {} : { 'distinct': List.from(distinct, growable: false), }, model: modelName, - action: OrmAction.read, where: normalizedWhere, skip: isCollectionRead && distinct.isEmpty ? skip : null, take: isCollectionRead && distinct.isEmpty ? resolvedTake : null, orderBy: isCollectionRead ? orderBy : const [], distinct: isCollectionRead ? distinct : const [], select: readSelect, + include: _buildOrmIncludePlanMap(normalizedInclude), + resultMode: resultMode, ), ); } diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index 707f8965..a6fa6051 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -210,7 +210,7 @@ final class _RepositoryMutationExecutor { include: include, ); final normalizedInclude = prepared.include; - final normalizedWhere = prepared.plan.where; + final normalizedWhere = prepared.plan.mutation!.where; final preDeleteRow = await _preloadDeleteRow( action: action, where: normalizedWhere, @@ -258,13 +258,12 @@ final class _RepositoryMutationExecutor { return _PreparedMutationPlan( include: normalizedInclude, - plan: OrmPlan( + plan: OrmPlan.mutation( contractHash: _delegate._client.contract.hash, target: _delegate._client.contract.target, storageHash: _delegate._client.contract.markerStorageHash, profileHash: _delegate._client.contract.profileHash, lane: 'orm', - mutationResultMode: mutationResultMode, model: _delegate.modelName, action: action, where: normalizedWhere, @@ -274,6 +273,7 @@ final class _RepositoryMutationExecutor { select: select, include: normalizedInclude, ), + resultMode: mutationResultMode, ), ); } diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index b594d7bd..243b5f5f 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -91,25 +91,26 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { } EngineResponse _read(List bucket, OrmPlan plan) { - var rows = bucket.where((row) => _matches(row, plan.where)).toList(); + final read = plan.read!; + var rows = bucket.where((row) => _matches(row, read.where)).toList(); - if (plan.orderBy.isNotEmpty) { - rows.sort((left, right) => _compareRows(left, right, plan.orderBy)); + if (read.orderBy.isNotEmpty) { + rows.sort((left, right) => _compareRows(left, right, read.orderBy)); } - if (plan.skip case final skip?) { + if (read.skip case final skip?) { rows = skip >= rows.length ? [] : rows.sublist(skip); } - if (plan.take case final take?) { + if (read.take case final take?) { rows = take >= rows.length ? rows : rows.sublist(0, take); } final projected = rows - .map((row) => _projectRow(row, plan.select)) + .map((row) => _projectRow(row, read.select)) .toList(growable: false); - return switch (plan.resultMode) { + return switch (read.resultMode) { OrmReadResultMode.firstOrNull || OrmReadResultMode.oneOrNull => EngineResponse(data: _firstOrNull(projected)), _ => EngineResponse(data: projected), @@ -117,22 +118,27 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { } EngineResponse _create(List bucket, OrmPlan plan) { - final row = _cloneRow(plan.data); + final mutation = plan.mutation!; + final row = _cloneRow(mutation.data); bucket.add(row); - return EngineResponse(data: _projectRow(row, plan.select), affectedRows: 1); + return EngineResponse( + data: _projectRow(row, mutation.select), + affectedRows: 1, + ); } EngineResponse _update(List bucket, OrmPlan plan) { + final mutation = plan.mutation!; for (var index = 0; index < bucket.length; index++) { final row = bucket[index]; - if (!_matches(row, plan.where)) { + if (!_matches(row, mutation.where)) { continue; } - final updated = {...row, ...plan.data}; + final updated = {...row, ...mutation.data}; bucket[index] = updated; return EngineResponse( - data: _projectRow(updated, plan.select), + data: _projectRow(updated, mutation.select), affectedRows: 1, ); } @@ -140,15 +146,16 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { } EngineResponse _delete(List bucket, OrmPlan plan) { + final mutation = plan.mutation!; for (var index = 0; index < bucket.length; index++) { final row = bucket[index]; - if (!_matches(row, plan.where)) { + if (!_matches(row, mutation.where)) { continue; } bucket.removeAt(index); return EngineResponse( - data: _projectRow(row, plan.select), + data: _projectRow(row, mutation.select), affectedRows: 1, ); } diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 15104e47..22e98ae8 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -345,8 +345,42 @@ final class OrmRuntimeCore implements RuntimeCore { } final model = contract.models[plan.model]!; + _assertPlanModes(plan); + switch (plan.action) { + case OrmAction.read: + _assertReadPlan(model: model, plan: plan.read!); + case OrmAction.create || OrmAction.update || OrmAction.delete: + _assertMutationPlan(model: model, plan: plan.mutation!); + } + } + + void _assertPlanModes(OrmPlan plan) { + switch (plan.action) { + case OrmAction.read: + if (plan.read == null || plan.mutation != null) { + throw PlanResultModeActionInvalidException( + action: plan.action, + readResultMode: plan.read?.resultMode, + mutationResultMode: plan.mutation?.resultMode, + hasRead: plan.read != null, + hasMutation: plan.mutation != null, + ); + } + case OrmAction.create || OrmAction.update || OrmAction.delete: + if (plan.mutation == null || plan.read != null) { + throw PlanResultModeActionInvalidException( + action: plan.action, + readResultMode: plan.read?.resultMode, + mutationResultMode: plan.mutation?.resultMode, + hasRead: plan.read != null, + hasMutation: plan.mutation != null, + ); + } + } + } + + void _assertReadPlan({required ModelContract model, required OrmReadPlan plan}) { _assertWhereFields(model: model, where: plan.where, source: 'where'); - _assertKnownFields(model: model, fields: plan.data.keys, source: 'data'); _assertKnownFields( model: model, fields: plan.orderBy.map((entry) => entry.field), @@ -362,29 +396,15 @@ final class OrmRuntimeCore implements RuntimeCore { if (plan.take case final take? when take < 0) { throw PlanInvalidPaginationException(key: 'take', value: take); } - - _assertPlanModes(plan); } - void _assertPlanModes(OrmPlan plan) { - switch (plan.action) { - case OrmAction.read: - if (plan.mutationResultMode != null) { - throw PlanResultModeActionInvalidException( - action: plan.action, - resultMode: plan.resultMode, - mutationResultMode: plan.mutationResultMode, - ); - } - case OrmAction.create || OrmAction.update || OrmAction.delete: - if (plan.resultMode != null) { - throw PlanResultModeActionInvalidException( - action: plan.action, - resultMode: plan.resultMode, - mutationResultMode: plan.mutationResultMode, - ); - } - } + void _assertMutationPlan({ + required ModelContract model, + required OrmMutationPlan plan, + }) { + _assertWhereFields(model: model, where: plan.where, source: 'where'); + _assertKnownFields(model: model, fields: plan.data.keys, source: 'data'); + _assertKnownFields(model: model, fields: plan.select, source: 'select'); } void _assertKnownFields({ diff --git a/pub/orm/lib/src/runtime/errors.dart b/pub/orm/lib/src/runtime/errors.dart index 5e19b9ba..f7d25681 100644 --- a/pub/orm/lib/src/runtime/errors.dart +++ b/pub/orm/lib/src/runtime/errors.dart @@ -247,21 +247,26 @@ final class PlanInvalidPaginationException extends OrmRuntimeError { final class PlanResultModeActionInvalidException extends OrmRuntimeError { final OrmAction action; - final OrmReadResultMode? resultMode; + final OrmReadResultMode? readResultMode; final OrmMutationResultMode? mutationResultMode; + final bool hasRead; + final bool hasMutation; PlanResultModeActionInvalidException({ required this.action, - required this.resultMode, + required this.readResultMode, required this.mutationResultMode, + required this.hasRead, + required this.hasMutation, }) : super( code: 'PLAN.RESULT_MODE_ACTION_INVALID', category: RuntimeErrorCategory.plan, - message: - 'Plan result modes do not match the requested action semantics.', + message: 'Plan branch shape does not match the requested action.', details: { 'action': action.name, - 'resultMode': resultMode?.name, + 'hasRead': hasRead, + 'readResultMode': readResultMode?.name, + 'hasMutation': hasMutation, 'mutationResultMode': mutationResultMode?.name, }, ); diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index 7bac1244..50ee4968 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -39,6 +39,50 @@ final class OrmIncludePlan { include = Map.unmodifiable(include); } +@immutable +final class OrmReadPlan { + final JsonMap where; + final int? skip; + final int? take; + final List orderBy; + final List distinct; + final List select; + final Map include; + final OrmReadResultMode resultMode; + + OrmReadPlan({ + JsonMap where = const {}, + this.skip, + this.take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + required this.resultMode, + }) : where = Map.unmodifiable(where), + orderBy = List.unmodifiable(orderBy), + distinct = List.unmodifiable(distinct), + select = List.unmodifiable(select), + include = Map.unmodifiable(include); +} + +@immutable +final class OrmMutationPlan { + final JsonMap where; + final JsonMap data; + final List select; + final OrmMutationResultMode resultMode; + + OrmMutationPlan({ + JsonMap where = const {}, + JsonMap data = const {}, + List select = const [], + required this.resultMode, + }) : where = Map.unmodifiable(where), + data = Map.unmodifiable(data), + select = List.unmodifiable(select); +} + @immutable final class OrmPlan { final String contractHash; @@ -46,19 +90,11 @@ final class OrmPlan { final String? storageHash; final String? profileHash; final String? lane; - final OrmReadResultMode? resultMode; - final OrmMutationResultMode? mutationResultMode; - final Map include; final JsonMap annotations; final String model; final OrmAction action; - final JsonMap where; - final JsonMap data; - final int? skip; - final int? take; - final List orderBy; - final List distinct; - final List select; + final OrmReadPlan? read; + final OrmMutationPlan? mutation; OrmPlan({ required this.contractHash, @@ -66,24 +102,81 @@ final class OrmPlan { this.storageHash, this.profileHash, this.lane, - this.resultMode, - this.mutationResultMode, - Map include = const {}, JsonMap annotations = const {}, required this.model, required this.action, + this.read, + this.mutation, + }) : annotations = Map.unmodifiable(annotations); + + factory OrmPlan.read({ + required String contractHash, + String? target, + String? storageHash, + String? profileHash, + String? lane, + JsonMap annotations = const {}, + required String model, JsonMap where = const {}, - JsonMap data = const {}, - this.skip, - this.take, + int? skip, + int? take, List orderBy = const [], List distinct = const [], List select = const [], - }) : include = Map.unmodifiable(include), - annotations = Map.unmodifiable(annotations), - where = Map.unmodifiable(where), - data = Map.unmodifiable(data), - orderBy = List.unmodifiable(orderBy), - distinct = List.unmodifiable(distinct), - select = List.unmodifiable(select); + Map include = const {}, + required OrmReadResultMode resultMode, + }) { + return OrmPlan( + contractHash: contractHash, + target: target, + storageHash: storageHash, + profileHash: profileHash, + lane: lane, + annotations: annotations, + model: model, + action: OrmAction.read, + read: OrmReadPlan( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + resultMode: resultMode, + ), + ); + } + + factory OrmPlan.mutation({ + required String contractHash, + String? target, + String? storageHash, + String? profileHash, + String? lane, + JsonMap annotations = const {}, + required String model, + required OrmAction action, + JsonMap where = const {}, + JsonMap data = const {}, + List select = const [], + required OrmMutationResultMode resultMode, + }) { + return OrmPlan( + contractHash: contractHash, + target: target, + storageHash: storageHash, + profileHash: profileHash, + lane: lane, + annotations: annotations, + model: model, + action: action, + mutation: OrmMutationPlan( + where: where, + data: data, + select: select, + resultMode: resultMode, + ), + ); + } } diff --git a/pub/orm/lib/src/runtime/plugins/budgets.dart b/pub/orm/lib/src/runtime/plugins/budgets.dart index 5d2dbb0f..16aa159d 100644 --- a/pub/orm/lib/src/runtime/plugins/budgets.dart +++ b/pub/orm/lib/src/runtime/plugins/budgets.dart @@ -32,9 +32,11 @@ final class _BudgetsPlugin extends OrmPlugin { @override void beforeExecute(OrmPlan plan, PluginContext ctx) { + final read = plan.read; if (plan.action == OrmAction.read && - plan.take != null && - plan.take! > options.maxRows) { + read != null && + read.take != null && + read.take! > options.maxRows) { _handle( ctx: ctx, severity: options.rowSeverity, @@ -42,7 +44,7 @@ final class _BudgetsPlugin extends OrmPlugin { message: 'Requested row budget exceeds configured maxRows limit.', details: { 'maxRows': options.maxRows, - 'requestedRows': plan.take, + 'requestedRows': read.take, 'model': plan.model, }, ); diff --git a/pub/orm/lib/src/runtime/plugins/lints.dart b/pub/orm/lib/src/runtime/plugins/lints.dart index e925d878..247e662b 100644 --- a/pub/orm/lib/src/runtime/plugins/lints.dart +++ b/pub/orm/lib/src/runtime/plugins/lints.dart @@ -30,7 +30,10 @@ final class _LintsPlugin extends OrmPlugin { @override void beforeExecute(OrmPlan plan, PluginContext ctx) { - if (_isMutation(plan) && plan.where.isEmpty) { + final read = plan.read; + final mutation = plan.mutation; + + if (_isMutation(plan) && (mutation == null || mutation.where.isEmpty)) { _handle( ctx: ctx, severity: options.mutationWithoutWhere, @@ -45,8 +48,9 @@ final class _LintsPlugin extends OrmPlugin { } if (plan.action == OrmAction.read && - plan.resultMode == OrmReadResultMode.oneOrNull && - plan.where.isEmpty) { + read != null && + read.resultMode == OrmReadResultMode.oneOrNull && + read.where.isEmpty) { _handle( ctx: ctx, severity: options.uniqueWithoutWhere, @@ -57,8 +61,9 @@ final class _LintsPlugin extends OrmPlugin { } if (plan.action == OrmAction.read && - plan.resultMode == OrmReadResultMode.all && - plan.take == null) { + read != null && + read.resultMode == OrmReadResultMode.all && + read.take == null) { _handle( ctx: ctx, severity: options.unboundedRead, diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index f13b652a..715e0528 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -68,22 +68,8 @@ final class SqlAdapter implements TargetAdapter { throw ModelNotFoundException(plan.model, contract.models.keys); } - final params = []; - final whereClause = _buildWhereClause( - model: plan.model, - where: plan.where, - params: params, - ); - final orderByClause = _buildOrderByClause(plan.orderBy); - return switch (plan.action) { - OrmAction.read => SqlStatement( - action: plan.action, - text: - 'SELECT ${_buildSelectColumns(plan.select)} FROM ${_id(model.table)}' - '$whereClause$orderByClause${_buildReadLimitOffsetClause(plan, params)}', - parameters: params, - ), + OrmAction.read => _lowerRead(plan: plan, table: model.table, model: plan.model), OrmAction.create => _lowerCreate( plan: plan, table: model.table, @@ -102,6 +88,29 @@ final class SqlAdapter implements TargetAdapter { }; } + SqlStatement _lowerRead({ + required OrmPlan plan, + required String table, + required String model, + }) { + final read = plan.read!; + final params = []; + final whereClause = _buildWhereClause( + model: model, + where: read.where, + params: params, + ); + final orderByClause = _buildOrderByClause(read.orderBy); + + return SqlStatement( + action: plan.action, + text: + 'SELECT ${_buildSelectColumns(read.select)} FROM ${_id(table)}' + '$whereClause$orderByClause${_buildReadLimitOffsetClause(read, params)}', + parameters: params, + ); + } + @override EngineResponse decode(SqlResult response, OrmPlan plan) { final resolver = codecResolver; @@ -142,13 +151,14 @@ final class SqlAdapter implements TargetAdapter { required String table, required String model, }) { - final columns = plan.data.keys.toList(growable: false); + final mutation = plan.mutation!; + final columns = mutation.data.keys.toList(growable: false); final values = columns .map( (column) => _encodeValue( model: model, field: column, - value: plan.data[column], + value: mutation.data[column], ), ) .toList(growable: false); @@ -158,7 +168,7 @@ final class SqlAdapter implements TargetAdapter { action: plan.action, text: 'INSERT INTO ${_id(table)} (${columns.map(_id).join(', ')}) ' - 'VALUES ($placeholders)${_buildMutationReturningClause(plan.select)}', + 'VALUES ($placeholders)${_buildMutationReturningClause(mutation.select)}', parameters: values, ); } @@ -168,13 +178,14 @@ final class SqlAdapter implements TargetAdapter { required String table, required String model, }) { - final setColumns = plan.data.keys.toList(growable: false); + final mutation = plan.mutation!; + final setColumns = mutation.data.keys.toList(growable: false); final setValues = setColumns .map( (column) => _encodeValue( model: model, field: column, - value: plan.data[column], + value: mutation.data[column], ), ) .toList(growable: false); @@ -182,7 +193,7 @@ final class SqlAdapter implements TargetAdapter { final params = [...setValues]; final wherePart = _buildWhereClause( model: model, - where: plan.where, + where: mutation.where, params: params, ); @@ -191,7 +202,7 @@ final class SqlAdapter implements TargetAdapter { text: 'UPDATE ${_id(table)} SET ' '${setColumns.map((column) => '${_id(column)} = ?').join(', ')}' - '$wherePart${_buildMutationReturningClause(plan.select)}', + '$wherePart${_buildMutationReturningClause(mutation.select)}', parameters: params, ); } @@ -201,7 +212,7 @@ final class SqlAdapter implements TargetAdapter { required int affectedRows, required OrmPlan plan, }) { - return switch (plan.resultMode) { + return switch (plan.read!.resultMode) { OrmReadResultMode.firstOrNull || OrmReadResultMode.oneOrNull => EngineResponse(data: _firstOrNull(rows), affectedRows: affectedRows), _ => EngineResponse(data: rows, affectedRows: affectedRows), @@ -213,10 +224,11 @@ final class SqlAdapter implements TargetAdapter { required String table, required String model, }) { + final mutation = plan.mutation!; final params = []; final wherePart = _buildWhereClause( model: model, - where: plan.where, + where: mutation.where, params: params, ); @@ -224,7 +236,7 @@ final class SqlAdapter implements TargetAdapter { action: plan.action, text: 'DELETE FROM ${_id(table)}' - '$wherePart${_buildMutationReturningClause(plan.select)}', + '$wherePart${_buildMutationReturningClause(mutation.select)}', parameters: params, ); } @@ -904,7 +916,7 @@ final class SqlAdapter implements TargetAdapter { return ' ORDER BY ${clauses.join(', ')}'; } - String _buildReadLimitOffsetClause(OrmPlan plan, List params) { + String _buildReadLimitOffsetClause(OrmReadPlan plan, List params) { final clauses = []; final effectiveTake = switch (plan.resultMode) { OrmReadResultMode.oneOrNull => 1, diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index bbc29a5c..e75b92a3 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -202,6 +202,7 @@ void main() { contractHash: 'mismatch', model: 'User', action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), ), ), throwsA(isA()), @@ -240,6 +241,7 @@ void main() { profileHash: profileContract.profileHash, model: 'User', action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), ), ), throwsA(isA()), @@ -254,6 +256,7 @@ void main() { profileHash: profileContract.profileHash, model: 'User', action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), ), ), throwsA(isA()), @@ -268,6 +271,7 @@ void main() { profileHash: 'other-profile', model: 'User', action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), ), ), throwsA(isA()), @@ -286,8 +290,10 @@ void main() { contractHash: contract.hash, model: 'User', action: OrmAction.read, - resultMode: OrmReadResultMode.all, - mutationResultMode: OrmMutationResultMode.rowOrNull, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), + mutation: OrmMutationPlan( + resultMode: OrmMutationResultMode.rowOrNull, + ), ), ), throwsA( @@ -310,8 +316,10 @@ void main() { contractHash: contract.hash, model: 'User', action: action, - resultMode: OrmReadResultMode.oneOrNull, - mutationResultMode: OrmMutationResultMode.rowOrNull, + read: OrmReadPlan(resultMode: OrmReadResultMode.oneOrNull), + mutation: OrmMutationPlan( + resultMode: OrmMutationResultMode.rowOrNull, + ), ), ), throwsA( @@ -324,6 +332,28 @@ void main() { ); } + await expectLater( + client.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.read, + ), + ), + throwsA(isA()), + ); + + await expectLater( + client.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + ), + ), + throwsA(isA()), + ); + await client.disconnect(); }); @@ -830,10 +860,10 @@ void main() { expect(plan.lane, 'orm'); expect(plan.action, OrmAction.read); - expect(plan.take, 5); - expect(plan.resultMode, OrmReadResultMode.all); - expect(plan.include.keys, ['posts']); - final posts = plan.include['posts']; + expect(plan.read?.take, 5); + expect(plan.read?.resultMode, OrmReadResultMode.all); + expect(plan.read?.include.keys, ['posts']); + final posts = plan.read?.include['posts']; expect(posts, isNotNull); expect(posts?.take, 3); expect(posts?.include.keys, ['author']); @@ -852,7 +882,7 @@ void main() { final createPlan = engine.executedPlans.single; expect(createPlan.lane, 'orm'); expect(createPlan.action, OrmAction.create); - expect(createPlan.mutationResultMode, OrmMutationResultMode.row); + expect(createPlan.mutation?.resultMode, OrmMutationResultMode.row); engine.reset(); await client.db.orm.model('User').update( @@ -863,7 +893,7 @@ void main() { expect(updatePlan.lane, 'orm'); expect(updatePlan.action, OrmAction.update); expect( - updatePlan.mutationResultMode, + updatePlan.mutation?.resultMode, OrmMutationResultMode.rowOrNull, ); @@ -874,7 +904,7 @@ void main() { .toPlan(); expect(sqlPlan.lane, 'sql'); expect(sqlPlan.action, OrmAction.update); - expect(sqlPlan.mutationResultMode, OrmMutationResultMode.rowOrNull); + expect(sqlPlan.mutation?.resultMode, OrmMutationResultMode.rowOrNull); await client.disconnect(); }); @@ -1571,7 +1601,7 @@ void main() { expect(countingEngine.executeCount, 1); expect(countingEngine.executedPlans.single.model, 'User'); - expect(countingEngine.executedPlans.single.where, { + expect(countingEngine.executedPlans.single.read?.where, { 'posts': { 'some': {'title': 'Post A'}, }, @@ -2256,7 +2286,10 @@ void main() { contractHash: contract.hash, model: 'User', action: OrmAction.create, - data: {'id': 'u1', 'email': 'a@example.com'}, + mutation: OrmMutationPlan( + data: {'id': 'u1', 'email': 'a@example.com'}, + resultMode: OrmMutationResultMode.row, + ), ), ); @@ -2266,8 +2299,11 @@ void main() { contractHash: contract.hash, model: 'User', action: OrmAction.update, - where: {'id': 'u1'}, - data: {'email': 'b@example.com'}, + mutation: OrmMutationPlan( + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + resultMode: OrmMutationResultMode.rowOrNull, + ), ), ); await transaction.commit(); @@ -2309,7 +2345,7 @@ void main() { expect(engine.connectionCount, 1); expect(engine.connectionExecutePlans, hasLength(1)); expect(engine.connectionExecutePlans.single.action, OrmAction.read); - expect(engine.connectionExecutePlans.single.take, 1); + expect(engine.connectionExecutePlans.single.read?.take, 1); await client.disconnect(); }); @@ -2554,8 +2590,11 @@ void main() { contractHash: contract.hash, model: 'User', action: OrmAction.update, - where: {'id': 'u1'}, - data: {'email': 'b@example.com'}, + mutation: OrmMutationPlan( + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + resultMode: OrmMutationResultMode.rowOrNull, + ), ), ); await transaction.rollback(); @@ -2578,6 +2617,7 @@ void main() { contractHash: contract.hash, model: 'User', action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), ), ), throwsA(isA()), @@ -2592,6 +2632,7 @@ void main() { contractHash: contract.hash, model: 'User', action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), ), ), throwsA(isA()), diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 706afed6..bd60b9ae 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -57,12 +57,60 @@ void main() { final contract = buildContract(); + OrmPlan readPlan({ + required OrmContract contract, + required String model, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + OrmReadResultMode resultMode = OrmReadResultMode.all, + }) { + return OrmPlan( + contractHash: contract.hash, + model: model, + action: OrmAction.read, + read: OrmReadPlan( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + resultMode: resultMode, + ), + ); + } + + OrmPlan mutationPlan({ + required OrmContract contract, + required String model, + required OrmAction action, + JsonMap where = const {}, + JsonMap data = const {}, + List select = const [], + OrmMutationResultMode resultMode = OrmMutationResultMode.rowOrNull, + }) { + return OrmPlan( + contractHash: contract.hash, + model: model, + action: action, + mutation: OrmMutationPlan( + where: where, + data: data, + select: select, + resultMode: resultMode, + ), + ); + } + test('lowers findMany with where/order/pagination/select', () { final adapter = SqlAdapter(contract: contract); - final plan = OrmPlan( - contractHash: contract.hash, + final plan = readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: {'email': 'a@example.com'}, orderBy: const [OrmOrderBy('id')], take: 10, @@ -81,10 +129,9 @@ void main() { test('lowers where operators with deterministic SQL and parameters', () { final adapter = SqlAdapter(contract: contract); - final plan = OrmPlan( - contractHash: contract.hash, + final plan = readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: { 'email': { 'lt': 'z@example.com', @@ -125,10 +172,9 @@ void main() { test('lowers string operators with LIKE and escaped patterns', () { final adapter = SqlAdapter(contract: contract); - final plan = OrmPlan( - contractHash: contract.hash, + final plan = readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: { 'email': { 'contains': 'a%b', @@ -151,10 +197,9 @@ void main() { test('lowers logical where AND/OR/NOT with field filters', () { final adapter = SqlAdapter(contract: contract); - final plan = OrmPlan( - contractHash: contract.hash, + final plan = readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: { 'id': 'u1', 'AND': [ @@ -195,10 +240,9 @@ void main() { final adapter = SqlAdapter(contract: contract); final emptyOperandStatement = adapter.lower( - OrmPlan( - contractHash: contract.hash, + readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: { 'AND': const [], 'OR': const [], @@ -213,10 +257,9 @@ void main() { expect(emptyOperandStatement.parameters, isEmpty); final invalidOperandStatement = adapter.lower( - OrmPlan( - contractHash: contract.hash, + readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: {'AND': 'bad', 'OR': 1, 'NOT': true}, ), ); @@ -230,10 +273,9 @@ void main() { test('lowers to-many relation where using EXISTS predicates', () { final contract = buildRelationalContract(); final adapter = SqlAdapter(contract: contract); - final plan = OrmPlan( - contractHash: contract.hash, + final plan = readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: { 'posts': { 'some': { @@ -261,10 +303,9 @@ void main() { test('lowers to-one relation where including null semantics', () { final contract = buildRelationalContract(); final adapter = SqlAdapter(contract: contract); - final plan = OrmPlan( - contractHash: contract.hash, + final plan = readPlan( + contract: contract, model: 'Post', - action: OrmAction.read, where: { 'author': { 'is': {'email': 'u1@example.com'}, @@ -291,10 +332,9 @@ void main() { 'contains': 'literal', 'profile': 'standard', }; - final plan = OrmPlan( - contractHash: contract.hash, + final plan = readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: {'id': 'u1', 'email': jsonPayload}, ); @@ -309,10 +349,9 @@ void main() { test('uses deterministic empty semantics for in/notIn', () { final adapter = SqlAdapter(contract: contract); - final plan = OrmPlan( - contractHash: contract.hash, + final plan = readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: { 'id': {'in': const []}, 'email': {'notIn': const []}, @@ -329,8 +368,8 @@ void main() { final adapter = SqlAdapter(contract: contract); final createStatement = adapter.lower( - OrmPlan( - contractHash: contract.hash, + mutationPlan( + contract: contract, model: 'User', action: OrmAction.create, data: {'id': 'u1', 'email': 'a@example.com'}, @@ -343,8 +382,8 @@ void main() { expect(createStatement.parameters, ['u1', 'a@example.com']); final updateStatement = adapter.lower( - OrmPlan( - contractHash: contract.hash, + mutationPlan( + contract: contract, model: 'User', action: OrmAction.update, where: {'id': 'u1'}, @@ -358,8 +397,8 @@ void main() { expect(updateStatement.parameters, ['b@example.com', 'u1']); final deleteStatement = adapter.lower( - OrmPlan( - contractHash: contract.hash, + mutationPlan( + contract: contract, model: 'User', action: OrmAction.delete, where: {'id': 'u1'}, @@ -373,8 +412,8 @@ void main() { final adapter = SqlAdapter(contract: buildContract()); final createDefaultSelect = adapter.lower( - OrmPlan( - contractHash: contract.hash, + mutationPlan( + contract: contract, model: 'User', action: OrmAction.create, data: {'id': 'u1', 'email': 'a@example.com'}, @@ -387,8 +426,8 @@ void main() { expect(createDefaultSelect.parameters, ['u1', 'a@example.com']); final updateSelectedColumns = adapter.lower( - OrmPlan( - contractHash: contract.hash, + mutationPlan( + contract: contract, model: 'User', action: OrmAction.update, where: {'id': 'u1'}, @@ -403,8 +442,8 @@ void main() { expect(updateSelectedColumns.parameters, ['b@example.com', 'u1']); final deleteSelectedColumns = adapter.lower( - OrmPlan( - contractHash: contract.hash, + mutationPlan( + contract: contract, model: 'User', action: OrmAction.delete, where: {'id': 'u1'}, @@ -428,11 +467,7 @@ void main() { {'id': 'u2'}, ], ), - OrmPlan( - contractHash: contract.hash, - model: 'User', - action: OrmAction.read, - ), + readPlan(contract: contract, model: 'User'), ); expect(findMany.data, isA>()); @@ -442,10 +477,9 @@ void main() { {'id': 'u1'}, ], ), - OrmPlan( - contractHash: contract.hash, + readPlan( + contract: contract, model: 'User', - action: OrmAction.read, resultMode: OrmReadResultMode.oneOrNull, ), ); @@ -462,11 +496,7 @@ void main() { ], affectedRows: 1, ), - OrmPlan( - contractHash: contract.hash, - model: 'User', - action: OrmAction.update, - ), + mutationPlan(contract: contract, model: 'User', action: OrmAction.update), ); expect(mutation.affectedRows, 1); if (mutation.data case final Map row) { @@ -491,8 +521,8 @@ void main() { ); final update = adapter.lower( - OrmPlan( - contractHash: contract.hash, + mutationPlan( + contract: contract, model: 'User', action: OrmAction.update, where: {'email': 'find@example.com', 'id': 'u1'}, @@ -512,10 +542,9 @@ void main() { {'email': 'wire:db@example.com', 'id': 'u1'}, ], ), - OrmPlan( - contractHash: contract.hash, + readPlan( + contract: contract, model: 'User', - action: OrmAction.read, resultMode: OrmReadResultMode.oneOrNull, ), ); @@ -544,10 +573,9 @@ void main() { ); final statement = adapter.lower( - OrmPlan( - contractHash: contract.hash, + readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: { 'email': { 'in': ['a@example.com', 'b@example.com'], @@ -585,10 +613,9 @@ void main() { ); final statement = adapter.lower( - OrmPlan( - contractHash: contract.hash, + readPlan( + contract: contract, model: 'User', - action: OrmAction.read, where: { 'email': { 'contains': 'example', @@ -620,8 +647,8 @@ void main() { codecResolver: SqlCodecRegistry(), ); - final plan = OrmPlan( - contractHash: contract.hash, + final plan = mutationPlan( + contract: contract, model: 'User', action: OrmAction.update, where: {'email': 'find@example.com', 'id': 'u1'}, @@ -646,10 +673,9 @@ void main() { {'id': 'u1', 'email': 'db@example.com'}, ], ); - final decodePlan = OrmPlan( - contractHash: contract.hash, + final decodePlan = readPlan( + contract: contract, model: 'User', - action: OrmAction.read, resultMode: OrmReadResultMode.oneOrNull, ); @@ -700,8 +726,8 @@ void main() { codecResolver: codecRegistry, ); final statement = adapter.lower( - OrmPlan( - contractHash: contract.hash, + mutationPlan( + contract: contract, model: 'User', action: OrmAction.update, data: {'id': 'u2', 'email': 'next@example.com'}, @@ -725,10 +751,9 @@ void main() { }, ], ), - OrmPlan( - contractHash: contract.hash, + readPlan( + contract: contract, model: 'User', - action: OrmAction.read, resultMode: OrmReadResultMode.oneOrNull, ), ); @@ -748,11 +773,7 @@ void main() { expect( () => adapter.lower( - OrmPlan( - contractHash: contract.hash, - model: 'Missing', - action: OrmAction.read, - ), + readPlan(contract: contract, model: 'Missing'), ), throwsA(isA()), ); diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index 70cd356f..5a77651e 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -188,7 +188,7 @@ OrmPlan _plan({JsonMap where = const {}}) { contractHash: 'hash', model: 'User', action: OrmAction.read, - where: where, + read: OrmReadPlan(where: where, resultMode: OrmReadResultMode.all), ); } @@ -209,7 +209,7 @@ final class _TrackingAdapter implements TargetAdapter { data: { 'request': '${plan.model}:${plan.action.name}', 'action': plan.action.name, - 'whereId': plan.where['id'], + 'whereId': plan.read?.where['id'], }, affectedRows: 1, ); From 12dbab206cda2c5a3e8d3bc6466d3c005125e4c6 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:15:43 +0800 Subject: [PATCH 095/154] feat(repository): add operation trace annotations to orchestration plans --- pub/orm/lib/src/client/client.dart | 86 ++++- pub/orm/lib/src/client/include_planner.dart | 20 + .../lib/src/client/mutation_repository.dart | 203 ++++++++-- pub/orm/test/client/client_test.dart | 357 +++++++++++++++++- 4 files changed, 630 insertions(+), 36 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 9f011be3..58521260 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -164,6 +164,63 @@ Map _mergeIncludeSpecMap( return merged; } +JsonMap _mergePlanAnnotations( + JsonMap current, + JsonMap next, +) { + if (current.isEmpty) { + if (next.isEmpty) { + return const {}; + } + return Map.unmodifiable(Map.from(next)); + } + if (next.isEmpty) { + return Map.unmodifiable(Map.from(current)); + } + return Map.unmodifiable({ + ...current, + ...next, + }); +} + +int _repositoryOperationSeed = 0; + +final class _RepositoryOperation { + final String id; + final String kind; + var _step = 0; + + _RepositoryOperation._({required this.id, required this.kind}); + + factory _RepositoryOperation.start({required String kind}) { + _repositoryOperationSeed += 1; + return _RepositoryOperation._( + id: 'repo_${kind}_$_repositoryOperationSeed', + kind: kind, + ); + } + + JsonMap nextAnnotations({ + required String phase, + required String strategy, + String? relation, + int? itemIndex, + }) { + _step += 1; + return { + 'repository': { + 'operationId': id, + 'kind': kind, + 'step': _step, + 'phase': phase, + 'strategy': strategy, + if (relation != null) 'relation': relation, + if (itemIndex != null) 'itemIndex': itemIndex, + }, + }; + } +} + OrmIncludePlan _buildOrmIncludePlan(IncludeSpec spec) { return OrmIncludePlan( where: spec.where, @@ -1306,6 +1363,7 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, + JsonMap annotations = const {}, }) async { if (skip case final offset? when offset < 0) { throw PlanInvalidPaginationException(key: 'skip', value: offset); @@ -1344,11 +1402,14 @@ class ModelDelegate { storageHash: _client.contract.markerStorageHash, profileHash: _client.contract.profileHash, lane: 'orm', - annotations: distinct.isEmpty - ? const {} - : { - 'distinct': List.from(distinct, growable: false), - }, + annotations: _mergePlanAnnotations( + annotations, + distinct.isEmpty + ? const {} + : { + 'distinct': List.from(distinct, growable: false), + }, + ), model: modelName, where: normalizedWhere, skip: isCollectionRead && distinct.isEmpty ? skip : null, @@ -1371,6 +1432,7 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, + JsonMap annotations = const {}, required int includeDepth, }) async { final prepared = await _buildReadPlan( @@ -1382,6 +1444,7 @@ class ModelDelegate { distinct: distinct, select: select, include: include, + annotations: annotations, ); final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); @@ -1409,6 +1472,7 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, + JsonMap annotations = const {}, required int includeDepth, }) async { final prepared = await _buildReadPlan( @@ -1419,6 +1483,7 @@ class ModelDelegate { distinct: distinct, select: select, include: include, + annotations: annotations, ); final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); @@ -1447,6 +1512,7 @@ class ModelDelegate { JsonMap where = const {}, List select = const [], Map include = const {}, + JsonMap annotations = const {}, required int includeDepth, }) async { final prepared = await _buildReadPlan( @@ -1454,6 +1520,7 @@ class ModelDelegate { where: where, select: select, include: include, + annotations: annotations, ); final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); @@ -1482,10 +1549,17 @@ class ModelDelegate { required List rows, required Map include, required int depth, + _RepositoryOperation? operation, }) { return _RepositoryIncludePlanner( this, - ).resolve(action: action, rows: rows, include: include, depth: depth); + ).resolve( + action: action, + rows: rows, + include: include, + depth: depth, + operation: operation, + ); } ModelRelationContract _resolveRelation({ diff --git a/pub/orm/lib/src/client/include_planner.dart b/pub/orm/lib/src/client/include_planner.dart index 3f5c3211..5219ba4f 100644 --- a/pub/orm/lib/src/client/include_planner.dart +++ b/pub/orm/lib/src/client/include_planner.dart @@ -10,6 +10,7 @@ final class _RepositoryIncludePlanner { required List rows, required Map include, required int depth, + _RepositoryOperation? operation, }) { if (rows.isEmpty || include.isEmpty) { return Future>.value(rows); @@ -28,17 +29,22 @@ final class _RepositoryIncludePlanner { include: include, depth: depth, ); + final trace = + operation ?? + _RepositoryOperation.start(kind: '${_delegate.modelName}.include'); return switch (strategy) { IncludeExecutionStrategy.singleQuery => _resolveSingleQuery( rows: rows, include: include, depth: depth, + operation: trace, ), IncludeExecutionStrategy.multiQuery => _resolveMultiQuery( rows: rows, include: include, depth: depth, + operation: trace, ), }; } @@ -47,6 +53,7 @@ final class _RepositoryIncludePlanner { required List rows, required Map include, required int depth, + required _RepositoryOperation operation, }) async { var hydrated = rows; @@ -67,6 +74,7 @@ final class _RepositoryIncludePlanner { relation: relation, relationInclude: relationInclude, depth: depth, + operation: operation, ); final rowsByRelationKey = _delegate._groupRowsByRelationFields( rows: relatedRows, @@ -130,6 +138,7 @@ final class _RepositoryIncludePlanner { required List rows, required Map include, required int depth, + required _RepositoryOperation operation, }) async { var hydrated = rows; @@ -171,6 +180,11 @@ final class _RepositoryIncludePlanner { orderBy: relationInclude.orderBy, select: relationInclude.select, include: relationInclude.include, + annotations: operation.nextAnnotations( + phase: 'include.load', + strategy: 'multiQuery', + relation: relationName, + ), includeDepth: depth + 1, ); @@ -196,6 +210,7 @@ final class _RepositoryIncludePlanner { required ModelRelationContract relation, required IncludeSpec relationInclude, required int depth, + required _RepositoryOperation operation, }) { final baseWhere = _delegate._buildSingleQueryRelationBaseWhere( includeWhere: relationInclude.where, @@ -211,6 +226,11 @@ final class _RepositoryIncludePlanner { relation: relation, ), include: relationInclude.include, + annotations: operation.nextAnnotations( + phase: 'include.load', + strategy: 'singleQuery', + relation: relation.name, + ), includeDepth: depth + 1, ); } diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index a6fa6051..5f0a0d8d 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -8,22 +8,55 @@ final class _PreparedMutationPlan { const _PreparedMutationPlan({required this.plan, required this.include}); } +@immutable +final class _NormalizedMutationInput { + final JsonMap where; + final List select; + final Map include; + + const _NormalizedMutationInput({ + required this.where, + required this.select, + required this.include, + }); +} + final class _RepositoryMutationExecutor { final ModelDelegate _delegate; const _RepositoryMutationExecutor(this._delegate); + _RepositoryOperation _startOperation(String kind) { + return _RepositoryOperation.start(kind: '${_delegate.modelName}.$kind'); + } + Future create({ required JsonMap data, required List select, required Map include, + _RepositoryOperation? operation, + String phase = 'write', + String strategy = 'singlePlan', + String? relation, + int? itemIndex, }) async { - final prepared = await _buildMutationPlan( + final trace = operation ?? _startOperation('create'); + final normalized = await _normalizeMutationInput( + where: const {}, + select: select, + include: include, + ); + final prepared = _composeMutationPlan( action: OrmAction.create, mutationResultMode: OrmMutationResultMode.row, data: data, - select: select, - include: include, + normalized: normalized, + annotations: trace.nextAnnotations( + phase: phase, + strategy: strategy, + relation: relation, + itemIndex: itemIndex, + ), ); final normalizedInclude = prepared.include; final response = await _delegate._client.execute(prepared.plan); @@ -54,7 +87,13 @@ final class _RepositoryMutationExecutor { required JsonMap data, required List select, required Map include, + _RepositoryOperation? operation, + String phase = 'write', + String strategy = 'singlePlan', + String? relation, + int? itemIndex, }) { + final trace = operation ?? _startOperation('update'); return _runNullableMutation( action: OrmAction.update, mutationResultMode: OrmMutationResultMode.rowOrNull, @@ -63,6 +102,11 @@ final class _RepositoryMutationExecutor { select: select, include: include, responseAction: 'update', + operation: trace, + phase: phase, + strategy: strategy, + relation: relation, + itemIndex: itemIndex, ); } @@ -70,7 +114,12 @@ final class _RepositoryMutationExecutor { required JsonMap where, required List select, required Map include, + _RepositoryOperation? operation, + String phase = 'write', + String strategy = 'singlePlan', + int? itemIndex, }) { + final trace = operation ?? _startOperation('delete'); return _runNullableMutation( action: OrmAction.delete, mutationResultMode: OrmMutationResultMode.rowOrNull, @@ -79,6 +128,10 @@ final class _RepositoryMutationExecutor { select: select, include: include, responseAction: 'delete', + operation: trace, + phase: phase, + strategy: strategy, + itemIndex: itemIndex, ); } @@ -88,6 +141,7 @@ final class _RepositoryMutationExecutor { required List select, required Map include, }) { + final trace = _startOperation('createNested'); return _delegate._client.transaction((txDb) async { final scoped = txDb.orm.model(_delegate.modelName); return _RepositoryMutationExecutor(scoped)._createNestedInScope( @@ -95,6 +149,7 @@ final class _RepositoryMutationExecutor { nestedCreate: nestedCreate, select: select, include: include, + operation: trace, ); }); } @@ -106,6 +161,7 @@ final class _RepositoryMutationExecutor { required List select, required Map include, }) { + final trace = _startOperation('updateNested'); return _delegate._client.transaction((txDb) async { final scoped = txDb.orm.model(_delegate.modelName); return _RepositoryMutationExecutor(scoped)._updateNestedInScope( @@ -114,6 +170,7 @@ final class _RepositoryMutationExecutor { nestedCreate: nestedCreate, select: select, include: include, + operation: trace, ); }); } @@ -123,13 +180,23 @@ final class _RepositoryMutationExecutor { required List select, required Map include, }) { + final trace = _startOperation('createMany'); return _delegate._client.transaction((txDb) async { final scoped = txDb.orm.model(_delegate.modelName); final executor = _RepositoryMutationExecutor(scoped); final rows = []; - for (final item in data) { + for (var index = 0; index < data.length; index++) { + final item = data[index]; rows.add( - await executor.create(data: item, select: select, include: include), + await executor.create( + data: item, + select: select, + include: include, + operation: trace, + phase: 'item.create', + strategy: 'transaction', + itemIndex: index, + ), ); } return rows; @@ -137,16 +204,23 @@ final class _RepositoryMutationExecutor { } Future deleteMany({required JsonMap where}) { + final trace = _startOperation('deleteMany'); return _delegate._client.transaction((txDb) async { final scoped = txDb.orm.model(_delegate.modelName); final executor = _RepositoryMutationExecutor(scoped); var deleted = 0; + var attempt = 0; while (true) { final row = await executor.delete( where: where, select: const [], include: const {}, + operation: trace, + phase: 'item.delete', + strategy: 'transaction', + itemIndex: attempt, ); + attempt += 1; if (row == null) { break; } @@ -163,12 +237,28 @@ final class _RepositoryMutationExecutor { required List select, required Map include, }) { + final trace = _startOperation('upsert'); return _delegate._client.transaction((txDb) async { final scoped = txDb.orm.model(_delegate.modelName); final executor = _RepositoryMutationExecutor(scoped); - final existing = await scoped.oneOrNull(where: where); + final existing = await scoped._readOneInternal( + action: OrmAction.read, + where: where, + annotations: trace.nextAnnotations( + phase: 'branch.lookup', + strategy: 'branch', + ), + includeDepth: 0, + ); if (existing == null) { - return executor.create(data: create, select: select, include: include); + return executor.create( + data: create, + select: select, + include: include, + operation: trace, + phase: 'branch.create', + strategy: 'branch', + ); } final updatedRow = await executor.update( @@ -176,6 +266,9 @@ final class _RepositoryMutationExecutor { data: update, select: select, include: include, + operation: trace, + phase: 'branch.update', + strategy: 'branch', ); if (updatedRow != null) { return updatedRow; @@ -200,22 +293,37 @@ final class _RepositoryMutationExecutor { required List select, required Map include, required String responseAction, + required _RepositoryOperation operation, + required String phase, + required String strategy, + String? relation, + int? itemIndex, }) async { - final prepared = await _buildMutationPlan( - action: action, - mutationResultMode: mutationResultMode, + final normalized = await _normalizeMutationInput( where: where, - data: data, select: select, include: include, ); - final normalizedInclude = prepared.include; - final normalizedWhere = prepared.plan.mutation!.where; + final normalizedInclude = normalized.include; + final normalizedWhere = normalized.where; final preDeleteRow = await _preloadDeleteRow( action: action, where: normalizedWhere, select: select, include: normalizedInclude, + operation: operation, + ); + final prepared = _composeMutationPlan( + action: action, + mutationResultMode: mutationResultMode, + data: data, + normalized: normalized, + annotations: operation.nextAnnotations( + phase: phase, + strategy: strategy, + relation: relation, + itemIndex: itemIndex, + ), ); final response = await _delegate._client.execute(prepared.plan); @@ -227,6 +335,7 @@ final class _RepositoryMutationExecutor { select: select, include: normalizedInclude, preDeleteRow: preDeleteRow, + operation: operation, ); if (row == null) { return null; @@ -240,11 +349,8 @@ final class _RepositoryMutationExecutor { ); } - Future<_PreparedMutationPlan> _buildMutationPlan({ - required OrmAction action, - required OrmMutationResultMode mutationResultMode, + Future<_NormalizedMutationInput> _normalizeMutationInput({ JsonMap where = const {}, - JsonMap data = const {}, List select = const [], Map include = const {}, }) async { @@ -256,23 +362,38 @@ final class _RepositoryMutationExecutor { where: where, ); - return _PreparedMutationPlan( + return _NormalizedMutationInput( + where: normalizedWhere, + select: _delegate._expandSelectForInclude( + model: _delegate.modelName, + select: select, + include: normalizedInclude, + ), include: normalizedInclude, + ); + } + + _PreparedMutationPlan _composeMutationPlan({ + required OrmAction action, + required OrmMutationResultMode mutationResultMode, + required JsonMap data, + required _NormalizedMutationInput normalized, + required JsonMap annotations, + }) { + return _PreparedMutationPlan( + include: normalized.include, plan: OrmPlan.mutation( contractHash: _delegate._client.contract.hash, target: _delegate._client.contract.target, storageHash: _delegate._client.contract.markerStorageHash, profileHash: _delegate._client.contract.profileHash, lane: 'orm', + annotations: annotations, model: _delegate.modelName, action: action, - where: normalizedWhere, + where: normalized.where, data: data, - select: _delegate._expandSelectForInclude( - model: _delegate.modelName, - select: select, - include: normalizedInclude, - ), + select: normalized.select, resultMode: mutationResultMode, ), ); @@ -283,6 +404,7 @@ final class _RepositoryMutationExecutor { required JsonMap where, required List select, required Map include, + required _RepositoryOperation operation, }) { if (action != OrmAction.delete || _delegate._client.contract.capabilities.mutationReturning) { @@ -297,6 +419,10 @@ final class _RepositoryMutationExecutor { select: select, include: include, ), + annotations: operation.nextAnnotations( + phase: 'fallback.preload', + strategy: 'returningDisabledFallback', + ), include: const {}, includeDepth: 0, ); @@ -310,6 +436,7 @@ final class _RepositoryMutationExecutor { required List select, required Map include, required JsonMap? preDeleteRow, + required _RepositoryOperation operation, }) async { var row = _readRow(response.data, action: responseAction); if (row == null && @@ -324,6 +451,10 @@ final class _RepositoryMutationExecutor { select: select, include: include, ), + annotations: operation.nextAnnotations( + phase: 'fallback.reload', + strategy: 'returningDisabledFallback', + ), include: const {}, includeDepth: 0, ), @@ -339,6 +470,7 @@ final class _RepositoryMutationExecutor { required Map> nestedCreate, required List select, required Map include, + required _RepositoryOperation operation, }) async { final normalizedCreate = _delegate._normalizeNestedCreate(nestedCreate); final normalizedInclude = _delegate._normalizeInclude(include); @@ -351,6 +483,9 @@ final class _RepositoryMutationExecutor { create: normalizedCreate, ), include: const {}, + operation: operation, + phase: 'root.create', + strategy: 'transaction', ); for (final entry in normalizedCreate.entries) { @@ -362,7 +497,8 @@ final class _RepositoryMutationExecutor { relation.relatedModel, ); final relatedExecutor = _RepositoryMutationExecutor(related); - for (final child in entry.value) { + for (var index = 0; index < entry.value.length; index++) { + final child = entry.value[index]; final linkedData = _delegate._linkNestedData( parent: created, relationName: entry.key, @@ -373,6 +509,11 @@ final class _RepositoryMutationExecutor { data: linkedData, select: const [], include: const {}, + operation: operation, + phase: 'child.create', + strategy: 'transaction', + relation: entry.key, + itemIndex: index, ); } } @@ -392,6 +533,7 @@ final class _RepositoryMutationExecutor { required Map> nestedCreate, required List select, required Map include, + required _RepositoryOperation operation, }) async { final normalizedCreate = _delegate._normalizeNestedCreate(nestedCreate); final normalizedInclude = _delegate._normalizeInclude(include); @@ -405,6 +547,9 @@ final class _RepositoryMutationExecutor { create: normalizedCreate, ), include: const {}, + operation: operation, + phase: 'root.update', + strategy: 'transaction', ); if (updated == null) { return null; @@ -419,7 +564,8 @@ final class _RepositoryMutationExecutor { relation.relatedModel, ); final relatedExecutor = _RepositoryMutationExecutor(related); - for (final child in entry.value) { + for (var index = 0; index < entry.value.length; index++) { + final child = entry.value[index]; final linkedData = _delegate._linkNestedData( parent: updated, relationName: entry.key, @@ -430,6 +576,11 @@ final class _RepositoryMutationExecutor { data: linkedData, select: const [], include: const {}, + operation: operation, + phase: 'child.create', + strategy: 'transaction', + relation: entry.key, + itemIndex: index, ); } } diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index e75b92a3..0891d46a 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -1103,7 +1103,8 @@ void main() { }); test('supports createMany and deleteMany helpers', () async { - final client = OrmClient(contract: contract, engine: MemoryEngine()); + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient(contract: contract, engine: engine); await client.connect(); final users = client.db.orm.model('User'); @@ -1115,11 +1116,78 @@ void main() { ], ); expect(createdRows, hasLength(3)); + expect( + engine.executedPlans.map((plan) => plan.action).toList(growable: false), + [OrmAction.create, OrmAction.create, OrmAction.create], + ); + final createTraces = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final createOperationId = createTraces.first['operationId']; + expect(createOperationId, isNotNull); + expect( + createTraces.map((trace) => trace['operationId']).toSet(), + {createOperationId}, + ); + expect( + createTraces.map((trace) => trace['kind']).toList(growable: false), + ['User.createMany', 'User.createMany', 'User.createMany'], + ); + expect( + createTraces.map((trace) => trace['phase']).toList(growable: false), + ['item.create', 'item.create', 'item.create'], + ); + expect( + createTraces.map((trace) => trace['strategy']).toList(growable: false), + ['transaction', 'transaction', 'transaction'], + ); + expect( + createTraces.map((trace) => trace['step']).toList(growable: false), + [1, 2, 3], + ); + expect( + createTraces.map((trace) => trace['itemIndex']).toList(growable: false), + [0, 1, 2], + ); + engine.reset(); final deleted = await users.deleteMany( where: {'email': 'a@x.com'}, ); expect(deleted, 2); + expect( + engine.executedPlans.map((plan) => plan.action).toList(growable: false), + [OrmAction.delete, OrmAction.delete, OrmAction.delete], + ); + final deleteTraces = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final deleteOperationId = deleteTraces.first['operationId']; + expect(deleteOperationId, isNotNull); + expect( + deleteTraces.map((trace) => trace['operationId']).toSet(), + {deleteOperationId}, + ); + expect( + deleteTraces.map((trace) => trace['kind']).toList(growable: false), + ['User.deleteMany', 'User.deleteMany', 'User.deleteMany'], + ); + expect( + deleteTraces.map((trace) => trace['phase']).toList(growable: false), + ['item.delete', 'item.delete', 'item.delete'], + ); + expect( + deleteTraces.map((trace) => trace['strategy']).toList(growable: false), + ['transaction', 'transaction', 'transaction'], + ); + expect( + deleteTraces.map((trace) => trace['step']).toList(growable: false), + [1, 2, 3], + ); + expect( + deleteTraces.map((trace) => trace['itemIndex']).toList(growable: false), + [0, 1, 2], + ); final remaining = await users.count(); expect(remaining, 1); @@ -1255,6 +1323,17 @@ void main() { engine.executedPlans.map((plan) => plan.action).toList(), [OrmAction.update, OrmAction.read], ); + final updateTrace = _readRepositoryTrace(engine.executedPlans.first); + final reloadTrace = _readRepositoryTrace(engine.executedPlans.last); + expect(updateTrace['kind'], 'User.update'); + expect(updateTrace['phase'], 'write'); + expect(updateTrace['strategy'], 'singlePlan'); + expect(updateTrace['step'], 1); + expect(reloadTrace['kind'], 'User.update'); + expect(reloadTrace['phase'], 'fallback.reload'); + expect(reloadTrace['strategy'], 'returningDisabledFallback'); + expect(reloadTrace['step'], 2); + expect(reloadTrace['operationId'], updateTrace['operationId']); await client.disconnect(); }, @@ -1292,6 +1371,17 @@ void main() { engine.executedPlans.map((plan) => plan.action).toList(), [OrmAction.read, OrmAction.delete], ); + final preloadTrace = _readRepositoryTrace(engine.executedPlans.first); + final deleteTrace = _readRepositoryTrace(engine.executedPlans.last); + expect(preloadTrace['kind'], 'User.delete'); + expect(preloadTrace['phase'], 'fallback.preload'); + expect(preloadTrace['strategy'], 'returningDisabledFallback'); + expect(preloadTrace['step'], 1); + expect(deleteTrace['kind'], 'User.delete'); + expect(deleteTrace['phase'], 'write'); + expect(deleteTrace['strategy'], 'singlePlan'); + expect(deleteTrace['step'], 2); + expect(deleteTrace['operationId'], preloadTrace['operationId']); final remaining = await users.oneOrNull( where: {'id': 'u1'}, @@ -1474,6 +1564,86 @@ void main() { await client.disconnect(); }); + test('annotates upsert branch plans with operation sequence metadata', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + final users = client.db.orm.model('User'); + + final created = await users.upsert( + where: {'id': 'u1'}, + create: {'id': 'u1', 'email': 'a@example.com'}, + update: {'email': 'b@example.com'}, + ); + expect(created['email'], 'a@example.com'); + expect( + engine.executedPlans.map((plan) => plan.action).toList(growable: false), + [OrmAction.read, OrmAction.create], + ); + final createBranch = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final createOperationId = createBranch.first['operationId']; + expect( + createBranch.map((trace) => trace['operationId']).toSet(), + {createOperationId}, + ); + expect( + createBranch.map((trace) => trace['kind']).toList(growable: false), + ['User.upsert', 'User.upsert'], + ); + expect( + createBranch.map((trace) => trace['phase']).toList(growable: false), + ['branch.lookup', 'branch.create'], + ); + expect( + createBranch.map((trace) => trace['strategy']).toList(growable: false), + ['branch', 'branch'], + ); + expect( + createBranch.map((trace) => trace['step']).toList(growable: false), + [1, 2], + ); + + engine.reset(); + final updated = await users.upsert( + where: {'id': 'u1'}, + create: {'id': 'u1', 'email': 'x@example.com'}, + update: {'email': 'b@example.com'}, + ); + expect(updated['email'], 'b@example.com'); + expect( + engine.executedPlans.map((plan) => plan.action).toList(growable: false), + [OrmAction.read, OrmAction.update], + ); + final updateBranch = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final updateOperationId = updateBranch.first['operationId']; + expect( + updateBranch.map((trace) => trace['operationId']).toSet(), + {updateOperationId}, + ); + expect( + updateBranch.map((trace) => trace['kind']).toList(growable: false), + ['User.upsert', 'User.upsert'], + ); + expect( + updateBranch.map((trace) => trace['phase']).toList(growable: false), + ['branch.lookup', 'branch.update'], + ); + expect( + updateBranch.map((trace) => trace['strategy']).toList(growable: false), + ['branch', 'branch'], + ); + expect( + updateBranch.map((trace) => trace['step']).toList(growable: false), + [1, 2], + ); + + await client.disconnect(); + }); + test('supports relation where with nested logical operators', () async { final client = OrmClient( contract: relationalContract, @@ -1736,6 +1906,118 @@ void main() { } }); + test('singleQuery include annotates repository relation load plans', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient( + contract: relationalContract, + engine: engine, + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => IncludeExecutionStrategy.singleQuery, + ); + await client.connect(); + try { + await _seedRelationalData(client); + engine.reset(); + + final rows = await client.db.orm.model('User') + .all( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }, + ); + + expect(rows, hasLength(2)); + final includePlans = engine.executedPlans + .where((plan) => plan.annotations.containsKey('repository')) + .toList(growable: false); + expect(includePlans, hasLength(1)); + final trace = _readRepositoryTrace(includePlans.single); + expect(trace['kind'], 'User.include'); + expect(trace['phase'], 'include.load'); + expect(trace['strategy'], 'singleQuery'); + expect(trace['relation'], 'posts'); + expect(trace['step'], 1); + } finally { + await client.disconnect(); + } + }); + + test('multiQuery include annotates repository relation load sequence', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient( + contract: relationalContract, + engine: engine, + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => IncludeExecutionStrategy.multiQuery, + ); + await client.connect(); + try { + await _seedRelationalData(client); + engine.reset(); + + final rows = await client.db.orm.model('User') + .all( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }, + ); + + expect(rows, hasLength(2)); + final includePlans = engine.executedPlans + .where((plan) => plan.annotations.containsKey('repository')) + .toList(growable: false); + expect(includePlans, hasLength(2)); + final traces = includePlans + .map(_readRepositoryTrace) + .toList(growable: false); + final operationId = traces.first['operationId']; + expect( + traces.map((trace) => trace['operationId']).toSet(), + {operationId}, + ); + expect( + traces.map((trace) => trace['kind']).toList(growable: false), + ['User.include', 'User.include'], + ); + expect( + traces.map((trace) => trace['phase']).toList(growable: false), + ['include.load', 'include.load'], + ); + expect( + traces.map((trace) => trace['strategy']).toList(growable: false), + ['multiQuery', 'multiQuery'], + ); + expect( + traces.map((trace) => trace['relation']).toList(growable: false), + ['posts', 'posts'], + ); + expect( + traces.map((trace) => trace['step']).toList(growable: false), + [1, 2], + ); + } finally { + await client.disconnect(); + } + }); + test( 'singleQuery include throws structured error for unsupported response shape', () async { @@ -3019,6 +3301,19 @@ JsonMap? _readRowValue(Object? value) { fail('Expected row map but got ${value.runtimeType}.'); } +Map _readRepositoryTrace(OrmPlan plan) { + final trace = plan.annotations['repository']; + if (trace is Map) { + return Map.unmodifiable(trace); + } + if (trace is Map) { + return Map.unmodifiable( + trace.map((key, value) => MapEntry(key.toString(), value)), + ); + } + fail('Expected repository annotations on plan ${plan.action.name}.'); +} + List _readRowsValue(Object? value) { if (value == null) { return const []; @@ -3118,7 +3413,7 @@ final class _NoMutationReturnEngine implements OrmEngine { Future open() => inner.open(); } -final class _CountingEngine implements OrmEngine { +final class _CountingEngine implements OrmEngine, ConnectionCapableEngine { final OrmEngine inner; var executeCount = 0; final List executedPlans = []; @@ -3130,18 +3425,72 @@ final class _CountingEngine implements OrmEngine { @override Future execute(OrmPlan plan) async { - executeCount += 1; - executedPlans.add(plan); + _record(plan); return inner.execute(plan); } @override Future open() => inner.open(); + @override + Future connection() async { + if (inner case final ConnectionCapableEngine connectionEngine) { + final connection = await connectionEngine.connection(); + return _CountingEngineConnection(this, connection); + } + throw UnsupportedError('Inner engine does not support connections.'); + } + void reset() { executeCount = 0; executedPlans.clear(); } + + void _record(OrmPlan plan) { + executeCount += 1; + executedPlans.add(plan); + } +} + +final class _CountingEngineConnection implements EngineConnection { + final _CountingEngine _engine; + final EngineConnection _inner; + + _CountingEngineConnection(this._engine, this._inner); + + @override + Future execute(OrmPlan plan) async { + _engine._record(plan); + return _inner.execute(plan); + } + + @override + Future release() => _inner.release(); + + @override + Future transaction() async { + final transaction = await _inner.transaction(); + return _CountingEngineTransaction(_engine, transaction); + } +} + +final class _CountingEngineTransaction implements EngineTransaction { + final _CountingEngine _engine; + final EngineTransaction _inner; + + _CountingEngineTransaction(this._engine, this._inner); + + @override + Future commit() => _inner.commit(); + + @override + Future execute(OrmPlan plan) async { + _engine._record(plan); + return _inner.execute(plan); + } + + @override + Future rollback() => _inner.rollback(); } final class _BadRelatedFindManyShapeEngine implements OrmEngine { From 0f08b9ba17e9f55e7624d9c57f58c6b2d59de3ec Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:25:39 +0800 Subject: [PATCH 096/154] feat(runtime)!: promote repository trace to typed plan metadata BREAKING CHANGE: runtime rejects legacy repository plan annotations; repository operation metadata now lives in OrmPlan.repositoryTrace and runtime telemetry. --- pub/orm/lib/src/client/client.dart | 30 +- pub/orm/lib/src/client/include_planner.dart | 4 +- .../lib/src/client/mutation_repository.dart | 14 +- pub/orm/lib/src/runtime/core.dart | 83 ++++++ pub/orm/lib/src/runtime/errors.dart | 12 + pub/orm/lib/src/runtime/plan.dart | 27 ++ pub/orm/test/client/client_test.dart | 264 +++++++++++------- 7 files changed, 317 insertions(+), 117 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 58521260..82502719 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -200,24 +200,22 @@ final class _RepositoryOperation { ); } - JsonMap nextAnnotations({ + OrmRepositoryTrace nextTrace({ required String phase, required String strategy, String? relation, int? itemIndex, }) { _step += 1; - return { - 'repository': { - 'operationId': id, - 'kind': kind, - 'step': _step, - 'phase': phase, - 'strategy': strategy, - if (relation != null) 'relation': relation, - if (itemIndex != null) 'itemIndex': itemIndex, - }, - }; + return OrmRepositoryTrace( + operationId: id, + kind: kind, + step: _step, + phase: phase, + strategy: strategy, + relation: relation, + itemIndex: itemIndex, + ); } } @@ -1364,6 +1362,7 @@ class ModelDelegate { List select = const [], Map include = const {}, JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, }) async { if (skip case final offset? when offset < 0) { throw PlanInvalidPaginationException(key: 'skip', value: offset); @@ -1410,6 +1409,7 @@ class ModelDelegate { 'distinct': List.from(distinct, growable: false), }, ), + repositoryTrace: repositoryTrace, model: modelName, where: normalizedWhere, skip: isCollectionRead && distinct.isEmpty ? skip : null, @@ -1433,6 +1433,7 @@ class ModelDelegate { List select = const [], Map include = const {}, JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, required int includeDepth, }) async { final prepared = await _buildReadPlan( @@ -1445,6 +1446,7 @@ class ModelDelegate { select: select, include: include, annotations: annotations, + repositoryTrace: repositoryTrace, ); final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); @@ -1473,6 +1475,7 @@ class ModelDelegate { List select = const [], Map include = const {}, JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, required int includeDepth, }) async { final prepared = await _buildReadPlan( @@ -1484,6 +1487,7 @@ class ModelDelegate { select: select, include: include, annotations: annotations, + repositoryTrace: repositoryTrace, ); final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); @@ -1513,6 +1517,7 @@ class ModelDelegate { List select = const [], Map include = const {}, JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, required int includeDepth, }) async { final prepared = await _buildReadPlan( @@ -1521,6 +1526,7 @@ class ModelDelegate { select: select, include: include, annotations: annotations, + repositoryTrace: repositoryTrace, ); final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); diff --git a/pub/orm/lib/src/client/include_planner.dart b/pub/orm/lib/src/client/include_planner.dart index 5219ba4f..b1d4ac9f 100644 --- a/pub/orm/lib/src/client/include_planner.dart +++ b/pub/orm/lib/src/client/include_planner.dart @@ -180,7 +180,7 @@ final class _RepositoryIncludePlanner { orderBy: relationInclude.orderBy, select: relationInclude.select, include: relationInclude.include, - annotations: operation.nextAnnotations( + repositoryTrace: operation.nextTrace( phase: 'include.load', strategy: 'multiQuery', relation: relationName, @@ -226,7 +226,7 @@ final class _RepositoryIncludePlanner { relation: relation, ), include: relationInclude.include, - annotations: operation.nextAnnotations( + repositoryTrace: operation.nextTrace( phase: 'include.load', strategy: 'singleQuery', relation: relation.name, diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index 5f0a0d8d..0111f461 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -51,7 +51,7 @@ final class _RepositoryMutationExecutor { mutationResultMode: OrmMutationResultMode.row, data: data, normalized: normalized, - annotations: trace.nextAnnotations( + repositoryTrace: trace.nextTrace( phase: phase, strategy: strategy, relation: relation, @@ -244,7 +244,7 @@ final class _RepositoryMutationExecutor { final existing = await scoped._readOneInternal( action: OrmAction.read, where: where, - annotations: trace.nextAnnotations( + repositoryTrace: trace.nextTrace( phase: 'branch.lookup', strategy: 'branch', ), @@ -318,7 +318,7 @@ final class _RepositoryMutationExecutor { mutationResultMode: mutationResultMode, data: data, normalized: normalized, - annotations: operation.nextAnnotations( + repositoryTrace: operation.nextTrace( phase: phase, strategy: strategy, relation: relation, @@ -378,7 +378,7 @@ final class _RepositoryMutationExecutor { required OrmMutationResultMode mutationResultMode, required JsonMap data, required _NormalizedMutationInput normalized, - required JsonMap annotations, + OrmRepositoryTrace? repositoryTrace, }) { return _PreparedMutationPlan( include: normalized.include, @@ -388,7 +388,7 @@ final class _RepositoryMutationExecutor { storageHash: _delegate._client.contract.markerStorageHash, profileHash: _delegate._client.contract.profileHash, lane: 'orm', - annotations: annotations, + repositoryTrace: repositoryTrace, model: _delegate.modelName, action: action, where: normalized.where, @@ -419,7 +419,7 @@ final class _RepositoryMutationExecutor { select: select, include: include, ), - annotations: operation.nextAnnotations( + repositoryTrace: operation.nextTrace( phase: 'fallback.preload', strategy: 'returningDisabledFallback', ), @@ -451,7 +451,7 @@ final class _RepositoryMutationExecutor { select: select, include: include, ), - annotations: operation.nextAnnotations( + repositoryTrace: operation.nextTrace( phase: 'fallback.reload', strategy: 'returningDisabledFallback', ), diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 22e98ae8..0ea5d569 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -53,6 +53,7 @@ final class RuntimeTelemetryEvent { final RuntimeTelemetryOutcome outcome; final int durationMs; final DateTime recordedAt; + final OrmRepositoryTrace? repositoryTrace; const RuntimeTelemetryEvent({ required this.model, @@ -60,7 +61,18 @@ final class RuntimeTelemetryEvent { required this.outcome, required this.durationMs, required this.recordedAt, + this.repositoryTrace, }); + + String? get operationId => repositoryTrace?.operationId; + + String? get operationKind => repositoryTrace?.kind; + + int? get operationStep => repositoryTrace?.step; + + String? get operationPhase => repositoryTrace?.phase; + + String? get operationStrategy => repositoryTrace?.strategy; } abstract interface class OrmRuntimeQueryable { @@ -226,6 +238,7 @@ final class OrmRuntimeCore implements RuntimeCore { outcome: RuntimeTelemetryOutcome.success, durationMs: result.latencyMs, recordedAt: DateTime.now(), + repositoryTrace: plan.repositoryTrace, ); return response; @@ -237,6 +250,7 @@ final class OrmRuntimeCore implements RuntimeCore { outcome: RuntimeTelemetryOutcome.runtimeError, durationMs: latencyMs, recordedAt: DateTime.now(), + repositoryTrace: plan.repositoryTrace, ); for (final plugin in _plugins) { @@ -346,6 +360,7 @@ final class OrmRuntimeCore implements RuntimeCore { final model = contract.models[plan.model]!; _assertPlanModes(plan); + _assertRepositoryTrace(plan); switch (plan.action) { case OrmAction.read: _assertReadPlan(model: model, plan: plan.read!); @@ -379,6 +394,74 @@ final class OrmRuntimeCore implements RuntimeCore { } } + void _assertRepositoryTrace(OrmPlan plan) { + if (plan.annotations.containsKey('repository')) { + throw PlanRepositoryTraceInvalidException( + reason: 'legacyAnnotation', + details: {'model': plan.model}, + ); + } + + final trace = plan.repositoryTrace; + if (trace == null) { + return; + } + + if (trace.operationId.trim().isEmpty) { + throw PlanRepositoryTraceInvalidException( + reason: 'operationIdEmpty', + details: {'model': plan.model}, + ); + } + if (trace.kind.trim().isEmpty) { + throw PlanRepositoryTraceInvalidException( + reason: 'kindEmpty', + details: { + 'model': plan.model, + 'operationId': trace.operationId, + }, + ); + } + if (trace.phase.trim().isEmpty) { + throw PlanRepositoryTraceInvalidException( + reason: 'phaseEmpty', + details: { + 'model': plan.model, + 'operationId': trace.operationId, + }, + ); + } + if (trace.strategy.trim().isEmpty) { + throw PlanRepositoryTraceInvalidException( + reason: 'strategyEmpty', + details: { + 'model': plan.model, + 'operationId': trace.operationId, + }, + ); + } + if (trace.step <= 0) { + throw PlanRepositoryTraceInvalidException( + reason: 'stepInvalid', + details: { + 'model': plan.model, + 'operationId': trace.operationId, + 'step': trace.step, + }, + ); + } + if (trace.itemIndex case final itemIndex? when itemIndex < 0) { + throw PlanRepositoryTraceInvalidException( + reason: 'itemIndexInvalid', + details: { + 'model': plan.model, + 'operationId': trace.operationId, + 'itemIndex': itemIndex, + }, + ); + } + } + void _assertReadPlan({required ModelContract model, required OrmReadPlan plan}) { _assertWhereFields(model: model, where: plan.where, source: 'where'); _assertKnownFields( diff --git a/pub/orm/lib/src/runtime/errors.dart b/pub/orm/lib/src/runtime/errors.dart index f7d25681..676b6837 100644 --- a/pub/orm/lib/src/runtime/errors.dart +++ b/pub/orm/lib/src/runtime/errors.dart @@ -272,6 +272,18 @@ final class PlanResultModeActionInvalidException extends OrmRuntimeError { ); } +final class PlanRepositoryTraceInvalidException extends OrmRuntimeError { + PlanRepositoryTraceInvalidException({ + required String reason, + Map details = const {}, + }) : super( + code: 'PLAN.REPOSITORY_TRACE_INVALID', + category: RuntimeErrorCategory.plan, + message: 'Repository trace metadata is invalid for this plan.', + details: {'reason': reason, ...details}, + ); +} + final class RuntimeCreateResultMissingException extends OrmRuntimeError { RuntimeCreateResultMissingException({required String model}) : super( diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index 50ee4968..706c0b03 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -9,6 +9,27 @@ enum OrmReadResultMode { all, firstOrNull, oneOrNull } enum OrmMutationResultMode { row, rowOrNull } +@immutable +final class OrmRepositoryTrace { + final String operationId; + final String kind; + final int step; + final String phase; + final String strategy; + final String? relation; + final int? itemIndex; + + const OrmRepositoryTrace({ + required this.operationId, + required this.kind, + required this.step, + required this.phase, + required this.strategy, + this.relation, + this.itemIndex, + }); +} + @immutable final class OrmOrderBy { final String field; @@ -91,6 +112,7 @@ final class OrmPlan { final String? profileHash; final String? lane; final JsonMap annotations; + final OrmRepositoryTrace? repositoryTrace; final String model; final OrmAction action; final OrmReadPlan? read; @@ -103,6 +125,7 @@ final class OrmPlan { this.profileHash, this.lane, JsonMap annotations = const {}, + this.repositoryTrace, required this.model, required this.action, this.read, @@ -116,6 +139,7 @@ final class OrmPlan { String? profileHash, String? lane, JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, required String model, JsonMap where = const {}, int? skip, @@ -133,6 +157,7 @@ final class OrmPlan { profileHash: profileHash, lane: lane, annotations: annotations, + repositoryTrace: repositoryTrace, model: model, action: OrmAction.read, read: OrmReadPlan( @@ -155,6 +180,7 @@ final class OrmPlan { String? profileHash, String? lane, JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, required String model, required OrmAction action, JsonMap where = const {}, @@ -169,6 +195,7 @@ final class OrmPlan { profileHash: profileHash, lane: lane, annotations: annotations, + repositoryTrace: repositoryTrace, model: model, action: action, mutation: OrmMutationPlan( diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 0891d46a..223bcdef 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -357,6 +357,57 @@ void main() { await client.disconnect(); }); + test('rejects legacy and invalid typed repository trace metadata', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + await expectLater( + client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + annotations: const { + 'repository': {'operationId': 'legacy'}, + }, + ), + ), + throwsA( + isA().having( + (error) => error.details['reason'], + 'reason', + 'legacyAnnotation', + ), + ), + ); + + await expectLater( + client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + repositoryTrace: const OrmRepositoryTrace( + operationId: '', + kind: 'User.include', + step: 1, + phase: 'include.load', + strategy: 'multiQuery', + ), + ), + ), + throwsA( + isA().having( + (error) => error.details['reason'], + 'reason', + 'operationIdEmpty', + ), + ), + ); + + await client.disconnect(); + }); + test('supports ordering and pagination in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -1123,31 +1174,31 @@ void main() { final createTraces = engine.executedPlans .map(_readRepositoryTrace) .toList(growable: false); - final createOperationId = createTraces.first['operationId']; + final createOperationId = createTraces.first.operationId; expect(createOperationId, isNotNull); expect( - createTraces.map((trace) => trace['operationId']).toSet(), - {createOperationId}, + createTraces.map((trace) => trace.operationId).toSet(), + {createOperationId}, ); expect( - createTraces.map((trace) => trace['kind']).toList(growable: false), - ['User.createMany', 'User.createMany', 'User.createMany'], + createTraces.map((trace) => trace.kind).toList(growable: false), + ['User.createMany', 'User.createMany', 'User.createMany'], ); expect( - createTraces.map((trace) => trace['phase']).toList(growable: false), - ['item.create', 'item.create', 'item.create'], + createTraces.map((trace) => trace.phase).toList(growable: false), + ['item.create', 'item.create', 'item.create'], ); expect( - createTraces.map((trace) => trace['strategy']).toList(growable: false), - ['transaction', 'transaction', 'transaction'], + createTraces.map((trace) => trace.strategy).toList(growable: false), + ['transaction', 'transaction', 'transaction'], ); expect( - createTraces.map((trace) => trace['step']).toList(growable: false), - [1, 2, 3], + createTraces.map((trace) => trace.step).toList(growable: false), + [1, 2, 3], ); expect( - createTraces.map((trace) => trace['itemIndex']).toList(growable: false), - [0, 1, 2], + createTraces.map((trace) => trace.itemIndex).toList(growable: false), + [0, 1, 2], ); engine.reset(); @@ -1162,31 +1213,31 @@ void main() { final deleteTraces = engine.executedPlans .map(_readRepositoryTrace) .toList(growable: false); - final deleteOperationId = deleteTraces.first['operationId']; + final deleteOperationId = deleteTraces.first.operationId; expect(deleteOperationId, isNotNull); expect( - deleteTraces.map((trace) => trace['operationId']).toSet(), - {deleteOperationId}, + deleteTraces.map((trace) => trace.operationId).toSet(), + {deleteOperationId}, ); expect( - deleteTraces.map((trace) => trace['kind']).toList(growable: false), - ['User.deleteMany', 'User.deleteMany', 'User.deleteMany'], + deleteTraces.map((trace) => trace.kind).toList(growable: false), + ['User.deleteMany', 'User.deleteMany', 'User.deleteMany'], ); expect( - deleteTraces.map((trace) => trace['phase']).toList(growable: false), - ['item.delete', 'item.delete', 'item.delete'], + deleteTraces.map((trace) => trace.phase).toList(growable: false), + ['item.delete', 'item.delete', 'item.delete'], ); expect( - deleteTraces.map((trace) => trace['strategy']).toList(growable: false), - ['transaction', 'transaction', 'transaction'], + deleteTraces.map((trace) => trace.strategy).toList(growable: false), + ['transaction', 'transaction', 'transaction'], ); expect( - deleteTraces.map((trace) => trace['step']).toList(growable: false), - [1, 2, 3], + deleteTraces.map((trace) => trace.step).toList(growable: false), + [1, 2, 3], ); expect( - deleteTraces.map((trace) => trace['itemIndex']).toList(growable: false), - [0, 1, 2], + deleteTraces.map((trace) => trace.itemIndex).toList(growable: false), + [0, 1, 2], ); final remaining = await users.count(); @@ -1325,15 +1376,15 @@ void main() { ); final updateTrace = _readRepositoryTrace(engine.executedPlans.first); final reloadTrace = _readRepositoryTrace(engine.executedPlans.last); - expect(updateTrace['kind'], 'User.update'); - expect(updateTrace['phase'], 'write'); - expect(updateTrace['strategy'], 'singlePlan'); - expect(updateTrace['step'], 1); - expect(reloadTrace['kind'], 'User.update'); - expect(reloadTrace['phase'], 'fallback.reload'); - expect(reloadTrace['strategy'], 'returningDisabledFallback'); - expect(reloadTrace['step'], 2); - expect(reloadTrace['operationId'], updateTrace['operationId']); + expect(updateTrace.kind, 'User.update'); + expect(updateTrace.phase, 'write'); + expect(updateTrace.strategy, 'singlePlan'); + expect(updateTrace.step, 1); + expect(reloadTrace.kind, 'User.update'); + expect(reloadTrace.phase, 'fallback.reload'); + expect(reloadTrace.strategy, 'returningDisabledFallback'); + expect(reloadTrace.step, 2); + expect(reloadTrace.operationId, updateTrace.operationId); await client.disconnect(); }, @@ -1373,15 +1424,15 @@ void main() { ); final preloadTrace = _readRepositoryTrace(engine.executedPlans.first); final deleteTrace = _readRepositoryTrace(engine.executedPlans.last); - expect(preloadTrace['kind'], 'User.delete'); - expect(preloadTrace['phase'], 'fallback.preload'); - expect(preloadTrace['strategy'], 'returningDisabledFallback'); - expect(preloadTrace['step'], 1); - expect(deleteTrace['kind'], 'User.delete'); - expect(deleteTrace['phase'], 'write'); - expect(deleteTrace['strategy'], 'singlePlan'); - expect(deleteTrace['step'], 2); - expect(deleteTrace['operationId'], preloadTrace['operationId']); + expect(preloadTrace.kind, 'User.delete'); + expect(preloadTrace.phase, 'fallback.preload'); + expect(preloadTrace.strategy, 'returningDisabledFallback'); + expect(preloadTrace.step, 1); + expect(deleteTrace.kind, 'User.delete'); + expect(deleteTrace.phase, 'write'); + expect(deleteTrace.strategy, 'singlePlan'); + expect(deleteTrace.step, 2); + expect(deleteTrace.operationId, preloadTrace.operationId); final remaining = await users.oneOrNull( where: {'id': 'u1'}, @@ -1583,26 +1634,26 @@ void main() { final createBranch = engine.executedPlans .map(_readRepositoryTrace) .toList(growable: false); - final createOperationId = createBranch.first['operationId']; + final createOperationId = createBranch.first.operationId; expect( - createBranch.map((trace) => trace['operationId']).toSet(), - {createOperationId}, + createBranch.map((trace) => trace.operationId).toSet(), + {createOperationId}, ); expect( - createBranch.map((trace) => trace['kind']).toList(growable: false), - ['User.upsert', 'User.upsert'], + createBranch.map((trace) => trace.kind).toList(growable: false), + ['User.upsert', 'User.upsert'], ); expect( - createBranch.map((trace) => trace['phase']).toList(growable: false), - ['branch.lookup', 'branch.create'], + createBranch.map((trace) => trace.phase).toList(growable: false), + ['branch.lookup', 'branch.create'], ); expect( - createBranch.map((trace) => trace['strategy']).toList(growable: false), - ['branch', 'branch'], + createBranch.map((trace) => trace.strategy).toList(growable: false), + ['branch', 'branch'], ); expect( - createBranch.map((trace) => trace['step']).toList(growable: false), - [1, 2], + createBranch.map((trace) => trace.step).toList(growable: false), + [1, 2], ); engine.reset(); @@ -1619,26 +1670,26 @@ void main() { final updateBranch = engine.executedPlans .map(_readRepositoryTrace) .toList(growable: false); - final updateOperationId = updateBranch.first['operationId']; + final updateOperationId = updateBranch.first.operationId; expect( - updateBranch.map((trace) => trace['operationId']).toSet(), - {updateOperationId}, + updateBranch.map((trace) => trace.operationId).toSet(), + {updateOperationId}, ); expect( - updateBranch.map((trace) => trace['kind']).toList(growable: false), - ['User.upsert', 'User.upsert'], + updateBranch.map((trace) => trace.kind).toList(growable: false), + ['User.upsert', 'User.upsert'], ); expect( - updateBranch.map((trace) => trace['phase']).toList(growable: false), - ['branch.lookup', 'branch.update'], + updateBranch.map((trace) => trace.phase).toList(growable: false), + ['branch.lookup', 'branch.update'], ); expect( - updateBranch.map((trace) => trace['strategy']).toList(growable: false), - ['branch', 'branch'], + updateBranch.map((trace) => trace.strategy).toList(growable: false), + ['branch', 'branch'], ); expect( - updateBranch.map((trace) => trace['step']).toList(growable: false), - [1, 2], + updateBranch.map((trace) => trace.step).toList(growable: false), + [1, 2], ); await client.disconnect(); @@ -1937,15 +1988,15 @@ void main() { expect(rows, hasLength(2)); final includePlans = engine.executedPlans - .where((plan) => plan.annotations.containsKey('repository')) + .where((plan) => plan.repositoryTrace != null) .toList(growable: false); expect(includePlans, hasLength(1)); final trace = _readRepositoryTrace(includePlans.single); - expect(trace['kind'], 'User.include'); - expect(trace['phase'], 'include.load'); - expect(trace['strategy'], 'singleQuery'); - expect(trace['relation'], 'posts'); - expect(trace['step'], 1); + expect(trace.kind, 'User.include'); + expect(trace.phase, 'include.load'); + expect(trace.strategy, 'singleQuery'); + expect(trace.relation, 'posts'); + expect(trace.step, 1); } finally { await client.disconnect(); } @@ -1982,36 +2033,36 @@ void main() { expect(rows, hasLength(2)); final includePlans = engine.executedPlans - .where((plan) => plan.annotations.containsKey('repository')) + .where((plan) => plan.repositoryTrace != null) .toList(growable: false); expect(includePlans, hasLength(2)); final traces = includePlans .map(_readRepositoryTrace) .toList(growable: false); - final operationId = traces.first['operationId']; + final operationId = traces.first.operationId; expect( - traces.map((trace) => trace['operationId']).toSet(), - {operationId}, + traces.map((trace) => trace.operationId).toSet(), + {operationId}, ); expect( - traces.map((trace) => trace['kind']).toList(growable: false), - ['User.include', 'User.include'], + traces.map((trace) => trace.kind).toList(growable: false), + ['User.include', 'User.include'], ); expect( - traces.map((trace) => trace['phase']).toList(growable: false), - ['include.load', 'include.load'], + traces.map((trace) => trace.phase).toList(growable: false), + ['include.load', 'include.load'], ); expect( - traces.map((trace) => trace['strategy']).toList(growable: false), - ['multiQuery', 'multiQuery'], + traces.map((trace) => trace.strategy).toList(growable: false), + ['multiQuery', 'multiQuery'], ); expect( - traces.map((trace) => trace['relation']).toList(growable: false), - ['posts', 'posts'], + traces.map((trace) => trace.relation).toList(growable: false), + ['posts', 'posts'], ); expect( - traces.map((trace) => trace['step']).toList(growable: false), - [1, 2], + traces.map((trace) => trace.step).toList(growable: false), + [1, 2], ); } finally { await client.disconnect(); @@ -2933,6 +2984,32 @@ void main() { expect(telemetry?.model, 'User'); expect(telemetry?.action, OrmAction.read); expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.repositoryTrace, isNull); + await client.disconnect(); + }); + + test('records repository operation trace in telemetry', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'b@x.com'}, + ], + ); + + final lastPlanTrace = _readRepositoryTrace(engine.executedPlans.last); + final telemetry = client.telemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.operationId, lastPlanTrace.operationId); + expect(telemetry?.operationKind, 'User.createMany'); + expect(telemetry?.operationPhase, 'item.create'); + expect(telemetry?.operationStrategy, 'transaction'); + expect(telemetry?.operationStep, 2); + expect(telemetry?.repositoryTrace?.itemIndex, 1); await client.disconnect(); }); @@ -3301,17 +3378,12 @@ JsonMap? _readRowValue(Object? value) { fail('Expected row map but got ${value.runtimeType}.'); } -Map _readRepositoryTrace(OrmPlan plan) { - final trace = plan.annotations['repository']; - if (trace is Map) { - return Map.unmodifiable(trace); - } - if (trace is Map) { - return Map.unmodifiable( - trace.map((key, value) => MapEntry(key.toString(), value)), - ); +OrmRepositoryTrace _readRepositoryTrace(OrmPlan plan) { + final trace = plan.repositoryTrace; + if (trace == null) { + fail('Expected repository trace on plan ${plan.action.name}.'); } - fail('Expected repository annotations on plan ${plan.action.name}.'); + return trace; } List _readRowsValue(Object? value) { From c19090e2ba6a6018b309ec540b2cdbde300c09c5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:33:01 +0800 Subject: [PATCH 097/154] feat(runtime): aggregate repository operation telemetry --- pub/orm/lib/src/client/client.dart | 10 + pub/orm/lib/src/runtime/core.dart | 184 ++++++++++++++++ .../operation_telemetry_aggregation_test.dart | 199 ++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 pub/orm/test/runtime/operation_telemetry_aggregation_test.dart diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 82502719..1d71066f 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -371,6 +371,16 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { RuntimeTelemetryEvent? telemetry() => _runtime.telemetry(); + RuntimeOperationTelemetryEvent? operationTelemetry([String? operationId]) { + return _runtime.operationTelemetry(operationId); + } + + List recentOperationTelemetry({ + int limit = 50, + }) { + return _runtime.recentOperationTelemetry(limit: limit); + } + @override OrmDbNamespace get db => _db; diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 0ea5d569..48af5d4f 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:meta/meta.dart'; import '../contract/contract.dart'; @@ -46,6 +48,58 @@ final class RuntimeVerifyOptions { enum RuntimeTelemetryOutcome { success, runtimeError } +@immutable +final class RuntimeOperationStepTelemetry { + final String model; + final OrmAction action; + final RuntimeTelemetryOutcome outcome; + final int rowCount; + final int affectedRows; + final int durationMs; + final DateTime recordedAt; + final OrmRepositoryTrace trace; + + const RuntimeOperationStepTelemetry({ + required this.model, + required this.action, + required this.outcome, + required this.rowCount, + required this.affectedRows, + required this.durationMs, + required this.recordedAt, + required this.trace, + }); +} + +@immutable +final class RuntimeOperationTelemetryEvent { + final String operationId; + final String kind; + final RuntimeTelemetryOutcome outcome; + final int statementCount; + final int rowCount; + final int affectedRows; + final int durationMs; + final DateTime startedAt; + final DateTime recordedAt; + final List steps; + + RuntimeOperationTelemetryEvent({ + required this.operationId, + required this.kind, + required this.outcome, + required this.statementCount, + required this.rowCount, + required this.affectedRows, + required this.durationMs, + required this.startedAt, + required this.recordedAt, + required List steps, + }) : steps = List.unmodifiable(steps); + + int get lastStep => steps.isEmpty ? 0 : steps.last.trace.step; +} + @immutable final class RuntimeTelemetryEvent { final String model; @@ -101,9 +155,15 @@ abstract interface class RuntimeCore implements OrmRuntimeQueryable { Future connection(); RuntimeTelemetryEvent? telemetry(); + + RuntimeOperationTelemetryEvent? operationTelemetry([String? operationId]); + + List recentOperationTelemetry({int limit = 50}); } final class OrmRuntimeCore implements RuntimeCore { + static const int _maxOperationTelemetryEntries = 128; + final OrmContract contract; final OrmEngine engine; final RuntimeVerifyOptions verify; @@ -116,6 +176,10 @@ final class OrmRuntimeCore implements RuntimeCore { bool _startupVerified = false; bool _firstUseVerified = false; RuntimeTelemetryEvent? _telemetry; + RuntimeOperationTelemetryEvent? _operationTelemetry; + final LinkedHashMap + _operationTelemetryById = + LinkedHashMap(); OrmRuntimeCore({ required this.contract, @@ -173,6 +237,8 @@ final class OrmRuntimeCore implements RuntimeCore { _startupVerified = false; _firstUseVerified = false; _telemetry = null; + _operationTelemetry = null; + _operationTelemetryById.clear(); } @override @@ -196,6 +262,28 @@ final class OrmRuntimeCore implements RuntimeCore { @override RuntimeTelemetryEvent? telemetry() => _telemetry; + @override + RuntimeOperationTelemetryEvent? operationTelemetry([String? operationId]) { + if (operationId == null) { + return _operationTelemetry; + } + return _operationTelemetryById[operationId]; + } + + @override + List recentOperationTelemetry({ + int limit = 50, + }) { + if (limit <= 0 || _operationTelemetryById.isEmpty) { + return const []; + } + final values = _operationTelemetryById.values.toList(growable: false); + if (limit >= values.length) { + return values.reversed.toList(growable: false); + } + return values.sublist(values.length - limit).reversed.toList(growable: false); + } + Future _executeOnQueryable( OrmPlan plan, RuntimeQueryable queryable, @@ -240,6 +328,15 @@ final class OrmRuntimeCore implements RuntimeCore { recordedAt: DateTime.now(), repositoryTrace: plan.repositoryTrace, ); + _recordOperationTelemetry( + plan: plan, + outcome: RuntimeTelemetryOutcome.success, + rowCount: rowCount, + affectedRows: response.affectedRows, + durationMs: result.latencyMs, + startedAt: startedAt, + recordedAt: _telemetry!.recordedAt, + ); return response; } catch (error, stackTrace) { @@ -252,6 +349,17 @@ final class OrmRuntimeCore implements RuntimeCore { recordedAt: DateTime.now(), repositoryTrace: plan.repositoryTrace, ); + if (error is! PlanRepositoryTraceInvalidException) { + _recordOperationTelemetry( + plan: plan, + outcome: RuntimeTelemetryOutcome.runtimeError, + rowCount: rowCount, + affectedRows: 0, + durationMs: latencyMs, + startedAt: startedAt, + recordedAt: _telemetry!.recordedAt, + ); + } for (final plugin in _plugins) { try { @@ -462,6 +570,82 @@ final class OrmRuntimeCore implements RuntimeCore { } } + void _recordOperationTelemetry({ + required OrmPlan plan, + required RuntimeTelemetryOutcome outcome, + required int rowCount, + required int affectedRows, + required int durationMs, + required DateTime startedAt, + required DateTime recordedAt, + }) { + final trace = plan.repositoryTrace; + if (trace == null) { + return; + } + + final current = _operationTelemetryById[trace.operationId]; + if (current != null) { + if (current.kind != trace.kind) { + throw PlanRepositoryTraceInvalidException( + reason: 'kindMismatch', + details: { + 'operationId': trace.operationId, + 'expectedKind': current.kind, + 'actualKind': trace.kind, + }, + ); + } + if (trace.step <= current.lastStep) { + throw PlanRepositoryTraceInvalidException( + reason: 'stepOutOfOrder', + details: { + 'operationId': trace.operationId, + 'lastStep': current.lastStep, + 'actualStep': trace.step, + }, + ); + } + } + + final nextStep = RuntimeOperationStepTelemetry( + model: plan.model, + action: plan.action, + outcome: outcome, + rowCount: rowCount, + affectedRows: affectedRows, + durationMs: durationMs, + recordedAt: recordedAt, + trace: trace, + ); + final next = RuntimeOperationTelemetryEvent( + operationId: trace.operationId, + kind: trace.kind, + outcome: current?.outcome == RuntimeTelemetryOutcome.runtimeError + ? RuntimeTelemetryOutcome.runtimeError + : outcome, + statementCount: (current?.statementCount ?? 0) + 1, + rowCount: (current?.rowCount ?? 0) + rowCount, + affectedRows: (current?.affectedRows ?? 0) + affectedRows, + durationMs: (current?.durationMs ?? 0) + durationMs, + startedAt: current?.startedAt ?? startedAt, + recordedAt: recordedAt, + steps: [ + ...?current?.steps, + nextStep, + ], + ); + + if (current != null) { + _operationTelemetryById.remove(trace.operationId); + } + _operationTelemetryById[trace.operationId] = next; + while (_operationTelemetryById.length > _maxOperationTelemetryEntries) { + _operationTelemetryById.remove(_operationTelemetryById.keys.first); + } + _operationTelemetry = next; + } + void _assertReadPlan({required ModelContract model, required OrmReadPlan plan}) { _assertWhereFields(model: model, where: plan.where, source: 'where'); _assertKnownFields( diff --git a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart new file mode 100644 index 00000000..4286ec80 --- /dev/null +++ b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart @@ -0,0 +1,199 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + final contract = OrmContract( + version: '1', + hash: 'contract-v1', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + aliases: {'users': 'User'}, + ); + + group('operation telemetry aggregation', () { + test('aggregates createMany into one operation record', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + final rows = await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'b@x.com'}, + ], + ); + + expect(rows, hasLength(2)); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.createMany'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.statementCount, 2); + expect(telemetry?.affectedRows, 2); + expect(telemetry?.steps.map((step) => step.trace.phase).toList(), [ + 'item.create', + 'item.create', + ]); + expect(telemetry?.steps.map((step) => step.trace.step).toList(), [ + 1, + 2, + ]); + expect( + client.operationTelemetry(telemetry!.operationId)?.operationId, + telemetry.operationId, + ); + await client.disconnect(); + }); + + test('aggregates fallback update reload into one operation record', () async { + final noReturningContract = OrmContract( + version: contract.version, + hash: contract.hash, + models: contract.models, + aliases: contract.aliases, + capabilities: const ContractCapabilities(mutationReturning: false), + ); + final client = OrmClient( + contract: noReturningContract, + engine: _NoMutationReturnEngine(inner: MemoryEngine()), + ); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + final row = await users.update( + where: {'id': 'u1'}, + data: {'email': 'b@x.com'}, + select: const ['id', 'email'], + ); + + expect(row, {'id': 'u1', 'email': 'b@x.com'}); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.update'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.statementCount, 2); + expect(telemetry?.rowCount, 1); + expect(telemetry?.affectedRows, 1); + expect(telemetry?.steps.map((step) => step.trace.phase).toList(), [ + 'write', + 'fallback.reload', + ]); + expect( + telemetry?.steps.map((step) => step.outcome).toList(), + [ + RuntimeTelemetryOutcome.success, + RuntimeTelemetryOutcome.success, + ], + ); + await client.disconnect(); + }); + + test('aggregates fallback delete preload into one operation record', () async { + final noReturningContract = OrmContract( + version: contract.version, + hash: contract.hash, + models: contract.models, + aliases: contract.aliases, + capabilities: const ContractCapabilities(mutationReturning: false), + ); + final client = OrmClient( + contract: noReturningContract, + engine: _NoMutationReturnEngine(inner: MemoryEngine()), + ); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + final row = await users.delete( + where: {'id': 'u1'}, + select: const ['id', 'email'], + ); + + expect(row, {'id': 'u1', 'email': 'a@x.com'}); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.delete'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.statementCount, 2); + expect(telemetry?.rowCount, 1); + expect(telemetry?.affectedRows, 1); + expect(telemetry?.steps.map((step) => step.trace.phase).toList(), [ + 'fallback.preload', + 'write', + ]); + expect( + telemetry?.steps.map((step) => step.trace.step).toList(), + [1, 2], + ); + await client.disconnect(); + }); + + test('keeps repeated operations isolated in recent history', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + ], + ); + final first = client.operationTelemetry(); + await users.createMany( + data: [ + {'id': 'u2', 'email': 'b@x.com'}, + {'id': 'u3', 'email': 'c@x.com'}, + ], + ); + final second = client.operationTelemetry(); + final recent = client.recentOperationTelemetry(limit: 2); + + expect(first, isNotNull); + expect(second, isNotNull); + expect(second?.operationId, isNot(first?.operationId)); + expect(recent, hasLength(2)); + expect(recent.first.operationId, second?.operationId); + expect(recent.first.statementCount, 2); + expect(recent.last.operationId, first?.operationId); + expect(recent.last.statementCount, 1); + expect( + recent.map((event) => event.kind).toList(growable: false), + ['User.createMany', 'User.createMany'], + ); + await client.disconnect(); + }); + }); +} + +final class _NoMutationReturnEngine implements OrmEngine { + final OrmEngine inner; + + _NoMutationReturnEngine({required this.inner}); + + @override + Future close() => inner.close(); + + @override + Future execute(OrmPlan plan) async { + final response = await inner.execute(plan); + if (plan.action == OrmAction.create || + plan.action == OrmAction.update || + plan.action == OrmAction.delete) { + return EngineResponse(affectedRows: response.affectedRows); + } + return response; + } + + @override + Future open() => inner.open(); +} From b264babf242302eb11a9d11f5ca2bd71d8486ea2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:39:39 +0800 Subject: [PATCH 098/154] feat(client): lock api surface with stable placeholders --- docs/orm-v6-api-surface.md | 142 ++++++++++++++++++++++ pub/orm/lib/src/client/client.dart | 101 +++++++++++++++ pub/orm/lib/src/runtime/errors.dart | 12 ++ pub/orm/test/client/api_surface_test.dart | 123 +++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 docs/orm-v6-api-surface.md create mode 100644 pub/orm/test/client/api_surface_test.dart diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md new file mode 100644 index 00000000..351c58df --- /dev/null +++ b/docs/orm-v6-api-surface.md @@ -0,0 +1,142 @@ +# ORM V6 API Surface + +This document locks the intended public API surface before implementation +completeness. The rule for this phase is simple: + +- surface first +- missing behavior may throw a stable placeholder error +- runtime, repository, and plan boundaries stay explicit + +Placeholder methods must throw `RUNTIME.API_NOT_IMPLEMENTED`. + +## Runtime Root + +Single entrypoint: + +```dart +final db = client.db; +``` + +Runtime access: + +```dart +client.connect(); +client.disconnect(); +client.connection(); +client.telemetry(); +client.operationTelemetry(); +client.recentOperationTelemetry(); +client.withConnection(...); +client.withTransaction(...); +``` + +Namespaces: + +```dart +db.orm.model('User'); +db.sql.from('User'); +db.sql.insertInto('User'); +db.sql.update('User'); +db.sql.deleteFrom('User'); +``` + +## ORM Read Surface + +Root: + +```dart +final users = client.db.orm.model('User'); +final query = users.query(); +``` + +Read authoring: + +| Method | Status | +| --- | --- | +| `query()` | implemented | +| `where(...)` | implemented | +| `whereWith(...)` | implemented | +| `orderBy(...)` | implemented | +| `orderByField(...)` | implemented | +| `distinct(...)` | implemented | +| `distinctField(...)` | implemented | +| `select(...)` | implemented | +| `selectField(...)` | implemented | +| `selectWith(...)` | implemented | +| `include(...)` | implemented | +| `includeWith(...)` | implemented | +| `includeRelation(...)` | implemented | +| `skip(...)` | implemented | +| `take(...)` | implemented | +| `unbounded()` | implemented | +| `cursor(...)` | placeholder | +| `page(...)` | placeholder | + +Read terminals: + +| Method | Status | +| --- | --- | +| `toPlan()` | implemented | +| `all()` | implemented | +| `stream()` | implemented | +| `firstOrNull()` | implemented | +| `oneOrNull()` | implemented | +| `count()` | implemented | +| `exists()` | implemented | +| `aggregate(...)` | implemented | +| `groupBy(...)` | implemented | +| `explain()` | placeholder | + +## ORM Mutation Surface + +Direct mutations: + +| Method | Status | +| --- | --- | +| `create(...)` | implemented | +| `createMany(...)` | implemented | +| `createNested(...)` | implemented | +| `update(...)` | implemented | +| `updateNested(...)` | implemented | +| `delete(...)` | implemented | +| `deleteMany(...)` | implemented | +| `upsert(...)` | implemented | +| `updateMany(...)` | placeholder | + +Chained mutations: + +```dart +users.where({...}).update(data: {...}); +users.where({...}).delete(); +users.where({...}).upsert(create: {...}, update: {...}); +users.where({...}).updateMany(data: {...}); +``` + +## SQL Surface + +Read: + +```dart +client.db.sql.from('User').where({...}).orderBy(...).take(10).toPlan(); +client.db.sql.from('User').all(); +client.db.sql.from('User').firstOrNull(); +client.db.sql.from('User').stream(); +``` + +Mutation: + +```dart +client.db.sql.insertInto('User').values({...}).execute(); +client.db.sql.update('User').set({...}).where({...}).execute(); +client.db.sql.deleteFrom('User').where({...}).execute(); +``` + +## Phase Rules + +1. New public methods must be added here first. +2. If semantics are not ready, expose the method and throw the stable + placeholder error. +3. Repository orchestrates multi-step behavior. +4. Runtime observes plans, verifies contracts, runs plugins, and records + telemetry. +5. Lanes build plans; they do not hide multi-step workflows. diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 1d71066f..308dfe82 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -185,6 +185,13 @@ JsonMap _mergePlanAnnotations( int _repositoryOperationSeed = 0; +Never _throwApiNotImplemented( + String surface, { + Map details = const {}, +}) { + throw ApiNotImplementedException(surface: surface, details: details); +} + final class _RepositoryOperation { final String id; final String kind; @@ -968,6 +975,11 @@ class ModelDelegate { ModelQuery where(JsonMap where) => query().where(where); + ModelQuery whereWith( + JsonMap Function(JsonMap where) build, { + bool merge = true, + }) => query().whereWith(build, merge: merge); + ModelQuery orderBy(List orderBy) => query().orderBy(orderBy); ModelQuery orderByField(String field, {SortOrder order = SortOrder.asc}) => @@ -979,6 +991,11 @@ class ModelDelegate { ModelQuery select(List fields) => query().select(fields); + ModelQuery selectWith( + List Function(List fields) build, { + bool append = false, + }) => query().selectWith(build, append: append); + ModelQuery selectField(String field) => query().selectField(field); ModelQuery distinct(List fields, {bool append = false}) => @@ -1323,6 +1340,24 @@ class ModelDelegate { include: include, ); + Future updateMany({ + JsonMap where = const {}, + required JsonMap data, + List select = const [], + Map include = const {}, + }) async { + _throwApiNotImplemented( + 'orm.updateMany', + details: { + 'model': modelName, + 'where': where, + 'data': data, + 'select': select, + 'include': include.keys.toList(growable: false), + }, + ); + } + Future deleteMany({JsonMap where = const {}}) => _RepositoryMutationExecutor(this).deleteMany(where: where); @@ -3126,6 +3161,15 @@ final class ModelQuery { ); } + ModelQuery whereWith( + JsonMap Function(JsonMap where) build, { + bool merge = true, + }) { + final current = Map.from(_state.where); + final next = build(Map.unmodifiable(current)); + return where(next, merge: merge); + } + ModelQuery orderBy(List orderBy, {bool append = true}) { final nextOrderBy = append ? [..._state.orderBy, ...orderBy] @@ -3185,6 +3229,15 @@ final class ModelQuery { ); } + ModelQuery selectWith( + List Function(List fields) build, { + bool append = false, + }) { + final current = List.from(_state.select, growable: false); + final next = build(List.unmodifiable(current)); + return select(next, append: append); + } + ModelQuery selectField(String field) { return select([field], append: true); } @@ -3251,6 +3304,32 @@ final class ModelQuery { ); } + ModelQuery cursor(JsonMap cursor) { + _throwApiNotImplemented( + 'orm.query.cursor', + details: { + 'model': _delegate.modelName, + 'cursor': cursor, + }, + ); + } + + ModelQuery page({ + required int size, + JsonMap? after, + JsonMap? before, + }) { + _throwApiNotImplemented( + 'orm.query.page', + details: { + 'model': _delegate.modelName, + 'size': size, + if (after != null) 'after': after, + if (before != null) 'before': before, + }, + ); + } + ModelQuery unbounded() { return _next( ModelQueryState( @@ -3320,6 +3399,18 @@ final class ModelQuery { Future exists() => _delegate.exists(where: _state.where); + Future explain() async { + _throwApiNotImplemented( + 'orm.query.explain', + details: { + 'model': _delegate.modelName, + 'where': _state.where, + if (_state.skip != null) 'skip': _state.skip, + if (_state.take != null) 'take': _state.take, + }, + ); + } + Future aggregate({ bool countAll = false, List count = const [], @@ -3408,6 +3499,16 @@ final class ModelQuery { ); } + Future updateMany({required JsonMap data}) { + _assertMutationQueryState(action: 'updateMany'); + return _delegate.updateMany( + where: _state.where, + data: data, + select: _state.select, + include: _state.include, + ); + } + Future deleteMany() { _assertMutationQueryState(action: 'deleteMany'); return _delegate.deleteMany(where: _state.where); diff --git a/pub/orm/lib/src/runtime/errors.dart b/pub/orm/lib/src/runtime/errors.dart index 676b6837..19c4279b 100644 --- a/pub/orm/lib/src/runtime/errors.dart +++ b/pub/orm/lib/src/runtime/errors.dart @@ -284,6 +284,18 @@ final class PlanRepositoryTraceInvalidException extends OrmRuntimeError { ); } +final class ApiNotImplementedException extends OrmRuntimeError { + ApiNotImplementedException({ + required String surface, + Map details = const {}, + }) : super( + code: 'RUNTIME.API_NOT_IMPLEMENTED', + category: RuntimeErrorCategory.runtime, + message: 'API surface is declared but not implemented yet.', + details: {'surface': surface, ...details}, + ); +} + final class RuntimeCreateResultMissingException extends OrmRuntimeError { RuntimeCreateResultMissingException({required String model}) : super( diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart new file mode 100644 index 00000000..df70ee2d --- /dev/null +++ b/pub/orm/test/client/api_surface_test.dart @@ -0,0 +1,123 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + final contract = OrmContract( + version: '1', + hash: 'contract-v1', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + aliases: {'users': 'User'}, + ); + + group('api surface shell', () { + test('whereWith merges immutable query state', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + final base = users.where({'id': 'u1'}); + final next = base.whereWith( + (where) => {...where, 'email': 'a@x.com'}, + ); + + expect(base.whereClause, {'id': 'u1'}); + expect(next.whereClause, { + 'id': 'u1', + 'email': 'a@x.com', + }); + }); + + test('selectWith appends from immutable selected fields snapshot', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + final base = users.select(const ['id']); + final next = base.selectWith( + (fields) => [...fields, 'email'], + append: false, + ); + + expect(base.selectedFields, ['id']); + expect(next.selectedFields, ['id', 'email']); + }); + + test('cursor placeholder throws stable not implemented error', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users.query().cursor({'id': 'u1'}), + throwsA( + isA().having( + (error) => error.details['surface'], + 'surface', + 'orm.query.cursor', + ), + ), + ); + }); + + test('page placeholder throws stable not implemented error', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users.query().page(size: 20, after: {'id': 'u1'}), + throwsA( + isA().having( + (error) => error.details['surface'], + 'surface', + 'orm.query.page', + ), + ), + ); + }); + + test('explain placeholder throws stable not implemented error', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await expectLater( + users.where({'id': 'u1'}).explain(), + throwsA( + isA().having( + (error) => error.details['surface'], + 'surface', + 'orm.query.explain', + ), + ), + ); + } finally { + await client.disconnect(); + } + }); + + test('updateMany placeholder throws stable not implemented error', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await expectLater( + users + .where({'id': 'u1'}) + .updateMany(data: {'email': 'b@x.com'}), + throwsA( + isA().having( + (error) => error.details['surface'], + 'surface', + 'orm.updateMany', + ), + ), + ); + } finally { + await client.disconnect(); + } + }); + }); +} From 666b2e5ce7abafb7e507199390c38c5f804a2ef2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:20:36 +0800 Subject: [PATCH 099/154] feat(generator)!: align typed api shell with runtime surface --- pub/orm/lib/src/generator/writer.dart | 362 +++++++++++++++++++++- pub/orm/test/generator/generate_test.dart | 155 ++++++++- 2 files changed, 512 insertions(+), 5 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index acb482f0..fe619f3b 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1392,6 +1392,64 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln('}'); buffer.writeln(); + + buffer.writeln('class ${model.nestedCreateInputClassName} {'); + for (final relation in relationFields) { + final relationModel = lookup[relation.relationModel]; + if (relationModel == null) { + continue; + } + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + buffer.writeln( + ' final List<${relationModel.createInputClassName}>? $memberName;', + ); + } + if (relationFields.any((relation) => lookup[relation.relationModel] != null)) { + buffer.writeln(); + buffer.writeln(' const ${model.nestedCreateInputClassName}({'); + for (final relation in relationFields) { + final relationModel = lookup[relation.relationModel]; + if (relationModel == null) { + continue; + } + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + buffer.writeln(' this.$memberName,'); + } + buffer.writeln(' });'); + } else { + buffer.writeln(); + buffer.writeln(' const ${model.nestedCreateInputClassName}();'); + } + buffer.writeln(); + buffer.writeln(' Map> toJson() {'); + if (!relationFields.any((relation) => lookup[relation.relationModel] != null)) { + buffer.writeln(' return const >{};'); + } else { + buffer.writeln(' final create = >{};'); + for (final relation in relationFields) { + final relationModel = lookup[relation.relationModel]; + if (relationModel == null) { + continue; + } + final memberName = _toLowerCamelIdentifier( + relation.name, + fallback: 'relation', + ); + buffer.writeln( + " if ($memberName != null) create['${_escapeString(relation.name)}'] = $memberName!.map((entry) => entry.toJson()).toList(growable: false);", + ); + } + buffer.writeln(' return create;'); + } + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); } void _writeAggregateBucketClass({ @@ -1544,6 +1602,83 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' ${model.queryClassName} where(${model.whereInputClassName} where, {bool merge = true}) => query().where(where, merge: merge);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} whereWith(${model.whereInputClassName} Function(${model.whereInputClassName} where) build, {bool merge = true}) => query().whereWith(build, merge: merge);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} orderBy(List<${model.orderByClassName}> orderBy) => query().orderBy(orderBy);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} distinct(List<${model.distinctClassName}> distinct, {bool append = false}) => query().distinct(distinct, append: append);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} skip(int? skip) => query().skip(skip);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} take(int? take) => query().take(take);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} select(${model.selectClassName}? select, {bool merge = false}) => query().select(select, merge: merge);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} selectWith(${model.selectClassName} Function(${model.selectClassName} select) build, {bool merge = false}) => query().selectWith(build, merge: merge);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} include(${model.includeClassName}? include, {bool merge = true}) => query().include(include, merge: merge);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} includeWith(${model.includeClassName} Function(${model.includeClassName} include) build, {bool merge = true}) => query().includeWith(build, merge: merge);', + ); + buffer.writeln(); + + buffer.writeln(' Future toPlan({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln( + ' List<${model.orderByClassName}> orderBy = const <${model.orderByClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).toPlan();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future> all({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -1670,6 +1805,30 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}> createNested({'); + buffer.writeln(' required ${model.createInputClassName} data,'); + buffer.writeln( + ' ${model.nestedCreateInputClassName} create = const ${model.nestedCreateInputClassName}(),', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) async {'); + buffer.writeln( + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', + ); + buffer.writeln(' final row = await _delegate.createNested('); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' create: create.toJson(),'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future> createMany({'); buffer.writeln(' required List<${model.createInputClassName}> data,'); buffer.writeln(' ${model.selectClassName}? select,'); @@ -1719,6 +1878,37 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> updateNested({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln( + ' ${model.nestedCreateInputClassName} create = const ${model.nestedCreateInputClassName}(),', + ); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) async {'); + buffer.writeln( + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', + ); + buffer.writeln(' final row = await _delegate.updateNested('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' create: create.toJson(),'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> delete({'); buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? select,'); @@ -1766,6 +1956,29 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future updateMany({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) {'); + buffer.writeln( + ' final runtimeSelect = select?.toFields() ?? const [];', + ); + buffer.writeln( + ' final runtimeInclude = include?.toIncludeMap() ?? const {};', + ); + buffer.writeln(' return _delegate.updateMany('); + buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' select: runtimeSelect,'); + buffer.writeln(' include: runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future deleteMany({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -2223,6 +2436,7 @@ final class TypedClientWriter { required StringBuffer buffer, required _ResolvedModel model, }) { + final runtimeName = _escapeString(model.model.runtimeName); final relationFields = model.model.fields .where((field) => field.isRelation) .toList(growable: false); @@ -2275,6 +2489,14 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' ${model.queryClassName} whereWith(${model.whereInputClassName} Function(${model.whereInputClassName} where) build, {bool merge = true}) {', + ); + buffer.writeln(' final next = build(_where);'); + buffer.writeln(' return where(next, merge: merge);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' ${model.queryClassName} skip(int? skip) {'); buffer.writeln(' return ${model.queryClassName}._('); buffer.writeln(' delegate: _delegate,'); @@ -2303,6 +2525,50 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' ${model.queryClassName} unbounded() {'); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: null,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} cursor(${model.whereUniqueInputClassName} cursor) {', + ); + buffer.writeln(' throw ApiNotImplementedException('); + buffer.writeln(" surface: 'orm.query.cursor',"); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'cursor': cursor.toJson(),"); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' ${model.queryClassName} page({'); + buffer.writeln(' required int size,'); + buffer.writeln(' ${model.whereUniqueInputClassName}? after,'); + buffer.writeln(' ${model.whereUniqueInputClassName}? before,'); + buffer.writeln(' }) {'); + buffer.writeln(' throw ApiNotImplementedException('); + buffer.writeln(" surface: 'orm.query.page',"); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'size': size,"); + buffer.writeln(" if (after != null) 'after': after.toJson(),"); + buffer.writeln(" if (before != null) 'before': before.toJson(),"); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( ' ${model.queryClassName} orderBy(List<${model.orderByClassName}> orderBy) {', ); @@ -2320,7 +2586,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' ${model.queryClassName} distinct(List<${model.distinctClassName}> distinct) {', + ' ${model.queryClassName} distinct(List<${model.distinctClassName}> distinct, {bool append = false}) {', ); buffer.writeln(' return ${model.queryClassName}._('); buffer.writeln(' delegate: _delegate,'); @@ -2328,7 +2594,11 @@ final class TypedClientWriter { buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); - buffer.writeln(' distinct: distinct,'); + buffer.writeln(' distinct: append'); + buffer.writeln( + ' ? <${model.distinctClassName}>[..._distinct, ...distinct]', + ); + buffer.writeln(' : distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); @@ -2336,7 +2606,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' ${model.queryClassName} select(${model.selectClassName}? select) {', + ' ${model.queryClassName} select(${model.selectClassName}? select, {bool merge = false}) {', ); buffer.writeln(' return ${model.queryClassName}._('); buffer.writeln(' delegate: _delegate,'); @@ -2345,12 +2615,27 @@ final class TypedClientWriter { buffer.writeln(' take: _take,'); buffer.writeln(' orderBy: _orderBy,'); buffer.writeln(' distinct: _distinct,'); - buffer.writeln(' select: select,'); + buffer.writeln(' select: merge'); + buffer.writeln( + ' ? (select == null ? _select : (_select?.merge(select) ?? select))', + ); + buffer.writeln(' : select,'); buffer.writeln(' include: _include,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' ${model.queryClassName} selectWith(${model.selectClassName} Function(${model.selectClassName} select) build, {bool merge = false}) {', + ); + buffer.writeln( + ' final current = _select ?? const ${model.selectClassName}();', + ); + buffer.writeln(' final next = build(current);'); + buffer.writeln(' return select(next, merge: merge);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( ' ${model.queryClassName} include(${model.includeClassName}? include, {bool merge = true}) {', ); @@ -2409,6 +2694,19 @@ final class TypedClientWriter { buffer.writeln(); } + buffer.writeln(' Future toPlan() {'); + buffer.writeln(' return _delegate.toPlan('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future> all() {'); buffer.writeln(' return _delegate.all('); buffer.writeln(' where: _where,'); @@ -2422,6 +2720,17 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> oneOrNull() {'); + buffer.writeln(' return _delegate.oneOrNull('); + buffer.writeln( + ' where: ${model.whereUniqueInputClassName}.fromJson(_where.toJson()),', + ); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> firstOrNull() {'); buffer.writeln(' return _delegate.firstOrNull('); buffer.writeln(' where: _where,'); @@ -2447,6 +2756,19 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future explain() async {'); + buffer.writeln(' throw ApiNotImplementedException('); + buffer.writeln(" surface: 'orm.query.explain',"); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'where': _where.toJson(),"); + buffer.writeln(" if (_skip != null) 'skip': _skip,"); + buffer.writeln(" if (_take != null) 'take': _take,"); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.aggregateResultClassName}> aggregate({'); buffer.writeln(' bool countAll = false,'); buffer.writeln( @@ -2530,6 +2852,36 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' Future<${model.dataClassName}?> updateNested({', + ); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln( + ' ${model.nestedCreateInputClassName} create = const ${model.nestedCreateInputClassName}(),', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return _delegate.updateNested('); + buffer.writeln(' where: _where,'); + buffer.writeln(' data: data,'); + buffer.writeln(' create: create,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future updateMany({required ${model.updateInputClassName} data}) {', + ); + buffer.writeln(' return _delegate.updateMany('); + buffer.writeln(' where: _where,'); + buffer.writeln(' data: data,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future deleteMany() {'); buffer.writeln(' return _delegate.deleteMany(where: _where);'); buffer.writeln(' }'); @@ -3511,6 +3863,8 @@ final class _ResolvedModel { String get createInputClassName => '${classBaseName}CreateInput'; + String get nestedCreateInputClassName => '${classBaseName}NestedCreateInput'; + String get updateInputClassName => '${classBaseName}UpdateInput'; String get orderByClassName => '${classBaseName}OrderBy'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 0d056df2..883f5f06 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -545,6 +545,62 @@ typedef Post = ({ reason: 'Expected UserQuery.where(...) chaining to stay immutable by returning a new query object.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+whereWith\(\s*UserWhereInput\s+Function\(\s*UserWhereInput\s+where\s*\)\s+build,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.whereWith(...) to expose typed callback authoring helper.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+whereWith\(\s*UserWhereInput\s+Function\(\s*UserWhereInput\s+where\s*\)\s+build,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.whereWith(...) to expose typed callback authoring helper.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+whereWith\([\s\S]*?return\s+where\(next,\s*merge:\s*merge\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.whereWith(...) to route through where(..., merge: merge).', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+selectWith\(\s*UserSelect\s+Function\(\s*UserSelect\s+select\s*\)\s+build,\s*\{\s*bool\s+merge\s*=\s*false,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.selectWith(...) to expose typed select callback authoring helper.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+select\(\s*UserSelect\?\s+select,\s*\{\s*bool\s+merge\s*=\s*false,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.select(...) to expose merge flag for typed select state.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+selectWith\(\s*UserSelect\s+Function\(\s*UserSelect\s+select\s*\)\s+build,\s*\{\s*bool\s+merge\s*=\s*false,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.selectWith(...) to expose typed select callback authoring helper.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+selectWith\([\s\S]*?return\s+select\(next,\s*merge:\s*merge\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.selectWith(...) to route through select(..., merge: merge).', + ); expect( RegExp( r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+include\(\s*UserInclude\?\s+include,\s*\{\s*bool\s+merge\s*=\s*true,?\s*\}\s*\)', @@ -609,6 +665,57 @@ typedef Post = ({ reason: 'Expected UserQuery execution path to keep include state forwarding from query chain.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+unbounded\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.unbounded() in generated source.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserWhereUniqueInput\s+cursor\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.cursor(...) placeholder to use typed unique cursor input.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+page\(\{\s*required\s+int\s+size,\s*UserWhereUniqueInput\?\s+after,\s*UserWhereUniqueInput\?\s+before,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.page(...) placeholder to use typed unique cursor inputs.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+toPlan\(\{', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserDelegate.toPlan(...) in generated source.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+toPlan\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.toPlan() in generated source.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+oneOrNull\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.oneOrNull() in generated source.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+explain\(\s*\)\s+async', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.explain() placeholder in generated source.', + ); expect( RegExp( r'\bFuture>\s+all\s*\(\s*\)', @@ -736,6 +843,52 @@ typedef Post = ({ reason: 'Expected update where parameter to use UserWhereUniqueInput.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateMany\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),\s*required\s+UserUpdateInput\s+data,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.updateMany(...) placeholder to expose typed where and data input.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+updateMany\(\{\s*required\s+UserUpdateInput\s+data\}\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.updateMany(...) placeholder to expose typed update data input.', + ); + expect( + RegExp(r'\bclass UserNestedCreateInput\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed nested create input wrapper.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+createNested\(\{\s*required\s+UserCreateInput\s+data,\s*UserNestedCreateInput\s+create\s*=\s*const\s+UserNestedCreateInput\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.createNested(...) to expose typed nested create input.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateNested\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),\s*required\s+UserUpdateInput\s+data,\s*UserNestedCreateInput\s+create\s*=\s*const\s+UserNestedCreateInput\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.updateNested(...) to expose typed nested create input.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+updateNested\(\{\s*required\s+UserUpdateInput\s+data,\s*UserNestedCreateInput\s+create\s*=\s*const\s+UserNestedCreateInput\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.updateNested(...) to expose typed nested create input.', + ); expect( RegExp( r'Future\s+delete\(\{\s*required\s+UserWhereUniqueInput\s+where,', @@ -834,7 +987,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+distinct\(List\s+distinct\)', + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+distinct\(\s*List\s+distinct,\s*\{\s*bool\s+append\s*=\s*false,?\s*\}\s*\)', ).hasMatch(generatedSource), isTrue, reason: From e94c0b5561202135b2fd7a11a8e35da4bbc5973d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:51:00 +0800 Subject: [PATCH 100/154] feat(runtime)!: promote cursor page and explain to plan surface --- docs/orm-v6-api-surface.md | 9 +- pub/orm/lib/src/client/client.dart | 201 ++++++++++--- pub/orm/lib/src/generator/writer.dart | 306 +++++++++++++------- pub/orm/lib/src/runtime/core.dart | 92 ++++++ pub/orm/lib/src/runtime/errors.dart | 12 + pub/orm/lib/src/runtime/plan.dart | 98 +++++++ pub/orm/test/client/api_surface_test.dart | 129 +++++++-- pub/orm/test/generator/generate_test.dart | 30 +- pub/orm/test/runtime/plan_surface_test.dart | 43 +++ 9 files changed, 746 insertions(+), 174 deletions(-) create mode 100644 pub/orm/test/runtime/plan_surface_test.dart diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 351c58df..a9dd4ea9 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -9,6 +9,9 @@ completeness. The rule for this phase is simple: Placeholder methods must throw `RUNTIME.API_NOT_IMPLEMENTED`. +`plan-only` means the API can build or inspect structured plans, but execution +still throws the stable placeholder error. + ## Runtime Root Single entrypoint: @@ -69,8 +72,8 @@ Read authoring: | `skip(...)` | implemented | | `take(...)` | implemented | | `unbounded()` | implemented | -| `cursor(...)` | placeholder | -| `page(...)` | placeholder | +| `cursor(...)` | plan-only | +| `page(...)` | plan-only | Read terminals: @@ -85,7 +88,7 @@ Read terminals: | `exists()` | implemented | | `aggregate(...)` | implemented | | `groupBy(...)` | implemented | -| `explain()` | placeholder | +| `explain()` | plan-only | ## ORM Mutation Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 308dfe82..5071013f 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -989,6 +989,14 @@ class ModelDelegate { ModelQuery take(int value) => query().take(value); + ModelQuery cursor(JsonMap cursor) => query().cursor(cursor); + + ModelQuery page({ + required int size, + JsonMap? after, + JsonMap? before, + }) => query().page(size: size, after: after, before: before); + ModelQuery select(List fields) => query().select(fields); ModelQuery selectWith( @@ -1024,6 +1032,8 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, }) async { final prepared = await _buildReadPlan( resultMode: OrmReadResultMode.all, @@ -1034,6 +1044,8 @@ class ModelDelegate { distinct: distinct, select: select, include: include, + cursor: cursor, + page: page, ); return prepared.plan; } @@ -1406,6 +1418,8 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, JsonMap annotations = const {}, OrmRepositoryTrace? repositoryTrace, }) async { @@ -1463,6 +1477,8 @@ class ModelDelegate { distinct: isCollectionRead ? distinct : const [], select: readSelect, include: _buildOrmIncludePlanMap(normalizedInclude), + cursor: cursor == null ? null : OrmReadCursorPlan(values: cursor), + page: page, resultMode: resultMode, ), ); @@ -3111,6 +3127,8 @@ final class ModelQueryState { final List distinct; final List select; final Map include; + final JsonMap? cursor; + final OrmReadPagePlan? page; const ModelQueryState({ this.where = const {}, @@ -3120,6 +3138,8 @@ final class ModelQueryState { this.distinct = const [], this.select = const [], this.include = const {}, + this.cursor, + this.page, }); } @@ -3144,6 +3164,10 @@ final class ModelQuery { Map get includeValues => _state.include; + JsonMap? get cursorValues => _state.cursor; + + OrmReadPagePlan? get pageWindow => _state.page; + ModelQuery where(JsonMap where, {bool merge = true}) { final nextWhere = merge ? {..._state.where, ...where} @@ -3157,6 +3181,8 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: _state.page, ), ); } @@ -3183,6 +3209,8 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: _state.page, ), ); } @@ -3204,6 +3232,8 @@ final class ModelQuery { distinct: nextDistinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: _state.page, ), ); } @@ -3225,6 +3255,8 @@ final class ModelQuery { distinct: _state.distinct, select: nextSelect, include: _state.include, + cursor: _state.cursor, + page: _state.page, ), ); } @@ -3256,6 +3288,8 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: nextInclude, + cursor: _state.cursor, + page: _state.page, ), ); } @@ -3286,6 +3320,8 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: null, ), ); } @@ -3300,17 +3336,33 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: null, ), ); } ModelQuery cursor(JsonMap cursor) { - _throwApiNotImplemented( - 'orm.query.cursor', - details: { - 'model': _delegate.modelName, - 'cursor': cursor, - }, + if (cursor.isEmpty) { + throw PlanCursorWindowInvalidException( + reason: 'cursorEmpty', + details: {'model': _delegate.modelName}, + ); + } + return _next( + ModelQueryState( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + distinct: _state.distinct, + select: _state.select, + include: _state.include, + cursor: Map.unmodifiable( + Map.from(cursor), + ), + page: null, + ), ); } @@ -3319,14 +3371,49 @@ final class ModelQuery { JsonMap? after, JsonMap? before, }) { - _throwApiNotImplemented( - 'orm.query.page', - details: { - 'model': _delegate.modelName, - 'size': size, - if (after != null) 'after': after, - if (before != null) 'before': before, - }, + if (size <= 0) { + throw PlanCursorWindowInvalidException( + reason: 'pageSizeInvalid', + details: { + 'model': _delegate.modelName, + 'size': size, + }, + ); + } + if (after != null && before != null) { + throw PlanCursorWindowInvalidException( + reason: 'pageDirectionAmbiguous', + details: {'model': _delegate.modelName}, + ); + } + if (after != null && after.isEmpty) { + throw PlanCursorWindowInvalidException( + reason: 'pageAfterEmpty', + details: {'model': _delegate.modelName}, + ); + } + if (before != null && before.isEmpty) { + throw PlanCursorWindowInvalidException( + reason: 'pageBeforeEmpty', + details: {'model': _delegate.modelName}, + ); + } + return _next( + ModelQueryState( + where: _state.where, + skip: null, + take: null, + orderBy: _state.orderBy, + distinct: _state.distinct, + select: _state.select, + include: _state.include, + cursor: null, + page: OrmReadPagePlan( + size: size, + after: after == null ? null : Map.from(after), + before: before == null ? null : Map.from(before), + ), + ), ); } @@ -3340,6 +3427,8 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: null, ), ); } @@ -3353,10 +3442,13 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: _state.page, ); } Future> all() { + _assertReadExecutionSupported('all'); return _delegate.all( where: _state.where, skip: _state.skip, @@ -3369,6 +3461,7 @@ final class ModelQuery { } Stream stream() { + _assertReadExecutionSupported('stream'); return _delegate.stream( where: _state.where, skip: _state.skip, @@ -3380,35 +3473,40 @@ final class ModelQuery { ); } - Future oneOrNull() => _delegate.oneOrNull( - where: _state.where, - select: _state.select, - include: _state.include, - ); + Future oneOrNull() { + _assertReadExecutionSupported('oneOrNull'); + return _delegate.oneOrNull( + where: _state.where, + select: _state.select, + include: _state.include, + ); + } - Future firstOrNull() => _delegate.firstOrNull( - where: _state.where, - skip: _state.skip, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - ); + Future firstOrNull() { + _assertReadExecutionSupported('firstOrNull'); + return _delegate.firstOrNull( + where: _state.where, + skip: _state.skip, + orderBy: _state.orderBy, + distinct: _state.distinct, + select: _state.select, + include: _state.include, + ); + } - Future count() => _delegate.count(where: _state.where); + Future count() { + _assertReadExecutionSupported('count'); + return _delegate.count(where: _state.where); + } - Future exists() => _delegate.exists(where: _state.where); + Future exists() { + _assertReadExecutionSupported('exists'); + return _delegate.exists(where: _state.where); + } Future explain() async { - _throwApiNotImplemented( - 'orm.query.explain', - details: { - 'model': _delegate.modelName, - 'where': _state.where, - if (_state.skip != null) 'skip': _state.skip, - if (_state.take != null) 'take': _state.take, - }, - ); + final plan = await toPlan(); + return plan.toJson(); } Future aggregate({ @@ -3419,6 +3517,7 @@ final class ModelQuery { List sum = const [], List avg = const [], }) { + _assertReadExecutionSupported('aggregate'); return _delegate.aggregate( where: _state.where, countAll: countAll, @@ -3440,6 +3539,7 @@ final class ModelQuery { List sum = const [], List avg = const [], }) { + _assertReadExecutionSupported('groupBy'); return _delegate.groupBy( by: by, where: _state.where, @@ -3456,6 +3556,29 @@ final class ModelQuery { ); } + void _assertReadExecutionSupported(String terminal) { + if (_state.cursor != null) { + _throwApiNotImplemented( + 'orm.query.cursor.execute', + details: { + 'model': _delegate.modelName, + 'terminal': terminal, + 'cursor': _state.cursor, + }, + ); + } + if (_state.page != null) { + _throwApiNotImplemented( + 'orm.query.page.execute', + details: { + 'model': _delegate.modelName, + 'terminal': terminal, + 'page': _state.page!.toJson(), + }, + ); + } + } + void _assertMutationQueryState({ required String action, bool allowWhere = true, @@ -3466,6 +3589,8 @@ final class ModelQuery { if (_state.take != null) 'take', if (_state.orderBy.isNotEmpty) 'orderBy', if (_state.distinct.isNotEmpty) 'distinct', + if (_state.cursor != null) 'cursor', + if (_state.page != null) 'page', ]; if (invalidKeys.isEmpty) { return; diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index fe619f3b..f74f92b4 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1632,6 +1632,18 @@ final class TypedClientWriter { ); buffer.writeln(); + buffer.writeln( + ' ${model.queryClassName} cursor(${model.whereUniqueInputClassName} cursor) => query().cursor(cursor);', + ); + buffer.writeln(); + + buffer.writeln(' ${model.queryClassName} page({'); + buffer.writeln(' required int size,'); + buffer.writeln(' ${model.whereUniqueInputClassName}? after,'); + buffer.writeln(' ${model.whereUniqueInputClassName}? before,'); + buffer.writeln(' }) => query().page(size: size, after: after, before: before);'); + buffer.writeln(); + buffer.writeln( ' ${model.queryClassName} select(${model.selectClassName}? select, {bool merge = false}) => query().select(select, merge: merge);', ); @@ -2449,6 +2461,10 @@ final class TypedClientWriter { buffer.writeln(' final List<${model.distinctClassName}> _distinct;'); buffer.writeln(' final ${model.selectClassName}? _select;'); buffer.writeln(' final ${model.includeClassName}? _include;'); + buffer.writeln(' final ${model.whereUniqueInputClassName}? _cursor;'); + buffer.writeln(' final int? _pageSize;'); + buffer.writeln(' final ${model.whereUniqueInputClassName}? _pageAfter;'); + buffer.writeln(' final ${model.whereUniqueInputClassName}? _pageBefore;'); buffer.writeln(); buffer.writeln(' ${model.queryClassName}._({'); buffer.writeln(' required ${model.delegateClassName} delegate,'); @@ -2459,6 +2475,10 @@ final class TypedClientWriter { buffer.writeln(' required List<${model.distinctClassName}> distinct,'); buffer.writeln(' required ${model.selectClassName}? select,'); buffer.writeln(' required ${model.includeClassName}? include,'); + buffer.writeln(' ${model.whereUniqueInputClassName}? cursor,'); + buffer.writeln(' int? pageSize,'); + buffer.writeln(' ${model.whereUniqueInputClassName}? pageAfter,'); + buffer.writeln(' ${model.whereUniqueInputClassName}? pageBefore,'); buffer.writeln(' }) : _delegate = delegate,'); buffer.writeln(' _where = where,'); buffer.writeln(' _skip = skip,'); @@ -2470,7 +2490,11 @@ final class TypedClientWriter { ' _distinct = List<${model.distinctClassName}>.unmodifiable(distinct),', ); buffer.writeln(' _select = select,'); - buffer.writeln(' _include = include;'); + buffer.writeln(' _include = include,'); + buffer.writeln(' _cursor = cursor,'); + buffer.writeln(' _pageSize = pageSize,'); + buffer.writeln(' _pageAfter = pageAfter,'); + buffer.writeln(' _pageBefore = pageBefore;'); buffer.writeln(); buffer.writeln( @@ -2485,6 +2509,10 @@ final class TypedClientWriter { buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: _cursor,'); + buffer.writeln(' pageSize: _pageSize,'); + buffer.writeln(' pageAfter: _pageAfter,'); + buffer.writeln(' pageBefore: _pageBefore,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2507,6 +2535,10 @@ final class TypedClientWriter { buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: _cursor,'); + buffer.writeln(' pageSize: _pageSize,'); + buffer.writeln(' pageAfter: _pageAfter,'); + buffer.writeln(' pageBefore: _pageBefore,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2521,6 +2553,10 @@ final class TypedClientWriter { buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: _cursor,'); + buffer.writeln(' pageSize: null,'); + buffer.writeln(' pageAfter: null,'); + buffer.writeln(' pageBefore: null,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2535,6 +2571,10 @@ final class TypedClientWriter { buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: _cursor,'); + buffer.writeln(' pageSize: null,'); + buffer.writeln(' pageAfter: null,'); + buffer.writeln(' pageBefore: null,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2542,12 +2582,19 @@ final class TypedClientWriter { buffer.writeln( ' ${model.queryClassName} cursor(${model.whereUniqueInputClassName} cursor) {', ); - buffer.writeln(' throw ApiNotImplementedException('); - buffer.writeln(" surface: 'orm.query.cursor',"); - buffer.writeln(' details: {'); - buffer.writeln(" 'model': '$runtimeName',"); - buffer.writeln(" 'cursor': cursor.toJson(),"); - buffer.writeln(' },'); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: cursor,'); + buffer.writeln(' pageSize: null,'); + buffer.writeln(' pageAfter: null,'); + buffer.writeln(' pageBefore: null,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2557,14 +2604,33 @@ final class TypedClientWriter { buffer.writeln(' ${model.whereUniqueInputClassName}? after,'); buffer.writeln(' ${model.whereUniqueInputClassName}? before,'); buffer.writeln(' }) {'); - buffer.writeln(' throw ApiNotImplementedException('); - buffer.writeln(" surface: 'orm.query.page',"); - buffer.writeln(' details: {'); - buffer.writeln(" 'model': '$runtimeName',"); - buffer.writeln(" 'size': size,"); - buffer.writeln(" if (after != null) 'after': after.toJson(),"); - buffer.writeln(" if (before != null) 'before': before.toJson(),"); - buffer.writeln(' },'); + buffer.writeln(' if (size <= 0) {'); + buffer.writeln(' throw PlanCursorWindowInvalidException('); + buffer.writeln(" reason: 'pageSizeInvalid',"); + buffer.writeln( + " details: {'model': '$runtimeName', 'size': size},", + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' if (after != null && before != null) {'); + buffer.writeln(' throw PlanCursorWindowInvalidException('); + buffer.writeln(" reason: 'pageDirectionAmbiguous',"); + buffer.writeln(" details: {'model': '$runtimeName'},"); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: null,'); + buffer.writeln(' take: null,'); + buffer.writeln(' orderBy: _orderBy,'); + buffer.writeln(' distinct: _distinct,'); + buffer.writeln(' select: _select,'); + buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: null,'); + buffer.writeln(' pageSize: size,'); + buffer.writeln(' pageAfter: after,'); + buffer.writeln(' pageBefore: before,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2581,6 +2647,10 @@ final class TypedClientWriter { buffer.writeln(' distinct: _distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: _cursor,'); + buffer.writeln(' pageSize: _pageSize,'); + buffer.writeln(' pageAfter: _pageAfter,'); + buffer.writeln(' pageBefore: _pageBefore,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2601,6 +2671,10 @@ final class TypedClientWriter { buffer.writeln(' : distinct,'); buffer.writeln(' select: _select,'); buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: _cursor,'); + buffer.writeln(' pageSize: _pageSize,'); + buffer.writeln(' pageAfter: _pageAfter,'); + buffer.writeln(' pageBefore: _pageBefore,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2621,6 +2695,10 @@ final class TypedClientWriter { ); buffer.writeln(' : select,'); buffer.writeln(' include: _include,'); + buffer.writeln(' cursor: _cursor,'); + buffer.writeln(' pageSize: _pageSize,'); + buffer.writeln(' pageAfter: _pageAfter,'); + buffer.writeln(' pageBefore: _pageBefore,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2652,6 +2730,10 @@ final class TypedClientWriter { ' ? (include == null ? _include : (_include?.merge(include) ?? include))', ); buffer.writeln(' : include,'); + buffer.writeln(' cursor: _cursor,'); + buffer.writeln(' pageSize: _pageSize,'); + buffer.writeln(' pageAfter: _pageAfter,'); + buffer.writeln(' pageBefore: _pageBefore,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2694,78 +2776,78 @@ final class TypedClientWriter { buffer.writeln(); } - buffer.writeln(' Future toPlan() {'); - buffer.writeln(' return _delegate.toPlan('); - buffer.writeln(' where: _where,'); + buffer.writeln(' ModelQuery _runtimeQuery() {'); + buffer.writeln( + ' var query = _delegate._delegate.query(', + ); + buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _orderBy,'); - buffer.writeln(' distinct: _distinct,'); - buffer.writeln(' select: _select,'); - buffer.writeln(' include: _include,'); + buffer.writeln( + ' orderBy: _orderBy.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' distinct: _distinct.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' select: _select?.toFields() ?? const [],'); + buffer.writeln( + ' include: _include?.toIncludeMap() ?? const {},', + ); buffer.writeln(' );'); + buffer.writeln(' if (_cursor != null) {'); + buffer.writeln(' query = query.cursor(_cursor!.toJson());'); + buffer.writeln(' }'); + buffer.writeln(' if (_pageSize != null) {'); + buffer.writeln(' query = query.page('); + buffer.writeln(' size: _pageSize!,'); + buffer.writeln(" after: _pageAfter?.toJson(),"); + buffer.writeln(" before: _pageBefore?.toJson(),"); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' return query;'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future> all() {'); - buffer.writeln(' return _delegate.all('); - buffer.writeln(' where: _where,'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _orderBy,'); - buffer.writeln(' distinct: _distinct,'); - buffer.writeln(' select: _select,'); - buffer.writeln(' include: _include,'); - buffer.writeln(' );'); + buffer.writeln(' Future toPlan() {'); + buffer.writeln(' return _runtimeQuery().toPlan();'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> oneOrNull() {'); - buffer.writeln(' return _delegate.oneOrNull('); + buffer.writeln(' Future> all() async {'); + buffer.writeln(' final rows = await _runtimeQuery().all();'); buffer.writeln( - ' where: ${model.whereUniqueInputClassName}.fromJson(_where.toJson()),', + ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', ); - buffer.writeln(' select: _select,'); - buffer.writeln(' include: _include,'); - buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> firstOrNull() {'); - buffer.writeln(' return _delegate.firstOrNull('); - buffer.writeln(' where: _where,'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' orderBy: _orderBy,'); - buffer.writeln(' distinct: _distinct,'); - buffer.writeln(' select: _select,'); - buffer.writeln(' include: _include,'); - buffer.writeln(' );'); + buffer.writeln(' Future<${model.dataClassName}?> oneOrNull() async {'); + buffer.writeln(' final row = await _runtimeQuery().oneOrNull();'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Stream<${model.dataClassName}> stream() {'); - buffer.writeln(' return _delegate.stream('); - buffer.writeln(' where: _where,'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _orderBy,'); - buffer.writeln(' distinct: _distinct,'); - buffer.writeln(' select: _select,'); - buffer.writeln(' include: _include,'); - buffer.writeln(' );'); + buffer.writeln(' Future<${model.dataClassName}?> firstOrNull() async {'); + buffer.writeln(' final row = await _runtimeQuery().firstOrNull();'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Stream<${model.dataClassName}> stream() async* {'); + buffer.writeln(' await for (final row in _runtimeQuery().stream()) {'); + buffer.writeln(' yield ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future explain() async {'); - buffer.writeln(' throw ApiNotImplementedException('); - buffer.writeln(" surface: 'orm.query.explain',"); - buffer.writeln(' details: {'); - buffer.writeln(" 'model': '$runtimeName',"); - buffer.writeln(" 'where': _where.toJson(),"); - buffer.writeln(" if (_skip != null) 'skip': _skip,"); - buffer.writeln(" if (_take != null) 'take': _take,"); - buffer.writeln(' },'); - buffer.writeln(' );'); + buffer.writeln(' return _runtimeQuery().explain();'); buffer.writeln(' }'); buffer.writeln(); @@ -2787,15 +2869,24 @@ final class TypedClientWriter { ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); buffer.writeln(' }) {'); - buffer.writeln(' return _delegate.aggregate('); - buffer.writeln(' where: _where,'); + buffer.writeln(' return _runtimeQuery().aggregate('); buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' );'); + buffer.writeln( + ' count: count.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' min: min.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' max: max.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' sum: sum.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' avg: avg.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' ).then(${model.aggregateResultClassName}.fromJson);'); buffer.writeln(' }'); buffer.writeln(); @@ -2825,30 +2916,45 @@ final class TypedClientWriter { ); buffer.writeln(' }) {'); buffer.writeln(' return _delegate.groupBy('); - buffer.writeln(' by: by,'); + buffer.writeln( + ' by: by.map((entry) => entry.value).toList(growable: false),', + ); buffer.writeln(' where: _where,'); - buffer.writeln(' typedHaving: typedHaving,'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); + buffer.writeln(' typedHaving: typedHaving,'); buffer.writeln(' groupByOrderBy: groupByOrderBy,'); buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); + buffer.writeln( + ' count: count,', + ); + buffer.writeln( + ' min: min,', + ); + buffer.writeln( + ' max: max,', + ); + buffer.writeln( + ' sum: sum,', + ); + buffer.writeln( + ' avg: avg,', + ); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln( - ' Future> createMany({required List<${model.createInputClassName}> data}) {', + ' Future> createMany({required List<${model.createInputClassName}> data}) async {', + ); + buffer.writeln(' final rows = await _runtimeQuery().createMany('); + buffer.writeln( + ' data: data.map((entry) => entry.toJson()).toList(growable: false),', ); - buffer.writeln(' return _delegate.createMany('); - buffer.writeln(' data: data,'); - buffer.writeln(' select: _select,'); - buffer.writeln(' include: _include,'); buffer.writeln(' );'); + buffer.writeln( + ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', + ); buffer.writeln(' }'); buffer.writeln(); @@ -2859,41 +2965,37 @@ final class TypedClientWriter { buffer.writeln( ' ${model.nestedCreateInputClassName} create = const ${model.nestedCreateInputClassName}(),', ); - buffer.writeln(' }) {'); - buffer.writeln(' return _delegate.updateNested('); - buffer.writeln(' where: _where,'); - buffer.writeln(' data: data,'); - buffer.writeln(' create: create,'); - buffer.writeln(' select: _select,'); - buffer.writeln(' include: _include,'); + buffer.writeln(' }) async {'); + buffer.writeln(' final row = await _runtimeQuery().updateNested('); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' create: create.toJson(),'); buffer.writeln(' );'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln( ' Future updateMany({required ${model.updateInputClassName} data}) {', ); - buffer.writeln(' return _delegate.updateMany('); - buffer.writeln(' where: _where,'); - buffer.writeln(' data: data,'); - buffer.writeln(' select: _select,'); - buffer.writeln(' include: _include,'); - buffer.writeln(' );'); + buffer.writeln(' return _runtimeQuery().updateMany(data: data.toJson());'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future deleteMany() {'); - buffer.writeln(' return _delegate.deleteMany(where: _where);'); + buffer.writeln(' return _runtimeQuery().deleteMany();'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future count() {'); - buffer.writeln(' return _delegate.count(where: _where);'); + buffer.writeln(' return _runtimeQuery().count();'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future exists() {'); - buffer.writeln(' return _delegate.exists(where: _where);'); + buffer.writeln(' return _runtimeQuery().exists();'); buffer.writeln(' }'); buffer.writeln('}'); buffer.writeln(); diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 48af5d4f..f552f6c9 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -663,6 +663,98 @@ final class OrmRuntimeCore implements RuntimeCore { if (plan.take case final take? when take < 0) { throw PlanInvalidPaginationException(key: 'take', value: take); } + + final cursor = plan.cursor; + if (cursor != null) { + if (cursor.values.isEmpty) { + throw PlanCursorWindowInvalidException( + reason: 'cursorEmpty', + details: {'model': model.name}, + ); + } + _assertKnownFields( + model: model, + fields: cursor.values.keys, + source: 'cursor', + ); + } + + final page = plan.page; + if (page != null) { + if (page.size <= 0) { + throw PlanCursorWindowInvalidException( + reason: 'pageSizeInvalid', + details: {'model': model.name, 'size': page.size}, + ); + } + if (page.after != null && page.before != null) { + throw PlanCursorWindowInvalidException( + reason: 'pageDirectionAmbiguous', + details: {'model': model.name}, + ); + } + if (page.after case final after? when after.isEmpty) { + throw PlanCursorWindowInvalidException( + reason: 'pageAfterEmpty', + details: {'model': model.name}, + ); + } + if (page.before case final before? when before.isEmpty) { + throw PlanCursorWindowInvalidException( + reason: 'pageBeforeEmpty', + details: {'model': model.name}, + ); + } + if (cursor != null) { + throw PlanCursorWindowInvalidException( + reason: 'cursorAndPageTogether', + details: {'model': model.name}, + ); + } + if (plan.skip != null || plan.take != null) { + throw PlanCursorWindowInvalidException( + reason: 'pageWithOffsetLimit', + details: { + 'model': model.name, + if (plan.skip != null) 'skip': plan.skip, + if (plan.take != null) 'take': plan.take, + }, + ); + } + if (page.after != null) { + _assertKnownFields( + model: model, + fields: page.after!.keys, + source: 'page.after', + ); + } + if (page.before != null) { + _assertKnownFields( + model: model, + fields: page.before!.keys, + source: 'page.before', + ); + } + } + + if (cursor != null) { + throw ApiNotImplementedException( + surface: 'orm.plan.cursor.execute', + details: { + 'model': model.name, + 'cursor': cursor.toJson(), + }, + ); + } + if (page != null) { + throw ApiNotImplementedException( + surface: 'orm.plan.page.execute', + details: { + 'model': model.name, + 'page': page.toJson(), + }, + ); + } } void _assertMutationPlan({ diff --git a/pub/orm/lib/src/runtime/errors.dart b/pub/orm/lib/src/runtime/errors.dart index 19c4279b..6e1eac15 100644 --- a/pub/orm/lib/src/runtime/errors.dart +++ b/pub/orm/lib/src/runtime/errors.dart @@ -245,6 +245,18 @@ final class PlanInvalidPaginationException extends OrmRuntimeError { ); } +final class PlanCursorWindowInvalidException extends OrmRuntimeError { + PlanCursorWindowInvalidException({ + required String reason, + Map details = const {}, + }) : super( + code: 'PLAN.CURSOR_WINDOW_INVALID', + category: RuntimeErrorCategory.plan, + message: 'Cursor or page window is invalid for this read plan.', + details: {'reason': reason, ...details}, + ); +} + final class PlanResultModeActionInvalidException extends OrmRuntimeError { final OrmAction action; final OrmReadResultMode? readResultMode; diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index 706c0b03..ea2e6468 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -9,6 +9,37 @@ enum OrmReadResultMode { all, firstOrNull, oneOrNull } enum OrmMutationResultMode { row, rowOrNull } +@immutable +final class OrmReadCursorPlan { + final JsonMap values; + + OrmReadCursorPlan({ + JsonMap values = const {}, + }) : values = Map.unmodifiable(values); + + JsonMap toJson() => {'values': values}; +} + +@immutable +final class OrmReadPagePlan { + final int size; + final JsonMap? after; + final JsonMap? before; + + OrmReadPagePlan({ + required this.size, + JsonMap? after, + JsonMap? before, + }) : after = after == null ? null : Map.unmodifiable(after), + before = before == null ? null : Map.unmodifiable(before); + + JsonMap toJson() => { + 'size': size, + if (after != null) 'after': after, + if (before != null) 'before': before, + }; +} + @immutable final class OrmRepositoryTrace { final String operationId; @@ -28,6 +59,16 @@ final class OrmRepositoryTrace { this.relation, this.itemIndex, }); + + JsonMap toJson() => { + 'operationId': operationId, + 'kind': kind, + 'step': step, + 'phase': phase, + 'strategy': strategy, + if (relation != null) 'relation': relation, + if (itemIndex != null) 'itemIndex': itemIndex, + }; } @immutable @@ -36,6 +77,8 @@ final class OrmOrderBy { final SortOrder order; const OrmOrderBy(this.field, {this.order = SortOrder.asc}); + + JsonMap toJson() => {'field': field, 'order': order.name}; } @immutable @@ -58,6 +101,17 @@ final class OrmIncludePlan { orderBy = List.unmodifiable(orderBy), select = List.unmodifiable(select), include = Map.unmodifiable(include); + + JsonMap toJson() => { + 'where': where, + if (skip != null) 'skip': skip, + if (take != null) 'take': take, + 'orderBy': orderBy.map((entry) => entry.toJson()).toList(growable: false), + 'select': select, + 'include': { + for (final entry in include.entries) entry.key: entry.value.toJson(), + }, + }; } @immutable @@ -69,6 +123,8 @@ final class OrmReadPlan { final List distinct; final List select; final Map include; + final OrmReadCursorPlan? cursor; + final OrmReadPagePlan? page; final OrmReadResultMode resultMode; OrmReadPlan({ @@ -79,12 +135,29 @@ final class OrmReadPlan { List distinct = const [], List select = const [], Map include = const {}, + this.cursor, + this.page, required this.resultMode, }) : where = Map.unmodifiable(where), orderBy = List.unmodifiable(orderBy), distinct = List.unmodifiable(distinct), select = List.unmodifiable(select), include = Map.unmodifiable(include); + + JsonMap toJson() => { + 'where': where, + if (skip != null) 'skip': skip, + if (take != null) 'take': take, + 'orderBy': orderBy.map((entry) => entry.toJson()).toList(growable: false), + 'distinct': distinct, + 'select': select, + 'include': { + for (final entry in include.entries) entry.key: entry.value.toJson(), + }, + if (cursor != null) 'cursor': cursor!.toJson(), + if (page != null) 'page': page!.toJson(), + 'resultMode': resultMode.name, + }; } @immutable @@ -102,6 +175,13 @@ final class OrmMutationPlan { }) : where = Map.unmodifiable(where), data = Map.unmodifiable(data), select = List.unmodifiable(select); + + JsonMap toJson() => { + 'where': where, + 'data': data, + 'select': select, + 'resultMode': resultMode.name, + }; } @immutable @@ -148,6 +228,8 @@ final class OrmPlan { List distinct = const [], List select = const [], Map include = const {}, + OrmReadCursorPlan? cursor, + OrmReadPagePlan? page, required OrmReadResultMode resultMode, }) { return OrmPlan( @@ -168,6 +250,8 @@ final class OrmPlan { distinct: distinct, select: select, include: include, + cursor: cursor, + page: page, resultMode: resultMode, ), ); @@ -206,4 +290,18 @@ final class OrmPlan { ), ); } + + JsonMap toJson() => { + 'contractHash': contractHash, + if (target != null) 'target': target, + if (storageHash != null) 'storageHash': storageHash, + if (profileHash != null) 'profileHash': profileHash, + if (lane != null) 'lane': lane, + 'annotations': annotations, + if (repositoryTrace != null) 'repositoryTrace': repositoryTrace!.toJson(), + 'model': model, + 'action': action.name, + if (read != null) 'read': read!.toJson(), + if (mutation != null) 'mutation': mutation!.toJson(), + }; } diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index df70ee2d..ac0eb294 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -46,50 +46,109 @@ void main() { expect(next.selectedFields, ['id', 'email']); }); - test('cursor placeholder throws stable not implemented error', () { + test('cursor compiles into structured query plan state', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); - expect( - () => users.query().cursor({'id': 'u1'}), - throwsA( - isA().having( - (error) => error.details['surface'], - 'surface', - 'orm.query.cursor', - ), - ), - ); + final plan = await users.query().cursor({'id': 'u1'}).toPlan(); + + expect(plan.read?.cursor?.values, {'id': 'u1'}); + expect(plan.read?.page, isNull); }); - test('page placeholder throws stable not implemented error', () { + test('page compiles into structured query plan state', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); - expect( - () => users.query().page(size: 20, after: {'id': 'u1'}), - throwsA( - isA().having( - (error) => error.details['surface'], - 'surface', - 'orm.query.page', + final plan = await users + .query() + .page(size: 20, after: {'id': 'u1'}) + .toPlan(); + + expect(plan.read?.cursor, isNull); + expect(plan.read?.page?.size, 20); + expect(plan.read?.page?.after, {'id': 'u1'}); + }); + + test('explain returns structured plan json', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + final explained = await users + .where({'id': 'u1'}) + .take(1) + .explain(); + + expect(explained['lane'], 'orm'); + final read = explained['read'] as Map; + expect(read['where'], {'id': 'u1'}); + expect(read['take'], 1); + expect(read['resultMode'], 'all'); + }); + + test('cursor and page execution remain explicit placeholders', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + expect( + () => users.query().cursor({'id': 'u1'}).all(), + throwsA( + isA().having( + (error) => error.details['surface'], + 'surface', + 'orm.query.cursor.execute', + ), ), - ), - ); + ); + expect( + () => users + .query() + .page(size: 10, after: {'id': 'u1'}) + .all(), + throwsA( + isA().having( + (error) => error.details['surface'], + 'surface', + 'orm.query.page.execute', + ), + ), + ); + } finally { + await client.disconnect(); + } }); - test('explain placeholder throws stable not implemented error', () async { + test('direct plan execution rejects cursor and page plans', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); try { final users = client.db.orm.model('User'); + final cursorPlan = await users + .query() + .cursor({'id': 'u1'}) + .toPlan(); + final pagePlan = await users + .query() + .page(size: 10, after: {'id': 'u1'}) + .toPlan(); + + await expectLater( + client.execute(cursorPlan), + throwsA( + isA().having( + (error) => error.details['surface'], + 'surface', + 'orm.plan.cursor.execute', + ), + ), + ); await expectLater( - users.where({'id': 'u1'}).explain(), + client.execute(pagePlan), throwsA( isA().having( (error) => error.details['surface'], 'surface', - 'orm.query.explain', + 'orm.plan.page.execute', ), ), ); @@ -98,6 +157,28 @@ void main() { } }); + test('cursor and page validation are deterministic', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users.query().cursor(const {}), + throwsA(isA()), + ); + expect( + () => users.query().page(size: 0), + throwsA(isA()), + ); + expect( + () => users.query().page( + size: 10, + after: {'id': 'u1'}, + before: {'id': 'u2'}, + ), + throwsA(isA()), + ); + }); + test('updateMany placeholder throws stable not implemented error', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 883f5f06..bdbeae1e 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -659,11 +659,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+all\(\)\s*\{[\s\S]*?include:\s*_include,', + r'class\s+UserQuery\s*\{[\s\S]*?ModelQuery\s+_runtimeQuery\(\)[\s\S]*?Future>\s+all\(\)\s+async\s*\{[\s\S]*?_runtimeQuery\(\)\.all\(\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery execution path to keep include state forwarding from query chain.', + 'Expected UserQuery read execution to flow through runtime query builder.', ); expect( RegExp( @@ -672,13 +672,29 @@ typedef Post = ({ isTrue, reason: 'Expected UserQuery.unbounded() in generated source.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserWhereUniqueInput\s+cursor\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.cursor(...) convenience helper in generated source.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+page\(\{\s*required\s+int\s+size,\s*UserWhereUniqueInput\?\s+after,\s*UserWhereUniqueInput\?\s+before,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.page(...) convenience helper in generated source.', + ); expect( RegExp( r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserWhereUniqueInput\s+cursor\s*\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.cursor(...) placeholder to use typed unique cursor input.', + 'Expected UserQuery.cursor(...) to use typed unique cursor input.', ); expect( RegExp( @@ -686,7 +702,7 @@ typedef Post = ({ ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.page(...) placeholder to use typed unique cursor inputs.', + 'Expected UserQuery.page(...) to use typed unique cursor inputs.', ); expect( RegExp( @@ -697,7 +713,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+toPlan\(\s*\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+toPlan\(\s*\)[\s\S]*?_runtimeQuery\(\)\.toPlan\(\)', ).hasMatch(generatedSource), isTrue, reason: 'Expected UserQuery.toPlan() in generated source.', @@ -711,10 +727,10 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+explain\(\s*\)\s+async', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+explain\(\s*\)\s+async[\s\S]*?_runtimeQuery\(\)\.explain\(\)', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserQuery.explain() placeholder in generated source.', + reason: 'Expected UserQuery.explain() to route through runtime query explain.', ); expect( RegExp( diff --git a/pub/orm/test/runtime/plan_surface_test.dart b/pub/orm/test/runtime/plan_surface_test.dart new file mode 100644 index 00000000..978d2c14 --- /dev/null +++ b/pub/orm/test/runtime/plan_surface_test.dart @@ -0,0 +1,43 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + test('read plan keeps cursor and page immutable and serializable', () { + final cursor = {'id': 'u1'}; + final after = {'id': 'u2'}; + + final plan = OrmPlan.read( + contractHash: 'contract-v1', + lane: 'orm', + model: 'User', + where: const {'email': 'a@x.com'}, + cursor: OrmReadCursorPlan(values: cursor), + page: OrmReadPagePlan(size: 20, after: after), + resultMode: OrmReadResultMode.all, + ); + + cursor['id'] = 'mutated'; + after['id'] = 'mutated'; + + expect(plan.read?.cursor?.values, {'id': 'u1'}); + expect(plan.read?.page?.after, {'id': 'u2'}); + + final encoded = plan.toJson(); + expect(encoded['lane'], 'orm'); + expect(encoded['action'], 'read'); + final read = encoded['read'] as Map; + expect( + read['cursor'], + { + 'values': {'id': 'u1'}, + }, + ); + expect( + read['page'], + { + 'size': 20, + 'after': {'id': 'u2'}, + }, + ); + }); +} From d5966670232bc970339576cbbd18fa138b0aa202 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:05:39 +0800 Subject: [PATCH 101/154] feat(runtime)!: split plan inspection from explain and execute cursor windows --- docs/orm-v6-api-surface.md | 18 +- pub/orm/lib/src/client/client.dart | 321 +++++++++++++++++++--- pub/orm/lib/src/engine/memory_engine.dart | 98 ++++++- pub/orm/lib/src/generator/writer.dart | 5 + pub/orm/lib/src/runtime/core.dart | 163 +++++++++-- pub/orm/lib/src/sql/adapter.dart | 145 +++++++++- pub/orm/test/client/api_surface_test.dart | 149 ++++++---- pub/orm/test/generator/generate_test.dart | 7 + pub/orm/test/sql/sql_adapter_test.dart | 64 +++++ 9 files changed, 852 insertions(+), 118 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index a9dd4ea9..16527cfb 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -9,9 +9,6 @@ completeness. The rule for this phase is simple: Placeholder methods must throw `RUNTIME.API_NOT_IMPLEMENTED`. -`plan-only` means the API can build or inspect structured plans, but execution -still throws the stable placeholder error. - ## Runtime Root Single entrypoint: @@ -72,14 +69,15 @@ Read authoring: | `skip(...)` | implemented | | `take(...)` | implemented | | `unbounded()` | implemented | -| `cursor(...)` | plan-only | -| `page(...)` | plan-only | +| `cursor(...)` | implemented | +| `page(...)` | implemented | Read terminals: | Method | Status | | --- | --- | | `toPlan()` | implemented | +| `inspectPlan()` | implemented | | `all()` | implemented | | `stream()` | implemented | | `firstOrNull()` | implemented | @@ -88,7 +86,15 @@ Read terminals: | `exists()` | implemented | | `aggregate(...)` | implemented | | `groupBy(...)` | implemented | -| `explain()` | plan-only | +| `explain()` | implemented | + +Rules: + +1. `toPlan()` and `inspectPlan()` are pure authoring inspection. +2. `explain()` is runtime-facing and requires an active runtime connection. +3. `cursor(...)` and `page(...)` require deterministic ordering. +4. When no `orderBy(...)` is present, the boundary fields are promoted to + ascending `orderBy(...)` fields during plan compilation. ## ORM Mutation Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 5071013f..30959713 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -249,6 +249,53 @@ Map _buildOrmIncludePlanMap( }; } +List _readBoundaryFields({ + JsonMap? cursor, + OrmReadPagePlan? page, +}) { + final boundary = cursor ?? page?.after ?? page?.before; + if (boundary == null || boundary.isEmpty) { + return const []; + } + return boundary.keys.toList(growable: false); +} + +List _resolveCursorWindowOrderBy({ + required String modelName, + required List orderBy, + JsonMap? cursor, + OrmReadPagePlan? page, +}) { + final boundaryFields = _readBoundaryFields(cursor: cursor, page: page); + if (boundaryFields.isEmpty) { + return orderBy; + } + + if (orderBy.isEmpty) { + return boundaryFields + .map((field) => OrmOrderBy(field)) + .toList(growable: false); + } + + final orderByFields = orderBy + .map((entry) => entry.field) + .toList(growable: false); + if (orderByFields.length == boundaryFields.length && + orderByFields.every(boundaryFields.contains)) { + return orderBy; + } + + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_FIELDS_INVALID', + 'Cursor or page boundary fields must match orderBy fields.', + details: { + 'model': modelName, + 'orderBy': orderByFields, + 'boundaryFields': boundaryFields, + }, + ); +} + abstract interface class OrmDbContext { OrmDbNamespace get db; } @@ -269,6 +316,8 @@ abstract interface class OrmCollectionContext implements OrmExecutionContext { abstract interface class _OrmDelegateRuntime implements OrmCollectionContext { ModelDelegate _resolveDelegate(String modelKey); + + Future explainPlan(OrmPlan plan); } final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { @@ -324,14 +373,15 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { Future Function(OrmScopedClient connection) run, ) async { final connection = await _runtime.connection(); - final scoped = OrmScopedClient._( - contract: contract, - executePlan: connection.execute, - modelAliases: _modelAliases, - collectionRegistry: _collectionRegistry, - includeStrategySelector: includeStrategySelector, - maxIncludeDepth: maxIncludeDepth, - ); + final scoped = OrmScopedClient._( + contract: contract, + executePlan: connection.execute, + explainPlan: _runtime.explain, + modelAliases: _modelAliases, + collectionRegistry: _collectionRegistry, + includeStrategySelector: includeStrategySelector, + maxIncludeDepth: maxIncludeDepth, + ); try { return await run(scoped); @@ -352,6 +402,7 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { final scoped = OrmScopedClient._( contract: contract, executePlan: openedTransaction.execute, + explainPlan: _runtime.explain, modelAliases: _modelAliases, collectionRegistry: _collectionRegistry, includeStrategySelector: includeStrategySelector, @@ -406,6 +457,9 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { @override Future execute(OrmPlan plan) => _runtime.execute(plan); + @override + Future explainPlan(OrmPlan plan) => _runtime.explain(plan); + @override Future transaction(Future Function(OrmDbNamespace txDb) run) { return withTransaction((scoped) => run(scoped.db)); @@ -439,6 +493,7 @@ final class OrmScopedClient implements OrmDbContext, _OrmDelegateRuntime { @override final OrmContract contract; final Future Function(OrmPlan plan) _executePlan; + final Future Function(OrmPlan plan) _explainPlan; final Map _modelAliases; final Map _collectionRegistry; final Map _delegates = {}; @@ -454,11 +509,13 @@ final class OrmScopedClient implements OrmDbContext, _OrmDelegateRuntime { OrmScopedClient._({ required this.contract, required Future Function(OrmPlan plan) executePlan, + required Future Function(OrmPlan plan) explainPlan, required Map modelAliases, required Map collectionRegistry, required this.includeStrategySelector, required this.maxIncludeDepth, }) : _executePlan = executePlan, + _explainPlan = explainPlan, _modelAliases = modelAliases, _collectionRegistry = collectionRegistry; @@ -480,6 +537,9 @@ final class OrmScopedClient implements OrmDbContext, _OrmDelegateRuntime { @override Future execute(OrmPlan plan) => _executePlan(plan); + @override + Future explainPlan(OrmPlan plan) => _explainPlan(plan); + @override Future transaction(Future Function(OrmDbNamespace txDb) run) { return run(db); @@ -1058,6 +1118,8 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, }) { return _readAllInternal( action: OrmAction.read, @@ -1068,6 +1130,8 @@ class ModelDelegate { distinct: distinct, select: select, include: include, + cursor: cursor, + page: page, includeDepth: 0, ); } @@ -1080,6 +1144,8 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, }) async* { final rows = await all( where: where, @@ -1089,6 +1155,8 @@ class ModelDelegate { distinct: distinct, select: select, include: include, + cursor: cursor, + page: page, ); for (final row in rows) { @@ -1130,22 +1198,93 @@ class ModelDelegate { ); } - Future count({JsonMap where = const {}}) async { + Future count({ + JsonMap where = const {}, + List orderBy = const [], + JsonMap? cursor, + OrmReadPagePlan? page, + }) async { final rows = await _readAllInternal( action: OrmAction.read, where: where, + orderBy: orderBy, + cursor: cursor, + page: page, includeDepth: 0, ); return rows.length; } - Future exists({JsonMap where = const {}}) async { - final row = await firstOrNull(where: where, select: const []); - return row != null; + Future exists({ + JsonMap where = const {}, + List orderBy = const [], + JsonMap? cursor, + OrmReadPagePlan? page, + }) async { + final rowCount = await count( + where: where, + orderBy: orderBy, + cursor: cursor, + page: page, + ); + return rowCount > 0; + } + + Future inspectPlan({ + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + }) async { + final plan = await toPlan( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ); + return plan.toJson(); + } + + Future explain({ + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + }) async { + final plan = await toPlan( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ); + return _runtime.explainPlan(plan); } Future aggregate({ JsonMap where = const {}, + List orderBy = const [], + JsonMap? cursor, + OrmReadPagePlan? page, bool countAll = false, List count = const [], List min = const [], @@ -1162,6 +1301,9 @@ class ModelDelegate { final rows = await _readAllInternal( action: OrmAction.read, where: where, + orderBy: orderBy, + cursor: cursor, + page: page, select: _buildAggregateSelect( count: count, min: min, @@ -1186,6 +1328,8 @@ class ModelDelegate { Future> groupBy({ required List by, JsonMap where = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, JsonMap having = const {}, int? skip, int? take, @@ -1210,6 +1354,17 @@ class ModelDelegate { if (take case final limit? when limit < 0) { throw PlanInvalidPaginationException(key: 'take', value: limit); } + if (cursor != null || page != null) { + throw runtimeError( + 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', + 'GroupBy does not support cursor or page windows yet.', + details: { + 'model': modelName, + if (cursor != null) 'cursor': cursor, + if (page != null) 'page': page.toJson(), + }, + ); + } _assertKnownAggregateFields(fields: by, source: 'groupBy.by'); _assertKnownAggregateFields(fields: count, source: 'groupBy.count'); @@ -1435,8 +1590,41 @@ class ModelDelegate { model: modelName, where: where, ); + final resolvedOrderBy = _resolveCursorWindowOrderBy( + modelName: modelName, + orderBy: orderBy, + cursor: cursor, + page: page, + ); + if ((cursor != null || page != null) && distinct.isNotEmpty) { + throw runtimeError( + 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', + 'Cursor and page windows do not support distinct yet.', + details: { + 'model': modelName, + 'distinct': distinct, + if (cursor != null) 'cursor': cursor, + if (page != null) 'page': page.toJson(), + }, + ); + } + if (page != null && resultMode != OrmReadResultMode.all) { + throw runtimeError( + 'PLAN.PAGE_RESULT_MODE_INVALID', + 'Page windows currently compile only to collection read plans.', + details: { + 'model': modelName, + 'resultMode': resultMode.name, + 'page': page.toJson(), + }, + ); + } final isCollectionRead = resultMode != OrmReadResultMode.oneOrNull; - final resolvedTake = resultMode == OrmReadResultMode.firstOrNull ? 1 : take; + final resolvedTake = page != null + ? null + : resultMode == OrmReadResultMode.firstOrNull + ? 1 + : take; final readSelect = switch (resultMode) { OrmReadResultMode.oneOrNull => _expandSelectForInclude( model: modelName, @@ -1473,7 +1661,7 @@ class ModelDelegate { where: normalizedWhere, skip: isCollectionRead && distinct.isEmpty ? skip : null, take: isCollectionRead && distinct.isEmpty ? resolvedTake : null, - orderBy: isCollectionRead ? orderBy : const [], + orderBy: isCollectionRead ? resolvedOrderBy : const [], distinct: isCollectionRead ? distinct : const [], select: readSelect, include: _buildOrmIncludePlanMap(normalizedInclude), @@ -1493,6 +1681,8 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, JsonMap annotations = const {}, OrmRepositoryTrace? repositoryTrace, required int includeDepth, @@ -1506,6 +1696,8 @@ class ModelDelegate { distinct: distinct, select: select, include: include, + cursor: cursor, + page: page, annotations: annotations, repositoryTrace: repositoryTrace, ); @@ -3447,6 +3639,20 @@ final class ModelQuery { ); } + Future inspectPlan() { + return _delegate.inspectPlan( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + distinct: _state.distinct, + select: _state.select, + include: _state.include, + cursor: _state.cursor, + page: _state.page, + ); + } + Future> all() { _assertReadExecutionSupported('all'); return _delegate.all( @@ -3457,6 +3663,8 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: _state.page, ); } @@ -3470,11 +3678,20 @@ final class ModelQuery { distinct: _state.distinct, select: _state.select, include: _state.include, + cursor: _state.cursor, + page: _state.page, ); } - Future oneOrNull() { + Future oneOrNull() async { _assertReadExecutionSupported('oneOrNull'); + if (_state.cursor != null || _state.page != null) { + final rows = await all(); + if (rows.isEmpty) { + return null; + } + return rows.first; + } return _delegate.oneOrNull( where: _state.where, select: _state.select, @@ -3482,8 +3699,15 @@ final class ModelQuery { ); } - Future firstOrNull() { + Future firstOrNull() async { _assertReadExecutionSupported('firstOrNull'); + if (_state.cursor != null || _state.page != null) { + final rows = await all(); + if (rows.isEmpty) { + return null; + } + return rows.first; + } return _delegate.firstOrNull( where: _state.where, skip: _state.skip, @@ -3496,17 +3720,36 @@ final class ModelQuery { Future count() { _assertReadExecutionSupported('count'); - return _delegate.count(where: _state.where); + return _delegate.count( + where: _state.where, + orderBy: _state.orderBy, + cursor: _state.cursor, + page: _state.page, + ); } Future exists() { _assertReadExecutionSupported('exists'); - return _delegate.exists(where: _state.where); + return _delegate.exists( + where: _state.where, + orderBy: _state.orderBy, + cursor: _state.cursor, + page: _state.page, + ); } - Future explain() async { - final plan = await toPlan(); - return plan.toJson(); + Future explain() { + return _delegate.explain( + where: _state.where, + skip: _state.skip, + take: _state.take, + orderBy: _state.orderBy, + distinct: _state.distinct, + select: _state.select, + include: _state.include, + cursor: _state.cursor, + page: _state.page, + ); } Future aggregate({ @@ -3520,6 +3763,9 @@ final class ModelQuery { _assertReadExecutionSupported('aggregate'); return _delegate.aggregate( where: _state.where, + orderBy: _state.orderBy, + cursor: _state.cursor, + page: _state.page, countAll: countAll, count: count, min: min, @@ -3540,9 +3786,22 @@ final class ModelQuery { List avg = const [], }) { _assertReadExecutionSupported('groupBy'); + if (_state.cursor != null || _state.page != null) { + throw runtimeError( + 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', + 'GroupBy does not support cursor or page windows yet.', + details: { + 'model': _delegate.modelName, + if (_state.cursor != null) 'cursor': _state.cursor, + if (_state.page != null) 'page': _state.page!.toJson(), + }, + ); + } return _delegate.groupBy( by: by, where: _state.where, + cursor: _state.cursor, + page: _state.page, having: having, skip: _state.skip, take: _state.take, @@ -3557,23 +3816,15 @@ final class ModelQuery { } void _assertReadExecutionSupported(String terminal) { - if (_state.cursor != null) { - _throwApiNotImplemented( - 'orm.query.cursor.execute', - details: { - 'model': _delegate.modelName, - 'terminal': terminal, - 'cursor': _state.cursor, - }, - ); - } - if (_state.page != null) { - _throwApiNotImplemented( - 'orm.query.page.execute', + if ((_state.cursor != null || _state.page != null) && + _state.distinct.isNotEmpty) { + throw runtimeError( + 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', + 'Cursor and page windows do not support distinct yet.', details: { 'model': _delegate.modelName, 'terminal': terminal, - 'page': _state.page!.toJson(), + 'distinct': _state.distinct, }, ); } diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index 243b5f5f..9999e7f3 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -98,13 +98,7 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { rows.sort((left, right) => _compareRows(left, right, read.orderBy)); } - if (read.skip case final skip?) { - rows = skip >= rows.length ? [] : rows.sublist(skip); - } - - if (read.take case final take?) { - rows = take >= rows.length ? rows : rows.sublist(0, take); - } + rows = _applyReadWindow(rows, read); final projected = rows .map((row) => _projectRow(row, read.select)) @@ -117,6 +111,81 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { }; } + List _applyReadWindow(List rows, OrmReadPlan read) { + var next = rows; + + if (read.page case final page?) { + return _applyPageWindow(next, read.orderBy, page); + } + + if (read.cursor case final cursor?) { + next = next + .where( + (row) => + _compareRowToBoundary( + row: row, + boundary: cursor.values, + orderBy: read.orderBy, + ) >= + 0, + ) + .toList(growable: false); + } + + if (read.skip case final skip?) { + next = skip >= next.length ? [] : next.sublist(skip); + } + + if (read.take case final take?) { + next = take >= next.length ? next : next.sublist(0, take); + } + + return next; + } + + List _applyPageWindow( + List rows, + List orderBy, + OrmReadPagePlan page, + ) { + if (page.after case final after?) { + final filtered = rows + .where( + (row) => + _compareRowToBoundary( + row: row, + boundary: after, + orderBy: orderBy, + ) > + 0, + ) + .toList(growable: false); + return page.size >= filtered.length + ? filtered + : filtered.sublist(0, page.size); + } + + if (page.before case final before?) { + final filtered = rows + .where( + (row) => + _compareRowToBoundary( + row: row, + boundary: before, + orderBy: orderBy, + ) < + 0, + ) + .toList(growable: false); + if (page.size >= filtered.length) { + return filtered; + } + return filtered.sublist(filtered.length - page.size); + } + + return page.size >= rows.length ? rows : rows.sublist(0, page.size); + } + EngineResponse _create(List bucket, OrmPlan plan) { final mutation = plan.mutation!; final row = _cloneRow(mutation.data); @@ -437,6 +506,21 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { return 0; } + int _compareRowToBoundary({ + required JsonMap row, + required JsonMap boundary, + required List orderBy, + }) { + for (final order in orderBy) { + final comparison = _compareValues(row[order.field], boundary[order.field]); + if (comparison == 0) { + continue; + } + return order.order == SortOrder.asc ? comparison : -comparison; + } + return 0; + } + int _compareValues(Object? left, Object? right) { if (left == right) { return 0; diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index f74f92b4..95137149 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2813,6 +2813,11 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future inspectPlan() {'); + buffer.writeln(' return _runtimeQuery().inspectPlan();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future> all() async {'); buffer.writeln(' final rows = await _runtimeQuery().all();'); buffer.writeln( diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index f552f6c9..50bcfc7a 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -33,6 +33,70 @@ final class CallbackMarkerReader implements ContractMarkerReader { enum RuntimeVerifyMode { startup, onFirstUse, always } +String _readPaginationMode(OrmReadPlan? read) { + if (read == null) { + return 'none'; + } + if (read.page != null) { + return 'page'; + } + if (read.cursor != null) { + return 'cursor'; + } + if (read.skip != null || read.take != null) { + return 'offset'; + } + return 'none'; +} + +int? _estimatedRowsForExplain(OrmPlan plan) { + final read = plan.read; + if (read == null) { + return null; + } + if (read.page case final page?) { + return page.size; + } + if (read.resultMode != OrmReadResultMode.all) { + return 1; + } + return read.take; +} + +JsonMap _buildExplainResult(OrmPlan plan) { + final read = plan.read; + final mutation = plan.mutation; + + return Map.unmodifiable({ + 'source': 'heuristic', + 'estimatedRows': _estimatedRowsForExplain(plan), + 'usedIndexes': const [], + 'planSummary': Map.unmodifiable({ + 'model': plan.model, + 'action': plan.action.name, + if (plan.lane != null) 'lane': plan.lane, + 'executionMode': 'buffered', + if (read != null) 'readResultMode': read.resultMode.name, + if (mutation != null) 'mutationResultMode': mutation.resultMode.name, + if (read != null) 'selectedFieldCount': read.select.length, + if (mutation != null) 'selectedFieldCount': mutation.select.length, + if (read != null) 'includeCount': read.include.length, + 'pagination': { + 'mode': _readPaginationMode(read), + if (read?.skip != null) 'skip': read!.skip, + if (read?.take != null) 'take': read!.take, + if (read?.cursor != null) 'cursor': read!.cursor!.toJson(), + if (read?.page != null) 'page': read!.page!.toJson(), + if (read != null) + 'orderBy': read.orderBy + .map((entry) => entry.toJson()) + .toList(growable: false), + }, + }), + 'plan': plan.toJson(), + }); +} + @immutable final class RuntimeVerifyOptions { final RuntimeVerifyMode mode; @@ -156,6 +220,8 @@ abstract interface class RuntimeCore implements OrmRuntimeQueryable { RuntimeTelemetryEvent? telemetry(); + Future explain(OrmPlan plan); + RuntimeOperationTelemetryEvent? operationTelemetry([String? operationId]); List recentOperationTelemetry({int limit = 50}); @@ -262,6 +328,14 @@ final class OrmRuntimeCore implements RuntimeCore { @override RuntimeTelemetryEvent? telemetry() => _telemetry; + @override + Future explain(OrmPlan plan) async { + _ensureConnected(); + _assertPlan(plan); + await _verifyForRequest(); + return _buildExplainResult(plan); + } + @override RuntimeOperationTelemetryEvent? operationTelemetry([String? operationId]) { if (operationId == null) { @@ -680,6 +754,42 @@ final class OrmRuntimeCore implements RuntimeCore { } final page = plan.page; + if ((cursor != null || page != null) && plan.distinct.isNotEmpty) { + throw runtimeError( + 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', + 'Cursor and page windows do not support distinct yet.', + details: { + 'model': model.name, + 'distinct': plan.distinct, + }, + ); + } + if ((cursor != null || page != null) && plan.orderBy.isEmpty) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + 'Cursor and page windows require explicit orderBy fields in the plan.', + details: { + 'model': model.name, + if (cursor != null) 'cursor': cursor.toJson(), + if (page != null) 'page': page.toJson(), + }, + ); + } + if (cursor != null && + !_matchesBoundaryFields( + orderBy: plan.orderBy, + boundaryFields: cursor.values.keys, + )) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_FIELDS_INVALID', + 'Cursor boundary fields must match orderBy fields.', + details: { + 'model': model.name, + 'orderBy': plan.orderBy.map((entry) => entry.field).toList(), + 'boundaryFields': cursor.values.keys.toList(growable: false), + }, + ); + } if (page != null) { if (page.size <= 0) { throw PlanCursorWindowInvalidException( @@ -735,26 +845,43 @@ final class OrmRuntimeCore implements RuntimeCore { source: 'page.before', ); } + if (plan.resultMode != OrmReadResultMode.all) { + throw runtimeError( + 'PLAN.PAGE_RESULT_MODE_INVALID', + 'Page windows currently require read result mode "all".', + details: { + 'model': model.name, + 'resultMode': plan.resultMode.name, + }, + ); + } + final boundaryFields = page.after?.keys ?? page.before?.keys; + if (boundaryFields != null && + !_matchesBoundaryFields( + orderBy: plan.orderBy, + boundaryFields: boundaryFields, + )) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_FIELDS_INVALID', + 'Page boundary fields must match orderBy fields.', + details: { + 'model': model.name, + 'orderBy': plan.orderBy.map((entry) => entry.field).toList(), + 'boundaryFields': boundaryFields.toList(growable: false), + }, + ); + } } + } - if (cursor != null) { - throw ApiNotImplementedException( - surface: 'orm.plan.cursor.execute', - details: { - 'model': model.name, - 'cursor': cursor.toJson(), - }, - ); - } - if (page != null) { - throw ApiNotImplementedException( - surface: 'orm.plan.page.execute', - details: { - 'model': model.name, - 'page': page.toJson(), - }, - ); - } + bool _matchesBoundaryFields({ + required List orderBy, + required Iterable boundaryFields, + }) { + final orderByFields = orderBy.map((entry) => entry.field).toList(growable: false); + final boundary = boundaryFields.toList(growable: false); + return orderByFields.length == boundary.length && + orderByFields.every(boundary.contains); } void _assertMutationPlan({ diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 715e0528..c4022aa3 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -1,3 +1,4 @@ +import '../core/sort_order.dart'; import '../contract/contract.dart'; import '../engine/engine.dart'; import '../runtime/errors.dart'; @@ -94,19 +95,46 @@ final class SqlAdapter implements TargetAdapter { required String model, }) { final read = plan.read!; - final params = []; + final whereParams = []; final whereClause = _buildWhereClause( model: model, where: read.where, - params: params, + params: whereParams, + ); + final windowParams = []; + final windowPredicate = _buildCursorWindowPredicate( + read: read, + params: windowParams, ); + final mergedWhereClause = _mergeWhereClauses(whereClause, windowPredicate); final orderByClause = _buildOrderByClause(read.orderBy); + if (read.page?.before != null) { + final limitParams = []; + final selectColumns = _buildSelectColumns(read.select); + final innerOrderByClause = _buildOrderByClause(_reverseOrderBy(read.orderBy)); + final innerLimitClause = _buildReadLimitOffsetClause(read, limitParams); + return SqlStatement( + action: plan.action, + text: + 'SELECT $selectColumns FROM (' + 'SELECT * FROM ${_id(table)}' + '$mergedWhereClause$innerOrderByClause$innerLimitClause' + ') AS ${_id('_page')}$orderByClause', + parameters: [ + ...whereParams, + ...windowParams, + ...limitParams, + ], + ); + } + + final params = [...whereParams, ...windowParams]; return SqlStatement( action: plan.action, text: 'SELECT ${_buildSelectColumns(read.select)} FROM ${_id(table)}' - '$whereClause$orderByClause${_buildReadLimitOffsetClause(read, params)}', + '$mergedWhereClause$orderByClause${_buildReadLimitOffsetClause(read, params)}', parameters: params, ); } @@ -916,11 +944,120 @@ final class SqlAdapter implements TargetAdapter { return ' ORDER BY ${clauses.join(', ')}'; } + String _mergeWhereClauses(String whereClause, String predicate) { + if (predicate.isEmpty) { + return whereClause; + } + if (whereClause.isEmpty) { + return ' WHERE $predicate'; + } + return '$whereClause AND $predicate'; + } + + String _buildCursorWindowPredicate({ + required OrmReadPlan read, + required List params, + }) { + if (read.page?.after case final after?) { + return _buildBoundaryPredicate( + orderBy: read.orderBy, + boundary: after, + params: params, + inclusive: false, + before: false, + ); + } + if (read.page?.before case final before?) { + return _buildBoundaryPredicate( + orderBy: read.orderBy, + boundary: before, + params: params, + inclusive: false, + before: true, + ); + } + if (read.cursor case final cursor?) { + return _buildBoundaryPredicate( + orderBy: read.orderBy, + boundary: cursor.values, + params: params, + inclusive: true, + before: false, + ); + } + return ''; + } + + String _buildBoundaryPredicate({ + required List orderBy, + required JsonMap boundary, + required List params, + required bool inclusive, + required bool before, + }) { + if (orderBy.isEmpty) { + return ''; + } + + final equalityClauses = []; + final strictClauses = []; + for (var index = 0; index < orderBy.length; index++) { + final prefixClauses = [...equalityClauses]; + final order = orderBy[index]; + final operator = _boundaryOperator(order: order, before: before); + prefixClauses.add('${_id(order.field)} $operator ?'); + strictClauses.add('(${prefixClauses.join(' AND ')})'); + + for (var valueIndex = 0; valueIndex < index; valueIndex++) { + params.add(boundary[orderBy[valueIndex].field]); + } + params.add(boundary[order.field]); + + equalityClauses.add('${_id(order.field)} = ?'); + } + + final strictPredicate = strictClauses.join(' OR '); + if (!inclusive) { + return '($strictPredicate)'; + } + + final equalityPredicate = equalityClauses.join(' AND '); + for (final order in orderBy) { + params.add(boundary[order.field]); + } + return '(($strictPredicate) OR ($equalityPredicate))'; + } + + String _boundaryOperator({ + required OrmOrderBy order, + required bool before, + }) { + return switch ((order.order, before)) { + (SortOrder.asc, false) => '>', + (SortOrder.asc, true) => '<', + (SortOrder.desc, false) => '<', + (SortOrder.desc, true) => '>', + }; + } + + List _reverseOrderBy(List orderBy) { + return orderBy + .map( + (entry) => OrmOrderBy( + entry.field, + order: entry.order == SortOrder.asc + ? SortOrder.desc + : SortOrder.asc, + ), + ) + .toList(growable: false); + } + String _buildReadLimitOffsetClause(OrmReadPlan plan, List params) { final clauses = []; final effectiveTake = switch (plan.resultMode) { OrmReadResultMode.oneOrNull => 1, - _ => plan.take, + _ => plan.page?.size ?? plan.take, }; if (effectiveTake case final take?) { diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index ac0eb294..2c4aaeb1 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -54,6 +54,7 @@ void main() { expect(plan.read?.cursor?.values, {'id': 'u1'}); expect(plan.read?.page, isNull); + expect(plan.read?.orderBy.map((entry) => entry.field).toList(), ['id']); }); test('page compiles into structured query plan state', () async { @@ -68,89 +69,141 @@ void main() { expect(plan.read?.cursor, isNull); expect(plan.read?.page?.size, 20); expect(plan.read?.page?.after, {'id': 'u1'}); + expect(plan.read?.orderBy.map((entry) => entry.field).toList(), ['id']); }); - test('explain returns structured plan json', () async { + test('inspectPlan returns structured plan json without connecting', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); - final explained = await users + final inspected = await users .where({'id': 'u1'}) .take(1) - .explain(); + .inspectPlan(); - expect(explained['lane'], 'orm'); - final read = explained['read'] as Map; + expect(inspected['lane'], 'orm'); + final read = inspected['read'] as Map; expect(read['where'], {'id': 'u1'}); expect(read['take'], 1); expect(read['resultMode'], 'all'); }); - test('cursor and page execution remain explicit placeholders', () async { + test('explain requires an active runtime connection', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + await expectLater( + users.query().orderByField('id').page(size: 2).explain(), + throwsA(isA()), + ); + }); + + test('explain returns structured runtime report when connected', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + final explained = await users + .query() + .orderByField('id') + .page(size: 2, after: {'id': 'u1'}) + .explain(); + + expect(explained['source'], 'heuristic'); + final summary = explained['planSummary'] as Map; + expect(summary['model'], 'User'); + final pagination = summary['pagination'] as Map; + expect(pagination['mode'], 'page'); + expect(explained['plan'], isA>()); + } finally { + await client.disconnect(); + } + }); + + test('cursor and page execution return deterministic windows', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); try { final users = client.db.orm.model('User'); + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': 'b@x.com'}); + await users.create(data: {'id': 3, 'email': 'c@x.com'}); + await users.create(data: {'id': 4, 'email': 'd@x.com'}); + + final cursorRows = await users + .query() + .orderByField('id') + .cursor({'id': 2}) + .skip(1) + .take(2) + .all(); + final afterRows = await users + .query() + .orderByField('id') + .page(size: 2, after: {'id': 2}) + .all(); + final beforeRows = await users + .query() + .orderByField('id') + .page(size: 2, before: {'id': 4}) + .all(); + expect( - () => users.query().cursor({'id': 'u1'}).all(), - throwsA( - isA().having( - (error) => error.details['surface'], - 'surface', - 'orm.query.cursor.execute', - ), - ), + cursorRows.map((row) => row['id']).toList(growable: false), + [3, 4], ); expect( - () => users - .query() - .page(size: 10, after: {'id': 'u1'}) - .all(), - throwsA( - isA().having( - (error) => error.details['surface'], - 'surface', - 'orm.query.page.execute', - ), - ), + afterRows.map((row) => row['id']).toList(growable: false), + [3, 4], + ); + expect( + beforeRows.map((row) => row['id']).toList(growable: false), + [2, 3], ); } finally { await client.disconnect(); } }); - test('direct plan execution rejects cursor and page plans', () async { + test('direct plan execution supports cursor and page plans', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); try { final users = client.db.orm.model('User'); - final cursorPlan = await users - .query() - .cursor({'id': 'u1'}) - .toPlan(); + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': 'b@x.com'}); + await users.create(data: {'id': 3, 'email': 'c@x.com'}); + await users.create(data: {'id': 4, 'email': 'd@x.com'}); + final pagePlan = await users .query() - .page(size: 10, after: {'id': 'u1'}) + .orderByField('id') + .page(size: 2, after: {'id': 2}) .toPlan(); - await expectLater( - client.execute(cursorPlan), - throwsA( - isA().having( - (error) => error.details['surface'], - 'surface', - 'orm.plan.cursor.execute', - ), + final cursorResponse = await client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + lane: 'orm', + where: const {}, + orderBy: const [OrmOrderBy('id')], + cursor: OrmReadCursorPlan(values: const {'id': 2}), + resultMode: OrmReadResultMode.all, ), ); - await expectLater( - client.execute(pagePlan), - throwsA( - isA().having( - (error) => error.details['surface'], - 'surface', - 'orm.plan.page.execute', - ), - ), + final pageResponse = await client.execute(pagePlan); + + expect( + (cursorResponse.data as List) + .map((row) => row['id']) + .toList(growable: false), + [2, 3, 4], + ); + expect( + (pageResponse.data as List) + .map((row) => row['id']) + .toList(growable: false), + [3, 4], ); } finally { await client.disconnect(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index bdbeae1e..fcf90bbb 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -718,6 +718,13 @@ typedef Post = ({ isTrue, reason: 'Expected UserQuery.toPlan() in generated source.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+inspectPlan\(\s*\)[\s\S]*?_runtimeQuery\(\)\.inspectPlan\(\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.inspectPlan() in generated source.', + ); expect( RegExp( r'class\s+UserQuery\s*\{[\s\S]*?Future\s+oneOrNull\(\s*\)', diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index bd60b9ae..e564086a 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -66,6 +66,8 @@ void main() { List orderBy = const [], List distinct = const [], List select = const [], + OrmReadCursorPlan? cursor, + OrmReadPagePlan? page, OrmReadResultMode resultMode = OrmReadResultMode.all, }) { return OrmPlan( @@ -79,6 +81,8 @@ void main() { orderBy: orderBy, distinct: distinct, select: select, + cursor: cursor, + page: page, resultMode: resultMode, ), ); @@ -127,6 +131,66 @@ void main() { expect(statement.parameters, ['a@example.com', 10, 5]); }); + test('lowers cursor reads with inclusive boundary predicate', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + orderBy: const [OrmOrderBy('id')], + cursor: OrmReadCursorPlan(values: const {'id': 2}), + take: 2, + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT * FROM "users" WHERE ((("id" > ?)) OR ("id" = ?)) ' + 'ORDER BY "id" ASC LIMIT ?', + ); + expect(statement.parameters, [2, 2, 2]); + }); + + test('lowers page after with strict boundary predicate and limit', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + orderBy: const [OrmOrderBy('id')], + page: OrmReadPagePlan( + size: 2, + after: const {'id': 2}, + ), + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT * FROM "users" WHERE (("id" > ?)) ORDER BY "id" ASC LIMIT ?', + ); + expect(statement.parameters, [2, 2]); + }); + + test('lowers page before with reverse inner query and outer reorder', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + orderBy: const [OrmOrderBy('id')], + page: OrmReadPagePlan( + size: 2, + before: const {'id': 4}, + ), + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT * FROM (SELECT * FROM "users" WHERE (("id" < ?)) ' + 'ORDER BY "id" DESC LIMIT ?) AS "_page" ORDER BY "id" ASC', + ); + expect(statement.parameters, [4, 2]); + }); + test('lowers where operators with deterministic SQL and parameters', () { final adapter = SqlAdapter(contract: contract); final plan = readPlan( From 9f226db6406110eb4921a9bade42c570da294708 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:20:12 +0800 Subject: [PATCH 102/154] feat(client)!: require orderBy before cursor windows --- docs/orm-v6-api-surface.md | 5 +- pub/orm/lib/src/client/client.dart | 80 ++++++++--------------- pub/orm/test/client/api_surface_test.dart | 39 +++++++++-- 3 files changed, 63 insertions(+), 61 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 16527cfb..ed95a8d6 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -92,9 +92,8 @@ Rules: 1. `toPlan()` and `inspectPlan()` are pure authoring inspection. 2. `explain()` is runtime-facing and requires an active runtime connection. -3. `cursor(...)` and `page(...)` require deterministic ordering. -4. When no `orderBy(...)` is present, the boundary fields are promoted to - ascending `orderBy(...)` fields during plan compilation. +3. `cursor(...)` and `page(...)` require `orderBy(...)` first. +4. Boundary fields must match the declared `orderBy(...)` fields. ## ORM Mutation Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 30959713..a8c5f7a9 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -249,53 +249,6 @@ Map _buildOrmIncludePlanMap( }; } -List _readBoundaryFields({ - JsonMap? cursor, - OrmReadPagePlan? page, -}) { - final boundary = cursor ?? page?.after ?? page?.before; - if (boundary == null || boundary.isEmpty) { - return const []; - } - return boundary.keys.toList(growable: false); -} - -List _resolveCursorWindowOrderBy({ - required String modelName, - required List orderBy, - JsonMap? cursor, - OrmReadPagePlan? page, -}) { - final boundaryFields = _readBoundaryFields(cursor: cursor, page: page); - if (boundaryFields.isEmpty) { - return orderBy; - } - - if (orderBy.isEmpty) { - return boundaryFields - .map((field) => OrmOrderBy(field)) - .toList(growable: false); - } - - final orderByFields = orderBy - .map((entry) => entry.field) - .toList(growable: false); - if (orderByFields.length == boundaryFields.length && - orderByFields.every(boundaryFields.contains)) { - return orderBy; - } - - throw runtimeError( - 'PLAN.CURSOR_ORDER_BY_FIELDS_INVALID', - 'Cursor or page boundary fields must match orderBy fields.', - details: { - 'model': modelName, - 'orderBy': orderByFields, - 'boundaryFields': boundaryFields, - }, - ); -} - abstract interface class OrmDbContext { OrmDbNamespace get db; } @@ -1590,12 +1543,17 @@ class ModelDelegate { model: modelName, where: where, ); - final resolvedOrderBy = _resolveCursorWindowOrderBy( - modelName: modelName, - orderBy: orderBy, - cursor: cursor, - page: page, - ); + if ((cursor != null || page != null) && orderBy.isEmpty) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + 'Cursor and page windows require orderBy() first.', + details: { + 'model': modelName, + if (cursor != null) 'cursor': cursor, + if (page != null) 'page': page.toJson(), + }, + ); + } if ((cursor != null || page != null) && distinct.isNotEmpty) { throw runtimeError( 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', @@ -1661,7 +1619,7 @@ class ModelDelegate { where: normalizedWhere, skip: isCollectionRead && distinct.isEmpty ? skip : null, take: isCollectionRead && distinct.isEmpty ? resolvedTake : null, - orderBy: isCollectionRead ? resolvedOrderBy : const [], + orderBy: isCollectionRead ? orderBy : const [], distinct: isCollectionRead ? distinct : const [], select: readSelect, include: _buildOrmIncludePlanMap(normalizedInclude), @@ -3535,6 +3493,13 @@ final class ModelQuery { } ModelQuery cursor(JsonMap cursor) { + if (_state.orderBy.isEmpty) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + 'cursor() requires orderBy() first.', + details: {'model': _delegate.modelName}, + ); + } if (cursor.isEmpty) { throw PlanCursorWindowInvalidException( reason: 'cursorEmpty', @@ -3563,6 +3528,13 @@ final class ModelQuery { JsonMap? after, JsonMap? before, }) { + if (_state.orderBy.isEmpty) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + 'page() requires orderBy() first.', + details: {'model': _delegate.modelName}, + ); + } if (size <= 0) { throw PlanCursorWindowInvalidException( reason: 'pageSizeInvalid', diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 2c4aaeb1..c6da8fbd 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -50,7 +50,11 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); - final plan = await users.query().cursor({'id': 'u1'}).toPlan(); + final plan = await users + .query() + .orderByField('id') + .cursor({'id': 'u1'}) + .toPlan(); expect(plan.read?.cursor?.values, {'id': 'u1'}); expect(plan.read?.page, isNull); @@ -63,6 +67,7 @@ void main() { final plan = await users .query() + .orderByField('id') .page(size: 20, after: {'id': 'u1'}) .toPlan(); @@ -215,15 +220,15 @@ void main() { final users = client.db.orm.model('User'); expect( - () => users.query().cursor(const {}), + () => users.query().orderByField('id').cursor(const {}), throwsA(isA()), ); expect( - () => users.query().page(size: 0), + () => users.query().orderByField('id').page(size: 0), throwsA(isA()), ); expect( - () => users.query().page( + () => users.query().orderByField('id').page( size: 10, after: {'id': 'u1'}, before: {'id': 'u2'}, @@ -232,6 +237,32 @@ void main() { ); }); + test('cursor and page require orderBy first', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users.query().cursor({'id': 'u1'}), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + ), + ), + ); + expect( + () => users.query().page(size: 2, after: {'id': 'u1'}), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + ), + ), + ); + }); + test('updateMany placeholder throws stable not implemented error', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); From ce15a26779c951d3961f767ee98ee52084355a51 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:28:25 +0800 Subject: [PATCH 103/154] feat(repository): add structured page result envelopes --- docs/orm-v6-api-surface.md | 3 + pub/orm/lib/src/client/client.dart | 311 ++++++++++++++++++++++ pub/orm/lib/src/generator/writer.dart | 10 + pub/orm/test/client/api_surface_test.dart | 62 +++++ pub/orm/test/generator/generate_test.dart | 8 + 5 files changed, 394 insertions(+) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index ed95a8d6..0ad221da 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -79,6 +79,7 @@ Read terminals: | `toPlan()` | implemented | | `inspectPlan()` | implemented | | `all()` | implemented | +| `pageResult()` | implemented | | `stream()` | implemented | | `firstOrNull()` | implemented | | `oneOrNull()` | implemented | @@ -94,6 +95,8 @@ Rules: 2. `explain()` is runtime-facing and requires an active runtime connection. 3. `cursor(...)` and `page(...)` require `orderBy(...)` first. 4. Boundary fields must match the declared `orderBy(...)` fields. +5. `pageResult()` is the structured pagination terminal and returns + `items + pageInfo`. ## ORM Mutation Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index a8c5f7a9..a32a169d 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -139,6 +139,47 @@ final class IncludeSpec { } } +@immutable +final class OrmPageInfo { + final JsonMap? startCursor; + final JsonMap? endCursor; + final bool hasPreviousPage; + final bool hasNextPage; + + OrmPageInfo({ + JsonMap? startCursor, + JsonMap? endCursor, + this.hasPreviousPage = false, + this.hasNextPage = false, + }) : startCursor = startCursor == null ? null : Map.unmodifiable(startCursor), + endCursor = endCursor == null ? null : Map.unmodifiable(endCursor); + + JsonMap toJson() => { + if (startCursor != null) 'startCursor': startCursor, + if (endCursor != null) 'endCursor': endCursor, + 'hasPreviousPage': hasPreviousPage, + 'hasNextPage': hasNextPage, + }; +} + +@immutable +final class OrmPageResult { + final List items; + final OrmPageInfo pageInfo; + + OrmPageResult({ + required List items, + required this.pageInfo, + }) : items = List.unmodifiable(items); + + OrmPageResult mapItems(R Function(T item) transform) { + return OrmPageResult( + items: items.map(transform).toList(growable: false), + pageInfo: pageInfo, + ); + } +} + Map _mergeIncludeSpecMap( Map current, Map next, @@ -1089,6 +1130,24 @@ class ModelDelegate { ); } + Future> pageResult({ + JsonMap where = const {}, + List orderBy = const [], + List select = const [], + Map include = const {}, + required OrmReadPagePlan page, + }) { + return _readPageResultInternal( + action: OrmAction.read, + where: where, + orderBy: orderBy, + select: select, + include: include, + page: page, + includeDepth: 0, + ); + } + Stream stream({ JsonMap where = const {}, int? skip, @@ -1677,6 +1736,66 @@ class ModelDelegate { return _shapeRows(hydratedRows, select: select, include: normalizedInclude); } + Future> _readPageResultInternal({ + required OrmAction action, + JsonMap where = const {}, + List orderBy = const [], + List select = const [], + Map include = const {}, + required OrmReadPagePlan page, + required int includeDepth, + }) async { + final operation = _RepositoryOperation.start(kind: '$modelName.pageResult'); + final pageSelect = _expandSelectForPageExecution( + select: select, + orderBy: orderBy, + ); + final prepared = await _buildReadPlan( + resultMode: OrmReadResultMode.all, + where: where, + orderBy: orderBy, + select: pageSelect, + include: include, + page: OrmReadPagePlan( + size: page.size + 1, + after: page.after, + before: page.before, + ), + repositoryTrace: operation.nextTrace( + phase: 'page.items', + strategy: 'windowPlusOne', + ), + ); + final response = await _client.execute(prepared.plan); + final rawRows = _readRows(response.data, action: 'pageResult'); + final overflowed = rawRows.length > page.size; + final windowRows = _trimPageResultRows(rows: rawRows, page: page); + final hydratedRows = await _resolveIncludeRows( + action: action, + rows: windowRows, + include: prepared.include, + depth: includeDepth, + operation: operation, + ); + final pageInfo = await _buildPageInfo( + where: where, + orderBy: orderBy, + page: page, + rows: windowRows, + overflowed: overflowed, + operation: operation, + ); + + return OrmPageResult( + items: _shapeRows( + hydratedRows, + select: select, + include: prepared.include, + ), + pageInfo: pageInfo, + ); + } + Future _readFirstInternal({ required OrmAction action, JsonMap where = const {}, @@ -1941,6 +2060,179 @@ class ModelDelegate { return List.from(window, growable: false); } + List _expandSelectForPageExecution({ + required List select, + required List orderBy, + }) { + if (select.isEmpty || orderBy.isEmpty) { + return select; + } + final expanded = {...select}; + for (final entry in orderBy) { + expanded.add(entry.field); + } + return expanded.toList(growable: false); + } + + List _trimPageResultRows({ + required List rows, + required OrmReadPagePlan page, + }) { + if (rows.length <= page.size) { + return List.from(rows, growable: false); + } + if (page.before != null) { + return rows.sublist(rows.length - page.size); + } + return rows.sublist(0, page.size); + } + + Future _buildPageInfo({ + required JsonMap where, + required List orderBy, + required OrmReadPagePlan page, + required List rows, + required bool overflowed, + required _RepositoryOperation operation, + }) async { + final startCursor = rows.isEmpty + ? null + : _extractPageCursor(row: rows.first, orderBy: orderBy); + final endCursor = rows.isEmpty + ? null + : _extractPageCursor(row: rows.last, orderBy: orderBy); + + if (page.before != null) { + final hasPreviousPage = overflowed; + final hasNextPage = endCursor == null + ? false + : await _hasPageRowAfterCursorBeforeBoundary( + where: where, + orderBy: orderBy, + cursor: endCursor, + boundary: page.before!, + operation: operation, + ); + return OrmPageInfo( + startCursor: startCursor, + endCursor: endCursor, + hasPreviousPage: hasPreviousPage, + hasNextPage: hasNextPage, + ); + } + + final hasNextPage = overflowed; + final hasPreviousPage = switch ((page.after, startCursor)) { + (final JsonMap after?, _) => await _hasPageRowBeforeBoundary( + where: where, + orderBy: orderBy, + boundary: rows.isEmpty ? after : startCursor!, + operation: operation, + ), + _ => false, + }; + + return OrmPageInfo( + startCursor: startCursor, + endCursor: endCursor, + hasPreviousPage: hasPreviousPage, + hasNextPage: hasNextPage, + ); + } + + JsonMap _extractPageCursor({ + required JsonMap row, + required List orderBy, + }) { + final cursor = {}; + for (final entry in orderBy) { + if (!row.containsKey(entry.field)) { + throw runtimeError( + 'PLAN.PAGE_CURSOR_FIELD_MISSING', + 'Page result is missing an orderBy field required for cursor metadata.', + details: { + 'model': modelName, + 'field': entry.field, + 'orderBy': orderBy.map((item) => item.toJson()).toList(growable: false), + }, + ); + } + cursor[entry.field] = row[entry.field]; + } + return Map.unmodifiable(cursor); + } + + Future _hasPageRowBeforeBoundary({ + required JsonMap where, + required List orderBy, + required JsonMap boundary, + required _RepositoryOperation operation, + }) async { + final rows = await _readAllInternal( + action: OrmAction.read, + where: where, + orderBy: orderBy, + select: orderBy.map((entry) => entry.field).toList(growable: false), + page: OrmReadPagePlan(size: 1, before: boundary), + repositoryTrace: operation.nextTrace( + phase: 'page.probe', + strategy: 'beforeBoundary', + ), + includeDepth: 0, + ); + return rows.isNotEmpty; + } + + Future _hasPageRowAfterCursorBeforeBoundary({ + required JsonMap where, + required List orderBy, + required JsonMap cursor, + required JsonMap boundary, + required _RepositoryOperation operation, + }) async { + final rows = await _readAllInternal( + action: OrmAction.read, + where: where, + skip: 1, + take: 1, + orderBy: orderBy, + select: orderBy.map((entry) => entry.field).toList(growable: false), + cursor: cursor, + repositoryTrace: operation.nextTrace( + phase: 'page.probe', + strategy: 'afterCursor', + ), + includeDepth: 0, + ); + if (rows.isEmpty) { + return false; + } + return _compareRowToBoundary( + row: rows.first, + boundary: boundary, + orderBy: orderBy, + ) < + 0; + } + + int _compareRowToBoundary({ + required JsonMap row, + required JsonMap boundary, + required List orderBy, + }) { + for (final order in orderBy) { + final comparison = _compareOrderByValues( + row[order.field], + boundary[order.field], + ); + if (comparison == 0) { + continue; + } + return order.order == SortOrder.asc ? comparison : -comparison; + } + return 0; + } + List _expandSelectForExecution({ required String model, required List select, @@ -3640,6 +3932,25 @@ final class ModelQuery { ); } + Future> pageResult() { + _assertReadExecutionSupported('pageResult'); + final page = _state.page; + if (page == null) { + throw runtimeError( + 'PLAN.PAGE_RESULT_REQUIRES_PAGE_WINDOW', + 'pageResult() requires page() first.', + details: {'model': _delegate.modelName}, + ); + } + return _delegate.pageResult( + where: _state.where, + orderBy: _state.orderBy, + select: _state.select, + include: _state.include, + page: page, + ); + } + Stream stream() { _assertReadExecutionSupported('stream'); return _delegate.stream( diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 95137149..0def8158 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2826,6 +2826,16 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' Future> pageResult() async {', + ); + buffer.writeln(' final result = await _runtimeQuery().pageResult();'); + buffer.writeln( + ' return result.mapItems(${model.dataClassName}.fromJson);', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> oneOrNull() async {'); buffer.writeln(' final row = await _runtimeQuery().oneOrNull();'); buffer.writeln(' if (row == null) {'); diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index c6da8fbd..2ff5ed22 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -169,6 +169,52 @@ void main() { } }); + test('pageResult returns structured items and pageInfo', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': 'b@x.com'}); + await users.create(data: {'id': 3, 'email': 'c@x.com'}); + await users.create(data: {'id': 4, 'email': 'd@x.com'}); + + final firstPage = await users + .query() + .orderByField('id') + .select(const ['email']) + .page(size: 2) + .pageResult(); + final beforePage = await users + .query() + .orderByField('id') + .select(const ['email']) + .page(size: 2, before: {'id': 4}) + .pageResult(); + + expect( + firstPage.items.map((row) => row['email']).toList(growable: false), + ['a@x.com', 'b@x.com'], + ); + expect(firstPage.items.first.containsKey('id'), isFalse); + expect(firstPage.pageInfo.startCursor, {'id': 1}); + expect(firstPage.pageInfo.endCursor, {'id': 2}); + expect(firstPage.pageInfo.hasPreviousPage, isFalse); + expect(firstPage.pageInfo.hasNextPage, isTrue); + + expect( + beforePage.items.map((row) => row['email']).toList(growable: false), + ['b@x.com', 'c@x.com'], + ); + expect(beforePage.pageInfo.startCursor, {'id': 2}); + expect(beforePage.pageInfo.endCursor, {'id': 3}); + expect(beforePage.pageInfo.hasPreviousPage, isTrue); + expect(beforePage.pageInfo.hasNextPage, isFalse); + } finally { + await client.disconnect(); + } + }); + test('direct plan execution supports cursor and page plans', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -263,6 +309,22 @@ void main() { ); }); + test('pageResult requires page() first', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users.query().orderByField('id').pageResult(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.PAGE_RESULT_REQUIRES_PAGE_WINDOW', + ), + ), + ); + }); + test('updateMany placeholder throws stable not implemented error', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index fcf90bbb..02780d59 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -746,6 +746,14 @@ typedef Post = ({ isTrue, reason: 'Expected UserQuery.all() in generated source.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+pageResult\(\s*\)\s+async[\s\S]*?_runtimeQuery\(\)\.pageResult\(\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.pageResult() to expose structured page envelope mapping.', + ); expect( RegExp( r'\bFuture\s+firstOrNull\s*\(\s*\)', From e731cb2b52d1cf0d1846576aab9db2874497448d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:38:45 +0800 Subject: [PATCH 104/154] feat(contract)!: emit id fields and enforce stable cursor ordering --- docs/orm-v6-api-surface.md | 4 +- pub/orm/lib/src/client/client.dart | 45 +++++++++++++++++++ pub/orm/lib/src/contract/contract.dart | 42 +++++++++++++++++ .../lib/src/generator/contract_emitter.dart | 1 + pub/orm/test/client/api_surface_test.dart | 31 +++++++++++++ pub/orm/test/generator/generate_test.dart | 4 +- 6 files changed, 125 insertions(+), 2 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 0ad221da..e7cc49c9 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -95,7 +95,9 @@ Rules: 2. `explain()` is runtime-facing and requires an active runtime connection. 3. `cursor(...)` and `page(...)` require `orderBy(...)` first. 4. Boundary fields must match the declared `orderBy(...)` fields. -5. `pageResult()` is the structured pagination terminal and returns +5. When a model declares `idFields`, `cursor(...)` and `page(...)` require + `orderBy(...)` to end with those fields. +6. `pageResult()` is the structured pagination terminal and returns `items + pageInfo`. ## ORM Mutation Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index a32a169d..44401d4d 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1613,6 +1613,9 @@ class ModelDelegate { }, ); } + if (cursor != null || page != null) { + _validateStableCursorOrderBy(orderBy: orderBy); + } if ((cursor != null || page != null) && distinct.isNotEmpty) { throw runtimeError( 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', @@ -2233,6 +2236,46 @@ class ModelDelegate { return 0; } + void _validateStableCursorOrderBy({required List orderBy}) { + final model = _client.contract.models[modelName]; + if (model == null) { + throw ModelNotFoundException(modelName, _client.contract.models.keys); + } + + final idFields = model.idFields; + if (idFields.isEmpty) { + return; + } + if (orderBy.length < idFields.length) { + _throwStableCursorOrderError(orderBy: orderBy, idFields: idFields); + } + + final suffix = orderBy + .sublist(orderBy.length - idFields.length) + .map((entry) => entry.field) + .toList(growable: false); + if (_listEquals(suffix, idFields)) { + return; + } + + _throwStableCursorOrderError(orderBy: orderBy, idFields: idFields); + } + + Never _throwStableCursorOrderError({ + required List orderBy, + required List idFields, + }) { + throw runtimeError( + 'PLAN.CURSOR_STABLE_ORDER_REQUIRED', + 'Cursor and page windows require orderBy() to end with the model id fields.', + details: { + 'model': modelName, + 'idFields': idFields, + 'orderBy': orderBy.map((entry) => entry.toJson()).toList(growable: false), + }, + ); + } + List _expandSelectForExecution({ required String model, required List select, @@ -3792,6 +3835,7 @@ final class ModelQuery { details: {'model': _delegate.modelName}, ); } + _delegate._validateStableCursorOrderBy(orderBy: _state.orderBy); if (cursor.isEmpty) { throw PlanCursorWindowInvalidException( reason: 'cursorEmpty', @@ -3827,6 +3871,7 @@ final class ModelQuery { details: {'model': _delegate.modelName}, ); } + _delegate._validateStableCursorOrderBy(orderBy: _state.orderBy); if (size <= 0) { throw PlanCursorWindowInvalidException( reason: 'pageSizeInvalid', diff --git a/pub/orm/lib/src/contract/contract.dart b/pub/orm/lib/src/contract/contract.dart index 508a9d3f..a3cc0a6b 100644 --- a/pub/orm/lib/src/contract/contract.dart +++ b/pub/orm/lib/src/contract/contract.dart @@ -46,15 +46,20 @@ final class ModelContract { final String name; final String table; final Set fields; + final List idFields; final Map relations; ModelContract({ required this.name, required this.table, required Set fields, + List? idFields, Map relations = const {}, }) : fields = Set.unmodifiable(fields), + idFields = List.unmodifiable( + idFields ?? (fields.contains('id') ? const ['id'] : const []), + ), relations = Map.unmodifiable(relations); } @@ -92,6 +97,7 @@ final class OrmContract { }) : markerStorageHash = markerStorageHash ?? hash, models = Map.unmodifiable(models), aliases = Map.unmodifiable(aliases) { + _validateModelIdFields(this.models); _validateRelations(this.models); } @@ -214,6 +220,42 @@ void _validateRelations(Map models) { } } +void _validateModelIdFields(Map models) { + for (final model in models.values) { + if (model.idFields.isEmpty) { + continue; + } + + final uniqueIdFields = model.idFields.toSet(); + if (uniqueIdFields.length != model.idFields.length) { + throw ContractDefinitionException( + code: 'CONTRACT.ID_FIELDS_DUPLICATE', + message: 'Model idFields cannot contain duplicates.', + details: { + 'model': model.name, + 'idFields': model.idFields, + }, + ); + } + + for (final field in model.idFields) { + if (model.fields.contains(field)) { + continue; + } + throw ContractDefinitionException( + code: 'CONTRACT.ID_FIELD_MISSING', + message: + 'Model id field "$field" does not exist on model "${model.name}".', + details: { + 'model': model.name, + 'field': field, + 'idFields': model.idFields, + }, + ); + } + } +} + String _uppercaseFirst(String value) { if (value.isEmpty) { return value; diff --git a/pub/orm/lib/src/generator/contract_emitter.dart b/pub/orm/lib/src/generator/contract_emitter.dart index b980afb5..f7434e33 100644 --- a/pub/orm/lib/src/generator/contract_emitter.dart +++ b/pub/orm/lib/src/generator/contract_emitter.dart @@ -16,6 +16,7 @@ String emitContractArtifact({ 'name': modelInfo.model.name, 'table': _defaultTableName(modelInfo.model.name), 'fields': modelInfo.scalarFieldNames, + 'idFields': modelInfo.idFields, 'relations': _buildRelations(owner: modelInfo, modelInfos: modelInfos), }; } diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 2ff5ed22..0b532e72 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -309,6 +309,37 @@ void main() { ); }); + test('cursor and page require stable id-suffixed ordering', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users.query().orderByField('email').cursor( + {'email': 'a@x.com'}, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.CURSOR_STABLE_ORDER_REQUIRED', + ), + ), + ); + expect( + () => users.query().orderByField('email').page( + size: 2, + after: {'email': 'a@x.com'}, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.CURSOR_STABLE_ORDER_REQUIRED', + ), + ), + ); + }); + test('pageResult requires page() first', () { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 02780d59..08acc50c 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -155,7 +155,9 @@ void main() { expect(models.containsKey('User'), isTrue); final user = models['User']; expect(user is Map, isTrue); - final relations = (user as Map)['relations']; + final userMap = user as Map; + expect(userMap['idFields'], ['id']); + final relations = userMap['relations']; expect(relations is Map, isTrue); expect((relations as Map).isEmpty, isTrue); }); From 270325f830f26f3b3762a9163e182a1d6fb18a9a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:44:35 +0800 Subject: [PATCH 105/154] feat(generator)!: split cursor input from unique input --- pub/orm/lib/src/generator/writer.dart | 42 ++++++++++++++++------- pub/orm/test/generator/generate_test.dart | 37 +++++++++++++++----- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 0def8158..89dfda6b 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -64,6 +64,12 @@ final class TypedClientWriter { classKind: _TemplateClassKind.whereUnique, lookup: modelLookup, ); + _writeDataOrInputClass( + buffer: buffer, + model: model, + classKind: _TemplateClassKind.cursor, + lookup: modelLookup, + ); _writeDataOrInputClass( buffer: buffer, model: model, @@ -1633,14 +1639,14 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' ${model.queryClassName} cursor(${model.whereUniqueInputClassName} cursor) => query().cursor(cursor);', + ' ${model.queryClassName} cursor(${model.cursorInputClassName} cursor) => query().cursor(cursor);', ); buffer.writeln(); buffer.writeln(' ${model.queryClassName} page({'); buffer.writeln(' required int size,'); - buffer.writeln(' ${model.whereUniqueInputClassName}? after,'); - buffer.writeln(' ${model.whereUniqueInputClassName}? before,'); + buffer.writeln(' ${model.cursorInputClassName}? after,'); + buffer.writeln(' ${model.cursorInputClassName}? before,'); buffer.writeln(' }) => query().page(size: size, after: after, before: before);'); buffer.writeln(); @@ -2461,10 +2467,10 @@ final class TypedClientWriter { buffer.writeln(' final List<${model.distinctClassName}> _distinct;'); buffer.writeln(' final ${model.selectClassName}? _select;'); buffer.writeln(' final ${model.includeClassName}? _include;'); - buffer.writeln(' final ${model.whereUniqueInputClassName}? _cursor;'); + buffer.writeln(' final ${model.cursorInputClassName}? _cursor;'); buffer.writeln(' final int? _pageSize;'); - buffer.writeln(' final ${model.whereUniqueInputClassName}? _pageAfter;'); - buffer.writeln(' final ${model.whereUniqueInputClassName}? _pageBefore;'); + buffer.writeln(' final ${model.cursorInputClassName}? _pageAfter;'); + buffer.writeln(' final ${model.cursorInputClassName}? _pageBefore;'); buffer.writeln(); buffer.writeln(' ${model.queryClassName}._({'); buffer.writeln(' required ${model.delegateClassName} delegate,'); @@ -2475,10 +2481,10 @@ final class TypedClientWriter { buffer.writeln(' required List<${model.distinctClassName}> distinct,'); buffer.writeln(' required ${model.selectClassName}? select,'); buffer.writeln(' required ${model.includeClassName}? include,'); - buffer.writeln(' ${model.whereUniqueInputClassName}? cursor,'); + buffer.writeln(' ${model.cursorInputClassName}? cursor,'); buffer.writeln(' int? pageSize,'); - buffer.writeln(' ${model.whereUniqueInputClassName}? pageAfter,'); - buffer.writeln(' ${model.whereUniqueInputClassName}? pageBefore,'); + buffer.writeln(' ${model.cursorInputClassName}? pageAfter,'); + buffer.writeln(' ${model.cursorInputClassName}? pageBefore,'); buffer.writeln(' }) : _delegate = delegate,'); buffer.writeln(' _where = where,'); buffer.writeln(' _skip = skip,'); @@ -2580,7 +2586,7 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' ${model.queryClassName} cursor(${model.whereUniqueInputClassName} cursor) {', + ' ${model.queryClassName} cursor(${model.cursorInputClassName} cursor) {', ); buffer.writeln(' return ${model.queryClassName}._('); buffer.writeln(' delegate: _delegate,'); @@ -2601,8 +2607,8 @@ final class TypedClientWriter { buffer.writeln(' ${model.queryClassName} page({'); buffer.writeln(' required int size,'); - buffer.writeln(' ${model.whereUniqueInputClassName}? after,'); - buffer.writeln(' ${model.whereUniqueInputClassName}? before,'); + buffer.writeln(' ${model.cursorInputClassName}? after,'); + buffer.writeln(' ${model.cursorInputClassName}? before,'); buffer.writeln(' }) {'); buffer.writeln(' if (size <= 0) {'); buffer.writeln(' throw PlanCursorWindowInvalidException('); @@ -3556,6 +3562,7 @@ final class TypedClientWriter { _TemplateClassKind.whereUnique => model.fields.where( _includeInWhereUnique, ), + _TemplateClassKind.cursor => model.fields.where(_includeInCursor), _TemplateClassKind.create => model.fields.where( (field) => field.includeInCreate, ), @@ -3583,6 +3590,7 @@ final class TypedClientWriter { _TemplateClassKind.data => model.dataClassName, _TemplateClassKind.where => model.whereInputClassName, _TemplateClassKind.whereUnique => model.whereUniqueInputClassName, + _TemplateClassKind.cursor => model.cursorInputClassName, _TemplateClassKind.create => model.createInputClassName, _TemplateClassKind.update => model.updateInputClassName, }; @@ -3600,6 +3608,7 @@ final class TypedClientWriter { _TemplateClassKind.data => true, _TemplateClassKind.where => true, _TemplateClassKind.whereUnique => true, + _TemplateClassKind.cursor => true, _TemplateClassKind.create => field.isNullable, _TemplateClassKind.update => true, }; @@ -3804,6 +3813,7 @@ final class TypedClientWriter { _TemplateClassKind.data => 'Data', _TemplateClassKind.where => 'WhereInput', _TemplateClassKind.whereUnique => 'WhereUniqueInput', + _TemplateClassKind.cursor => 'CursorInput', _TemplateClassKind.create => 'CreateInput', _TemplateClassKind.update => 'UpdateInput', }; @@ -3830,6 +3840,10 @@ final class TypedClientWriter { return _isConventionalIdFieldName(field.name) && field.includeInWhere; } + bool _includeInCursor(TypedField field) { + return field.isScalar && !field.isList; + } + bool _isConventionalIdFieldName(String name) { return name.trim().toLowerCase() == 'id'; } @@ -3951,7 +3965,7 @@ final class TypedClientWriter { } } -enum _TemplateClassKind { data, where, whereUnique, create, update } +enum _TemplateClassKind { data, where, whereUnique, cursor, create, update } final class _ResolvedModel { final TypedModel model; @@ -3978,6 +3992,8 @@ final class _ResolvedModel { String get whereUniqueInputClassName => '${classBaseName}WhereUniqueInput'; + String get cursorInputClassName => '${classBaseName}CursorInput'; + String get createInputClassName => '${classBaseName}CreateInput'; String get nestedCreateInputClassName => '${classBaseName}NestedCreateInput'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 08acc50c..0ac14b30 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -676,35 +676,35 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserWhereUniqueInput\s+cursor\s*\)', + r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserCursorInput\s+cursor\s*\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.cursor(...) convenience helper in generated source.', + 'Expected UserDelegate.cursor(...) to use typed cursor input.', ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+page\(\{\s*required\s+int\s+size,\s*UserWhereUniqueInput\?\s+after,\s*UserWhereUniqueInput\?\s+before,', + r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+page\(\{\s*required\s+int\s+size,\s*UserCursorInput\?\s+after,\s*UserCursorInput\?\s+before,', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.page(...) convenience helper in generated source.', + 'Expected UserDelegate.page(...) to use typed cursor inputs.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserWhereUniqueInput\s+cursor\s*\)', + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserCursorInput\s+cursor\s*\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.cursor(...) to use typed unique cursor input.', + 'Expected UserQuery.cursor(...) to use typed cursor input.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+page\(\{\s*required\s+int\s+size,\s*UserWhereUniqueInput\?\s+after,\s*UserWhereUniqueInput\?\s+before,', + r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+page\(\{\s*required\s+int\s+size,\s*UserCursorInput\?\s+after,\s*UserCursorInput\?\s+before,', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.page(...) to use typed unique cursor inputs.', + 'Expected UserQuery.page(...) to use typed cursor inputs.', ); expect( RegExp( @@ -829,6 +829,11 @@ typedef Post = ({ isTrue, reason: 'Missing typed where unique input class in generated source.', ); + expect( + RegExp(r'\bclass UserCursorInput\b').hasMatch(generatedSource), + isTrue, + reason: 'Missing typed cursor input class in generated source.', + ); expect( RegExp( r'class\s+UserWhereUniqueInput\s*\{[\s\S]*?final\s+int\?\s+id;', @@ -860,6 +865,22 @@ typedef Post = ({ reason: 'Expected generated source to include where unique equals compatibility helper.', ); + expect( + RegExp( + r'class\s+UserCursorInput\s*\{[\s\S]*?final\s+int\?\s+id;[\s\S]*?final\s+String\?\s+email;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserCursorInput to include scalar cursor fields needed for ordered pagination.', + ); + expect( + RegExp( + r"class\s+UserCursorInput\s*\{[\s\S]*?if\s*\(id\s*!=\s*null\)\s*'id':\s*id![\s\S]*?if\s*\(email\s*!=\s*null\)\s*'email':\s*email!", + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserCursorInput.toJson to emit scalar cursor boundary values.', + ); expect( RegExp( r'Future\s+oneOrNull\(\{\s*required\s+UserWhereUniqueInput\s+where,', From 3a73cd7d56311830590f9011ae1805699843ea7a Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:55:08 +0800 Subject: [PATCH 106/154] feat(runtime): add adapter-aware explain reports --- docs/orm-v6-api-surface.md | 2 + pub/orm/lib/src/engine/engine.dart | 5 + pub/orm/lib/src/runtime/core.dart | 54 +++- pub/orm/lib/src/sql/adapter.dart | 40 ++- pub/orm/lib/src/target/adapter.dart | 5 + pub/orm/lib/src/target/engine.dart | 16 +- pub/orm/test/client/api_surface_test.dart | 231 +++++++++++++----- .../target/adapter_driver_engine_test.dart | 35 +++ 8 files changed, 300 insertions(+), 88 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index e7cc49c9..08c49892 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -93,6 +93,8 @@ Rules: 1. `toPlan()` and `inspectPlan()` are pure authoring inspection. 2. `explain()` is runtime-facing and requires an active runtime connection. + It returns the common runtime summary and may include target-lowered request + details when the active engine exposes them. 3. `cursor(...)` and `page(...)` require `orderBy(...)` first. 4. Boundary fields must match the declared `orderBy(...)` fields. 5. When a model declares `idFields`, `cursor(...)` and `page(...)` require diff --git a/pub/orm/lib/src/engine/engine.dart b/pub/orm/lib/src/engine/engine.dart index aa76a7b7..147338d9 100644 --- a/pub/orm/lib/src/engine/engine.dart +++ b/pub/orm/lib/src/engine/engine.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import '../runtime/plan.dart'; +import '../runtime/types.dart'; @immutable final class EngineResponse { @@ -35,3 +36,7 @@ abstract interface class OrmEngine implements RuntimeQueryable { Future close(); } + +abstract interface class ExplainCapableEngine { + Future describePlan(OrmPlan plan); +} diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 50bcfc7a..3d7f9c3f 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -97,6 +97,29 @@ JsonMap _buildExplainResult(OrmPlan plan) { }); } +JsonMap _mergeExplainResult(JsonMap base, JsonMap details) { + if (details.isEmpty) { + return base; + } + + final merged = {...base}; + for (final entry in details.entries) { + if (entry.key == 'planSummary' && + merged['planSummary'] is JsonMap && + entry.value is JsonMap) { + final current = merged['planSummary']! as JsonMap; + final next = entry.value as JsonMap; + merged['planSummary'] = Map.unmodifiable( + {...current, ...next}, + ); + continue; + } + merged[entry.key] = entry.value; + } + + return Map.unmodifiable(merged); +} + @immutable final class RuntimeVerifyOptions { final RuntimeVerifyMode mode; @@ -224,7 +247,9 @@ abstract interface class RuntimeCore implements OrmRuntimeQueryable { RuntimeOperationTelemetryEvent? operationTelemetry([String? operationId]); - List recentOperationTelemetry({int limit = 50}); + List recentOperationTelemetry({ + int limit = 50, + }); } final class OrmRuntimeCore implements RuntimeCore { @@ -333,7 +358,13 @@ final class OrmRuntimeCore implements RuntimeCore { _ensureConnected(); _assertPlan(plan); await _verifyForRequest(); - return _buildExplainResult(plan); + + final base = _buildExplainResult(plan); + if (engine case final ExplainCapableEngine explainEngine) { + final details = await explainEngine.describePlan(plan); + return _mergeExplainResult(base, details); + } + return base; } @override @@ -355,7 +386,10 @@ final class OrmRuntimeCore implements RuntimeCore { if (limit >= values.length) { return values.reversed.toList(growable: false); } - return values.sublist(values.length - limit).reversed.toList(growable: false); + return values + .sublist(values.length - limit) + .reversed + .toList(growable: false); } Future _executeOnQueryable( @@ -704,10 +738,7 @@ final class OrmRuntimeCore implements RuntimeCore { durationMs: (current?.durationMs ?? 0) + durationMs, startedAt: current?.startedAt ?? startedAt, recordedAt: recordedAt, - steps: [ - ...?current?.steps, - nextStep, - ], + steps: [...?current?.steps, nextStep], ); if (current != null) { @@ -720,7 +751,10 @@ final class OrmRuntimeCore implements RuntimeCore { _operationTelemetry = next; } - void _assertReadPlan({required ModelContract model, required OrmReadPlan plan}) { + void _assertReadPlan({ + required ModelContract model, + required OrmReadPlan plan, + }) { _assertWhereFields(model: model, where: plan.where, source: 'where'); _assertKnownFields( model: model, @@ -878,7 +912,9 @@ final class OrmRuntimeCore implements RuntimeCore { required List orderBy, required Iterable boundaryFields, }) { - final orderByFields = orderBy.map((entry) => entry.field).toList(growable: false); + final orderByFields = orderBy + .map((entry) => entry.field) + .toList(growable: false); final boundary = boundaryFields.toList(growable: false); return orderByFields.length == boundary.length && orderByFields.every(boundary.contains); diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index c4022aa3..162a0718 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -51,7 +51,10 @@ const Set _toManyRelationWhereOperators = { const Set _toOneRelationWhereOperators = {'is', 'isNot'}; const String _relationWhereAlias = '_rel'; -final class SqlAdapter implements TargetAdapter { +final class SqlAdapter + implements + TargetAdapter, + ExplainCapableTargetAdapter { final OrmContract contract; final String identifierQuote; final SqlFieldCodecResolver? codecResolver; @@ -70,7 +73,11 @@ final class SqlAdapter implements TargetAdapter { } return switch (plan.action) { - OrmAction.read => _lowerRead(plan: plan, table: model.table, model: plan.model), + OrmAction.read => _lowerRead( + plan: plan, + table: model.table, + model: plan.model, + ), OrmAction.create => _lowerCreate( plan: plan, table: model.table, @@ -89,6 +96,20 @@ final class SqlAdapter implements TargetAdapter { }; } + @override + JsonMap describe(OrmPlan plan, SqlStatement request) { + return Map.unmodifiable({ + 'source': 'adapter', + 'target': contract.target, + 'request': Map.unmodifiable({ + 'kind': 'sql', + 'action': request.action.name, + 'text': request.text, + 'parameterCount': request.parameters.length, + }), + }); + } + SqlStatement _lowerRead({ required OrmPlan plan, required String table, @@ -112,7 +133,9 @@ final class SqlAdapter implements TargetAdapter { if (read.page?.before != null) { final limitParams = []; final selectColumns = _buildSelectColumns(read.select); - final innerOrderByClause = _buildOrderByClause(_reverseOrderBy(read.orderBy)); + final innerOrderByClause = _buildOrderByClause( + _reverseOrderBy(read.orderBy), + ); final innerLimitClause = _buildReadLimitOffsetClause(read, limitParams); return SqlStatement( action: plan.action, @@ -121,11 +144,7 @@ final class SqlAdapter implements TargetAdapter { 'SELECT * FROM ${_id(table)}' '$mergedWhereClause$innerOrderByClause$innerLimitClause' ') AS ${_id('_page')}$orderByClause', - parameters: [ - ...whereParams, - ...windowParams, - ...limitParams, - ], + parameters: [...whereParams, ...windowParams, ...limitParams], ); } @@ -1028,10 +1047,7 @@ final class SqlAdapter implements TargetAdapter { return '(($strictPredicate) OR ($equalityPredicate))'; } - String _boundaryOperator({ - required OrmOrderBy order, - required bool before, - }) { + String _boundaryOperator({required OrmOrderBy order, required bool before}) { return switch ((order.order, before)) { (SortOrder.asc, false) => '>', (SortOrder.asc, true) => '<', diff --git a/pub/orm/lib/src/target/adapter.dart b/pub/orm/lib/src/target/adapter.dart index 7ccef392..ecb9aff8 100644 --- a/pub/orm/lib/src/target/adapter.dart +++ b/pub/orm/lib/src/target/adapter.dart @@ -1,8 +1,13 @@ import '../engine/engine.dart'; import '../runtime/plan.dart'; +import '../runtime/types.dart'; abstract interface class TargetAdapter { TRequest lower(OrmPlan plan); EngineResponse decode(TRawResponse response, OrmPlan plan); } + +abstract interface class ExplainCapableTargetAdapter { + JsonMap describe(OrmPlan plan, TRequest request); +} diff --git a/pub/orm/lib/src/target/engine.dart b/pub/orm/lib/src/target/engine.dart index 43457ff8..e73dec5b 100644 --- a/pub/orm/lib/src/target/engine.dart +++ b/pub/orm/lib/src/target/engine.dart @@ -1,11 +1,12 @@ import '../engine/engine.dart'; import '../runtime/errors.dart'; import '../runtime/plan.dart'; +import '../runtime/types.dart'; import 'adapter.dart'; import 'driver.dart'; final class AdapterDriverEngine - implements OrmEngine, ConnectionCapableEngine { + implements OrmEngine, ConnectionCapableEngine, ExplainCapableEngine { final TargetAdapter adapter; final TargetDriver driver; bool _opened = false; @@ -39,6 +40,19 @@ final class AdapterDriverEngine return adapter.decode(raw, plan); } + @override + Future describePlan(OrmPlan plan) async { + _ensureOpen(); + + final request = adapter.lower(plan); + if (adapter + case final ExplainCapableTargetAdapter + explainAdapter) { + return explainAdapter.describe(plan, request); + } + return const {}; + } + @override Future connection() async { _ensureOpen(); diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 0b532e72..c783e7ce 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -50,15 +50,15 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); - final plan = await users - .query() - .orderByField('id') - .cursor({'id': 'u1'}) - .toPlan(); + final plan = await users.query().orderByField('id').cursor( + {'id': 'u1'}, + ).toPlan(); expect(plan.read?.cursor?.values, {'id': 'u1'}); expect(plan.read?.page, isNull); - expect(plan.read?.orderBy.map((entry) => entry.field).toList(), ['id']); + expect(plan.read?.orderBy.map((entry) => entry.field).toList(), [ + 'id', + ]); }); test('page compiles into structured query plan state', () async { @@ -74,23 +74,28 @@ void main() { expect(plan.read?.cursor, isNull); expect(plan.read?.page?.size, 20); expect(plan.read?.page?.after, {'id': 'u1'}); - expect(plan.read?.orderBy.map((entry) => entry.field).toList(), ['id']); + expect(plan.read?.orderBy.map((entry) => entry.field).toList(), [ + 'id', + ]); }); - test('inspectPlan returns structured plan json without connecting', () async { - final client = OrmClient(contract: contract, engine: MemoryEngine()); - final users = client.db.orm.model('User'); - final inspected = await users - .where({'id': 'u1'}) - .take(1) - .inspectPlan(); - - expect(inspected['lane'], 'orm'); - final read = inspected['read'] as Map; - expect(read['where'], {'id': 'u1'}); - expect(read['take'], 1); - expect(read['resultMode'], 'all'); - }); + test( + 'inspectPlan returns structured plan json without connecting', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + final inspected = await users + .where({'id': 'u1'}) + .take(1) + .inspectPlan(); + + expect(inspected['lane'], 'orm'); + final read = inspected['read'] as Map; + expect(read['where'], {'id': 'u1'}); + expect(read['take'], 1); + expect(read['resultMode'], 'all'); + }, + ); test('explain requires an active runtime connection', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); @@ -124,15 +129,72 @@ void main() { } }); + test( + 'explain includes target-aware adapter details when available', + () async { + final sqlContract = OrmContract( + version: '1', + hash: 'contract-sql-v1', + target: 'sql-family', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + aliases: {'users': 'User'}, + ); + final client = OrmClient( + contract: sqlContract, + engine: AdapterDriverEngine( + adapter: SqlAdapter(contract: sqlContract), + driver: _ExplainOnlySqlDriver(), + ), + ); + await client.connect(); + try { + final users = client.db.orm.model('User'); + final explained = await users + .query() + .where({'id': 'u1'}) + .orderByField('id') + .page(size: 2) + .explain(); + + expect(explained['source'], 'adapter'); + expect(explained['target'], 'sql-family'); + final request = explained['request'] as Map; + expect(request['kind'], 'sql'); + expect(request['action'], 'read'); + expect(request['text'], contains('SELECT')); + expect(request['parameterCount'], greaterThan(0)); + + final summary = explained['planSummary'] as Map; + expect(summary['model'], 'User'); + } finally { + await client.disconnect(); + } + }, + ); + test('cursor and page execution return deterministic windows', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); try { final users = client.db.orm.model('User'); - await users.create(data: {'id': 1, 'email': 'a@x.com'}); - await users.create(data: {'id': 2, 'email': 'b@x.com'}); - await users.create(data: {'id': 3, 'email': 'c@x.com'}); - await users.create(data: {'id': 4, 'email': 'd@x.com'}); + await users.create( + data: {'id': 1, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 2, 'email': 'b@x.com'}, + ); + await users.create( + data: {'id': 3, 'email': 'c@x.com'}, + ); + await users.create( + data: {'id': 4, 'email': 'd@x.com'}, + ); final cursorRows = await users .query() @@ -174,10 +236,18 @@ void main() { await client.connect(); try { final users = client.db.orm.model('User'); - await users.create(data: {'id': 1, 'email': 'a@x.com'}); - await users.create(data: {'id': 2, 'email': 'b@x.com'}); - await users.create(data: {'id': 3, 'email': 'c@x.com'}); - await users.create(data: {'id': 4, 'email': 'd@x.com'}); + await users.create( + data: {'id': 1, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 2, 'email': 'b@x.com'}, + ); + await users.create( + data: {'id': 3, 'email': 'c@x.com'}, + ); + await users.create( + data: {'id': 4, 'email': 'd@x.com'}, + ); final firstPage = await users .query() @@ -220,10 +290,18 @@ void main() { await client.connect(); try { final users = client.db.orm.model('User'); - await users.create(data: {'id': 1, 'email': 'a@x.com'}); - await users.create(data: {'id': 2, 'email': 'b@x.com'}); - await users.create(data: {'id': 3, 'email': 'c@x.com'}); - await users.create(data: {'id': 4, 'email': 'd@x.com'}); + await users.create( + data: {'id': 1, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 2, 'email': 'b@x.com'}, + ); + await users.create( + data: {'id': 3, 'email': 'c@x.com'}, + ); + await users.create( + data: {'id': 4, 'email': 'd@x.com'}, + ); final pagePlan = await users .query() @@ -266,7 +344,8 @@ void main() { final users = client.db.orm.model('User'); expect( - () => users.query().orderByField('id').cursor(const {}), + () => + users.query().orderByField('id').cursor(const {}), throwsA(isA()), ); expect( @@ -274,11 +353,14 @@ void main() { throwsA(isA()), ); expect( - () => users.query().orderByField('id').page( - size: 10, - after: {'id': 'u1'}, - before: {'id': 'u2'}, - ), + () => users + .query() + .orderByField('id') + .page( + size: 10, + after: {'id': 'u1'}, + before: {'id': 'u2'}, + ), throwsA(isA()), ); }); @@ -314,9 +396,9 @@ void main() { final users = client.db.orm.model('User'); expect( - () => users.query().orderByField('email').cursor( - {'email': 'a@x.com'}, - ), + () => users.query().orderByField('email').cursor({ + 'email': 'a@x.com', + }), throwsA( isA().having( (error) => error.code, @@ -326,10 +408,10 @@ void main() { ), ); expect( - () => users.query().orderByField('email').page( - size: 2, - after: {'email': 'a@x.com'}, - ), + () => users + .query() + .orderByField('email') + .page(size: 2, after: {'email': 'a@x.com'}), throwsA( isA().having( (error) => error.code, @@ -356,26 +438,43 @@ void main() { ); }); - test('updateMany placeholder throws stable not implemented error', () async { - final client = OrmClient(contract: contract, engine: MemoryEngine()); - await client.connect(); - try { - final users = client.db.orm.model('User'); - await expectLater( - users - .where({'id': 'u1'}) - .updateMany(data: {'email': 'b@x.com'}), - throwsA( - isA().having( - (error) => error.details['surface'], - 'surface', - 'orm.updateMany', + test( + 'updateMany placeholder throws stable not implemented error', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await expectLater( + users + .where({'id': 'u1'}) + .updateMany(data: {'email': 'b@x.com'}), + throwsA( + isA().having( + (error) => error.details['surface'], + 'surface', + 'orm.updateMany', + ), ), - ), - ); - } finally { - await client.disconnect(); - } - }); + ); + } finally { + await client.disconnect(); + } + }, + ); }); } + +final class _ExplainOnlySqlDriver + implements TargetDriver { + @override + Future open() async {} + + @override + Future close() async {} + + @override + Future execute(SqlStatement request) { + throw StateError('explain() should not execute the SQL driver.'); + } +} diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index 5a77651e..7d3b38cd 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -99,6 +99,30 @@ void main() { await engine.close(); }); + test('describes lowered plan without executing the driver', () async { + final adapter = _ExplainTrackingAdapter(); + final driver = _TrackingDriver(); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final description = await engine.describePlan( + _plan(where: {'id': 'u1'}), + ); + + expect(adapter.loweredPlans, hasLength(1)); + expect(driver.requests, isEmpty); + expect(description['source'], 'adapter'); + expect(description['request'], { + 'kind': 'tracking', + 'value': 'User:read', + }); + + await engine.close(); + }); + test('supports connection lifecycle when driver is capable', () async { final adapter = _TrackingAdapter(); final driver = _ConnectionCapableTrackingDriver(); @@ -216,6 +240,17 @@ final class _TrackingAdapter implements TargetAdapter { } } +final class _ExplainTrackingAdapter extends _TrackingAdapter + implements ExplainCapableTargetAdapter { + @override + JsonMap describe(OrmPlan plan, String request) { + return { + 'source': 'adapter', + 'request': {'kind': 'tracking', 'value': request}, + }; + } +} + final class _TrackingDriver implements TargetDriver { int openCount = 0; int closeCount = 0; From 70350324c989b2d97e530ade25835c91ee526c97 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:57:26 +0800 Subject: [PATCH 107/154] test(runtime): cover explain and pageResult telemetry boundaries --- pub/orm/test/client/api_surface_test.dart | 2 + .../operation_telemetry_aggregation_test.dart | 237 +++++++++++------- 2 files changed, 148 insertions(+), 91 deletions(-) diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index c783e7ce..3a5e1f2b 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -172,6 +172,8 @@ void main() { final summary = explained['planSummary'] as Map; expect(summary['model'], 'User'); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); } finally { await client.disconnect(); } diff --git a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart index 4286ec80..7799f4ea 100644 --- a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart +++ b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart @@ -35,10 +35,10 @@ void main() { expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); expect(telemetry?.statementCount, 2); expect(telemetry?.affectedRows, 2); - expect(telemetry?.steps.map((step) => step.trace.phase).toList(), [ - 'item.create', - 'item.create', - ]); + expect( + telemetry?.steps.map((step) => step.trace.phase).toList(), + ['item.create', 'item.create'], + ); expect(telemetry?.steps.map((step) => step.trace.step).toList(), [ 1, 2, @@ -50,93 +50,99 @@ void main() { await client.disconnect(); }); - test('aggregates fallback update reload into one operation record', () async { - final noReturningContract = OrmContract( - version: contract.version, - hash: contract.hash, - models: contract.models, - aliases: contract.aliases, - capabilities: const ContractCapabilities(mutationReturning: false), - ); - final client = OrmClient( - contract: noReturningContract, - engine: _NoMutationReturnEngine(inner: MemoryEngine()), - ); - await client.connect(); - final users = client.db.orm.model('User'); - - await users.create( - data: {'id': 'u1', 'email': 'a@x.com'}, - ); - final row = await users.update( - where: {'id': 'u1'}, - data: {'email': 'b@x.com'}, - select: const ['id', 'email'], - ); - - expect(row, {'id': 'u1', 'email': 'b@x.com'}); - final telemetry = client.operationTelemetry(); - expect(telemetry, isNotNull); - expect(telemetry?.kind, 'User.update'); - expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); - expect(telemetry?.statementCount, 2); - expect(telemetry?.rowCount, 1); - expect(telemetry?.affectedRows, 1); - expect(telemetry?.steps.map((step) => step.trace.phase).toList(), [ - 'write', - 'fallback.reload', - ]); - expect( - telemetry?.steps.map((step) => step.outcome).toList(), - [ - RuntimeTelemetryOutcome.success, - RuntimeTelemetryOutcome.success, - ], - ); - await client.disconnect(); - }); - - test('aggregates fallback delete preload into one operation record', () async { - final noReturningContract = OrmContract( - version: contract.version, - hash: contract.hash, - models: contract.models, - aliases: contract.aliases, - capabilities: const ContractCapabilities(mutationReturning: false), - ); - final client = OrmClient( - contract: noReturningContract, - engine: _NoMutationReturnEngine(inner: MemoryEngine()), - ); - await client.connect(); - final users = client.db.orm.model('User'); - - await users.create( - data: {'id': 'u1', 'email': 'a@x.com'}, - ); - final row = await users.delete( - where: {'id': 'u1'}, - select: const ['id', 'email'], - ); - - expect(row, {'id': 'u1', 'email': 'a@x.com'}); - final telemetry = client.operationTelemetry(); - expect(telemetry, isNotNull); - expect(telemetry?.kind, 'User.delete'); - expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); - expect(telemetry?.statementCount, 2); - expect(telemetry?.rowCount, 1); - expect(telemetry?.affectedRows, 1); - expect(telemetry?.steps.map((step) => step.trace.phase).toList(), [ - 'fallback.preload', - 'write', - ]); - expect( - telemetry?.steps.map((step) => step.trace.step).toList(), - [1, 2], - ); - await client.disconnect(); - }); + test( + 'aggregates fallback update reload into one operation record', + () async { + final noReturningContract = OrmContract( + version: contract.version, + hash: contract.hash, + models: contract.models, + aliases: contract.aliases, + capabilities: const ContractCapabilities(mutationReturning: false), + ); + final client = OrmClient( + contract: noReturningContract, + engine: _NoMutationReturnEngine(inner: MemoryEngine()), + ); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + final row = await users.update( + where: {'id': 'u1'}, + data: {'email': 'b@x.com'}, + select: const ['id', 'email'], + ); + + expect(row, {'id': 'u1', 'email': 'b@x.com'}); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.update'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.statementCount, 2); + expect(telemetry?.rowCount, 1); + expect(telemetry?.affectedRows, 1); + expect( + telemetry?.steps.map((step) => step.trace.phase).toList(), + ['write', 'fallback.reload'], + ); + expect( + telemetry?.steps.map((step) => step.outcome).toList(), + [ + RuntimeTelemetryOutcome.success, + RuntimeTelemetryOutcome.success, + ], + ); + await client.disconnect(); + }, + ); + + test( + 'aggregates fallback delete preload into one operation record', + () async { + final noReturningContract = OrmContract( + version: contract.version, + hash: contract.hash, + models: contract.models, + aliases: contract.aliases, + capabilities: const ContractCapabilities(mutationReturning: false), + ); + final client = OrmClient( + contract: noReturningContract, + engine: _NoMutationReturnEngine(inner: MemoryEngine()), + ); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + final row = await users.delete( + where: {'id': 'u1'}, + select: const ['id', 'email'], + ); + + expect(row, {'id': 'u1', 'email': 'a@x.com'}); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.delete'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.statementCount, 2); + expect(telemetry?.rowCount, 1); + expect(telemetry?.affectedRows, 1); + expect( + telemetry?.steps.map((step) => step.trace.phase).toList(), + ['fallback.preload', 'write'], + ); + expect(telemetry?.steps.map((step) => step.trace.step).toList(), [ + 1, + 2, + ]); + await client.disconnect(); + }, + ); test('keeps repeated operations isolated in recent history', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); @@ -172,6 +178,55 @@ void main() { ); await client.disconnect(); }); + + test('aggregates pageResult probes into one operation record', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.createMany( + data: [ + {'id': 1, 'email': 'a@x.com'}, + {'id': 2, 'email': 'b@x.com'}, + {'id': 3, 'email': 'c@x.com'}, + {'id': 4, 'email': 'd@x.com'}, + ], + ); + + final result = await users + .query() + .orderByField('id') + .page(size: 2, after: {'id': 1}) + .pageResult(); + + expect( + result.items.map((row) => row['id']).toList(growable: false), + [2, 3], + ); + expect(result.pageInfo.hasPreviousPage, isTrue); + expect(result.pageInfo.hasNextPage, isTrue); + + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.pageResult'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.statementCount, 2); + expect( + telemetry?.steps.map((step) => step.trace.phase).toList(), + ['page.items', 'page.probe'], + ); + expect( + telemetry?.steps.map((step) => step.trace.strategy).toList(), + ['windowPlusOne', 'beforeBoundary'], + ); + expect(telemetry?.steps.map((step) => step.trace.step).toList(), [ + 1, + 2, + ]); + expect(telemetry?.steps.first.rowCount, 3); + expect(telemetry?.steps.last.rowCount, 1); + await client.disconnect(); + }); }); } From f88d5a78c926f7ba30c8f3245e8f9622c28a5d66 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:58:11 +0800 Subject: [PATCH 108/154] test(runtime): lock adapter explain and stream boundary --- pub/orm/test/client/api_surface_test.dart | 75 +++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 3a5e1f2b..e137d8a3 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -180,6 +180,61 @@ void main() { }, ); + test( + 'adapter explain stays non-executing while stream executes once', + () async { + final sqlContract = OrmContract( + version: '1', + hash: 'contract-sql-stream-v1', + target: 'sql-family', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + aliases: {'users': 'User'}, + ); + final driver = _CountingSqlDriver( + rows: [ + {'id': 'u1', 'email': 'a@x.com'}, + ], + ); + final client = OrmClient( + contract: sqlContract, + engine: AdapterDriverEngine( + adapter: SqlAdapter(contract: sqlContract), + driver: driver, + ), + ); + await client.connect(); + try { + final users = client.db.orm.model('User'); + + await users + .query() + .where({'id': 'u1'}) + .orderByField('id') + .page(size: 1) + .explain(); + expect(driver.executeCount, 0); + + final rows = await users + .query() + .where({'id': 'u1'}) + .stream() + .toList(); + expect(driver.executeCount, 1); + expect(rows, [ + {'id': 'u1', 'email': 'a@x.com'}, + ]); + } finally { + await client.disconnect(); + } + }, + ); + test('cursor and page execution return deterministic windows', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -480,3 +535,23 @@ final class _ExplainOnlySqlDriver throw StateError('explain() should not execute the SQL driver.'); } } + +final class _CountingSqlDriver + implements TargetDriver { + final List rows; + int executeCount = 0; + + _CountingSqlDriver({required this.rows}); + + @override + Future open() async {} + + @override + Future close() async {} + + @override + Future execute(SqlStatement request) async { + executeCount += 1; + return SqlResult(rows: rows); + } +} From 2b6bdd38f85af4ea85859f170ad247adb5bdd398 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:13:10 +0800 Subject: [PATCH 109/154] refactor(runtime)!: make execution row-stream first --- pub/orm/lib/src/client/client.dart | 215 +++--- .../lib/src/client/mutation_repository.dart | 24 +- pub/orm/lib/src/engine/engine.dart | 29 +- pub/orm/lib/src/engine/memory_engine.dart | 25 +- pub/orm/lib/src/runtime/core.dart | 226 ++++-- pub/orm/lib/src/sql/adapter.dart | 12 +- pub/orm/test/client/api_surface_test.dart | 21 +- pub/orm/test/client/client_test.dart | 668 ++++++++++-------- .../operation_telemetry_aggregation_test.dart | 2 +- pub/orm/test/sql/sql_adapter_test.dart | 215 +++--- .../target/adapter_driver_engine_test.dart | 16 +- 11 files changed, 821 insertions(+), 632 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 44401d4d..bf5d1878 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -167,10 +167,8 @@ final class OrmPageResult { final List items; final OrmPageInfo pageInfo; - OrmPageResult({ - required List items, - required this.pageInfo, - }) : items = List.unmodifiable(items); + OrmPageResult({required List items, required this.pageInfo}) + : items = List.unmodifiable(items); OrmPageResult mapItems(R Function(T item) transform) { return OrmPageResult( @@ -205,10 +203,7 @@ Map _mergeIncludeSpecMap( return merged; } -JsonMap _mergePlanAnnotations( - JsonMap current, - JsonMap next, -) { +JsonMap _mergePlanAnnotations(JsonMap current, JsonMap next) { if (current.isEmpty) { if (next.isEmpty) { return const {}; @@ -216,7 +211,9 @@ JsonMap _mergePlanAnnotations( return Map.unmodifiable(Map.from(next)); } if (next.isEmpty) { - return Map.unmodifiable(Map.from(current)); + return Map.unmodifiable( + Map.from(current), + ); } return Map.unmodifiable({ ...current, @@ -367,15 +364,15 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { Future Function(OrmScopedClient connection) run, ) async { final connection = await _runtime.connection(); - final scoped = OrmScopedClient._( - contract: contract, - executePlan: connection.execute, - explainPlan: _runtime.explain, - modelAliases: _modelAliases, - collectionRegistry: _collectionRegistry, - includeStrategySelector: includeStrategySelector, - maxIncludeDepth: maxIncludeDepth, - ); + final scoped = OrmScopedClient._( + contract: contract, + executePlan: connection.execute, + explainPlan: _runtime.explain, + modelAliases: _modelAliases, + collectionRegistry: _collectionRegistry, + includeStrategySelector: includeStrategySelector, + maxIncludeDepth: maxIncludeDepth, + ); try { return await run(scoped); @@ -723,20 +720,17 @@ final class OrmSqlSelectBuilder { Future> all() async { final response = await _client.execute(toPlan()); - return _readRows(response.data, action: 'sql.all'); + return _collectRows(response, action: 'sql.all'); } Future firstOrNull() async { final response = await _client.execute(take(1).toPlan()); - final rows = _readRows(response.data, action: 'sql.firstOrNull'); - return _firstOrNull(rows); + return _collectSingleRow(response, action: 'sql.firstOrNull'); } Stream stream() async* { - final rows = await all(); - for (final row in rows) { - yield row; - } + final response = await _client.execute(toPlan()); + yield* _streamRows(response, action: 'sql.stream'); } OrmSqlSelectBuilder _copy({ @@ -802,7 +796,7 @@ final class OrmSqlInsertBuilder { Future execute() async { final response = await _client.execute(toPlan()); return OrmSqlMutationResult( - row: _readRow(response.data, action: 'sql.insert'), + row: await _collectSingleRow(response, action: 'sql.insert'), affectedRows: response.affectedRows, ); } @@ -869,7 +863,7 @@ final class OrmSqlUpdateBuilder { Future execute() async { final response = await _client.execute(toPlan()); return OrmSqlMutationResult( - row: _readRow(response.data, action: 'sql.update'), + row: await _collectSingleRow(response, action: 'sql.update'), affectedRows: response.affectedRows, ); } @@ -933,7 +927,7 @@ final class OrmSqlDeleteBuilder { Future execute() async { final response = await _client.execute(toPlan()); return OrmSqlMutationResult( - row: _readRow(response.data, action: 'sql.delete'), + row: await _collectSingleRow(response, action: 'sql.delete'), affectedRows: response.affectedRows, ); } @@ -1045,11 +1039,8 @@ class ModelDelegate { ModelQuery cursor(JsonMap cursor) => query().cursor(cursor); - ModelQuery page({ - required int size, - JsonMap? after, - JsonMap? before, - }) => query().page(size: size, after: after, before: before); + ModelQuery page({required int size, JsonMap? after, JsonMap? before}) => + query().page(size: size, after: after, before: before); ModelQuery select(List fields) => query().select(fields); @@ -1159,7 +1150,8 @@ class ModelDelegate { JsonMap? cursor, OrmReadPagePlan? page, }) async* { - final rows = await all( + final prepared = await _buildReadPlan( + resultMode: OrmReadResultMode.all, where: where, skip: skip, take: take, @@ -1170,8 +1162,33 @@ class ModelDelegate { cursor: cursor, page: page, ); + final normalizedInclude = prepared.include; + final response = await _client.execute(prepared.plan); - for (final row in rows) { + if (normalizedInclude.isEmpty) { + await for (final row in _streamRows(response, action: 'stream')) { + yield _shapeRow(row, select: select, include: normalizedInclude); + } + return; + } + + final rows = await _collectRows(response, action: 'stream'); + if (rows.isEmpty) { + return; + } + + final hydratedRows = await _resolveIncludeRows( + action: OrmAction.read, + rows: rows, + include: normalizedInclude, + depth: 0, + ); + + for (final row in _shapeRows( + hydratedRows, + select: select, + include: normalizedInclude, + )) { yield row; } } @@ -1477,11 +1494,9 @@ class ModelDelegate { required JsonMap data, List select = const [], Map include = const {}, - }) => _RepositoryMutationExecutor(this).create( - data: data, - select: select, - include: include, - ); + }) => _RepositoryMutationExecutor( + this, + ).create(data: data, select: select, include: include); Future createNested({ required JsonMap data, @@ -1513,11 +1528,9 @@ class ModelDelegate { required List data, List select = const [], Map include = const {}, - }) => _RepositoryMutationExecutor(this).createMany( - data: data, - select: select, - include: include, - ); + }) => _RepositoryMutationExecutor( + this, + ).createMany(data: data, select: select, include: include); Future updateMany({ JsonMap where = const {}, @@ -1559,22 +1572,17 @@ class ModelDelegate { required JsonMap data, List select = const [], Map include = const {}, - }) => _RepositoryMutationExecutor(this).update( - where: where, - data: data, - select: select, - include: include, - ); + }) => _RepositoryMutationExecutor( + this, + ).update(where: where, data: data, select: select, include: include); Future delete({ JsonMap where = const {}, List select = const [], Map include = const {}, - }) => _RepositoryMutationExecutor(this).delete( - where: where, - select: select, - include: include, - ); + }) => _RepositoryMutationExecutor( + this, + ).delete(where: where, select: select, include: include); Future<_PreparedReadPlan> _buildReadPlan({ required OrmReadResultMode resultMode, @@ -1651,13 +1659,13 @@ class ModelDelegate { select: select, include: normalizedInclude, ), - OrmReadResultMode.all || OrmReadResultMode.firstOrNull => - _expandSelectForExecution( - model: modelName, - select: select, - include: normalizedInclude, - distinct: distinct, - ), + OrmReadResultMode.all || + OrmReadResultMode.firstOrNull => _expandSelectForExecution( + model: modelName, + select: select, + include: normalizedInclude, + distinct: distinct, + ), }; return _PreparedReadPlan( @@ -1724,7 +1732,7 @@ class ModelDelegate { final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); - var rows = _readRows(response.data); + var rows = await _collectRows(response, action: 'all'); if (distinct.isNotEmpty) { rows = _applyDistinctRows(rows: rows, distinct: distinct); rows = _sliceRows(rows: rows, skip: skip, take: take); @@ -1770,7 +1778,7 @@ class ModelDelegate { ), ); final response = await _client.execute(prepared.plan); - final rawRows = _readRows(response.data, action: 'pageResult'); + final rawRows = await _collectRows(response, action: 'pageResult'); final overflowed = rawRows.length > page.size; final windowRows = _trimPageResultRows(rows: rawRows, page: page); final hydratedRows = await _resolveIncludeRows( @@ -1825,7 +1833,7 @@ class ModelDelegate { final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); - final row = _readRow(response.data, action: 'firstOrNull'); + final row = await _collectSingleRow(response, action: 'firstOrNull'); if (row == null) { return null; } @@ -1864,7 +1872,7 @@ class ModelDelegate { final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); - final row = _readRow(response.data, action: 'oneOrNull'); + final row = await _collectSingleRow(response, action: 'oneOrNull'); if (row == null) { return null; } @@ -1890,9 +1898,7 @@ class ModelDelegate { required int depth, _RepositoryOperation? operation, }) { - return _RepositoryIncludePlanner( - this, - ).resolve( + return _RepositoryIncludePlanner(this).resolve( action: action, rows: rows, include: include, @@ -2156,7 +2162,9 @@ class ModelDelegate { details: { 'model': modelName, 'field': entry.field, - 'orderBy': orderBy.map((item) => item.toJson()).toList(growable: false), + 'orderBy': orderBy + .map((item) => item.toJson()) + .toList(growable: false), }, ); } @@ -2271,7 +2279,9 @@ class ModelDelegate { details: { 'model': modelName, 'idFields': idFields, - 'orderBy': orderBy.map((entry) => entry.toJson()).toList(growable: false), + 'orderBy': orderBy + .map((entry) => entry.toJson()) + .toList(growable: false), }, ); } @@ -3859,11 +3869,7 @@ final class ModelQuery { ); } - ModelQuery page({ - required int size, - JsonMap? after, - JsonMap? before, - }) { + ModelQuery page({required int size, JsonMap? after, JsonMap? before}) { if (_state.orderBy.isEmpty) { throw runtimeError( 'PLAN.CURSOR_ORDER_BY_REQUIRED', @@ -3875,10 +3881,7 @@ final class ModelQuery { if (size <= 0) { throw PlanCursorWindowInvalidException( reason: 'pageSizeInvalid', - details: { - 'model': _delegate.modelName, - 'size': size, - }, + details: {'model': _delegate.modelName, 'size': size}, ); } if (after != null && before != null) { @@ -4178,10 +4181,7 @@ final class ModelQuery { throw runtimeError( 'PLAN.MUTATION_QUERY_STATE_INVALID', '$action does not allow query state keys: ${invalidKeys.join(', ')}.', - details: { - 'action': action, - 'invalidKeys': invalidKeys, - }, + details: {'action': action, 'invalidKeys': invalidKeys}, ); } @@ -4334,27 +4334,38 @@ Map _createCollectionRegistry( return registry; } -List _readRows(Object? data, {String action = 'findMany'}) { - if (data == null) { - return const []; +Stream _streamRows( + EngineResponse response, { + required String action, +}) async* { + await for (final value in response.rows) { + yield _coerceRow(value, action: action); } - if (data is! List) { - throw RuntimeResponseShapeException( - action: action, - expected: 'List>', - actual: data, - ); - } - return data - .map((value) => _coerceRow(value, action: action)) - .toList(growable: false); } -JsonMap? _readRow(Object? data, {required String action}) { - if (data == null) { - return null; +Future> _collectRows( + EngineResponse response, { + String action = 'all', +}) { + return _streamRows(response, action: action).toList(); +} + +Future _collectSingleRow( + EngineResponse response, { + required String action, +}) async { + JsonMap? row; + await for (final value in response.rows) { + if (row != null) { + throw RuntimeResponseShapeException( + action: action, + expected: '0 or 1 row', + actual: const [null, null], + ); + } + row = _coerceRow(value, action: action); } - return _coerceRow(data, action: action); + return row; } JsonMap _coerceRow(Object? value, {required String action}) { diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index 0111f461..0d89c4ae 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -61,7 +61,7 @@ final class _RepositoryMutationExecutor { final normalizedInclude = prepared.include; final response = await _delegate._client.execute(prepared.plan); - var row = _readRow(response.data, action: 'create'); + var row = await _collectSingleRow(response, action: 'create'); if (row == null) { if (_delegate._client.contract.capabilities.mutationReturning && response.affectedRows > 0) { @@ -438,7 +438,7 @@ final class _RepositoryMutationExecutor { required JsonMap? preDeleteRow, required _RepositoryOperation operation, }) async { - var row = _readRow(response.data, action: responseAction); + var row = await _collectSingleRow(response, action: responseAction); if (row == null && response.affectedRows > 0 && !(_delegate._client.contract.capabilities.mutationReturning)) { @@ -606,11 +606,9 @@ final class _RepositoryMutationExecutor { include: include, depth: 0, ); - return _delegate._shapeRows( - hydratedRows, - select: select, - include: include, - ).single; + return _delegate + ._shapeRows(hydratedRows, select: select, include: include) + .single; } Future _shapeNestedMutationRow({ @@ -627,11 +625,13 @@ final class _RepositoryMutationExecutor { }; if (includeForReturn.isEmpty) { - return _delegate._shapeRows( - [row], - select: select, - include: const {}, - ).single; + return _delegate + ._shapeRows( + [row], + select: select, + include: const {}, + ) + .single; } return _shapeMutationRow( diff --git a/pub/orm/lib/src/engine/engine.dart b/pub/orm/lib/src/engine/engine.dart index 147338d9..ae1c8f03 100644 --- a/pub/orm/lib/src/engine/engine.dart +++ b/pub/orm/lib/src/engine/engine.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:meta/meta.dart'; import '../runtime/plan.dart'; @@ -5,10 +7,33 @@ import '../runtime/types.dart'; @immutable final class EngineResponse { - final Object? data; + final Stream rows; final int affectedRows; - const EngineResponse({this.data, this.affectedRows = 0}); + EngineResponse({required this.rows, this.affectedRows = 0}); + + factory EngineResponse.buffered(Object? data, {int affectedRows = 0}) { + if (data == null) { + return EngineResponse.empty(affectedRows: affectedRows); + } + if (data is List) { + return EngineResponse( + rows: Stream.fromIterable(data), + affectedRows: affectedRows, + ); + } + return EngineResponse( + rows: Stream.value(data), + affectedRows: affectedRows, + ); + } + + factory EngineResponse.empty({int affectedRows = 0}) { + return EngineResponse( + rows: const Stream.empty(), + affectedRows: affectedRows, + ); + } } abstract interface class RuntimeQueryable { diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index 9999e7f3..1e589b3e 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -106,8 +106,8 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { return switch (read.resultMode) { OrmReadResultMode.firstOrNull || OrmReadResultMode.oneOrNull => - EngineResponse(data: _firstOrNull(projected)), - _ => EngineResponse(data: projected), + EngineResponse.buffered(_firstOrNull(projected)), + _ => EngineResponse.buffered(projected), }; } @@ -190,8 +190,8 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { final mutation = plan.mutation!; final row = _cloneRow(mutation.data); bucket.add(row); - return EngineResponse( - data: _projectRow(row, mutation.select), + return EngineResponse.buffered( + _projectRow(row, mutation.select), affectedRows: 1, ); } @@ -206,12 +206,12 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { final updated = {...row, ...mutation.data}; bucket[index] = updated; - return EngineResponse( - data: _projectRow(updated, mutation.select), + return EngineResponse.buffered( + _projectRow(updated, mutation.select), affectedRows: 1, ); } - return const EngineResponse(data: null, affectedRows: 0); + return EngineResponse.empty(affectedRows: 0); } EngineResponse _delete(List bucket, OrmPlan plan) { @@ -223,12 +223,12 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { } bucket.removeAt(index); - return EngineResponse( - data: _projectRow(row, mutation.select), + return EngineResponse.buffered( + _projectRow(row, mutation.select), affectedRows: 1, ); } - return const EngineResponse(data: null, affectedRows: 0); + return EngineResponse.empty(affectedRows: 0); } bool _matches(JsonMap row, JsonMap where) { @@ -512,7 +512,10 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { required List orderBy, }) { for (final order in orderBy) { - final comparison = _compareValues(row[order.field], boundary[order.field]); + final comparison = _compareValues( + row[order.field], + boundary[order.field], + ); if (comparison == 0) { continue; } diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 3d7f9c3f..517711a7 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -401,7 +401,6 @@ final class OrmRuntimeCore implements RuntimeCore { await _verifyForRequest(); final startedAt = DateTime.now(); - var rowCount = 0; try { for (final plugin in _plugins) { @@ -409,89 +408,184 @@ final class OrmRuntimeCore implements RuntimeCore { } final response = await queryable.execute(plan); - final rows = _extractRows(response.data, action: plan.action.name); - rowCount = rows.length; - for (final row in rows) { - for (final plugin in _plugins) { - await plugin.onRow(row, plan, _pluginContext); - } - } - - final result = AfterExecuteResult( - rowCount: rowCount, + return EngineResponse( + rows: _observeExecutionRows( + plan: plan, + rows: response.rows, + affectedRows: response.affectedRows, + startedAt: startedAt, + ), affectedRows: response.affectedRows, - latencyMs: DateTime.now().difference(startedAt).inMilliseconds, - completed: true, ); + } catch (error, stackTrace) { + final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; + await _recordExecutionFailure( + plan: plan, + error: error, + stackTrace: stackTrace, + rowCount: 0, + latencyMs: latencyMs, + startedAt: startedAt, + ); + rethrow; + } + } - for (final plugin in _plugins) { - await plugin.afterExecute(plan, result, _pluginContext); + Stream _observeExecutionRows({ + required OrmPlan plan, + required Stream rows, + required int affectedRows, + required DateTime startedAt, + }) async* { + var rowCount = 0; + var completed = false; + var failed = false; + + try { + await for (final rawRow in rows) { + final row = _coerceToRow(rawRow, action: plan.action.name); + rowCount += 1; + for (final plugin in _plugins) { + await plugin.onRow(row, plan, _pluginContext); + } + yield row; } - _telemetry = RuntimeTelemetryEvent( - model: plan.model, - action: plan.action, - outcome: RuntimeTelemetryOutcome.success, - durationMs: result.latencyMs, - recordedAt: DateTime.now(), - repositoryTrace: plan.repositoryTrace, - ); - _recordOperationTelemetry( + completed = true; + await _recordExecutionSuccess( plan: plan, - outcome: RuntimeTelemetryOutcome.success, rowCount: rowCount, - affectedRows: response.affectedRows, - durationMs: result.latencyMs, + affectedRows: affectedRows, startedAt: startedAt, - recordedAt: _telemetry!.recordedAt, ); - - return response; } catch (error, stackTrace) { + failed = true; final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; - _telemetry = RuntimeTelemetryEvent( - model: plan.model, - action: plan.action, - outcome: RuntimeTelemetryOutcome.runtimeError, - durationMs: latencyMs, - recordedAt: DateTime.now(), - repositoryTrace: plan.repositoryTrace, + await _recordExecutionFailure( + plan: plan, + error: error, + stackTrace: stackTrace, + rowCount: rowCount, + latencyMs: latencyMs, + startedAt: startedAt, ); - if (error is! PlanRepositoryTraceInvalidException) { - _recordOperationTelemetry( + rethrow; + } finally { + if (!completed && !failed) { + await _recordExecutionInterrupted( plan: plan, - outcome: RuntimeTelemetryOutcome.runtimeError, rowCount: rowCount, - affectedRows: 0, - durationMs: latencyMs, + affectedRows: affectedRows, startedAt: startedAt, - recordedAt: _telemetry!.recordedAt, ); } + } + } - for (final plugin in _plugins) { - try { - await plugin.onError(plan, error, stackTrace, _pluginContext); - } catch (_) { - // Keep original error when error observers fail. - } - } + Future _recordExecutionSuccess({ + required OrmPlan plan, + required int rowCount, + required int affectedRows, + required DateTime startedAt, + }) async { + final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; + final result = AfterExecuteResult( + rowCount: rowCount, + affectedRows: affectedRows, + latencyMs: latencyMs, + completed: true, + ); + + for (final plugin in _plugins) { + await plugin.afterExecute(plan, result, _pluginContext); + } - final result = AfterExecuteResult( + _telemetry = RuntimeTelemetryEvent( + model: plan.model, + action: plan.action, + outcome: RuntimeTelemetryOutcome.success, + durationMs: latencyMs, + recordedAt: DateTime.now(), + repositoryTrace: plan.repositoryTrace, + ); + _recordOperationTelemetry( + plan: plan, + outcome: RuntimeTelemetryOutcome.success, + rowCount: rowCount, + affectedRows: affectedRows, + durationMs: latencyMs, + startedAt: startedAt, + recordedAt: _telemetry!.recordedAt, + ); + } + + Future _recordExecutionFailure({ + required OrmPlan plan, + required Object error, + required StackTrace stackTrace, + required int rowCount, + required int latencyMs, + required DateTime startedAt, + }) async { + _telemetry = RuntimeTelemetryEvent( + model: plan.model, + action: plan.action, + outcome: RuntimeTelemetryOutcome.runtimeError, + durationMs: latencyMs, + recordedAt: DateTime.now(), + repositoryTrace: plan.repositoryTrace, + ); + if (error is! PlanRepositoryTraceInvalidException) { + _recordOperationTelemetry( + plan: plan, + outcome: RuntimeTelemetryOutcome.runtimeError, rowCount: rowCount, affectedRows: 0, - latencyMs: latencyMs, - completed: false, + durationMs: latencyMs, + startedAt: startedAt, + recordedAt: _telemetry!.recordedAt, ); - for (final plugin in _plugins) { - try { - await plugin.afterExecute(plan, result, _pluginContext); - } catch (_) { - // Ignore afterExecute errors on failure path. - } + } + + for (final plugin in _plugins) { + try { + await plugin.onError(plan, error, stackTrace, _pluginContext); + } catch (_) { + // Keep original error when error observers fail. } + } - rethrow; + final result = AfterExecuteResult( + rowCount: rowCount, + affectedRows: 0, + latencyMs: latencyMs, + completed: false, + ); + for (final plugin in _plugins) { + try { + await plugin.afterExecute(plan, result, _pluginContext); + } catch (_) { + // Ignore afterExecute errors on failure path. + } + } + } + + Future _recordExecutionInterrupted({ + required OrmPlan plan, + required int rowCount, + required int affectedRows, + required DateTime startedAt, + }) async { + final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; + final result = AfterExecuteResult( + rowCount: rowCount, + affectedRows: affectedRows, + latencyMs: latencyMs, + completed: false, + ); + + for (final plugin in _plugins) { + await plugin.afterExecute(plan, result, _pluginContext); } } @@ -1104,18 +1198,6 @@ List? _coerceWhereList(Object? value) { return whereList; } -List _extractRows(Object? data, {required String action}) { - if (data == null) { - return const []; - } - if (data is List) { - return data - .map((value) => _coerceToRow(value, action: action)) - .toList(growable: false); - } - return [_coerceToRow(data, action: action)]; -} - JsonMap _coerceToRow(Object? value, {required String action}) { if (value is Map) { return Map.unmodifiable(value); diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 162a0718..7c5e3146 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -170,8 +170,8 @@ final class SqlAdapter ), OrmAction.create || OrmAction.update || - OrmAction.delete => EngineResponse( - data: _firstOrNull(response.rows), + OrmAction.delete => EngineResponse.buffered( + _firstOrNull(response.rows), affectedRows: response.affectedRows, ), }; @@ -186,8 +186,8 @@ final class SqlAdapter ), OrmAction.create || OrmAction.update || - OrmAction.delete => EngineResponse( - data: _firstOrNull(decodedRows), + OrmAction.delete => EngineResponse.buffered( + _firstOrNull(decodedRows), affectedRows: response.affectedRows, ), }; @@ -261,8 +261,8 @@ final class SqlAdapter }) { return switch (plan.read!.resultMode) { OrmReadResultMode.firstOrNull || OrmReadResultMode.oneOrNull => - EngineResponse(data: _firstOrNull(rows), affectedRows: affectedRows), - _ => EngineResponse(data: rows, affectedRows: affectedRows), + EngineResponse.buffered(_firstOrNull(rows), affectedRows: affectedRows), + _ => EngineResponse.buffered(rows, affectedRows: affectedRows), }; } diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index e137d8a3..4aeb6d6e 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -378,17 +378,15 @@ void main() { ), ); final pageResponse = await client.execute(pagePlan); + final cursorRows = await _readEngineRows(cursorResponse); + final pageRows = await _readEngineRows(pageResponse); expect( - (cursorResponse.data as List) - .map((row) => row['id']) - .toList(growable: false), + cursorRows.map((row) => row['id']).toList(growable: false), [2, 3, 4], ); expect( - (pageResponse.data as List) - .map((row) => row['id']) - .toList(growable: false), + pageRows.map((row) => row['id']).toList(growable: false), [3, 4], ); } finally { @@ -555,3 +553,14 @@ final class _CountingSqlDriver return SqlResult(rows: rows); } } + +Future> _readEngineRows(EngineResponse response) async { + final rows = []; + await for (final row in response.rows) { + if (row is! Map) { + throw StateError('Expected engine row map but got ${row.runtimeType}.'); + } + rows.add(row); + } + return rows; +} diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 223bcdef..36bcf141 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -185,9 +185,9 @@ void main() { }).firstOrNull(); expect(sqlRow?['email'], 'a@example.com'); - final ormRow = await client.db.orm.model('User').oneOrNull( - where: {'id': 'u1'}, - ); + final ormRow = await client.db.orm + .model('User') + .oneOrNull(where: {'id': 'u1'}); expect(ormRow?['id'], 'u1'); await client.disconnect(); }); @@ -357,56 +357,59 @@ void main() { await client.disconnect(); }); - test('rejects legacy and invalid typed repository trace metadata', () async { - final client = OrmClient(contract: contract, engine: MemoryEngine()); - await client.connect(); + test( + 'rejects legacy and invalid typed repository trace metadata', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); - await expectLater( - client.execute( - OrmPlan.read( - contractHash: contract.hash, - model: 'User', - resultMode: OrmReadResultMode.all, - annotations: const { - 'repository': {'operationId': 'legacy'}, - }, + await expectLater( + client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + annotations: const { + 'repository': {'operationId': 'legacy'}, + }, + ), ), - ), - throwsA( - isA().having( - (error) => error.details['reason'], - 'reason', - 'legacyAnnotation', + throwsA( + isA().having( + (error) => error.details['reason'], + 'reason', + 'legacyAnnotation', + ), ), - ), - ); + ); - await expectLater( - client.execute( - OrmPlan.read( - contractHash: contract.hash, - model: 'User', - resultMode: OrmReadResultMode.all, - repositoryTrace: const OrmRepositoryTrace( - operationId: '', - kind: 'User.include', - step: 1, - phase: 'include.load', - strategy: 'multiQuery', + await expectLater( + client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + repositoryTrace: const OrmRepositoryTrace( + operationId: '', + kind: 'User.include', + step: 1, + phase: 'include.load', + strategy: 'multiQuery', + ), ), ), - ), - throwsA( - isA().having( - (error) => error.details['reason'], - 'reason', - 'operationIdEmpty', + throwsA( + isA().having( + (error) => error.details['reason'], + 'reason', + 'operationIdEmpty', + ), ), - ), - ); + ); - await client.disconnect(); - }); + await client.disconnect(); + }, + ); test('supports ordering and pagination in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); @@ -927,26 +930,25 @@ void main() { final client = OrmClient(contract: contract, engine: engine); await client.connect(); - await client.db.orm.model('User').create( - data: {'id': 'u1', 'email': 'a@x.com'}, - ); + await client.db.orm + .model('User') + .create(data: {'id': 'u1', 'email': 'a@x.com'}); final createPlan = engine.executedPlans.single; expect(createPlan.lane, 'orm'); expect(createPlan.action, OrmAction.create); expect(createPlan.mutation?.resultMode, OrmMutationResultMode.row); engine.reset(); - await client.db.orm.model('User').update( - where: {'id': 'u1'}, - data: {'email': 'b@x.com'}, - ); + await client.db.orm + .model('User') + .update( + where: {'id': 'u1'}, + data: {'email': 'b@x.com'}, + ); final updatePlan = engine.executedPlans.single; expect(updatePlan.lane, 'orm'); expect(updatePlan.action, OrmAction.update); - expect( - updatePlan.mutation?.resultMode, - OrmMutationResultMode.rowOrNull, - ); + expect(updatePlan.mutation?.resultMode, OrmMutationResultMode.rowOrNull); final sqlPlan = client.db.sql .update('User') @@ -1088,8 +1090,11 @@ void main() { .create(data: {'id': 'u1', 'email': 'a@x.com'}), throwsA( isA() - .having((error) => error.code, 'code', - 'PLAN.MUTATION_QUERY_STATE_INVALID') + .having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID', + ) .having( (error) => error.details['invalidKeys'], 'invalidKeys', @@ -1105,8 +1110,11 @@ void main() { .update(data: {'email': 'b@x.com'}), throwsA( isA() - .having((error) => error.code, 'code', - 'PLAN.MUTATION_QUERY_STATE_INVALID') + .having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID', + ) .having( (error) => error.details['invalidKeys'], 'invalidKeys', @@ -1119,8 +1127,11 @@ void main() { () => users.take(1).deleteMany(), throwsA( isA() - .having((error) => error.code, 'code', - 'PLAN.MUTATION_QUERY_STATE_INVALID') + .having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID', + ) .having( (error) => error.details['invalidKeys'], 'invalidKeys', @@ -1176,10 +1187,9 @@ void main() { .toList(growable: false); final createOperationId = createTraces.first.operationId; expect(createOperationId, isNotNull); - expect( - createTraces.map((trace) => trace.operationId).toSet(), - {createOperationId}, - ); + expect(createTraces.map((trace) => trace.operationId).toSet(), { + createOperationId, + }); expect( createTraces.map((trace) => trace.kind).toList(growable: false), ['User.createMany', 'User.createMany', 'User.createMany'], @@ -1215,10 +1225,9 @@ void main() { .toList(growable: false); final deleteOperationId = deleteTraces.first.operationId; expect(deleteOperationId, isNotNull); - expect( - deleteTraces.map((trace) => trace.operationId).toSet(), - {deleteOperationId}, - ); + expect(deleteTraces.map((trace) => trace.operationId).toSet(), { + deleteOperationId, + }); expect( deleteTraces.map((trace) => trace.kind).toList(growable: false), ['User.deleteMany', 'User.deleteMany', 'User.deleteMany'], @@ -1331,9 +1340,9 @@ void main() { await client.connect(); await expectLater( - client.db.orm.model('User').create( - data: {'id': 'u1', 'email': 'a@x.com'}, - ), + client.db.orm + .model('User') + .create(data: {'id': 'u1', 'email': 'a@x.com'}), throwsA(isA()), ); @@ -1615,85 +1624,90 @@ void main() { await client.disconnect(); }); - test('annotates upsert branch plans with operation sequence metadata', () async { - final engine = _CountingEngine(inner: MemoryEngine()); - final client = OrmClient(contract: contract, engine: engine); - await client.connect(); - final users = client.db.orm.model('User'); + test( + 'annotates upsert branch plans with operation sequence metadata', + () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + final users = client.db.orm.model('User'); - final created = await users.upsert( - where: {'id': 'u1'}, - create: {'id': 'u1', 'email': 'a@example.com'}, - update: {'email': 'b@example.com'}, - ); - expect(created['email'], 'a@example.com'); - expect( - engine.executedPlans.map((plan) => plan.action).toList(growable: false), - [OrmAction.read, OrmAction.create], - ); - final createBranch = engine.executedPlans - .map(_readRepositoryTrace) - .toList(growable: false); - final createOperationId = createBranch.first.operationId; - expect( - createBranch.map((trace) => trace.operationId).toSet(), - {createOperationId}, - ); - expect( - createBranch.map((trace) => trace.kind).toList(growable: false), - ['User.upsert', 'User.upsert'], - ); - expect( - createBranch.map((trace) => trace.phase).toList(growable: false), - ['branch.lookup', 'branch.create'], - ); - expect( - createBranch.map((trace) => trace.strategy).toList(growable: false), - ['branch', 'branch'], - ); - expect( - createBranch.map((trace) => trace.step).toList(growable: false), - [1, 2], - ); + final created = await users.upsert( + where: {'id': 'u1'}, + create: {'id': 'u1', 'email': 'a@example.com'}, + update: {'email': 'b@example.com'}, + ); + expect(created['email'], 'a@example.com'); + expect( + engine.executedPlans + .map((plan) => plan.action) + .toList(growable: false), + [OrmAction.read, OrmAction.create], + ); + final createBranch = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final createOperationId = createBranch.first.operationId; + expect(createBranch.map((trace) => trace.operationId).toSet(), { + createOperationId, + }); + expect( + createBranch.map((trace) => trace.kind).toList(growable: false), + ['User.upsert', 'User.upsert'], + ); + expect( + createBranch.map((trace) => trace.phase).toList(growable: false), + ['branch.lookup', 'branch.create'], + ); + expect( + createBranch.map((trace) => trace.strategy).toList(growable: false), + ['branch', 'branch'], + ); + expect( + createBranch.map((trace) => trace.step).toList(growable: false), + [1, 2], + ); - engine.reset(); - final updated = await users.upsert( - where: {'id': 'u1'}, - create: {'id': 'u1', 'email': 'x@example.com'}, - update: {'email': 'b@example.com'}, - ); - expect(updated['email'], 'b@example.com'); - expect( - engine.executedPlans.map((plan) => plan.action).toList(growable: false), - [OrmAction.read, OrmAction.update], - ); - final updateBranch = engine.executedPlans - .map(_readRepositoryTrace) - .toList(growable: false); - final updateOperationId = updateBranch.first.operationId; - expect( - updateBranch.map((trace) => trace.operationId).toSet(), - {updateOperationId}, - ); - expect( - updateBranch.map((trace) => trace.kind).toList(growable: false), - ['User.upsert', 'User.upsert'], - ); - expect( - updateBranch.map((trace) => trace.phase).toList(growable: false), - ['branch.lookup', 'branch.update'], - ); - expect( - updateBranch.map((trace) => trace.strategy).toList(growable: false), - ['branch', 'branch'], - ); - expect( - updateBranch.map((trace) => trace.step).toList(growable: false), - [1, 2], - ); + engine.reset(); + final updated = await users.upsert( + where: {'id': 'u1'}, + create: {'id': 'u1', 'email': 'x@example.com'}, + update: {'email': 'b@example.com'}, + ); + expect(updated['email'], 'b@example.com'); + expect( + engine.executedPlans + .map((plan) => plan.action) + .toList(growable: false), + [OrmAction.read, OrmAction.update], + ); + final updateBranch = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final updateOperationId = updateBranch.first.operationId; + expect(updateBranch.map((trace) => trace.operationId).toSet(), { + updateOperationId, + }); + expect( + updateBranch.map((trace) => trace.kind).toList(growable: false), + ['User.upsert', 'User.upsert'], + ); + expect( + updateBranch.map((trace) => trace.phase).toList(growable: false), + ['branch.lookup', 'branch.update'], + ); + expect( + updateBranch.map((trace) => trace.strategy).toList(growable: false), + ['branch', 'branch'], + ); + expect( + updateBranch.map((trace) => trace.step).toList(growable: false), + [1, 2], + ); - await client.disconnect(); - }); + await client.disconnect(); + }, + ); test('supports relation where with nested logical operators', () async { final client = OrmClient( @@ -1811,7 +1825,8 @@ void main() { ); await client.connect(); - await client.db.orm.model('User') + await client.db.orm + .model('User') .all( where: { 'posts': { @@ -1838,7 +1853,8 @@ void main() { await client.connect(); await _seedRelationalData(client); - final rows = await client.db.orm.model('User') + final rows = await client.db.orm + .model('User') .all( orderBy: const [OrmOrderBy('id')], include: { @@ -1882,7 +1898,8 @@ void main() { await client.connect(); try { await _seedRelationalData(client); - final rows = await client.db.orm.model('User') + final rows = await client.db.orm + .model('User') .all( orderBy: const [OrmOrderBy('id')], include: { @@ -1931,7 +1948,8 @@ void main() { await _seedRelationalData(client); engine.reset(); - final rows = await client.db.orm.model('User') + final rows = await client.db.orm + .model('User') .all( orderBy: const [OrmOrderBy('id')], include: { @@ -1957,117 +1975,124 @@ void main() { } }); - test('singleQuery include annotates repository relation load plans', () async { - final engine = _CountingEngine(inner: MemoryEngine()); - final client = OrmClient( - contract: relationalContract, - engine: engine, - includeStrategySelector: - ({ - required OrmContract contract, - required String modelName, - required OrmAction action, - required Map include, - required int depth, - }) => IncludeExecutionStrategy.singleQuery, - ); - await client.connect(); - try { - await _seedRelationalData(client); - engine.reset(); + test( + 'singleQuery include annotates repository relation load plans', + () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient( + contract: relationalContract, + engine: engine, + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => IncludeExecutionStrategy.singleQuery, + ); + await client.connect(); + try { + await _seedRelationalData(client); + engine.reset(); - final rows = await client.db.orm.model('User') - .all( - orderBy: const [OrmOrderBy('id')], - include: { - 'posts': IncludeSpec( - orderBy: const [OrmOrderBy('id')], - ), - }, - ); + final rows = await client.db.orm + .model('User') + .all( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }, + ); - expect(rows, hasLength(2)); - final includePlans = engine.executedPlans - .where((plan) => plan.repositoryTrace != null) - .toList(growable: false); - expect(includePlans, hasLength(1)); - final trace = _readRepositoryTrace(includePlans.single); - expect(trace.kind, 'User.include'); - expect(trace.phase, 'include.load'); - expect(trace.strategy, 'singleQuery'); - expect(trace.relation, 'posts'); - expect(trace.step, 1); - } finally { - await client.disconnect(); - } - }); + expect(rows, hasLength(2)); + final includePlans = engine.executedPlans + .where((plan) => plan.repositoryTrace != null) + .toList(growable: false); + expect(includePlans, hasLength(1)); + final trace = _readRepositoryTrace(includePlans.single); + expect(trace.kind, 'User.include'); + expect(trace.phase, 'include.load'); + expect(trace.strategy, 'singleQuery'); + expect(trace.relation, 'posts'); + expect(trace.step, 1); + } finally { + await client.disconnect(); + } + }, + ); - test('multiQuery include annotates repository relation load sequence', () async { - final engine = _CountingEngine(inner: MemoryEngine()); - final client = OrmClient( - contract: relationalContract, - engine: engine, - includeStrategySelector: - ({ - required OrmContract contract, - required String modelName, - required OrmAction action, - required Map include, - required int depth, - }) => IncludeExecutionStrategy.multiQuery, - ); - await client.connect(); - try { - await _seedRelationalData(client); - engine.reset(); + test( + 'multiQuery include annotates repository relation load sequence', + () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient( + contract: relationalContract, + engine: engine, + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => IncludeExecutionStrategy.multiQuery, + ); + await client.connect(); + try { + await _seedRelationalData(client); + engine.reset(); - final rows = await client.db.orm.model('User') - .all( - orderBy: const [OrmOrderBy('id')], - include: { - 'posts': IncludeSpec( - orderBy: const [OrmOrderBy('id')], - ), - }, - ); + final rows = await client.db.orm + .model('User') + .all( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }, + ); - expect(rows, hasLength(2)); - final includePlans = engine.executedPlans - .where((plan) => plan.repositoryTrace != null) - .toList(growable: false); - expect(includePlans, hasLength(2)); - final traces = includePlans - .map(_readRepositoryTrace) - .toList(growable: false); - final operationId = traces.first.operationId; - expect( - traces.map((trace) => trace.operationId).toSet(), - {operationId}, - ); - expect( - traces.map((trace) => trace.kind).toList(growable: false), - ['User.include', 'User.include'], - ); - expect( - traces.map((trace) => trace.phase).toList(growable: false), - ['include.load', 'include.load'], - ); - expect( - traces.map((trace) => trace.strategy).toList(growable: false), - ['multiQuery', 'multiQuery'], - ); - expect( - traces.map((trace) => trace.relation).toList(growable: false), - ['posts', 'posts'], - ); - expect( - traces.map((trace) => trace.step).toList(growable: false), - [1, 2], - ); - } finally { - await client.disconnect(); - } - }); + expect(rows, hasLength(2)); + final includePlans = engine.executedPlans + .where((plan) => plan.repositoryTrace != null) + .toList(growable: false); + expect(includePlans, hasLength(2)); + final traces = includePlans + .map(_readRepositoryTrace) + .toList(growable: false); + final operationId = traces.first.operationId; + expect(traces.map((trace) => trace.operationId).toSet(), { + operationId, + }); + expect( + traces.map((trace) => trace.kind).toList(growable: false), + ['User.include', 'User.include'], + ); + expect( + traces.map((trace) => trace.phase).toList(growable: false), + ['include.load', 'include.load'], + ); + expect( + traces.map((trace) => trace.strategy).toList(growable: false), + ['multiQuery', 'multiQuery'], + ); + expect( + traces.map((trace) => trace.relation).toList(growable: false), + ['posts', 'posts'], + ); + expect( + traces.map((trace) => trace.step).toList(growable: false), + [1, 2], + ); + } finally { + await client.disconnect(); + } + }, + ); test( 'singleQuery include throws structured error for unsupported response shape', @@ -2088,7 +2113,8 @@ void main() { try { await _seedRelationalData(client); await expectLater( - client.db.orm.model('User') + client.db.orm + .model('User') .all( include: {'posts': const IncludeSpec()}, ), @@ -2148,7 +2174,8 @@ void main() { ); await client.connect(); - final created = await client.db.orm.model('User') + final created = await client.db.orm + .model('User') .createNested( data: {'id': 'u3', 'email': 'u3@example.com'}, create: >{ @@ -2164,7 +2191,8 @@ void main() { expect(createdPosts, hasLength(2)); expect(createdPosts.first['userId'], 'u3'); - final persistedPosts = await client.db.orm.model('Post') + final persistedPosts = await client.db.orm + .model('Post') .all(where: {'userId': 'u3'}); expect(persistedPosts, hasLength(2)); await client.disconnect(); @@ -2179,7 +2207,8 @@ void main() { await client.connect(); await expectLater( - client.db.orm.model('User') + client.db.orm + .model('User') .createNested( data: {'id': 'u4', 'email': 'u4@example.com'}, create: >{ @@ -2191,7 +2220,8 @@ void main() { throwsA(isA()), ); - final rolledBackUser = await client.db.orm.model('User') + final rolledBackUser = await client.db.orm + .model('User') .oneOrNull(where: {'id': 'u4'}); expect(rolledBackUser, isNull); await client.disconnect(); @@ -2207,7 +2237,8 @@ void main() { await client.connect(); await _seedRelationalData(client); - final updated = await client.db.orm.model('User') + final updated = await client.db.orm + .model('User') .updateNested( where: {'id': 'u1'}, data: {'email': 'u1+updated@example.com'}, @@ -2230,11 +2261,13 @@ void main() { expect(includedPosts.last['id'], 'p4'); expect(includedPosts.last['userId'], 'u1'); - final persistedUser = await client.db.orm.model('User') + final persistedUser = await client.db.orm + .model('User') .oneOrNull(where: {'id': 'u1'}); expect(persistedUser?['email'], 'u1+updated@example.com'); - final persistedChild = await client.db.orm.model('Post') + final persistedChild = await client.db.orm + .model('Post') .oneOrNull(where: {'id': 'p4'}); expect(persistedChild?['userId'], 'u1'); await client.disconnect(); @@ -2249,7 +2282,8 @@ void main() { await client.connect(); await _seedRelationalData(client); - final updated = await client.db.orm.model('User') + final updated = await client.db.orm + .model('User') .updateNested( where: {'id': 'ux'}, data: {'email': 'missing@example.com'}, @@ -2261,7 +2295,8 @@ void main() { ); expect(updated, isNull); - final createdChild = await client.db.orm.model('Post') + final createdChild = await client.db.orm + .model('Post') .oneOrNull(where: {'id': 'p9'}); expect(createdChild, isNull); await client.disconnect(); @@ -2276,7 +2311,8 @@ void main() { await _seedRelationalData(client); await expectLater( - client.db.orm.model('User') + client.db.orm + .model('User') .updateNested( where: {'id': 'u1'}, data: {'email': 'u1+rollback@example.com'}, @@ -2293,11 +2329,13 @@ void main() { throwsA(isA()), ); - final rolledBackUser = await client.db.orm.model('User') + final rolledBackUser = await client.db.orm + .model('User') .oneOrNull(where: {'id': 'u1'}); expect(rolledBackUser?['email'], 'u1@example.com'); - final rolledBackChild = await client.db.orm.model('Post') + final rolledBackChild = await client.db.orm + .model('Post') .oneOrNull(where: {'id': 'p10'}); expect(rolledBackChild, isNull); await client.disconnect(); @@ -2458,7 +2496,8 @@ void main() { await client.connect(); await _seedRelationalData(client); - final row = await client.db.orm.model('Post') + final row = await client.db.orm + .model('Post') .oneOrNull( where: {'id': 'p1'}, include: { @@ -2494,7 +2533,8 @@ void main() { await _seedRelationalData(client); await expectLater( - client.db.orm.model('User') + client.db.orm + .model('User') .all( include: {'unknown': const IncludeSpec()}, ), @@ -2513,7 +2553,8 @@ void main() { await _seedRelationalData(client); await expectLater( - client.db.orm.model('User') + client.db.orm + .model('User') .all( include: { 'posts': IncludeSpec( @@ -2534,7 +2575,8 @@ void main() { await client.connect(); await _seedRelationalData(client); - final row = await client.db.orm.model('User') + final row = await client.db.orm + .model('User') .oneOrNull( where: {'id': 'u1'}, select: const ['email'], @@ -2579,7 +2621,8 @@ void main() { await client.connect(); await _seedRelationalData(client); - await client.db.orm.model('User') + await client.db.orm + .model('User') .all(include: {'posts': const IncludeSpec()}); expect(callCount, greaterThan(0)); @@ -2594,7 +2637,10 @@ void main() { engine: MemoryEngine(), collections: { 'users': - ({required OrmCollectionContext client, required String modelName}) { + ({ + required OrmCollectionContext client, + required String modelName, + }) { return _UsersCollection(client: client, modelName: modelName); }, }, @@ -2642,7 +2688,8 @@ void main() { await transaction.commit(); await connection.release(); - final row = await client.db.orm.model('User') + final row = await client.db.orm + .model('User') .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'b@example.com'); await client.disconnect(); @@ -2653,13 +2700,15 @@ void main() { await client.connect(); await client.withConnection((connection) async { - await connection.db.orm.model('User') + await connection.db.orm + .model('User') .create( data: {'id': 'u1', 'email': 'a@example.com'}, ); }); - final row = await client.db.orm.model('User') + final row = await client.db.orm + .model('User') .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); @@ -2707,13 +2756,15 @@ void main() { await client.connect(); await client.withTransaction((transaction) async { - await transaction.db.orm.model('User') + await transaction.db.orm + .model('User') .create( data: {'id': 'u1', 'email': 'a@example.com'}, ); }); - final row = await client.db.orm.model('User') + final row = await client.db.orm + .model('User') .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); @@ -2730,7 +2781,8 @@ void main() { }).execute(); }); - final row = await client.db.orm.model('User') + final row = await client.db.orm + .model('User') .oneOrNull(where: {'id': 'u1'}); expect(row?['email'], 'a@example.com'); await client.disconnect(); @@ -2751,10 +2803,7 @@ void main() { expect(engine.connectionCount, 1); expect(engine.transactionCount, 1); expect(engine.transactionExecutePlans, hasLength(1)); - expect( - engine.transactionExecutePlans.single.action, - OrmAction.read, - ); + expect(engine.transactionExecutePlans.single.action, OrmAction.read); expect(engine.commitCount, 1); expect(engine.rollbackCount, 0); expect(engine.releaseCount, 1); @@ -2789,7 +2838,8 @@ void main() { await expectLater( () => client.withTransaction((transaction) async { - await transaction.db.orm.model('User') + await transaction.db.orm + .model('User') .create( data: {'id': 'u1', 'email': 'a@example.com'}, ); @@ -2798,7 +2848,8 @@ void main() { throwsA(isA()), ); - final row = await client.db.orm.model('User') + final row = await client.db.orm + .model('User') .oneOrNull(where: {'id': 'u1'}); expect(row, isNull); await client.disconnect(); @@ -2822,10 +2873,7 @@ void main() { expect(engine.connectionCount, 1); expect(engine.transactionCount, 1); expect(engine.transactionExecutePlans, hasLength(1)); - expect( - engine.transactionExecutePlans.single.action, - OrmAction.read, - ); + expect(engine.transactionExecutePlans.single.action, OrmAction.read); expect(engine.commitCount, 0); expect(engine.rollbackCount, 1); expect(engine.releaseCount, 1); @@ -3131,7 +3179,8 @@ void main() { await client.connect(); await expectLater( - client.db.orm.model('User') + client.db.orm + .model('User') .all( where: { 'OR': [ @@ -3147,7 +3196,8 @@ void main() { throwsA(isA()), ); await expectLater( - client.db.orm.model('User') + client.db.orm + .model('User') .all( where: { 'AND': [ @@ -3163,7 +3213,8 @@ void main() { throwsA(isA()), ); await expectLater( - client.db.orm.model('User') + client.db.orm + .model('User') .all(orderBy: const [OrmOrderBy('age')]), throwsA(isA()), ); @@ -3231,12 +3282,11 @@ void main() { ); await client.connect(); - await expectLater(client.db.orm.model('User').all(), throwsA(isA())); - expect(plugin.events, [ - 'before:read', - 'error:read', - 'after:read', - ]); + await expectLater( + client.db.orm.model('User').all(), + throwsA(isA()), + ); + expect(plugin.events, ['before:read', 'error:read', 'after:read']); expect(client.telemetry()?.outcome, RuntimeTelemetryOutcome.runtimeError); await client.disconnect(); }); @@ -3455,7 +3505,7 @@ final class _BadShapeEngine implements OrmEngine { @override Future execute(OrmPlan plan) async { - return const EngineResponse(data: 'bad-shape'); + return EngineResponse.buffered('bad-shape'); } @override @@ -3476,7 +3526,7 @@ final class _NoMutationReturnEngine implements OrmEngine { if (plan.action == OrmAction.create || plan.action == OrmAction.update || plan.action == OrmAction.delete) { - return EngineResponse(affectedRows: response.affectedRows); + return EngineResponse.empty(affectedRows: response.affectedRows); } return response; } @@ -3576,7 +3626,7 @@ final class _BadRelatedFindManyShapeEngine implements OrmEngine { @override Future execute(OrmPlan plan) async { if (plan.model == 'Post' && plan.action == OrmAction.read) { - return const EngineResponse(data: 'bad-shape'); + return EngineResponse.buffered('bad-shape'); } return inner.execute(plan); } @@ -3615,7 +3665,7 @@ final class _TrackingConnectionEngine @override Future execute(OrmPlan plan) async { - return const EngineResponse(data: []); + return EngineResponse.buffered(const []); } @override @@ -3630,7 +3680,7 @@ final class _TrackingEngineConnection implements EngineConnection { @override Future execute(OrmPlan plan) async { _engine.connectionExecutePlans.add(plan); - return const EngineResponse(data: []); + return EngineResponse.buffered(const []); } @override @@ -3664,7 +3714,7 @@ final class _TrackingEngineTransaction implements EngineTransaction { @override Future execute(OrmPlan plan) async { _engine.transactionExecutePlans.add(plan); - return const EngineResponse(data: []); + return EngineResponse.buffered(const []); } @override diff --git a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart index 7799f4ea..921c9b09 100644 --- a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart +++ b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart @@ -244,7 +244,7 @@ final class _NoMutationReturnEngine implements OrmEngine { if (plan.action == OrmAction.create || plan.action == OrmAction.update || plan.action == OrmAction.delete) { - return EngineResponse(affectedRows: response.affectedRows); + return EngineResponse.empty(affectedRows: response.affectedRows); } return response; } diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index e564086a..25adda1d 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -156,10 +156,7 @@ void main() { contract: contract, model: 'User', orderBy: const [OrmOrderBy('id')], - page: OrmReadPagePlan( - size: 2, - after: const {'id': 2}, - ), + page: OrmReadPagePlan(size: 2, after: const {'id': 2}), ); final statement = adapter.lower(plan); @@ -176,10 +173,7 @@ void main() { contract: contract, model: 'User', orderBy: const [OrmOrderBy('id')], - page: OrmReadPagePlan( - size: 2, - before: const {'id': 4}, - ), + page: OrmReadPagePlan(size: 2, before: const {'id': 4}), ); final statement = adapter.lower(plan); @@ -521,7 +515,7 @@ void main() { expect(deleteSelectedColumns.parameters, ['u1']); }); - test('decodes SQL result by action response shape', () { + test('decodes SQL result by action response shape', () async { final adapter = SqlAdapter(contract: contract); final findMany = adapter.decode( @@ -533,7 +527,7 @@ void main() { ), readPlan(contract: contract, model: 'User'), ); - expect(findMany.data, isA>()); + expect(_collectResponseRows(findMany), completion(isA>())); final findUnique = adapter.decode( const SqlResult( @@ -547,11 +541,9 @@ void main() { resultMode: OrmReadResultMode.oneOrNull, ), ); - if (findUnique.data case final Map row) { - expect(row['id'], 'u1'); - } else { - fail('Expected row map for findUnique decode.'); - } + expect(await _collectSingleResponseRow(findUnique), { + 'id': 'u1', + }); final mutation = adapter.decode( const SqlResult( @@ -563,14 +555,12 @@ void main() { mutationPlan(contract: contract, model: 'User', action: OrmAction.update), ); expect(mutation.affectedRows, 1); - if (mutation.data case final Map row) { - expect(row['id'], 'u1'); - } else { - fail('Expected row map for mutation decode.'); - } + expect(await _collectSingleResponseRow(mutation), { + 'id': 'u1', + }); }); - test('applies codec encode for where/data and decode for rows', () { + test('applies codec encode for where/data and decode for rows', () async { final codecRegistry = SqlCodecRegistry().withField( model: 'User', field: 'email', @@ -612,14 +602,10 @@ void main() { resultMode: OrmReadResultMode.oneOrNull, ), ); - if (decoded.data case final Map row) { - expect(row, { - 'email': 'app:wire:db@example.com', - 'id': 'u1', - }); - } else { - fail('Expected row map for codec decode.'); - } + expect(await _collectSingleResponseRow(decoded), { + 'email': 'app:wire:db@example.com', + 'id': 'u1', + }); }); test('encodes where operator values via codec resolver', () { @@ -704,7 +690,7 @@ void main() { ]); }); - test('keeps default no-codec behavior unchanged', () { + test('keeps default no-codec behavior unchanged', () async { final adapterWithoutCodec = SqlAdapter(contract: contract); final adapterWithEmptyCodec = SqlAdapter( contract: contract, @@ -751,95 +737,120 @@ void main() { response, decodePlan, ); - expect(decodedWithEmptyCodec.data, decodedWithoutCodec.data); - expect(decodedWithEmptyCodec.data, { + final withoutCodecRow = await _collectSingleResponseRow( + decodedWithoutCodec, + ); + final emptyCodecRow = await _collectSingleResponseRow( + decodedWithEmptyCodec, + ); + expect(emptyCodecRow, withoutCodecRow); + expect(emptyCodecRow, { 'id': 'u1', 'email': 'db@example.com', }); }); - test('matches codecs by model and field and only transforms hit fields', () { - final codecRegistry = SqlCodecRegistry() - .withField( - model: 'User', - field: 'email', - codec: SqlLambdaFieldCodec( - encode: (value) => value == null ? null : 'user-email:$value', - decode: (value) => value == null ? null : 'user-row:$value', - ), - ) - .withField( - model: 'OtherModel', - field: 'email', - codec: SqlLambdaFieldCodec( - encode: (value) => value == null ? null : 'other:$value', - decode: (value) => value == null ? null : 'other:$value', - ), - ) - .withField( - model: 'User', - field: 'otherField', - codec: SqlLambdaFieldCodec( - encode: (value) => value == null ? null : 'otherField:$value', - decode: (value) => value == null ? null : 'otherField:$value', - ), - ); - - final adapter = SqlAdapter( - contract: contract, - codecResolver: codecRegistry, - ); - final statement = adapter.lower( - mutationPlan( - contract: contract, - model: 'User', - action: OrmAction.update, - data: {'id': 'u2', 'email': 'next@example.com'}, - where: {'id': 'u1', 'email': 'find@example.com'}, - ), - ); - expect(statement.parameters, [ - 'u2', - 'user-email:next@example.com', - 'u1', - 'user-email:find@example.com', - ]); + test( + 'matches codecs by model and field and only transforms hit fields', + () async { + final codecRegistry = SqlCodecRegistry() + .withField( + model: 'User', + field: 'email', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'user-email:$value', + decode: (value) => value == null ? null : 'user-row:$value', + ), + ) + .withField( + model: 'OtherModel', + field: 'email', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'other:$value', + decode: (value) => value == null ? null : 'other:$value', + ), + ) + .withField( + model: 'User', + field: 'otherField', + codec: SqlLambdaFieldCodec( + encode: (value) => value == null ? null : 'otherField:$value', + decode: (value) => value == null ? null : 'otherField:$value', + ), + ); - final decoded = adapter.decode( - const SqlResult( - rows: [ - { - 'id': 'u1', - 'email': 'wire@example.com', - 'unmapped': 'keep', - }, - ], - ), - readPlan( + final adapter = SqlAdapter( contract: contract, - model: 'User', - resultMode: OrmReadResultMode.oneOrNull, - ), - ); - if (decoded.data case final Map row) { - expect(row, { + codecResolver: codecRegistry, + ); + final statement = adapter.lower( + mutationPlan( + contract: contract, + model: 'User', + action: OrmAction.update, + data: {'id': 'u2', 'email': 'next@example.com'}, + where: {'id': 'u1', 'email': 'find@example.com'}, + ), + ); + expect(statement.parameters, [ + 'u2', + 'user-email:next@example.com', + 'u1', + 'user-email:find@example.com', + ]); + + final decoded = adapter.decode( + const SqlResult( + rows: [ + { + 'id': 'u1', + 'email': 'wire@example.com', + 'unmapped': 'keep', + }, + ], + ), + readPlan( + contract: contract, + model: 'User', + resultMode: OrmReadResultMode.oneOrNull, + ), + ); + expect(await _collectSingleResponseRow(decoded), { 'id': 'u1', 'email': 'user-row:wire@example.com', 'unmapped': 'keep', }); - } else { - fail('Expected row map for model+field codec matching.'); - } - }); + }, + ); test('throws when lowering unknown model', () { final adapter = SqlAdapter(contract: contract); expect( - () => adapter.lower( - readPlan(contract: contract, model: 'Missing'), - ), + () => adapter.lower(readPlan(contract: contract, model: 'Missing')), throwsA(isA()), ); }); } + +Future> _collectResponseRows(EngineResponse response) async { + final rows = []; + await for (final row in response.rows) { + if (row is! Map) { + fail('Expected row map but got ${row.runtimeType}.'); + } + rows.add(row); + } + return rows; +} + +Future _collectSingleResponseRow(EngineResponse response) async { + final rows = await _collectResponseRows(response); + if (rows.isEmpty) { + return null; + } + if (rows.length > 1) { + fail('Expected a single row but got ${rows.length}.'); + } + return rows.single; +} diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index 7d3b38cd..57893684 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -86,7 +86,8 @@ void main() { expect(driver.requests, ['User:read']); expect(response.affectedRows, 1); - final row = response.data; + final rows = await response.rows.toList(); + final row = rows.single; expect(row, isA>()); if (row case final Map map) { expect(map['request'], 'User:read'); @@ -229,14 +230,11 @@ final class _TrackingAdapter implements TargetAdapter { @override EngineResponse decode(String response, OrmPlan plan) { decodedRaw.add(response); - return EngineResponse( - data: { - 'request': '${plan.model}:${plan.action.name}', - 'action': plan.action.name, - 'whereId': plan.read?.where['id'], - }, - affectedRows: 1, - ); + return EngineResponse.buffered({ + 'request': '${plan.model}:${plan.action.name}', + 'action': plan.action.name, + 'whereId': plan.read?.where['id'], + }, affectedRows: 1); } } From 333508fbb6d0d346ed5c1953a131c5a112fbe117 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:34:45 +0800 Subject: [PATCH 110/154] feat(execution)!: add native read streaming and distinct parity --- pub/orm/lib/src/client/client.dart | 53 +++++- pub/orm/lib/src/sql/adapter.dart | 16 +- pub/orm/lib/src/target/adapter.dart | 6 + pub/orm/lib/src/target/driver.dart | 20 +++ pub/orm/lib/src/target/engine.dart | 82 +++++++++ pub/orm/test/client/api_surface_test.dart | 76 ++++++++ pub/orm/test/client/client_test.dart | 164 ++++++++++++++++++ .../target/adapter_driver_engine_test.dart | 99 +++++++++++ 8 files changed, 506 insertions(+), 10 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index bf5d1878..df894523 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1165,14 +1165,20 @@ class ModelDelegate { final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); - if (normalizedInclude.isEmpty) { + if (normalizedInclude.isEmpty && distinct.isEmpty) { await for (final row in _streamRows(response, action: 'stream')) { yield _shapeRow(row, select: select, include: normalizedInclude); } return; } - final rows = await _collectRows(response, action: 'stream'); + final rows = await _collectCollectionRows( + response, + action: 'stream', + distinct: distinct, + skip: skip, + take: take, + ); if (rows.isEmpty) { return; } @@ -1732,11 +1738,13 @@ class ModelDelegate { final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); - var rows = await _collectRows(response, action: 'all'); - if (distinct.isNotEmpty) { - rows = _applyDistinctRows(rows: rows, distinct: distinct); - rows = _sliceRows(rows: rows, skip: skip, take: take); - } + final rows = await _collectCollectionRows( + response, + action: 'all', + distinct: distinct, + skip: skip, + take: take, + ); final hydratedRows = await _resolveIncludeRows( action: action, rows: rows, @@ -1747,6 +1755,21 @@ class ModelDelegate { return _shapeRows(hydratedRows, select: select, include: normalizedInclude); } + Future> _collectCollectionRows( + EngineResponse response, { + required String action, + required List distinct, + int? skip, + int? take, + }) async { + var rows = await _collectRows(response, action: action); + if (distinct.isEmpty) { + return rows; + } + rows = _applyDistinctRows(rows: rows, distinct: distinct); + return _sliceRows(rows: rows, skip: skip, take: take); + } + Future> _readPageResultInternal({ required OrmAction action, JsonMap where = const {}, @@ -1820,7 +1843,9 @@ class ModelDelegate { required int includeDepth, }) async { final prepared = await _buildReadPlan( - resultMode: OrmReadResultMode.firstOrNull, + resultMode: distinct.isEmpty + ? OrmReadResultMode.firstOrNull + : OrmReadResultMode.all, where: where, skip: skip, orderBy: orderBy, @@ -1833,7 +1858,17 @@ class ModelDelegate { final normalizedInclude = prepared.include; final response = await _client.execute(prepared.plan); - final row = await _collectSingleRow(response, action: 'firstOrNull'); + final row = distinct.isEmpty + ? await _collectSingleRow(response, action: 'firstOrNull') + : _firstOrNull( + await _collectCollectionRows( + response, + action: 'firstOrNull', + distinct: distinct, + skip: skip, + take: 1, + ), + ); if (row == null) { return null; } diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 7c5e3146..d90ba288 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import '../core/sort_order.dart'; import '../contract/contract.dart'; import '../engine/engine.dart'; @@ -54,7 +56,8 @@ const String _relationWhereAlias = '_rel'; final class SqlAdapter implements TargetAdapter, - ExplainCapableTargetAdapter { + ExplainCapableTargetAdapter, + ReadStreamCapableTargetAdapter { final OrmContract contract; final String identifierQuote; final SqlFieldCodecResolver? codecResolver; @@ -193,6 +196,17 @@ final class SqlAdapter }; } + @override + Stream decodeReadRows(Stream rows, OrmPlan plan) async* { + await for (final rawRow in rows) { + if (codecResolver == null) { + yield rawRow; + continue; + } + yield _decodeRow(model: plan.model, row: rawRow); + } + } + SqlStatement _lowerCreate({ required OrmPlan plan, required String table, diff --git a/pub/orm/lib/src/target/adapter.dart b/pub/orm/lib/src/target/adapter.dart index ecb9aff8..88f18227 100644 --- a/pub/orm/lib/src/target/adapter.dart +++ b/pub/orm/lib/src/target/adapter.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import '../engine/engine.dart'; import '../runtime/plan.dart'; import '../runtime/types.dart'; @@ -11,3 +13,7 @@ abstract interface class TargetAdapter { abstract interface class ExplainCapableTargetAdapter { JsonMap describe(OrmPlan plan, TRequest request); } + +abstract interface class ReadStreamCapableTargetAdapter { + Stream decodeReadRows(Stream rows, OrmPlan plan); +} diff --git a/pub/orm/lib/src/target/driver.dart b/pub/orm/lib/src/target/driver.dart index 8df748e2..73216c8a 100644 --- a/pub/orm/lib/src/target/driver.dart +++ b/pub/orm/lib/src/target/driver.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + abstract interface class TargetDriver { Future open(); @@ -25,3 +27,21 @@ abstract interface class TargetDriverTransaction { abstract interface class TargetDriverConnectionCapable { Future> connection(); } + +abstract interface class ReadStreamCapableTargetDriver { + Stream stream(TRequest request); +} + +abstract interface class ReadStreamCapableTargetDriverConnection< + TRequest, + TRawRow +> { + Stream stream(TRequest request); +} + +abstract interface class ReadStreamCapableTargetDriverTransaction< + TRequest, + TRawRow +> { + Stream stream(TRequest request); +} diff --git a/pub/orm/lib/src/target/engine.dart b/pub/orm/lib/src/target/engine.dart index e73dec5b..ce14e1fa 100644 --- a/pub/orm/lib/src/target/engine.dart +++ b/pub/orm/lib/src/target/engine.dart @@ -36,6 +36,15 @@ final class AdapterDriverEngine _ensureOpen(); final request = adapter.lower(plan); + final streamed = _tryExecuteReadStream( + plan: plan, + request: request, + adapter: adapter, + streamRows: _driverReadStream(driver), + ); + if (streamed != null) { + return streamed; + } final raw = await driver.execute(request); return adapter.decode(raw, plan); } @@ -92,6 +101,15 @@ final class _AdapterDriverConnection Future execute(OrmPlan plan) async { _ensureActive(); final request = adapter.lower(plan); + final streamed = _tryExecuteReadStream( + plan: plan, + request: request, + adapter: adapter, + streamRows: _connectionReadStream(connection), + ); + if (streamed != null) { + return streamed; + } final raw = await connection.execute(request); return adapter.decode(raw, plan); } @@ -138,6 +156,15 @@ final class _AdapterDriverTransaction Future execute(OrmPlan plan) async { _ensureActive(); final request = adapter.lower(plan); + final streamed = _tryExecuteReadStream( + plan: plan, + request: request, + adapter: adapter, + streamRows: _transactionReadStream(transaction), + ); + if (streamed != null) { + return streamed; + } final raw = await transaction.execute(request); return adapter.decode(raw, plan); } @@ -155,3 +182,58 @@ final class _AdapterDriverTransaction } } } + +EngineResponse? _tryExecuteReadStream({ + required OrmPlan plan, + required TRequest request, + required Object adapter, + required Stream Function(TRequest request)? streamRows, +}) { + if (plan.action != OrmAction.read || streamRows == null) { + return null; + } + if (adapter + case final ReadStreamCapableTargetAdapter + streamAdapter) { + return EngineResponse( + rows: streamAdapter.decodeReadRows(streamRows(request), plan), + ); + } + return null; +} + +Stream Function(TRequest request)? _driverReadStream< + TRequest, + TRawResponse +>(TargetDriver driver) { + if (driver + case final ReadStreamCapableTargetDriver + streamDriver) { + return streamDriver.stream; + } + return null; +} + +Stream Function(TRequest request)? _connectionReadStream< + TRequest, + TRawResponse +>(TargetDriverConnection connection) { + if (connection + case final ReadStreamCapableTargetDriverConnection + streamConnection) { + return streamConnection.stream; + } + return null; +} + +Stream Function(TRequest request)? _transactionReadStream< + TRequest, + TRawResponse +>(TargetDriverTransaction transaction) { + if (transaction + case final ReadStreamCapableTargetDriverTransaction + streamTransaction) { + return streamTransaction.stream; + } + return null; +} diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 4aeb6d6e..74280c8c 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -235,6 +235,67 @@ void main() { }, ); + test( + 'adapter explain stays non-executing while native stream executes once', + () async { + final sqlContract = OrmContract( + version: '1', + hash: 'contract-sql-native-stream-v1', + target: 'sql-family', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + aliases: {'users': 'User'}, + ); + final driver = _StreamingSqlDriver( + rows: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'b@x.com'}, + ], + ); + final client = OrmClient( + contract: sqlContract, + engine: AdapterDriverEngine( + adapter: SqlAdapter(contract: sqlContract), + driver: driver, + ), + ); + await client.connect(); + try { + final users = client.db.orm.model('User'); + + await users + .query() + .where({'id': 'u1'}) + .orderByField('id') + .page(size: 1) + .explain(); + expect(driver.executeCount, 0); + expect(driver.streamCount, 0); + + final rows = await users + .query() + .where({'id': 'u1'}) + .stream() + .take(1) + .toList(); + expect(driver.executeCount, 0); + expect(driver.streamCount, 1); + expect(rows, [ + {'id': 'u1', 'email': 'a@x.com'}, + ]); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + } finally { + await client.disconnect(); + } + }, + ); + test('cursor and page execution return deterministic windows', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -554,6 +615,21 @@ final class _CountingSqlDriver } } +final class _StreamingSqlDriver extends _CountingSqlDriver + implements ReadStreamCapableTargetDriver { + int streamCount = 0; + + _StreamingSqlDriver({required super.rows}); + + @override + Stream stream(SqlStatement request) async* { + streamCount += 1; + for (final row in rows) { + yield row; + } + } +} + Future> _readEngineRows(EngineResponse response) async { final rows = []; await for (final row in response.rows) { diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 36bcf141..140346a9 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -482,6 +482,26 @@ void main() { distinctFromQuery.map((row) => row['id']).toList(growable: false), ['u1', 'u3'], ); + + final distinctStreamRows = await users + .query() + .orderByField('id') + .distinctField('email') + .skip(1) + .take(1) + .stream() + .toList(); + expect( + distinctStreamRows.map((row) => row['id']).toList(growable: false), + ['u3'], + ); + + final firstDistinctRow = await users.firstOrNull( + orderBy: const [OrmOrderBy('id')], + distinct: const ['email'], + skip: 1, + ); + expect(firstDistinctRow?['id'], 'u3'); await client.disconnect(); }, ); @@ -1929,6 +1949,150 @@ void main() { }, ); + test( + 'include stream matches all for singleQuery and multiQuery', + () async { + Future<(List, List)> readWithStrategy( + IncludeExecutionStrategy strategy, + ) async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => strategy, + ); + await client.connect(); + try { + await _seedRelationalData(client); + final delegate = client.db.orm.model('User'); + final allRows = await delegate.all( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + select: const ['id', 'title'], + ), + }, + ); + final streamRows = await delegate + .query() + .orderByField('id') + .include({ + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + select: const ['id', 'title'], + ), + }) + .stream() + .toList(); + return (allRows, streamRows); + } finally { + await client.disconnect(); + } + } + + final single = await readWithStrategy( + IncludeExecutionStrategy.singleQuery, + ); + final multi = await readWithStrategy(IncludeExecutionStrategy.multiQuery); + + expect(single.$2, equals(single.$1)); + expect(multi.$2, equals(multi.$1)); + expect(single.$2, equals(multi.$2)); + }, + ); + + test( + 'include stream respects distinct skip and take for both strategies', + () async { + Future<(List, List)> readWithStrategy( + IncludeExecutionStrategy strategy, + ) async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => strategy, + ); + await client.connect(); + try { + final users = client.db.orm.model('User'); + final posts = client.db.orm.model('Post'); + + await users.create( + data: {'id': 'u1', 'email': 'same@x.com'}, + ); + await users.create( + data: {'id': 'u2', 'email': 'same@x.com'}, + ); + await users.create( + data: {'id': 'u3', 'email': 'other@x.com'}, + ); + + await posts.create( + data: {'id': 'p1', 'userId': 'u1', 'title': 'P1'}, + ); + await posts.create( + data: {'id': 'p2', 'userId': 'u2', 'title': 'P2'}, + ); + await posts.create( + data: {'id': 'p3', 'userId': 'u3', 'title': 'P3'}, + ); + + final include = { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + select: const ['id', 'title'], + ), + }; + + final allRows = await users.all( + orderBy: const [OrmOrderBy('id')], + distinct: const ['email'], + skip: 1, + take: 1, + include: include, + ); + final streamRows = await users + .query() + .orderByField('id') + .distinctField('email') + .skip(1) + .take(1) + .include(include) + .stream() + .toList(); + return (allRows, streamRows); + } finally { + await client.disconnect(); + } + } + + final single = await readWithStrategy( + IncludeExecutionStrategy.singleQuery, + ); + final multi = await readWithStrategy(IncludeExecutionStrategy.multiQuery); + + expect(single.$2, equals(single.$1)); + expect(multi.$2, equals(multi.$1)); + expect(single.$2, equals(multi.$2)); + expect(single.$2.single['id'], 'u3'); + expect(_readRowsValue(single.$2.single['posts']).single['id'], 'p3'); + }, + ); + test('singleQuery include avoids parent fanout by execute count', () async { final engine = _CountingEngine(inner: MemoryEngine()); final client = OrmClient( diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index 57893684..d77417a7 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -124,6 +124,61 @@ void main() { await engine.close(); }); + test('prefers native read streaming when adapter and driver support it', () async { + final adapter = _StreamingTrackingAdapter(); + final driver = _StreamingTrackingDriver( + streamedRows: ['stream:u1', 'stream:u2'], + ); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final response = await engine.execute(_plan()); + final rows = await response.rows.toList(); + + expect(driver.requests, isEmpty); + expect(driver.streamRequests, ['User:read']); + expect(adapter.decodedRaw, isEmpty); + expect(adapter.streamDecodedPlans, hasLength(1)); + expect(rows, [ + {'request': 'User:read', 'streamed': 'stream:u1'}, + {'request': 'User:read', 'streamed': 'stream:u2'}, + ]); + + await engine.close(); + }); + + test('keeps mutations on buffered execute when streaming is available', () async { + final adapter = _StreamingTrackingAdapter(); + final driver = _StreamingTrackingDriver(streamedRows: ['ignored']); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final response = await engine.execute(_createPlan()); + + expect(driver.streamRequests, isEmpty); + expect(driver.requests, ['User:create']); + expect(adapter.streamDecodedPlans, isEmpty); + expect(adapter.decodedRaw, ['driver:User:create']); + expect( + await response.rows.toList(), + [ + { + 'request': 'User:create', + 'action': 'create', + 'whereId': null, + }, + ], + ); + + await engine.close(); + }); + test('supports connection lifecycle when driver is capable', () async { final adapter = _TrackingAdapter(); final driver = _ConnectionCapableTrackingDriver(); @@ -217,6 +272,18 @@ OrmPlan _plan({JsonMap where = const {}}) { ); } +OrmPlan _createPlan() { + return OrmPlan( + contractHash: 'hash', + model: 'User', + action: OrmAction.create, + mutation: OrmMutationPlan( + data: const {'id': 'u1'}, + resultMode: OrmMutationResultMode.row, + ), + ); +} + final class _TrackingAdapter implements TargetAdapter { final List loweredPlans = []; final List decodedRaw = []; @@ -249,6 +316,22 @@ final class _ExplainTrackingAdapter extends _TrackingAdapter } } +final class _StreamingTrackingAdapter extends _TrackingAdapter + implements ReadStreamCapableTargetAdapter { + final List streamDecodedPlans = []; + + @override + Stream decodeReadRows(Stream rows, OrmPlan plan) async* { + streamDecodedPlans.add(plan); + await for (final row in rows) { + yield { + 'request': '${plan.model}:${plan.action.name}', + 'streamed': row, + }; + } + } +} + final class _TrackingDriver implements TargetDriver { int openCount = 0; int closeCount = 0; @@ -271,6 +354,22 @@ final class _TrackingDriver implements TargetDriver { } } +final class _StreamingTrackingDriver extends _TrackingDriver + implements ReadStreamCapableTargetDriver { + final List streamedRows; + final List streamRequests = []; + + _StreamingTrackingDriver({required this.streamedRows}); + + @override + Stream stream(String request) async* { + streamRequests.add(request); + for (final row in streamedRows) { + yield row; + } + } +} + final class _ConnectionCapableTrackingDriver implements TargetDriver, From 9c5ae5f3df59ee88b612a46fece5851c08fa9485 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:54:19 +0800 Subject: [PATCH 111/154] feat(runtime)!: expose execution mode and completion metadata --- docs/orm-v6-api-surface.md | 3 +- pub/orm/lib/src/engine/engine.dart | 19 +- pub/orm/lib/src/runtime/core.dart | 77 ++++- pub/orm/lib/src/target/engine.dart | 2 + pub/orm/test/client/api_surface_test.dart | 136 +++++++- pub/orm/test/client/client_test.dart | 324 +++++++++++++++--- .../operation_telemetry_aggregation_test.dart | 197 +++++++++++ 7 files changed, 700 insertions(+), 58 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 08c49892..d0b4c5be 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -94,7 +94,8 @@ Rules: 1. `toPlan()` and `inspectPlan()` are pure authoring inspection. 2. `explain()` is runtime-facing and requires an active runtime connection. It returns the common runtime summary and may include target-lowered request - details when the active engine exposes them. + details when the active engine exposes them. Because it does not execute the + plan, `planSummary.executionMode` is reported as `deferred`. 3. `cursor(...)` and `page(...)` require `orderBy(...)` first. 4. Boundary fields must match the declared `orderBy(...)` fields. 5. When a model declares `idFields`, `cursor(...)` and `page(...)` require diff --git a/pub/orm/lib/src/engine/engine.dart b/pub/orm/lib/src/engine/engine.dart index ae1c8f03..b9f6f2de 100644 --- a/pub/orm/lib/src/engine/engine.dart +++ b/pub/orm/lib/src/engine/engine.dart @@ -5,12 +5,23 @@ import 'package:meta/meta.dart'; import '../runtime/plan.dart'; import '../runtime/types.dart'; +enum EngineExecutionMode { buffered, stream } + +enum EngineExecutionSource { buffered, directStream } + @immutable final class EngineResponse { final Stream rows; final int affectedRows; + final EngineExecutionMode executionMode; + final EngineExecutionSource executionSource; - EngineResponse({required this.rows, this.affectedRows = 0}); + EngineResponse({ + required this.rows, + this.affectedRows = 0, + this.executionMode = EngineExecutionMode.buffered, + this.executionSource = EngineExecutionSource.buffered, + }); factory EngineResponse.buffered(Object? data, {int affectedRows = 0}) { if (data == null) { @@ -20,11 +31,15 @@ final class EngineResponse { return EngineResponse( rows: Stream.fromIterable(data), affectedRows: affectedRows, + executionMode: EngineExecutionMode.buffered, + executionSource: EngineExecutionSource.buffered, ); } return EngineResponse( rows: Stream.value(data), affectedRows: affectedRows, + executionMode: EngineExecutionMode.buffered, + executionSource: EngineExecutionSource.buffered, ); } @@ -32,6 +47,8 @@ final class EngineResponse { return EngineResponse( rows: const Stream.empty(), affectedRows: affectedRows, + executionMode: EngineExecutionMode.buffered, + executionSource: EngineExecutionSource.buffered, ); } } diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 517711a7..769d290e 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -75,7 +75,8 @@ JsonMap _buildExplainResult(OrmPlan plan) { 'model': plan.model, 'action': plan.action.name, if (plan.lane != null) 'lane': plan.lane, - 'executionMode': 'buffered', + 'executionMode': 'deferred', + 'executionSource': 'notExecuted', if (read != null) 'readResultMode': read.resultMode.name, if (mutation != null) 'mutationResultMode': mutation.resultMode.name, if (read != null) 'selectedFieldCount': read.select.length, @@ -140,6 +141,9 @@ final class RuntimeOperationStepTelemetry { final String model; final OrmAction action; final RuntimeTelemetryOutcome outcome; + final bool completed; + final EngineExecutionMode? executionMode; + final EngineExecutionSource? executionSource; final int rowCount; final int affectedRows; final int durationMs; @@ -150,6 +154,9 @@ final class RuntimeOperationStepTelemetry { required this.model, required this.action, required this.outcome, + required this.completed, + this.executionMode, + this.executionSource, required this.rowCount, required this.affectedRows, required this.durationMs, @@ -163,6 +170,7 @@ final class RuntimeOperationTelemetryEvent { final String operationId; final String kind; final RuntimeTelemetryOutcome outcome; + final bool completed; final int statementCount; final int rowCount; final int affectedRows; @@ -175,6 +183,7 @@ final class RuntimeOperationTelemetryEvent { required this.operationId, required this.kind, required this.outcome, + required this.completed, required this.statementCount, required this.rowCount, required this.affectedRows, @@ -192,6 +201,9 @@ final class RuntimeTelemetryEvent { final String model; final OrmAction action; final RuntimeTelemetryOutcome outcome; + final bool completed; + final EngineExecutionMode? executionMode; + final EngineExecutionSource? executionSource; final int durationMs; final DateTime recordedAt; final OrmRepositoryTrace? repositoryTrace; @@ -200,6 +212,9 @@ final class RuntimeTelemetryEvent { required this.model, required this.action, required this.outcome, + required this.completed, + this.executionMode, + this.executionSource, required this.durationMs, required this.recordedAt, this.repositoryTrace, @@ -413,9 +428,13 @@ final class OrmRuntimeCore implements RuntimeCore { plan: plan, rows: response.rows, affectedRows: response.affectedRows, + executionMode: response.executionMode, + executionSource: response.executionSource, startedAt: startedAt, ), affectedRows: response.affectedRows, + executionMode: response.executionMode, + executionSource: response.executionSource, ); } catch (error, stackTrace) { final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; @@ -435,6 +454,8 @@ final class OrmRuntimeCore implements RuntimeCore { required OrmPlan plan, required Stream rows, required int affectedRows, + required EngineExecutionMode executionMode, + required EngineExecutionSource executionSource, required DateTime startedAt, }) async* { var rowCount = 0; @@ -456,6 +477,8 @@ final class OrmRuntimeCore implements RuntimeCore { plan: plan, rowCount: rowCount, affectedRows: affectedRows, + executionMode: executionMode, + executionSource: executionSource, startedAt: startedAt, ); } catch (error, stackTrace) { @@ -467,6 +490,8 @@ final class OrmRuntimeCore implements RuntimeCore { stackTrace: stackTrace, rowCount: rowCount, latencyMs: latencyMs, + executionMode: executionMode, + executionSource: executionSource, startedAt: startedAt, ); rethrow; @@ -476,6 +501,8 @@ final class OrmRuntimeCore implements RuntimeCore { plan: plan, rowCount: rowCount, affectedRows: affectedRows, + executionMode: executionMode, + executionSource: executionSource, startedAt: startedAt, ); } @@ -486,6 +513,8 @@ final class OrmRuntimeCore implements RuntimeCore { required OrmPlan plan, required int rowCount, required int affectedRows, + required EngineExecutionMode executionMode, + required EngineExecutionSource executionSource, required DateTime startedAt, }) async { final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; @@ -504,6 +533,9 @@ final class OrmRuntimeCore implements RuntimeCore { model: plan.model, action: plan.action, outcome: RuntimeTelemetryOutcome.success, + completed: true, + executionMode: executionMode, + executionSource: executionSource, durationMs: latencyMs, recordedAt: DateTime.now(), repositoryTrace: plan.repositoryTrace, @@ -511,6 +543,9 @@ final class OrmRuntimeCore implements RuntimeCore { _recordOperationTelemetry( plan: plan, outcome: RuntimeTelemetryOutcome.success, + completed: true, + executionMode: executionMode, + executionSource: executionSource, rowCount: rowCount, affectedRows: affectedRows, durationMs: latencyMs, @@ -525,12 +560,17 @@ final class OrmRuntimeCore implements RuntimeCore { required StackTrace stackTrace, required int rowCount, required int latencyMs, + EngineExecutionMode? executionMode, + EngineExecutionSource? executionSource, required DateTime startedAt, }) async { _telemetry = RuntimeTelemetryEvent( model: plan.model, action: plan.action, outcome: RuntimeTelemetryOutcome.runtimeError, + completed: false, + executionMode: executionMode, + executionSource: executionSource, durationMs: latencyMs, recordedAt: DateTime.now(), repositoryTrace: plan.repositoryTrace, @@ -539,6 +579,9 @@ final class OrmRuntimeCore implements RuntimeCore { _recordOperationTelemetry( plan: plan, outcome: RuntimeTelemetryOutcome.runtimeError, + completed: false, + executionMode: executionMode, + executionSource: executionSource, rowCount: rowCount, affectedRows: 0, durationMs: latencyMs, @@ -574,9 +617,34 @@ final class OrmRuntimeCore implements RuntimeCore { required OrmPlan plan, required int rowCount, required int affectedRows, + required EngineExecutionMode executionMode, + required EngineExecutionSource executionSource, required DateTime startedAt, }) async { final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; + _telemetry = RuntimeTelemetryEvent( + model: plan.model, + action: plan.action, + outcome: RuntimeTelemetryOutcome.success, + completed: false, + executionMode: executionMode, + executionSource: executionSource, + durationMs: latencyMs, + recordedAt: DateTime.now(), + repositoryTrace: plan.repositoryTrace, + ); + _recordOperationTelemetry( + plan: plan, + outcome: RuntimeTelemetryOutcome.success, + completed: false, + executionMode: executionMode, + executionSource: executionSource, + rowCount: rowCount, + affectedRows: affectedRows, + durationMs: latencyMs, + startedAt: startedAt, + recordedAt: _telemetry!.recordedAt, + ); final result = AfterExecuteResult( rowCount: rowCount, affectedRows: affectedRows, @@ -775,6 +843,9 @@ final class OrmRuntimeCore implements RuntimeCore { void _recordOperationTelemetry({ required OrmPlan plan, required RuntimeTelemetryOutcome outcome, + required bool completed, + EngineExecutionMode? executionMode, + EngineExecutionSource? executionSource, required int rowCount, required int affectedRows, required int durationMs, @@ -814,6 +885,9 @@ final class OrmRuntimeCore implements RuntimeCore { model: plan.model, action: plan.action, outcome: outcome, + completed: completed, + executionMode: executionMode, + executionSource: executionSource, rowCount: rowCount, affectedRows: affectedRows, durationMs: durationMs, @@ -826,6 +900,7 @@ final class OrmRuntimeCore implements RuntimeCore { outcome: current?.outcome == RuntimeTelemetryOutcome.runtimeError ? RuntimeTelemetryOutcome.runtimeError : outcome, + completed: (current?.completed ?? true) && completed, statementCount: (current?.statementCount ?? 0) + 1, rowCount: (current?.rowCount ?? 0) + rowCount, affectedRows: (current?.affectedRows ?? 0) + affectedRows, diff --git a/pub/orm/lib/src/target/engine.dart b/pub/orm/lib/src/target/engine.dart index ce14e1fa..200be291 100644 --- a/pub/orm/lib/src/target/engine.dart +++ b/pub/orm/lib/src/target/engine.dart @@ -197,6 +197,8 @@ EngineResponse? _tryExecuteReadStream({ streamAdapter) { return EngineResponse( rows: streamAdapter.decodeReadRows(streamRows(request), plan), + executionMode: EngineExecutionMode.stream, + executionSource: EngineExecutionSource.directStream, ); } return null; diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 74280c8c..79c8dc55 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -121,6 +121,8 @@ void main() { expect(explained['source'], 'heuristic'); final summary = explained['planSummary'] as Map; expect(summary['model'], 'User'); + expect(summary['executionMode'], 'deferred'); + expect(summary['executionSource'], 'notExecuted'); final pagination = summary['pagination'] as Map; expect(pagination['mode'], 'page'); expect(explained['plan'], isA>()); @@ -129,6 +131,36 @@ void main() { } }); + test( + 'heuristic explain does not execute engines without explain support', + () async { + final engine = _ExecuteForbiddenEngine(); + final plugin = _TrackingPlugin(); + final client = OrmClient( + contract: contract, + engine: engine, + plugins: [plugin], + ); + await client.connect(); + try { + final users = client.db.orm.model('User'); + final explained = await users + .query() + .orderByField('id') + .page(size: 2) + .explain(); + + expect(explained['source'], 'heuristic'); + expect(engine.executeCount, 0); + expect(plugin.events, isEmpty); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + } finally { + await client.disconnect(); + } + }, + ); + test( 'explain includes target-aware adapter details when available', () async { @@ -288,7 +320,13 @@ void main() { expect(rows, [ {'id': 'u1', 'email': 'a@x.com'}, ]); - expect(client.telemetry(), isNull); + expect(client.telemetry()?.outcome, RuntimeTelemetryOutcome.success); + expect(client.telemetry()?.completed, isFalse); + expect(client.telemetry()?.executionMode, EngineExecutionMode.stream); + expect( + client.telemetry()?.executionSource, + EngineExecutionSource.directStream, + ); expect(client.operationTelemetry(), isNull); } finally { await client.disconnect(); @@ -296,6 +334,50 @@ void main() { }, ); + test('explain preserves existing telemetry snapshots', () async { + final plugin = _TrackingPlugin(); + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: [plugin], + ); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await users.create( + data: {'id': 1, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 2, 'email': 'b@x.com'}, + ); + await users.create( + data: {'id': 3, 'email': 'c@x.com'}, + ); + + await users.query().orderByField('id').page(size: 2).pageResult(); + final telemetryBefore = client.telemetry(); + final operationBefore = client.operationTelemetry(); + final pluginEventsBefore = List.from(plugin.events); + + await users.query().orderByField('id').page(size: 2).explain(); + + final telemetryAfter = client.telemetry(); + final operationAfter = client.operationTelemetry(); + expect(telemetryAfter, isNotNull); + expect(operationAfter, isNotNull); + expect(telemetryAfter?.model, telemetryBefore?.model); + expect(telemetryAfter?.action, telemetryBefore?.action); + expect(telemetryAfter?.outcome, telemetryBefore?.outcome); + expect(telemetryAfter?.completed, telemetryBefore?.completed); + expect(operationAfter?.operationId, operationBefore?.operationId); + expect(operationAfter?.statementCount, operationBefore?.statementCount); + expect(operationAfter?.completed, operationBefore?.completed); + expect(plugin.events, pluginEventsBefore); + } finally { + await client.disconnect(); + } + }); + test('cursor and page execution return deterministic windows', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -595,6 +677,58 @@ final class _ExplainOnlySqlDriver } } +final class _ExecuteForbiddenEngine implements OrmEngine { + int executeCount = 0; + + @override + Future close() async {} + + @override + Future execute(OrmPlan plan) async { + executeCount += 1; + throw StateError('heuristic explain should not execute the engine.'); + } + + @override + Future open() async {} +} + +final class _TrackingPlugin extends OrmPlugin { + final List events = []; + + @override + String get name => 'tracking'; + + @override + void beforeExecute(OrmPlan plan, PluginContext ctx) { + events.add('before:${plan.action.name}'); + } + + @override + void onRow(JsonMap row, OrmPlan plan, PluginContext ctx) { + events.add('row:${plan.action.name}'); + } + + @override + void afterExecute( + OrmPlan plan, + AfterExecuteResult result, + PluginContext ctx, + ) { + events.add('after:${plan.action.name}'); + } + + @override + void onError( + OrmPlan plan, + Object error, + StackTrace stackTrace, + PluginContext ctx, + ) { + events.add('error:${plan.action.name}'); + } +} + final class _CountingSqlDriver implements TargetDriver { final List rows; diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 140346a9..c9c71c26 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -1949,64 +1949,61 @@ void main() { }, ); - test( - 'include stream matches all for singleQuery and multiQuery', - () async { - Future<(List, List)> readWithStrategy( - IncludeExecutionStrategy strategy, - ) async { - final client = OrmClient( - contract: relationalContract, - engine: MemoryEngine(), - includeStrategySelector: - ({ - required OrmContract contract, - required String modelName, - required OrmAction action, - required Map include, - required int depth, - }) => strategy, + test('include stream matches all for singleQuery and multiQuery', () async { + Future<(List, List)> readWithStrategy( + IncludeExecutionStrategy strategy, + ) async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => strategy, + ); + await client.connect(); + try { + await _seedRelationalData(client); + final delegate = client.db.orm.model('User'); + final allRows = await delegate.all( + orderBy: const [OrmOrderBy('id')], + include: { + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + select: const ['id', 'title'], + ), + }, ); - await client.connect(); - try { - await _seedRelationalData(client); - final delegate = client.db.orm.model('User'); - final allRows = await delegate.all( - orderBy: const [OrmOrderBy('id')], - include: { + final streamRows = await delegate + .query() + .orderByField('id') + .include({ 'posts': IncludeSpec( orderBy: const [OrmOrderBy('id')], select: const ['id', 'title'], ), - }, - ); - final streamRows = await delegate - .query() - .orderByField('id') - .include({ - 'posts': IncludeSpec( - orderBy: const [OrmOrderBy('id')], - select: const ['id', 'title'], - ), - }) - .stream() - .toList(); - return (allRows, streamRows); - } finally { - await client.disconnect(); - } + }) + .stream() + .toList(); + return (allRows, streamRows); + } finally { + await client.disconnect(); } + } - final single = await readWithStrategy( - IncludeExecutionStrategy.singleQuery, - ); - final multi = await readWithStrategy(IncludeExecutionStrategy.multiQuery); + final single = await readWithStrategy( + IncludeExecutionStrategy.singleQuery, + ); + final multi = await readWithStrategy(IncludeExecutionStrategy.multiQuery); - expect(single.$2, equals(single.$1)); - expect(multi.$2, equals(multi.$1)); - expect(single.$2, equals(multi.$2)); - }, - ); + expect(single.$2, equals(single.$1)); + expect(multi.$2, equals(multi.$1)); + expect(single.$2, equals(multi.$2)); + }); test( 'include stream respects distinct skip and take for both strategies', @@ -2042,13 +2039,25 @@ void main() { ); await posts.create( - data: {'id': 'p1', 'userId': 'u1', 'title': 'P1'}, + data: { + 'id': 'p1', + 'userId': 'u1', + 'title': 'P1', + }, ); await posts.create( - data: {'id': 'p2', 'userId': 'u2', 'title': 'P2'}, + data: { + 'id': 'p2', + 'userId': 'u2', + 'title': 'P2', + }, ); await posts.create( - data: {'id': 'p3', 'userId': 'u3', 'title': 'P3'}, + data: { + 'id': 'p3', + 'userId': 'u3', + 'title': 'P3', + }, ); final include = { @@ -2083,7 +2092,9 @@ void main() { final single = await readWithStrategy( IncludeExecutionStrategy.singleQuery, ); - final multi = await readWithStrategy(IncludeExecutionStrategy.multiQuery); + final multi = await readWithStrategy( + IncludeExecutionStrategy.multiQuery, + ); expect(single.$2, equals(single.$1)); expect(multi.$2, equals(multi.$1)); @@ -3196,6 +3207,9 @@ void main() { expect(telemetry?.model, 'User'); expect(telemetry?.action, OrmAction.read); expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isTrue); + expect(telemetry?.executionMode, EngineExecutionMode.buffered); + expect(telemetry?.executionSource, EngineExecutionSource.buffered); expect(telemetry?.repositoryTrace, isNull); await client.disconnect(); }); @@ -3221,10 +3235,138 @@ void main() { expect(telemetry?.operationPhase, 'item.create'); expect(telemetry?.operationStrategy, 'transaction'); expect(telemetry?.operationStep, 2); + expect(telemetry?.completed, isTrue); expect(telemetry?.repositoryTrace?.itemIndex, 1); await client.disconnect(); }); + test( + 'marks telemetry incomplete when consumer stops stream early', + () async { + final plugin = _InspectingPlugin(); + final client = OrmClient( + contract: contract, + engine: MemoryEngine(), + plugins: [plugin], + ); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.create( + data: {'id': 'u1', 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 'u2', 'email': 'b@x.com'}, + ); + await users.create( + data: {'id': 'u3', 'email': 'c@x.com'}, + ); + plugin.reset(); + + final rows = await users + .stream(orderBy: const [OrmOrderBy('id')]) + .take(1) + .toList(); + + expect(rows, hasLength(1)); + expect(plugin.events, [ + 'before:read', + 'row:read', + 'after:read', + ]); + expect(plugin.afterResults, hasLength(1)); + expect(plugin.afterResults.single.completed, isFalse); + expect(plugin.afterResults.single.rowCount, 1); + expect(plugin.afterResults.single.affectedRows, 0); + expect(client.telemetry()?.outcome, RuntimeTelemetryOutcome.success); + expect(client.telemetry()?.completed, isFalse); + expect(client.telemetry()?.executionMode, EngineExecutionMode.buffered); + expect( + client.telemetry()?.executionSource, + EngineExecutionSource.buffered, + ); + await client.disconnect(); + }, + ); + + test( + 'records runtime error telemetry when stream fails after rows', + () async { + final plugin = _InspectingPlugin(); + final client = OrmClient( + contract: contract, + engine: _FailingStreamEngine(), + plugins: [plugin], + ); + await client.connect(); + + await expectLater( + client.db.orm.model('User').stream().toList(), + throwsA(isA()), + ); + + expect(plugin.events, [ + 'before:read', + 'row:read', + 'error:read', + 'after:read', + ]); + expect(plugin.afterResults, hasLength(1)); + expect(plugin.afterResults.single.completed, isFalse); + expect(plugin.afterResults.single.rowCount, 1); + expect( + client.telemetry()?.outcome, + RuntimeTelemetryOutcome.runtimeError, + ); + expect(client.telemetry()?.completed, isFalse); + expect(client.telemetry()?.executionMode, EngineExecutionMode.stream); + expect( + client.telemetry()?.executionSource, + EngineExecutionSource.directStream, + ); + await client.disconnect(); + }, + ); + + test('records runtime error telemetry when plugin onRow fails', () async { + final engine = MemoryEngine(); + final seeder = OrmClient(contract: contract, engine: engine); + await seeder.connect(); + await seeder.db.orm + .model('User') + .create(data: {'id': 'u1', 'email': 'a@x.com'}); + await seeder.disconnect(); + + final plugin = _OnRowThrowingPlugin(); + final client = OrmClient( + contract: contract, + engine: engine, + plugins: [plugin], + ); + await client.connect(); + final users = client.db.orm.model('User'); + + await expectLater(users.stream().toList(), throwsA(isA())); + + expect(plugin.events, [ + 'before:read', + 'row:read', + 'error:read', + 'after:read', + ]); + expect(plugin.afterResults, hasLength(1)); + expect(plugin.afterResults.single.completed, isFalse); + expect(plugin.afterResults.single.rowCount, 1); + expect(client.telemetry()?.outcome, RuntimeTelemetryOutcome.runtimeError); + expect(client.telemetry()?.completed, isFalse); + expect(client.telemetry()?.executionMode, EngineExecutionMode.buffered); + expect( + client.telemetry()?.executionSource, + EngineExecutionSource.buffered, + ); + await client.disconnect(); + }); + test('verify mode startup checks marker at connect once', () async { var readCount = 0; final client = OrmClient( @@ -3650,6 +3792,60 @@ final class _TrackingPlugin extends OrmPlugin { } } +final class _InspectingPlugin extends OrmPlugin { + final List events = []; + final List afterResults = []; + + @override + String get name => 'inspecting'; + + @override + void beforeExecute(OrmPlan plan, PluginContext ctx) { + events.add('before:${plan.action.name}'); + } + + @override + void onRow(JsonMap row, OrmPlan plan, PluginContext ctx) { + events.add('row:${plan.action.name}'); + } + + @override + void afterExecute( + OrmPlan plan, + AfterExecuteResult result, + PluginContext ctx, + ) { + afterResults.add(result); + events.add('after:${plan.action.name}'); + } + + @override + void onError( + OrmPlan plan, + Object error, + StackTrace stackTrace, + PluginContext ctx, + ) { + events.add('error:${plan.action.name}'); + } + + void reset() { + events.clear(); + afterResults.clear(); + } +} + +final class _OnRowThrowingPlugin extends _InspectingPlugin { + @override + String get name => 'row-throwing'; + + @override + void onRow(JsonMap row, OrmPlan plan, PluginContext ctx) { + super.onRow(row, plan, ctx); + throw StateError('plugin-row-boom'); + } +} + final class _ThrowingEngine implements OrmEngine { @override Future close() async {} @@ -3663,6 +3859,26 @@ final class _ThrowingEngine implements OrmEngine { Future open() async {} } +final class _FailingStreamEngine implements OrmEngine { + @override + Future close() async {} + + @override + Future execute(OrmPlan plan) async { + return EngineResponse( + rows: () async* { + yield {'id': 'u1', 'email': 'a@x.com'}; + throw StateError('stream-boom'); + }(), + executionMode: EngineExecutionMode.stream, + executionSource: EngineExecutionSource.directStream, + ); + } + + @override + Future open() async {} +} + final class _BadShapeEngine implements OrmEngine { @override Future close() async {} diff --git a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart index 921c9b09..98324203 100644 --- a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart +++ b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart @@ -33,6 +33,7 @@ void main() { expect(telemetry, isNotNull); expect(telemetry?.kind, 'User.createMany'); expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isTrue); expect(telemetry?.statementCount, 2); expect(telemetry?.affectedRows, 2); expect( @@ -81,6 +82,7 @@ void main() { expect(telemetry, isNotNull); expect(telemetry?.kind, 'User.update'); expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isTrue); expect(telemetry?.statementCount, 2); expect(telemetry?.rowCount, 1); expect(telemetry?.affectedRows, 1); @@ -129,6 +131,7 @@ void main() { expect(telemetry, isNotNull); expect(telemetry?.kind, 'User.delete'); expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isTrue); expect(telemetry?.statementCount, 2); expect(telemetry?.rowCount, 1); expect(telemetry?.affectedRows, 1); @@ -210,6 +213,7 @@ void main() { expect(telemetry, isNotNull); expect(telemetry?.kind, 'User.pageResult'); expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isTrue); expect(telemetry?.statementCount, 2); expect( telemetry?.steps.map((step) => step.trace.phase).toList(), @@ -227,6 +231,179 @@ void main() { expect(telemetry?.steps.last.rowCount, 1); await client.disconnect(); }); + + test( + 'records interrupted operation telemetry when consumer stops pulling', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'b@x.com'}, + {'id': 'u3', 'email': 'c@x.com'}, + ], + ); + + final response = await client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + repositoryTrace: const OrmRepositoryTrace( + operationId: 'op-stream-stop', + kind: 'User.streamProbe', + step: 1, + phase: 'stream.read', + strategy: 'manual', + ), + orderBy: const [OrmOrderBy('id')], + resultMode: OrmReadResultMode.all, + ), + ); + + final rows = await response.rows.take(1).toList(); + expect(rows, hasLength(1)); + + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.streamProbe'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isFalse); + expect(telemetry?.statementCount, 1); + expect(telemetry?.rowCount, 1); + expect(telemetry?.steps.single.completed, isFalse); + expect(telemetry?.steps.single.trace.phase, 'stream.read'); + await client.disconnect(); + }, + ); + + test( + 'records runtime error operation telemetry when stream fails after rows', + () async { + final client = OrmClient( + contract: contract, + engine: _FailingStreamEngine(), + ); + await client.connect(); + + final response = await client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + repositoryTrace: const OrmRepositoryTrace( + operationId: 'op-stream-fail', + kind: 'User.streamProbe', + step: 1, + phase: 'stream.read', + strategy: 'manual', + ), + orderBy: const [OrmOrderBy('id')], + resultMode: OrmReadResultMode.all, + ), + ); + + await expectLater(response.rows.toList(), throwsA(isA())); + + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.streamProbe'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.runtimeError); + expect(telemetry?.completed, isFalse); + expect(telemetry?.statementCount, 1); + expect(telemetry?.rowCount, 1); + expect( + telemetry?.steps.single.executionMode, + EngineExecutionMode.stream, + ); + expect( + telemetry?.steps.single.executionSource, + EngineExecutionSource.directStream, + ); + expect( + telemetry?.steps.single.outcome, + RuntimeTelemetryOutcome.runtimeError, + ); + expect(telemetry?.steps.single.completed, isFalse); + expect(telemetry?.steps.single.trace.phase, 'stream.read'); + await client.disconnect(); + }, + ); + + test( + 'keeps operation completed false after interrupted step then successful step', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'b@x.com'}, + {'id': 'u3', 'email': 'c@x.com'}, + ], + ); + + final first = await client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + repositoryTrace: const OrmRepositoryTrace( + operationId: 'op-stream-sticky', + kind: 'User.streamProbe', + step: 1, + phase: 'stream.read', + strategy: 'manual', + ), + orderBy: const [OrmOrderBy('id')], + resultMode: OrmReadResultMode.all, + ), + ); + await first.rows.take(1).toList(); + + final second = await client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + repositoryTrace: const OrmRepositoryTrace( + operationId: 'op-stream-sticky', + kind: 'User.streamProbe', + step: 2, + phase: 'stream.read', + strategy: 'manual', + ), + orderBy: const [OrmOrderBy('id')], + where: const {'id': 'u2'}, + resultMode: OrmReadResultMode.all, + ), + ); + final rows = await second.rows.toList(); + + expect(rows, hasLength(1)); + + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.streamProbe'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isFalse); + expect(telemetry?.statementCount, 2); + expect(telemetry?.rowCount, 2); + expect(telemetry?.steps.map((step) => step.completed).toList(), [ + false, + true, + ]); + expect( + telemetry?.steps.map((step) => step.executionMode).toList(), + [ + EngineExecutionMode.buffered, + EngineExecutionMode.buffered, + ], + ); + await client.disconnect(); + }, + ); }); } @@ -252,3 +429,23 @@ final class _NoMutationReturnEngine implements OrmEngine { @override Future open() => inner.open(); } + +final class _FailingStreamEngine implements OrmEngine { + @override + Future close() async {} + + @override + Future execute(OrmPlan plan) async { + return EngineResponse( + rows: () async* { + yield {'id': 'u1', 'email': 'a@x.com'}; + throw StateError('stream-boom'); + }(), + executionMode: EngineExecutionMode.stream, + executionSource: EngineExecutionSource.directStream, + ); + } + + @override + Future open() async {} +} From 8f4c8030bc7d4e4d9b10ed2b7b6ad769d1c727a8 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:06:40 +0800 Subject: [PATCH 112/154] feat(target)!: move explain capability to driver surfaces --- pub/orm/lib/src/client/client.dart | 4 +- pub/orm/lib/src/engine/engine.dart | 8 + pub/orm/lib/src/runtime/core.dart | 45 +++- pub/orm/lib/src/sql/adapter.dart | 9 +- pub/orm/lib/src/target/adapter.dart | 2 +- pub/orm/lib/src/target/driver.dart | 12 + pub/orm/lib/src/target/engine.dart | 91 ++++++- pub/orm/test/client/client_test.dart | 69 +++++- .../target/adapter_driver_engine_test.dart | 231 +++++++++++++----- 9 files changed, 377 insertions(+), 94 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index df894523..e39ca79d 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -367,7 +367,7 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { final scoped = OrmScopedClient._( contract: contract, executePlan: connection.execute, - explainPlan: _runtime.explain, + explainPlan: connection.explain, modelAliases: _modelAliases, collectionRegistry: _collectionRegistry, includeStrategySelector: includeStrategySelector, @@ -393,7 +393,7 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { final scoped = OrmScopedClient._( contract: contract, executePlan: openedTransaction.execute, - explainPlan: _runtime.explain, + explainPlan: openedTransaction.explain, modelAliases: _modelAliases, collectionRegistry: _collectionRegistry, includeStrategySelector: includeStrategySelector, diff --git a/pub/orm/lib/src/engine/engine.dart b/pub/orm/lib/src/engine/engine.dart index b9f6f2de..c304ca94 100644 --- a/pub/orm/lib/src/engine/engine.dart +++ b/pub/orm/lib/src/engine/engine.dart @@ -63,12 +63,20 @@ abstract interface class EngineConnection implements RuntimeQueryable { Future release(); } +abstract interface class ExplainCapableEngineConnection { + Future describePlan(OrmPlan plan); +} + abstract interface class EngineTransaction implements RuntimeQueryable { Future commit(); Future rollback(); } +abstract interface class ExplainCapableEngineTransaction { + Future describePlan(OrmPlan plan); +} + abstract interface class ConnectionCapableEngine { Future connection(); } diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 769d290e..8b80be6e 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -236,12 +236,16 @@ abstract interface class OrmRuntimeQueryable { } abstract interface class OrmRuntimeConnection implements OrmRuntimeQueryable { + Future explain(OrmPlan plan); + Future transaction(); Future release(); } abstract interface class OrmRuntimeTransaction implements OrmRuntimeQueryable { + Future explain(OrmPlan plan); + Future commit(); Future rollback(); @@ -370,16 +374,7 @@ final class OrmRuntimeCore implements RuntimeCore { @override Future explain(OrmPlan plan) async { - _ensureConnected(); - _assertPlan(plan); - await _verifyForRequest(); - - final base = _buildExplainResult(plan); - if (engine case final ExplainCapableEngine explainEngine) { - final details = await explainEngine.describePlan(plan); - return _mergeExplainResult(base, details); - } - return base; + return _explainOnSource(plan, engine); } @override @@ -509,6 +504,24 @@ final class OrmRuntimeCore implements RuntimeCore { } } + Future _explainOnSource(OrmPlan plan, Object source) async { + _ensureConnected(); + _assertPlan(plan); + await _verifyForRequest(); + + final base = _buildExplainResult(plan); + final details = switch (source) { + final ExplainCapableEngine explainEngine => + await explainEngine.describePlan(plan), + final ExplainCapableEngineConnection explainConnection => + await explainConnection.describePlan(plan), + final ExplainCapableEngineTransaction explainTransaction => + await explainTransaction.describePlan(plan), + _ => const {}, + }; + return _mergeExplainResult(base, details); + } + Future _recordExecutionSuccess({ required OrmPlan plan, required int rowCount, @@ -1302,6 +1315,12 @@ final class _RuntimeConnection implements OrmRuntimeConnection { return _core._executeOnQueryable(plan, _inner); } + @override + Future explain(OrmPlan plan) { + _ensureNotReleased(); + return _core._explainOnSource(plan, _inner); + } + @override Future transaction() async { _ensureNotReleased(); @@ -1352,6 +1371,12 @@ final class _RuntimeTransaction implements OrmRuntimeTransaction { return _core._executeOnQueryable(plan, _inner); } + @override + Future explain(OrmPlan plan) { + _ensureActive(); + return _core._explainOnSource(plan, _inner); + } + void _ensureActive() { if (_completed) { throw RuntimeTransactionCompletedException(); diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index d90ba288..994435dc 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -100,9 +100,13 @@ final class SqlAdapter } @override - JsonMap describe(OrmPlan plan, SqlStatement request) { + JsonMap describe( + OrmPlan plan, + SqlStatement request, { + JsonMap? driverExplain, + }) { return Map.unmodifiable({ - 'source': 'adapter', + 'source': driverExplain == null ? 'adapter' : 'driver', 'target': contract.target, 'request': Map.unmodifiable({ 'kind': 'sql', @@ -110,6 +114,7 @@ final class SqlAdapter 'text': request.text, 'parameterCount': request.parameters.length, }), + if (driverExplain != null) 'driver': driverExplain, }); } diff --git a/pub/orm/lib/src/target/adapter.dart b/pub/orm/lib/src/target/adapter.dart index 88f18227..c13d5fdf 100644 --- a/pub/orm/lib/src/target/adapter.dart +++ b/pub/orm/lib/src/target/adapter.dart @@ -11,7 +11,7 @@ abstract interface class TargetAdapter { } abstract interface class ExplainCapableTargetAdapter { - JsonMap describe(OrmPlan plan, TRequest request); + JsonMap describe(OrmPlan plan, TRequest request, {JsonMap? driverExplain}); } abstract interface class ReadStreamCapableTargetAdapter { diff --git a/pub/orm/lib/src/target/driver.dart b/pub/orm/lib/src/target/driver.dart index 73216c8a..3e03c0db 100644 --- a/pub/orm/lib/src/target/driver.dart +++ b/pub/orm/lib/src/target/driver.dart @@ -8,6 +8,10 @@ abstract interface class TargetDriver { Future execute(TRequest request); } +abstract interface class ExplainCapableTargetDriver { + Future> explain(TRequest request); +} + abstract interface class TargetDriverConnection { Future execute(TRequest request); @@ -16,6 +20,10 @@ abstract interface class TargetDriverConnection { Future release(); } +abstract interface class ExplainCapableTargetDriverConnection { + Future> explain(TRequest request); +} + abstract interface class TargetDriverTransaction { Future execute(TRequest request); @@ -24,6 +32,10 @@ abstract interface class TargetDriverTransaction { Future rollback(); } +abstract interface class ExplainCapableTargetDriverTransaction { + Future> explain(TRequest request); +} + abstract interface class TargetDriverConnectionCapable { Future> connection(); } diff --git a/pub/orm/lib/src/target/engine.dart b/pub/orm/lib/src/target/engine.dart index 200be291..a3d344c0 100644 --- a/pub/orm/lib/src/target/engine.dart +++ b/pub/orm/lib/src/target/engine.dart @@ -52,14 +52,12 @@ final class AdapterDriverEngine @override Future describePlan(OrmPlan plan) async { _ensureOpen(); - - final request = adapter.lower(plan); - if (adapter - case final ExplainCapableTargetAdapter - explainAdapter) { - return explainAdapter.describe(plan, request); - } - return const {}; + return _describeTargetPlan( + plan: plan, + adapter: adapter, + request: adapter.lower(plan), + describeRequest: _driverExplain(driver), + ); } @override @@ -90,7 +88,7 @@ final class AdapterDriverEngine } final class _AdapterDriverConnection - implements EngineConnection { + implements EngineConnection, ExplainCapableEngineConnection { final TargetAdapter adapter; final TargetDriverConnection connection; bool _released = false; @@ -130,6 +128,17 @@ final class _AdapterDriverConnection await connection.release(); } + @override + Future describePlan(OrmPlan plan) async { + _ensureActive(); + return _describeTargetPlan( + plan: plan, + adapter: adapter, + request: adapter.lower(plan), + describeRequest: _connectionExplain(connection), + ); + } + void _ensureActive() { if (_released) { throw StateError('Adapter driver connection has been released.'); @@ -138,7 +147,7 @@ final class _AdapterDriverConnection } final class _AdapterDriverTransaction - implements EngineTransaction { + implements EngineTransaction, ExplainCapableEngineTransaction { final TargetAdapter adapter; final TargetDriverTransaction transaction; bool _completed = false; @@ -176,6 +185,17 @@ final class _AdapterDriverTransaction await transaction.rollback(); } + @override + Future describePlan(OrmPlan plan) async { + _ensureActive(); + return _describeTargetPlan( + plan: plan, + adapter: adapter, + request: adapter.lower(plan), + describeRequest: _transactionExplain(transaction), + ); + } + void _ensureActive() { if (_completed) { throw StateError('Adapter driver transaction is already completed.'); @@ -204,6 +224,23 @@ EngineResponse? _tryExecuteReadStream({ return null; } +Future _describeTargetPlan({ + required OrmPlan plan, + required TargetAdapter adapter, + required TRequest request, + required Future Function(TRequest request)? describeRequest, +}) async { + final driverExplain = describeRequest == null + ? null + : await describeRequest(request); + if (adapter + case final ExplainCapableTargetAdapter + explainAdapter) { + return explainAdapter.describe(plan, request, driverExplain: driverExplain); + } + return driverExplain ?? const {}; +} + Stream Function(TRequest request)? _driverReadStream< TRequest, TRawResponse @@ -216,6 +253,16 @@ Stream Function(TRequest request)? _driverReadStream< return null; } +Future Function(TRequest request)? _driverExplain< + TRequest, + TRawResponse +>(TargetDriver driver) { + if (driver case final ExplainCapableTargetDriver explainDriver) { + return explainDriver.explain; + } + return null; +} + Stream Function(TRequest request)? _connectionReadStream< TRequest, TRawResponse @@ -228,6 +275,18 @@ Stream Function(TRequest request)? _connectionReadStream< return null; } +Future Function(TRequest request)? _connectionExplain< + TRequest, + TRawResponse +>(TargetDriverConnection connection) { + if (connection + case final ExplainCapableTargetDriverConnection + explainConnection) { + return explainConnection.explain; + } + return null; +} + Stream Function(TRequest request)? _transactionReadStream< TRequest, TRawResponse @@ -239,3 +298,15 @@ Stream Function(TRequest request)? _transactionReadStream< } return null; } + +Future Function(TRequest request)? _transactionExplain< + TRequest, + TRawResponse +>(TargetDriverTransaction transaction) { + if (transaction + case final ExplainCapableTargetDriverTransaction + explainTransaction) { + return explainTransaction.explain; + } + return null; +} diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index c9c71c26..9bae081f 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -2926,6 +2926,30 @@ void main() { }, ); + test('withConnection explain uses scoped connection surface', () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await client.withConnection((connection) async { + final explained = await connection.db.orm + .model('User') + .query() + .orderByField('id') + .page(size: 1) + .explain(); + expect(explained['source'], 'connection'); + }); + + expect(engine.connectionExplainPlans, hasLength(1)); + expect(engine.connectionExecutePlans, isEmpty); + expect(engine.connectionExplainPlans.single.action, OrmAction.read); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + expect(engine.releaseCount, 1); + await client.disconnect(); + }); + test('withTransaction commits on success', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -2986,6 +3010,31 @@ void main() { }, ); + test('withTransaction explain uses scoped transaction surface', () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + await client.withTransaction((transaction) async { + final explained = await transaction.db.orm + .model('User') + .query() + .orderByField('id') + .page(size: 1) + .explain(); + expect(explained['source'], 'transaction'); + }); + + expect(engine.transactionExplainPlans, hasLength(1)); + expect(engine.transactionExecutePlans, isEmpty); + expect(engine.transactionExplainPlans.single.action, OrmAction.read); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + expect(engine.commitCount, 1); + expect(engine.releaseCount, 1); + await client.disconnect(); + }); + test( 'withTransaction releases connection when opening transaction fails', () async { @@ -4026,7 +4075,9 @@ final class _TrackingConnectionEngine var commitCount = 0; var rollbackCount = 0; final List connectionExecutePlans = []; + final List connectionExplainPlans = []; final List transactionExecutePlans = []; + final List transactionExplainPlans = []; _TrackingConnectionEngine({ this.failOnTransactionStart = false, @@ -4052,7 +4103,8 @@ final class _TrackingConnectionEngine Future open() async {} } -final class _TrackingEngineConnection implements EngineConnection { +final class _TrackingEngineConnection + implements EngineConnection, ExplainCapableEngineConnection { final _TrackingConnectionEngine _engine; _TrackingEngineConnection(this._engine); @@ -4076,9 +4128,16 @@ final class _TrackingEngineConnection implements EngineConnection { } return _TrackingEngineTransaction(_engine); } + + @override + Future describePlan(OrmPlan plan) async { + _engine.connectionExplainPlans.add(plan); + return {'source': 'connection'}; + } } -final class _TrackingEngineTransaction implements EngineTransaction { +final class _TrackingEngineTransaction + implements EngineTransaction, ExplainCapableEngineTransaction { final _TrackingConnectionEngine _engine; _TrackingEngineTransaction(this._engine); @@ -4104,6 +4163,12 @@ final class _TrackingEngineTransaction implements EngineTransaction { throw StateError('rollback failed'); } } + + @override + Future describePlan(OrmPlan plan) async { + _engine.transactionExplainPlans.add(plan); + return {'source': 'transaction'}; + } } final class _EmptyNamePlugin extends OrmPlugin { diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index d77417a7..2c8a10f5 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -100,84 +100,97 @@ void main() { await engine.close(); }); - test('describes lowered plan without executing the driver', () async { - final adapter = _ExplainTrackingAdapter(); - final driver = _TrackingDriver(); - final engine = AdapterDriverEngine( - adapter: adapter, - driver: driver, - ); + test( + 'describes lowered plan through driver explain without executing', + () async { + final adapter = _ExplainTrackingAdapter(); + final driver = _ExplainTrackingDriver(); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); - await engine.open(); - final description = await engine.describePlan( - _plan(where: {'id': 'u1'}), - ); + await engine.open(); + final description = await engine.describePlan( + _plan(where: {'id': 'u1'}), + ); - expect(adapter.loweredPlans, hasLength(1)); - expect(driver.requests, isEmpty); - expect(description['source'], 'adapter'); - expect(description['request'], { - 'kind': 'tracking', - 'value': 'User:read', - }); + expect(adapter.loweredPlans, hasLength(1)); + expect(driver.requests, isEmpty); + expect(driver.explainRequests, ['User:read']); + expect(description['source'], 'driver'); + expect(description['request'], { + 'kind': 'tracking', + 'value': 'User:read', + }); + expect(description['driver'], { + 'scope': 'driver', + 'value': 'User:read', + }); - await engine.close(); - }); + await engine.close(); + }, + ); - test('prefers native read streaming when adapter and driver support it', () async { - final adapter = _StreamingTrackingAdapter(); - final driver = _StreamingTrackingDriver( - streamedRows: ['stream:u1', 'stream:u2'], - ); - final engine = AdapterDriverEngine( - adapter: adapter, - driver: driver, - ); + test( + 'prefers native read streaming when adapter and driver support it', + () async { + final adapter = _StreamingTrackingAdapter(); + final driver = _StreamingTrackingDriver( + streamedRows: ['stream:u1', 'stream:u2'], + ); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); - await engine.open(); - final response = await engine.execute(_plan()); - final rows = await response.rows.toList(); + await engine.open(); + final response = await engine.execute(_plan()); + final rows = await response.rows.toList(); + + expect(driver.requests, isEmpty); + expect(driver.streamRequests, ['User:read']); + expect(adapter.decodedRaw, isEmpty); + expect(adapter.streamDecodedPlans, hasLength(1)); + expect(rows, [ + {'request': 'User:read', 'streamed': 'stream:u1'}, + {'request': 'User:read', 'streamed': 'stream:u2'}, + ]); - expect(driver.requests, isEmpty); - expect(driver.streamRequests, ['User:read']); - expect(adapter.decodedRaw, isEmpty); - expect(adapter.streamDecodedPlans, hasLength(1)); - expect(rows, [ - {'request': 'User:read', 'streamed': 'stream:u1'}, - {'request': 'User:read', 'streamed': 'stream:u2'}, - ]); + await engine.close(); + }, + ); - await engine.close(); - }); + test( + 'keeps mutations on buffered execute when streaming is available', + () async { + final adapter = _StreamingTrackingAdapter(); + final driver = _StreamingTrackingDriver( + streamedRows: ['ignored'], + ); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); - test('keeps mutations on buffered execute when streaming is available', () async { - final adapter = _StreamingTrackingAdapter(); - final driver = _StreamingTrackingDriver(streamedRows: ['ignored']); - final engine = AdapterDriverEngine( - adapter: adapter, - driver: driver, - ); + await engine.open(); + final response = await engine.execute(_createPlan()); - await engine.open(); - final response = await engine.execute(_createPlan()); - - expect(driver.streamRequests, isEmpty); - expect(driver.requests, ['User:create']); - expect(adapter.streamDecodedPlans, isEmpty); - expect(adapter.decodedRaw, ['driver:User:create']); - expect( - await response.rows.toList(), - [ + expect(driver.streamRequests, isEmpty); + expect(driver.requests, ['User:create']); + expect(adapter.streamDecodedPlans, isEmpty); + expect(adapter.decodedRaw, ['driver:User:create']); + expect(await response.rows.toList(), [ { 'request': 'User:create', 'action': 'create', 'whereId': null, }, - ], - ); + ]); - await engine.close(); - }); + await engine.close(); + }, + ); test('supports connection lifecycle when driver is capable', () async { final adapter = _TrackingAdapter(); @@ -205,6 +218,32 @@ void main() { await engine.close(); }); + test('connection describePlan uses scoped driver explain surface', () async { + final adapter = _ExplainTrackingAdapter(); + final driver = _ConnectionCapableTrackingDriver(); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final connection = await engine.connection(); + final description = await (connection as ExplainCapableEngineConnection) + .describePlan(_plan(where: {'id': 'u1'})); + + expect(driver.connectionCount, 1); + expect(driver.connections.single.explainRequests, ['User:read']); + expect(driver.connections.single.requests, isEmpty); + expect(description['source'], 'driver'); + expect(description['driver'], { + 'scope': 'connection', + 'value': 'User:read', + }); + + await connection.release(); + await engine.close(); + }); + test('forwards transaction commit and marks transaction completed', () async { final adapter = _TrackingAdapter(); final driver = _ConnectionCapableTrackingDriver(); @@ -232,6 +271,34 @@ void main() { await engine.close(); }); + test('transaction describePlan uses scoped driver explain surface', () async { + final adapter = _ExplainTrackingAdapter(); + final driver = _ConnectionCapableTrackingDriver(); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final connection = await engine.connection(); + final transaction = await connection.transaction(); + final description = await (transaction as ExplainCapableEngineTransaction) + .describePlan(_plan(where: {'id': 'u2'})); + + final inner = driver.connections.single.transactions.single; + expect(inner.explainRequests, ['User:read']); + expect(inner.requests, isEmpty); + expect(description['source'], 'driver'); + expect(description['driver'], { + 'scope': 'transaction', + 'value': 'User:read', + }); + + await transaction.rollback(); + await connection.release(); + await engine.close(); + }); + test( 'forwards transaction rollback and marks transaction completed', () async { @@ -308,10 +375,11 @@ final class _TrackingAdapter implements TargetAdapter { final class _ExplainTrackingAdapter extends _TrackingAdapter implements ExplainCapableTargetAdapter { @override - JsonMap describe(OrmPlan plan, String request) { + JsonMap describe(OrmPlan plan, String request, {JsonMap? driverExplain}) { return { - 'source': 'adapter', + 'source': driverExplain == null ? 'adapter' : 'driver', 'request': {'kind': 'tracking', 'value': request}, + if (driverExplain != null) 'driver': driverExplain, }; } } @@ -354,6 +422,17 @@ final class _TrackingDriver implements TargetDriver { } } +final class _ExplainTrackingDriver extends _TrackingDriver + implements ExplainCapableTargetDriver { + final List explainRequests = []; + + @override + Future explain(String request) async { + explainRequests.add(request); + return {'scope': 'driver', 'value': request}; + } +} + final class _StreamingTrackingDriver extends _TrackingDriver implements ReadStreamCapableTargetDriver { final List streamedRows; @@ -406,10 +485,13 @@ final class _ConnectionCapableTrackingDriver } final class _TrackingConnection - implements TargetDriverConnection { + implements + TargetDriverConnection, + ExplainCapableTargetDriverConnection { int releaseCount = 0; int transactionCount = 0; final List requests = []; + final List explainRequests = []; final List<_TrackingTransaction> transactions = <_TrackingTransaction>[]; @override @@ -430,13 +512,22 @@ final class _TrackingConnection transactions.add(transaction); return transaction; } + + @override + Future explain(String request) async { + explainRequests.add(request); + return {'scope': 'connection', 'value': request}; + } } final class _TrackingTransaction - implements TargetDriverTransaction { + implements + TargetDriverTransaction, + ExplainCapableTargetDriverTransaction { int commitCount = 0; int rollbackCount = 0; final List requests = []; + final List explainRequests = []; @override Future commit() async { @@ -453,4 +544,10 @@ final class _TrackingTransaction requests.add(request); return 'transaction:$request'; } + + @override + Future explain(String request) async { + explainRequests.add(request); + return {'scope': 'transaction', 'value': request}; + } } From ee3bc14859d3beca357b409efcaa42f93fd50e24 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:32:15 +0800 Subject: [PATCH 113/154] feat(client)!: expose explicit terminal execution contracts --- docs/orm-v6-api-surface.md | 7 ++ pub/orm/lib/src/client/client.dart | 90 ++++++++++++++++++++++- pub/orm/test/client/api_surface_test.dart | 57 ++++++++++++++ pub/orm/test/client/client_test.dart | 51 +++++++++++++ 4 files changed, 201 insertions(+), 4 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index d0b4c5be..115aeff4 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -102,6 +102,13 @@ Rules: `orderBy(...)` to end with those fields. 6. `pageResult()` is the structured pagination terminal and returns `items + pageInfo`. +7. `inspectPlan()` and `explain()` expose `terminalExecution` metadata. + This makes stream delivery explicit: + - `stream()` stays `nativeStream` only when the repository can yield rows + directly from the runtime response. + - `include(...)` or `distinct(...)` force `stream()` to + `bufferedYield`, with reasons and include strategy surfaced in + `terminalExecution.stream`. ## ORM Mutation Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index e39ca79d..cc9a6c25 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -178,6 +178,63 @@ final class OrmPageResult { } } +JsonMap _terminalExecutionSummary({ + required OrmContract contract, + required IncludeExecutionStrategySelector includeStrategySelector, + required String modelName, + required List distinct, + required Map include, + JsonMap? cursor, + OrmReadPagePlan? page, +}) { + final hasWindow = cursor != null || page != null; + final includeStrategy = include.isEmpty + ? null + : includeStrategySelector( + contract: contract, + modelName: modelName, + action: OrmAction.read, + include: include, + depth: 0, + ).name; + final streamReasons = [ + if (include.isNotEmpty) 'include', + if (distinct.isNotEmpty) 'distinct', + ]; + + JsonMap terminal({ + required String delivery, + required bool degraded, + List reasons = const [], + bool? available, + }) { + return Map.unmodifiable({ + if (available != null) 'available': available, + 'delivery': delivery, + 'degraded': degraded, + 'reasons': List.unmodifiable(reasons), + 'windowAppliedAt': hasWindow ? 'engine' : 'none', + 'distinctAppliedAt': distinct.isEmpty ? 'none' : 'client', + 'includeAppliedAt': include.isEmpty ? 'none' : 'repository', + if (includeStrategy != null) 'includeStrategy': includeStrategy, + }); + } + + return Map.unmodifiable({ + 'all': terminal(delivery: 'bufferedCollection', degraded: false), + 'stream': terminal( + delivery: streamReasons.isEmpty ? 'nativeStream' : 'bufferedYield', + degraded: streamReasons.isNotEmpty, + reasons: streamReasons, + ), + 'pageResult': terminal( + delivery: page == null ? 'unavailable' : 'pageEnvelope', + degraded: false, + available: page != null, + ), + }); +} + Map _mergeIncludeSpecMap( Map current, Map next, @@ -1276,7 +1333,8 @@ class ModelDelegate { JsonMap? cursor, OrmReadPagePlan? page, }) async { - final plan = await toPlan( + final prepared = await _buildReadPlan( + resultMode: OrmReadResultMode.all, where: where, skip: skip, take: take, @@ -1287,7 +1345,18 @@ class ModelDelegate { cursor: cursor, page: page, ); - return plan.toJson(); + return Map.unmodifiable({ + ...prepared.plan.toJson(), + 'terminalExecution': _terminalExecutionSummary( + contract: _client.contract, + includeStrategySelector: _client.includeStrategySelector, + modelName: modelName, + distinct: distinct, + include: prepared.include, + cursor: cursor, + page: page, + ), + }); } Future explain({ @@ -1301,7 +1370,8 @@ class ModelDelegate { JsonMap? cursor, OrmReadPagePlan? page, }) async { - final plan = await toPlan( + final prepared = await _buildReadPlan( + resultMode: OrmReadResultMode.all, where: where, skip: skip, take: take, @@ -1312,7 +1382,19 @@ class ModelDelegate { cursor: cursor, page: page, ); - return _runtime.explainPlan(plan); + final explained = await _runtime.explainPlan(prepared.plan); + return Map.unmodifiable({ + ...explained, + 'terminalExecution': _terminalExecutionSummary( + contract: _client.contract, + includeStrategySelector: _client.includeStrategySelector, + modelName: modelName, + distinct: distinct, + include: prepared.include, + cursor: cursor, + page: page, + ), + }); } Future aggregate({ diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 79c8dc55..3d544317 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -97,6 +97,53 @@ void main() { }, ); + test( + 'inspectPlan exposes terminal execution metadata for native stream and page envelopes', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + final inspected = await users + .query() + .orderByField('id') + .page(size: 2, after: {'id': 'u1'}) + .inspectPlan(); + + final execution = + inspected['terminalExecution'] as Map; + final stream = execution['stream'] as Map; + final pageResult = execution['pageResult'] as Map; + + expect(stream['delivery'], 'nativeStream'); + expect(stream['degraded'], isFalse); + expect(stream['windowAppliedAt'], 'engine'); + expect(stream['includeAppliedAt'], 'none'); + expect(pageResult['available'], isTrue); + expect(pageResult['delivery'], 'pageEnvelope'); + }, + ); + + test( + 'inspectPlan marks stream as bufferedYield when distinct requires client-side collection', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + final inspected = await users + .query() + .orderByField('id') + .distinctField('email') + .inspectPlan(); + + final execution = + inspected['terminalExecution'] as Map; + final stream = execution['stream'] as Map; + + expect(stream['delivery'], 'bufferedYield'); + expect(stream['degraded'], isTrue); + expect(stream['reasons'], ['distinct']); + expect(stream['distinctAppliedAt'], 'client'); + }, + ); + test('explain requires an active runtime connection', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); @@ -125,6 +172,16 @@ void main() { expect(summary['executionSource'], 'notExecuted'); final pagination = summary['pagination'] as Map; expect(pagination['mode'], 'page'); + final execution = + explained['terminalExecution'] as Map; + expect( + (execution['stream'] as Map)['delivery'], + 'nativeStream', + ); + expect( + (execution['pageResult'] as Map)['available'], + isTrue, + ); expect(explained['plan'], isA>()); } finally { await client.disconnect(); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 9bae081f..3f54a4aa 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -2005,6 +2005,57 @@ void main() { expect(single.$2, equals(multi.$2)); }); + test( + 'inspectPlan and explain expose stream degradation metadata for include strategies', + () async { + Future expectStrategy(IncludeExecutionStrategy strategy) async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => strategy, + ); + await client.connect(); + try { + final query = client.db.orm + .model('User') + .query() + .orderByField('id') + .include({ + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }); + + final inspected = await query.inspectPlan(); + final explained = await query.explain(); + + for (final payload in [inspected, explained]) { + final execution = + payload['terminalExecution'] as Map; + final stream = execution['stream'] as Map; + expect(stream['delivery'], 'bufferedYield'); + expect(stream['degraded'], isTrue); + expect(stream['reasons'], ['include']); + expect(stream['includeAppliedAt'], 'repository'); + expect(stream['includeStrategy'], strategy.name); + } + } finally { + await client.disconnect(); + } + } + + await expectStrategy(IncludeExecutionStrategy.singleQuery); + await expectStrategy(IncludeExecutionStrategy.multiQuery); + }, + ); + test( 'include stream respects distinct skip and take for both strategies', () async { From fc054e308dd02049ba4cf1456df3dba7fa585afe Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:34:31 +0800 Subject: [PATCH 114/154] test(target): cover scoped native stream surfaces --- .../target/adapter_driver_engine_test.dart | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index 2c8a10f5..a3ef8e30 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -244,6 +244,40 @@ void main() { await engine.close(); }); + test( + 'connection prefers native read streaming when scoped driver supports it', + () async { + final adapter = _StreamingTrackingAdapter(); + final driver = _ConnectionCapableStreamingDriver( + streamedRows: ['connection:u1', 'connection:u2'], + ); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final connection = await engine.connection(); + final response = await connection.execute(_plan()); + final rows = await response.rows.toList(); + + final inner = driver.connections.single as _StreamingConnection; + expect(inner.requests, isEmpty); + expect(inner.streamRequests, ['User:read']); + expect(adapter.decodedRaw, isEmpty); + expect(adapter.streamDecodedPlans, hasLength(1)); + expect(response.executionMode, EngineExecutionMode.stream); + expect(response.executionSource, EngineExecutionSource.directStream); + expect(rows, [ + {'request': 'User:read', 'streamed': 'connection:u1'}, + {'request': 'User:read', 'streamed': 'connection:u2'}, + ]); + + await connection.release(); + await engine.close(); + }, + ); + test('forwards transaction commit and marks transaction completed', () async { final adapter = _TrackingAdapter(); final driver = _ConnectionCapableTrackingDriver(); @@ -271,6 +305,44 @@ void main() { await engine.close(); }); + test( + 'transaction prefers native read streaming when scoped driver supports it', + () async { + final adapter = _StreamingTrackingAdapter(); + final driver = _ConnectionCapableStreamingDriver( + streamedRows: ['transaction:u1', 'transaction:u2'], + ); + final engine = AdapterDriverEngine( + adapter: adapter, + driver: driver, + ); + + await engine.open(); + final connection = await engine.connection(); + final transaction = await connection.transaction(); + final response = await transaction.execute(_plan()); + final rows = await response.rows.toList(); + + final inner = + driver.connections.single.transactions.single + as _StreamingTransaction; + expect(inner.requests, isEmpty); + expect(inner.streamRequests, ['User:read']); + expect(adapter.decodedRaw, isEmpty); + expect(adapter.streamDecodedPlans, hasLength(1)); + expect(response.executionMode, EngineExecutionMode.stream); + expect(response.executionSource, EngineExecutionSource.directStream); + expect(rows, [ + {'request': 'User:read', 'streamed': 'transaction:u1'}, + {'request': 'User:read', 'streamed': 'transaction:u2'}, + ]); + + await transaction.rollback(); + await connection.release(); + await engine.close(); + }, + ); + test('transaction describePlan uses scoped driver explain surface', () async { final adapter = _ExplainTrackingAdapter(); final driver = _ConnectionCapableTrackingDriver(); @@ -484,6 +556,21 @@ final class _ConnectionCapableTrackingDriver } } +final class _ConnectionCapableStreamingDriver + extends _ConnectionCapableTrackingDriver { + final List streamedRows; + + _ConnectionCapableStreamingDriver({required this.streamedRows}); + + @override + Future> connection() async { + connectionCount += 1; + final connection = _StreamingConnection(streamedRows: streamedRows); + connections.add(connection); + return connection; + } +} + final class _TrackingConnection implements TargetDriverConnection, @@ -520,6 +607,30 @@ final class _TrackingConnection } } +final class _StreamingConnection extends _TrackingConnection + implements ReadStreamCapableTargetDriverConnection { + final List streamedRows; + final List streamRequests = []; + + _StreamingConnection({required this.streamedRows}); + + @override + Stream stream(String request) async* { + streamRequests.add(request); + for (final row in streamedRows) { + yield row; + } + } + + @override + Future> transaction() async { + transactionCount += 1; + final transaction = _StreamingTransaction(streamedRows: streamedRows); + transactions.add(transaction); + return transaction; + } +} + final class _TrackingTransaction implements TargetDriverTransaction, @@ -551,3 +662,19 @@ final class _TrackingTransaction return {'scope': 'transaction', 'value': request}; } } + +final class _StreamingTransaction extends _TrackingTransaction + implements ReadStreamCapableTargetDriverTransaction { + final List streamedRows; + final List streamRequests = []; + + _StreamingTransaction({required this.streamedRows}); + + @override + Stream stream(String request) async* { + streamRequests.add(request); + for (final row in streamedRows) { + yield row; + } + } +} From 256747d28d4f8ed56de0c5929fc2e824120a681d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:48:15 +0800 Subject: [PATCH 115/154] test(runtime): harden scoped explain guard coverage --- pub/orm/test/client/client_test.dart | 354 ++++++++++++++++++ .../target/adapter_driver_engine_test.dart | 12 + 2 files changed, 366 insertions(+) diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 3f54a4aa..71294330 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -3001,6 +3001,81 @@ void main() { await client.disconnect(); }); + test( + 'withConnection explain failure keeps telemetry clean and releases connection', + () async { + final engine = _TrackingConnectionEngine(failOnConnectionExplain: true); + final plugin = _TrackingPlugin(); + final client = OrmClient( + contract: contract, + engine: engine, + plugins: [plugin], + ); + await client.connect(); + + await expectLater( + client.withConnection((connection) async { + await connection.db.orm + .model('User') + .query() + .orderByField('id') + .page(size: 1) + .explain(); + }), + throwsA(isA()), + ); + + expect(engine.connectionExplainPlans, hasLength(1)); + expect(engine.connectionExecutePlans, isEmpty); + expect(plugin.events, isEmpty); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); + + test( + 'withConnection explain verify always marker mismatch releases and never describes', + () async { + final engine = _TrackingConnectionEngine(); + var readCount = 0; + final client = OrmClient( + contract: contract, + engine: engine, + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.always, + requireMarker: true, + markerReader: CallbackMarkerReader(() async { + readCount += 1; + return readCount == 1 ? contract.hash : 'other-hash'; + }), + ), + ); + await client.connect(); + + await expectLater( + client.withConnection((connection) async { + await connection.db.orm + .model('User') + .query() + .orderByField('id') + .page(size: 1) + .explain(); + }), + throwsA(isA()), + ); + + expect(readCount, 2); + expect(engine.connectionExplainPlans, isEmpty); + expect(engine.connectionExecutePlans, isEmpty); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); + test('withTransaction commits on success', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -3086,6 +3161,87 @@ void main() { await client.disconnect(); }); + test( + 'withTransaction explain failure rolls back without telemetry or plugin side effects', + () async { + final engine = _TrackingConnectionEngine( + failOnTransactionExplain: true, + ); + final plugin = _TrackingPlugin(); + final client = OrmClient( + contract: contract, + engine: engine, + plugins: [plugin], + ); + await client.connect(); + + await expectLater( + client.withTransaction((transaction) async { + await transaction.db.orm + .model('User') + .query() + .orderByField('id') + .page(size: 1) + .explain(); + }), + throwsA(isA()), + ); + + expect(engine.transactionExplainPlans, hasLength(1)); + expect(engine.transactionExecutePlans, isEmpty); + expect(engine.commitCount, 0); + expect(engine.rollbackCount, 1); + expect(engine.releaseCount, 1); + expect(plugin.events, isEmpty); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + await client.disconnect(); + }, + ); + + test( + 'withTransaction explain verify always marker missing rolls back and never describes', + () async { + final engine = _TrackingConnectionEngine(); + var readCount = 0; + final client = OrmClient( + contract: contract, + engine: engine, + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.always, + requireMarker: true, + markerReader: CallbackMarkerReader(() async { + readCount += 1; + return readCount == 1 ? contract.hash : null; + }), + ), + ); + await client.connect(); + + await expectLater( + client.withTransaction((transaction) async { + await transaction.db.orm + .model('User') + .query() + .orderByField('id') + .page(size: 1) + .explain(); + }), + throwsA(isA()), + ); + + expect(readCount, 2); + expect(engine.transactionExplainPlans, isEmpty); + expect(engine.transactionExecutePlans, isEmpty); + expect(engine.commitCount, 0); + expect(engine.rollbackCount, 1); + expect(engine.releaseCount, 1); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + await client.disconnect(); + }, + ); + test( 'withTransaction releases connection when opening transaction fails', () async { @@ -3278,6 +3434,10 @@ void main() { ), throwsA(isA()), ); + expect( + () => connection.explain(_scopedReadPlan(contract)), + throwsA(isA()), + ); final connection2 = await client.connection(); final transaction = await connection2.transaction(); @@ -3293,10 +3453,56 @@ void main() { ), throwsA(isA()), ); + expect( + () => transaction.explain(_scopedReadPlan(contract)), + throwsA(isA()), + ); await connection2.release(); await client.disconnect(); }); + test('scoped explain after scope end hits lifecycle guards', () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + late OrmScopedClient scopedConnection; + await client.withConnection((connection) async { + scopedConnection = connection; + }); + + await expectLater( + scopedConnection.db.orm + .model('User') + .query() + .orderByField('id') + .page(size: 1) + .explain(), + throwsA(isA()), + ); + + late OrmScopedClient scopedTransaction; + await client.withTransaction((transaction) async { + scopedTransaction = transaction; + }); + + await expectLater( + scopedTransaction.db.orm + .model('User') + .query() + .orderByField('id') + .page(size: 1) + .explain(), + throwsA(isA()), + ); + + expect(engine.connectionExplainPlans, isEmpty); + expect(engine.transactionExplainPlans, isEmpty); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + await client.disconnect(); + }); + test('records telemetry for successful execution', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -3542,6 +3748,34 @@ void main() { await client.disconnect(); }); + test( + 'scoped connection explain verifies marker on every request in always mode', + () async { + var readCount = 0; + final client = OrmClient( + contract: contract, + engine: _TrackingConnectionEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.always, + requireMarker: true, + markerReader: CallbackMarkerReader(() async { + readCount += 1; + return contract.hash; + }), + ), + ); + + await client.connect(); + final connection = await client.connection(); + await connection.explain(_scopedReadPlan(contract)); + await connection.explain(_scopedReadPlan(contract)); + + expect(readCount, 3); + await connection.release(); + await client.disconnect(); + }, + ); + test('fails when marker is required but missing', () async { final client = OrmClient( contract: contract, @@ -3561,6 +3795,40 @@ void main() { await client.disconnect(); }); + test( + 'scoped connection explain fails verification before reaching engine describe', + () async { + final engine = _TrackingConnectionEngine(); + var readCount = 0; + final client = OrmClient( + contract: contract, + engine: engine, + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.always, + requireMarker: true, + markerReader: CallbackMarkerReader(() async { + readCount += 1; + return readCount == 1 ? contract.hash : null; + }), + ), + ); + await client.connect(); + + final connection = await client.connection(); + await expectLater( + connection.explain(_scopedReadPlan(contract)), + throwsA(isA()), + ); + + expect(engine.connectionExplainPlans, isEmpty); + expect(readCount, 2); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + await connection.release(); + await client.disconnect(); + }, + ); + test('fails when marker hash does not match contract hash', () async { final client = OrmClient( contract: contract, @@ -3580,6 +3848,66 @@ void main() { await client.disconnect(); }); + test( + 'scoped connection explain rejects mismatched target before engine describe', + () async { + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + + final connection = await client.connection(); + await expectLater( + connection.explain(_scopedReadPlan(contract, target: 'other-target')), + throwsA(isA()), + ); + + expect(engine.connectionExplainPlans, isEmpty); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + await connection.release(); + await client.disconnect(); + }, + ); + + test( + 'scoped transaction explain rejects mismatched profile before engine describe', + () async { + final profileContract = OrmContract( + version: '1', + hash: 'contract-profile-v1', + target: 'sql-family', + markerStorageHash: 'storage-v1', + profileHash: 'profile-v1', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email'}, + ), + }, + ); + final engine = _TrackingConnectionEngine(); + final client = OrmClient(contract: profileContract, engine: engine); + await client.connect(); + + final connection = await client.connection(); + final transaction = await connection.transaction(); + await expectLater( + transaction.explain( + _scopedReadPlan(profileContract, profileHash: 'other-profile'), + ), + throwsA(isA()), + ); + + expect(engine.transactionExplainPlans, isEmpty); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + await transaction.rollback(); + await connection.release(); + await client.disconnect(); + }, + ); + test('rejects unknown plan fields by contract', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -3834,6 +4162,22 @@ JsonMap? _readRowValue(Object? value) { fail('Expected row map but got ${value.runtimeType}.'); } +OrmPlan _scopedReadPlan( + OrmContract contract, { + String? target, + String? storageHash, + String? profileHash, +}) { + return OrmPlan.read( + contractHash: contract.hash, + target: target ?? contract.target, + storageHash: storageHash ?? contract.markerStorageHash, + profileHash: profileHash ?? contract.profileHash, + model: 'User', + resultMode: OrmReadResultMode.all, + ); +} + OrmRepositoryTrace _readRepositoryTrace(OrmPlan plan) { final trace = plan.repositoryTrace; if (trace == null) { @@ -4120,6 +4464,8 @@ final class _TrackingConnectionEngine final bool failOnTransactionStart; final bool failOnCommit; final bool failOnRollback; + final bool failOnConnectionExplain; + final bool failOnTransactionExplain; var connectionCount = 0; var transactionCount = 0; var releaseCount = 0; @@ -4134,6 +4480,8 @@ final class _TrackingConnectionEngine this.failOnTransactionStart = false, this.failOnCommit = false, this.failOnRollback = false, + this.failOnConnectionExplain = false, + this.failOnTransactionExplain = false, }); @override @@ -4183,6 +4531,9 @@ final class _TrackingEngineConnection @override Future describePlan(OrmPlan plan) async { _engine.connectionExplainPlans.add(plan); + if (_engine.failOnConnectionExplain) { + throw StateError('connection explain failed'); + } return {'source': 'connection'}; } } @@ -4218,6 +4569,9 @@ final class _TrackingEngineTransaction @override Future describePlan(OrmPlan plan) async { _engine.transactionExplainPlans.add(plan); + if (_engine.failOnTransactionExplain) { + throw StateError('transaction explain failed'); + } return {'source': 'transaction'}; } } diff --git a/pub/orm/test/target/adapter_driver_engine_test.dart b/pub/orm/test/target/adapter_driver_engine_test.dart index a3ef8e30..8030598f 100644 --- a/pub/orm/test/target/adapter_driver_engine_test.dart +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -214,6 +214,10 @@ void main() { await connection.release(); expect(driver.connections.single.releaseCount, 1); await expectLater(connection.execute(_plan()), throwsA(isA())); + await expectLater( + (connection as ExplainCapableEngineConnection).describePlan(_plan()), + throwsA(isA()), + ); await expectLater(connection.transaction(), throwsA(isA())); await engine.close(); }); @@ -299,6 +303,10 @@ void main() { expect(inner.rollbackCount, 0); await expectLater(transaction.execute(_plan()), throwsA(isA())); + await expectLater( + (transaction as ExplainCapableEngineTransaction).describePlan(_plan()), + throwsA(isA()), + ); await expectLater(transaction.commit(), throwsA(isA())); await expectLater(transaction.rollback(), throwsA(isA())); await connection.release(); @@ -394,6 +402,10 @@ void main() { transaction.execute(_plan()), throwsA(isA()), ); + await expectLater( + (transaction as ExplainCapableEngineTransaction).describePlan(_plan()), + throwsA(isA()), + ); await expectLater(transaction.commit(), throwsA(isA())); await expectLater(transaction.rollback(), throwsA(isA())); await connection.release(); From f76438d1114b449f4fff6705a84657a80e40a1f8 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:19:02 +0800 Subject: [PATCH 116/154] refactor(generator)!: compile typed queries without runtime query bridge --- pub/orm/lib/src/generator/writer.dart | 306 ++++++++++++++++++---- pub/orm/test/generator/generate_test.dart | 44 ++-- 2 files changed, 278 insertions(+), 72 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 89dfda6b..c70e793c 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1413,7 +1413,9 @@ final class TypedClientWriter { ' final List<${relationModel.createInputClassName}>? $memberName;', ); } - if (relationFields.any((relation) => lookup[relation.relationModel] != null)) { + if (relationFields.any( + (relation) => lookup[relation.relationModel] != null, + )) { buffer.writeln(); buffer.writeln(' const ${model.nestedCreateInputClassName}({'); for (final relation in relationFields) { @@ -1434,7 +1436,9 @@ final class TypedClientWriter { } buffer.writeln(); buffer.writeln(' Map> toJson() {'); - if (!relationFields.any((relation) => lookup[relation.relationModel] != null)) { + if (!relationFields.any( + (relation) => lookup[relation.relationModel] != null, + )) { buffer.writeln(' return const >{};'); } else { buffer.writeln(' final create = >{};'); @@ -1647,7 +1651,9 @@ final class TypedClientWriter { buffer.writeln(' required int size,'); buffer.writeln(' ${model.cursorInputClassName}? after,'); buffer.writeln(' ${model.cursorInputClassName}? before,'); - buffer.writeln(' }) => query().page(size: size, after: after, before: before);'); + buffer.writeln( + ' }) => query().page(size: size, after: after, before: before);', + ); buffer.writeln(); buffer.writeln( @@ -2621,7 +2627,9 @@ final class TypedClientWriter { buffer.writeln(' if (after != null && before != null) {'); buffer.writeln(' throw PlanCursorWindowInvalidException('); buffer.writeln(" reason: 'pageDirectionAmbiguous',"); - buffer.writeln(" details: {'model': '$runtimeName'},"); + buffer.writeln( + " details: {'model': '$runtimeName'},", + ); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(' return ${model.queryClassName}._('); @@ -2782,50 +2790,130 @@ final class TypedClientWriter { buffer.writeln(); } - buffer.writeln(' ModelQuery _runtimeQuery() {'); buffer.writeln( - ' var query = _delegate._delegate.query(', + ' List get _runtimeOrderBy => _orderBy.map((entry) => entry.value).toList(growable: false);', ); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); + buffer.writeln(); buffer.writeln( - ' orderBy: _orderBy.map((entry) => entry.value).toList(growable: false),', + ' List get _runtimeDistinct => _distinct.map((entry) => entry.value).toList(growable: false);', ); + buffer.writeln(); buffer.writeln( - ' distinct: _distinct.map((entry) => entry.value).toList(growable: false),', + ' List get _runtimeSelect => _select?.toFields() ?? const [];', ); - buffer.writeln(' select: _select?.toFields() ?? const [],'); + buffer.writeln(); buffer.writeln( - ' include: _include?.toIncludeMap() ?? const {},', + ' Map get _runtimeInclude => _include?.toIncludeMap() ?? const {};', ); - buffer.writeln(' );'); - buffer.writeln(' if (_cursor != null) {'); - buffer.writeln(' query = query.cursor(_cursor!.toJson());'); + buffer.writeln(); + buffer.writeln(' JsonMap? get _runtimeCursor => _cursor?.toJson();'); + buffer.writeln(); + buffer.writeln(' OrmReadPagePlan? get _runtimePage {'); + buffer.writeln(' final size = _pageSize;'); + buffer.writeln(' if (size == null) {'); + buffer.writeln(' return null;'); buffer.writeln(' }'); - buffer.writeln(' if (_pageSize != null) {'); - buffer.writeln(' query = query.page('); - buffer.writeln(' size: _pageSize!,'); - buffer.writeln(" after: _pageAfter?.toJson(),"); - buffer.writeln(" before: _pageBefore?.toJson(),"); + buffer.writeln(' return OrmReadPagePlan('); + buffer.writeln(' size: size,'); + buffer.writeln(' after: _pageAfter?.toJson(),'); + buffer.writeln(' before: _pageBefore?.toJson(),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' void _assertReadExecutionSupported(String terminal) {'); + buffer.writeln( + ' if ((_cursor != null || _pageSize != null) && _distinct.isNotEmpty) {', + ); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.CURSOR_DISTINCT_UNSUPPORTED',"); + buffer.writeln( + " 'Cursor and page windows do not support distinct yet.',", + ); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'terminal': terminal,"); + buffer.writeln(" 'distinct': _runtimeDistinct,"); + buffer.writeln(' },'); buffer.writeln(' );'); buffer.writeln(' }'); - buffer.writeln(' return query;'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' void _assertMutationQueryState({'); + buffer.writeln(' required String action,'); + buffer.writeln(' bool allowWhere = true,'); + buffer.writeln(' }) {'); + buffer.writeln(' final invalidKeys = ['); + buffer.writeln(" if (!allowWhere && !_where.isEmpty) 'where',"); + buffer.writeln(" if (_skip != null) 'skip',"); + buffer.writeln(" if (_take != null) 'take',"); + buffer.writeln(" if (_orderBy.isNotEmpty) 'orderBy',"); + buffer.writeln(" if (_distinct.isNotEmpty) 'distinct',"); + buffer.writeln(" if (_cursor != null) 'cursor',"); + buffer.writeln(" if (_pageSize != null) 'page',"); + buffer.writeln(' ];'); + buffer.writeln(' if (invalidKeys.isEmpty) {'); + buffer.writeln(' return;'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.MUTATION_QUERY_STATE_INVALID',"); + buffer.writeln( + " '\$action does not allow query state keys: \${invalidKeys.join(', ')}.',", + ); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'action': action,"); + buffer.writeln(" 'invalidKeys': invalidKeys,"); + buffer.writeln(' },'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future toPlan() {'); - buffer.writeln(' return _runtimeQuery().toPlan();'); + buffer.writeln(' return _delegate._delegate.toPlan('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' distinct: _runtimeDistinct,'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future inspectPlan() {'); - buffer.writeln(' return _runtimeQuery().inspectPlan();'); + buffer.writeln(' return _delegate._delegate.inspectPlan('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' distinct: _runtimeDistinct,'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future> all() async {'); - buffer.writeln(' final rows = await _runtimeQuery().all();'); + buffer.writeln(" _assertReadExecutionSupported('all');"); + buffer.writeln(' final rows = await _delegate._delegate.all('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' distinct: _runtimeDistinct,'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); + buffer.writeln(' );'); buffer.writeln( ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', ); @@ -2835,7 +2923,24 @@ final class TypedClientWriter { buffer.writeln( ' Future> pageResult() async {', ); - buffer.writeln(' final result = await _runtimeQuery().pageResult();'); + buffer.writeln(" _assertReadExecutionSupported('pageResult');"); + buffer.writeln(' final page = _runtimePage;'); + buffer.writeln(' if (page == null) {'); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.PAGE_RESULT_REQUIRES_PAGE_WINDOW',"); + buffer.writeln(" 'pageResult() requires page() first.',"); + buffer.writeln( + " details: {'model': '$runtimeName'},", + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' final result = await _delegate._delegate.pageResult('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' page: page,'); + buffer.writeln(' );'); buffer.writeln( ' return result.mapItems(${model.dataClassName}.fromJson);', ); @@ -2843,7 +2948,19 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Future<${model.dataClassName}?> oneOrNull() async {'); - buffer.writeln(' final row = await _runtimeQuery().oneOrNull();'); + buffer.writeln(" _assertReadExecutionSupported('oneOrNull');"); + buffer.writeln(' if (_runtimeCursor != null || _runtimePage != null) {'); + buffer.writeln(' final rows = await all();'); + buffer.writeln(' if (rows.isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return rows.first;'); + buffer.writeln(' }'); + buffer.writeln(' final row = await _delegate._delegate.oneOrNull('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); buffer.writeln(' }'); @@ -2852,7 +2969,22 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Future<${model.dataClassName}?> firstOrNull() async {'); - buffer.writeln(' final row = await _runtimeQuery().firstOrNull();'); + buffer.writeln(" _assertReadExecutionSupported('firstOrNull');"); + buffer.writeln(' if (_runtimeCursor != null || _runtimePage != null) {'); + buffer.writeln(' final rows = await all();'); + buffer.writeln(' if (rows.isEmpty) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return rows.first;'); + buffer.writeln(' }'); + buffer.writeln(' final row = await _delegate._delegate.firstOrNull('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' distinct: _runtimeDistinct,'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); buffer.writeln(' }'); @@ -2861,14 +2993,35 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' Stream<${model.dataClassName}> stream() async* {'); - buffer.writeln(' await for (final row in _runtimeQuery().stream()) {'); + buffer.writeln(" _assertReadExecutionSupported('stream');"); + buffer.writeln(' await for (final row in _delegate._delegate.stream('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' distinct: _runtimeDistinct,'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); + buffer.writeln(' )) {'); buffer.writeln(' yield ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future explain() async {'); - buffer.writeln(' return _runtimeQuery().explain();'); + buffer.writeln(' Future explain() {'); + buffer.writeln(' return _delegate._delegate.explain('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' distinct: _runtimeDistinct,'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2890,7 +3043,12 @@ final class TypedClientWriter { ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); buffer.writeln(' }) {'); - buffer.writeln(' return _runtimeQuery().aggregate('); + buffer.writeln(" _assertReadExecutionSupported('aggregate');"); + buffer.writeln(' return _delegate._delegate.aggregate('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); buffer.writeln(' countAll: countAll,'); buffer.writeln( ' count: count.map((entry) => entry.value).toList(growable: false),', @@ -2936,6 +3094,24 @@ final class TypedClientWriter { ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); buffer.writeln(' }) {'); + buffer.writeln(" _assertReadExecutionSupported('groupBy');"); + buffer.writeln(' if (_cursor != null || _pageSize != null) {'); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED',"); + buffer.writeln( + " 'GroupBy does not support cursor or page windows yet.',", + ); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln( + " if (_runtimeCursor != null) 'cursor': _runtimeCursor,", + ); + buffer.writeln( + " if (_runtimePage != null) 'page': _runtimePage!.toJson(),", + ); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); buffer.writeln(' return _delegate.groupBy('); buffer.writeln( ' by: by.map((entry) => entry.value).toList(growable: false),', @@ -2946,21 +3122,11 @@ final class TypedClientWriter { buffer.writeln(' typedHaving: typedHaving,'); buffer.writeln(' groupByOrderBy: groupByOrderBy,'); buffer.writeln(' countAll: countAll,'); - buffer.writeln( - ' count: count,', - ); - buffer.writeln( - ' min: min,', - ); - buffer.writeln( - ' max: max,', - ); - buffer.writeln( - ' sum: sum,', - ); - buffer.writeln( - ' avg: avg,', - ); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2968,10 +3134,15 @@ final class TypedClientWriter { buffer.writeln( ' Future> createMany({required List<${model.createInputClassName}> data}) async {', ); - buffer.writeln(' final rows = await _runtimeQuery().createMany('); + buffer.writeln( + " _assertMutationQueryState(action: 'createMany', allowWhere: false);", + ); + buffer.writeln(' final rows = await _delegate._delegate.createMany('); buffer.writeln( ' data: data.map((entry) => entry.toJson()).toList(growable: false),', ); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); buffer.writeln(' );'); buffer.writeln( ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', @@ -2979,17 +3150,19 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln( - ' Future<${model.dataClassName}?> updateNested({', - ); + buffer.writeln(' Future<${model.dataClassName}?> updateNested({'); buffer.writeln(' required ${model.updateInputClassName} data,'); buffer.writeln( ' ${model.nestedCreateInputClassName} create = const ${model.nestedCreateInputClassName}(),', ); buffer.writeln(' }) async {'); - buffer.writeln(' final row = await _runtimeQuery().updateNested('); + buffer.writeln(" _assertMutationQueryState(action: 'updateNested');"); + buffer.writeln(' final row = await _delegate._delegate.updateNested('); + buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' data: data.toJson(),'); buffer.writeln(' create: create.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); buffer.writeln(' );'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); @@ -3001,22 +3174,43 @@ final class TypedClientWriter { buffer.writeln( ' Future updateMany({required ${model.updateInputClassName} data}) {', ); - buffer.writeln(' return _runtimeQuery().updateMany(data: data.toJson());'); + buffer.writeln(" _assertMutationQueryState(action: 'updateMany');"); + buffer.writeln(' return _delegate._delegate.updateMany('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future deleteMany() {'); - buffer.writeln(' return _runtimeQuery().deleteMany();'); + buffer.writeln(" _assertMutationQueryState(action: 'deleteMany');"); + buffer.writeln(' return _delegate._delegate.deleteMany('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future count() {'); - buffer.writeln(' return _runtimeQuery().count();'); + buffer.writeln(" _assertReadExecutionSupported('count');"); + buffer.writeln(' return _delegate._delegate.count('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future exists() {'); - buffer.writeln(' return _runtimeQuery().exists();'); + buffer.writeln(" _assertReadExecutionSupported('exists');"); + buffer.writeln(' return _delegate._delegate.exists('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln('}'); buffer.writeln(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 0ac14b30..9ce24637 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -661,11 +661,23 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?ModelQuery\s+_runtimeQuery\(\)[\s\S]*?Future>\s+all\(\)\s+async\s*\{[\s\S]*?_runtimeQuery\(\)\.all\(\)', + r'class\s+UserQuery\s*\{[\s\S]*?List\s+get\s+_runtimeOrderBy[\s\S]*?Future>\s+all\(\)\s+async\s*\{[\s\S]*?_delegate\._delegate\.all\(', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery read execution to flow through runtime query builder.', + 'Expected UserQuery read execution to compile typed state straight to delegate execution.', + ); + expect( + generatedSource.contains('ModelQuery _runtimeQuery('), + isFalse, + reason: + 'Expected generated query surface to stop materializing runtime ModelQuery bridge methods.', + ); + expect( + generatedSource.contains('_delegate._delegate.query('), + isFalse, + reason: + 'Expected generated query surface to stop re-entering dynamic query authoring.', ); expect( RegExp( @@ -687,24 +699,21 @@ typedef Post = ({ r'class\s+UserDelegate\s*\{[\s\S]*?UserQuery\s+page\(\{\s*required\s+int\s+size,\s*UserCursorInput\?\s+after,\s*UserCursorInput\?\s+before,', ).hasMatch(generatedSource), isTrue, - reason: - 'Expected UserDelegate.page(...) to use typed cursor inputs.', + reason: 'Expected UserDelegate.page(...) to use typed cursor inputs.', ); expect( RegExp( r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserCursorInput\s+cursor\s*\)', ).hasMatch(generatedSource), isTrue, - reason: - 'Expected UserQuery.cursor(...) to use typed cursor input.', + reason: 'Expected UserQuery.cursor(...) to use typed cursor input.', ); expect( RegExp( r'class\s+UserQuery\s*\{[\s\S]*?UserQuery\s+page\(\{\s*required\s+int\s+size,\s*UserCursorInput\?\s+after,\s*UserCursorInput\?\s+before,', ).hasMatch(generatedSource), isTrue, - reason: - 'Expected UserQuery.page(...) to use typed cursor inputs.', + reason: 'Expected UserQuery.page(...) to use typed cursor inputs.', ); expect( RegExp( @@ -715,17 +724,19 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+toPlan\(\s*\)[\s\S]*?_runtimeQuery\(\)\.toPlan\(\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+toPlan\(\s*\)[\s\S]*?_delegate\._delegate\.toPlan\(', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserQuery.toPlan() in generated source.', + reason: + 'Expected UserQuery.toPlan() to compile typed state directly through delegate planning.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+inspectPlan\(\s*\)[\s\S]*?_runtimeQuery\(\)\.inspectPlan\(\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+inspectPlan\(\s*\)[\s\S]*?_delegate\._delegate\.inspectPlan\(', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserQuery.inspectPlan() in generated source.', + reason: + 'Expected UserQuery.inspectPlan() to compile typed state directly through delegate inspection.', ); expect( RegExp( @@ -736,10 +747,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+explain\(\s*\)\s+async[\s\S]*?_runtimeQuery\(\)\.explain\(\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+explain\(\s*\)[\s\S]*?_delegate\._delegate\.explain\(', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserQuery.explain() to route through runtime query explain.', + reason: + 'Expected UserQuery.explain() to compile typed state directly through delegate explain.', ); expect( RegExp( @@ -750,11 +762,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+pageResult\(\s*\)\s+async[\s\S]*?_runtimeQuery\(\)\.pageResult\(\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+pageResult\(\s*\)\s+async[\s\S]*?_delegate\._delegate\.pageResult\(', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.pageResult() to expose structured page envelope mapping.', + 'Expected UserQuery.pageResult() to expose structured page envelope mapping without runtime query bridge.', ); expect( RegExp( From 9230ac873aab2342eb19b4c6e24828ccee1ae590 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:07:30 +0800 Subject: [PATCH 117/154] refactor(repository): extract read plan compiler --- pub/orm/lib/src/client/client.dart | 577 +---------------- .../lib/src/client/read_plan_compiler.dart | 607 ++++++++++++++++++ 2 files changed, 631 insertions(+), 553 deletions(-) create mode 100644 pub/orm/lib/src/client/read_plan_compiler.dart diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index cc9a6c25..4be7f2f5 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -11,6 +11,7 @@ import '../runtime/types.dart'; part 'include_planner.dart'; part 'mutation_repository.dart'; +part 'read_plan_compiler.dart'; typedef CollectionFactory = ModelDelegate Function({ @@ -1056,14 +1057,6 @@ OrmPlan _buildSqlPlan({ ); } -@immutable -final class _PreparedReadPlan { - final OrmPlan plan; - final Map include; - - const _PreparedReadPlan({required this.plan, required this.include}); -} - class ModelDelegate { final OrmCollectionContext _client; final String modelName; @@ -1075,6 +1068,9 @@ class ModelDelegate { OrmCollectionContext get client => _client; _OrmDelegateRuntime get _runtime => _client as _OrmDelegateRuntime; + late final _OrmReadPlanCompiler _readPlanCompiler = _OrmReadPlanCompiler( + this, + ); ModelQuery query() => ModelQuery._(this, const ModelQueryState()); @@ -1685,106 +1681,20 @@ class ModelDelegate { OrmReadPagePlan? page, JsonMap annotations = const {}, OrmRepositoryTrace? repositoryTrace, - }) async { - if (skip case final offset? when offset < 0) { - throw PlanInvalidPaginationException(key: 'skip', value: offset); - } - if (take case final limit? when limit < 0) { - throw PlanInvalidPaginationException(key: 'take', value: limit); - } - - final normalizedInclude = _normalizeInclude(include); - final normalizedWhere = await _normalizeWhereForExecution( - model: modelName, + }) { + return _readPlanCompiler.compile( + resultMode: resultMode, where: where, - ); - if ((cursor != null || page != null) && orderBy.isEmpty) { - throw runtimeError( - 'PLAN.CURSOR_ORDER_BY_REQUIRED', - 'Cursor and page windows require orderBy() first.', - details: { - 'model': modelName, - if (cursor != null) 'cursor': cursor, - if (page != null) 'page': page.toJson(), - }, - ); - } - if (cursor != null || page != null) { - _validateStableCursorOrderBy(orderBy: orderBy); - } - if ((cursor != null || page != null) && distinct.isNotEmpty) { - throw runtimeError( - 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', - 'Cursor and page windows do not support distinct yet.', - details: { - 'model': modelName, - 'distinct': distinct, - if (cursor != null) 'cursor': cursor, - if (page != null) 'page': page.toJson(), - }, - ); - } - if (page != null && resultMode != OrmReadResultMode.all) { - throw runtimeError( - 'PLAN.PAGE_RESULT_MODE_INVALID', - 'Page windows currently compile only to collection read plans.', - details: { - 'model': modelName, - 'resultMode': resultMode.name, - 'page': page.toJson(), - }, - ); - } - final isCollectionRead = resultMode != OrmReadResultMode.oneOrNull; - final resolvedTake = page != null - ? null - : resultMode == OrmReadResultMode.firstOrNull - ? 1 - : take; - final readSelect = switch (resultMode) { - OrmReadResultMode.oneOrNull => _expandSelectForInclude( - model: modelName, - select: select, - include: normalizedInclude, - ), - OrmReadResultMode.all || - OrmReadResultMode.firstOrNull => _expandSelectForExecution( - model: modelName, - select: select, - include: normalizedInclude, - distinct: distinct, - ), - }; - - return _PreparedReadPlan( - include: normalizedInclude, - plan: OrmPlan.read( - contractHash: _client.contract.hash, - target: _client.contract.target, - storageHash: _client.contract.markerStorageHash, - profileHash: _client.contract.profileHash, - lane: 'orm', - annotations: _mergePlanAnnotations( - annotations, - distinct.isEmpty - ? const {} - : { - 'distinct': List.from(distinct, growable: false), - }, - ), - repositoryTrace: repositoryTrace, - model: modelName, - where: normalizedWhere, - skip: isCollectionRead && distinct.isEmpty ? skip : null, - take: isCollectionRead && distinct.isEmpty ? resolvedTake : null, - orderBy: isCollectionRead ? orderBy : const [], - distinct: isCollectionRead ? distinct : const [], - select: readSelect, - include: _buildOrmIncludePlanMap(normalizedInclude), - cursor: cursor == null ? null : OrmReadCursorPlan(values: cursor), - page: page, - resultMode: resultMode, - ), + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + annotations: annotations, + repositoryTrace: repositoryTrace, ); } @@ -2362,73 +2272,7 @@ class ModelDelegate { } void _validateStableCursorOrderBy({required List orderBy}) { - final model = _client.contract.models[modelName]; - if (model == null) { - throw ModelNotFoundException(modelName, _client.contract.models.keys); - } - - final idFields = model.idFields; - if (idFields.isEmpty) { - return; - } - if (orderBy.length < idFields.length) { - _throwStableCursorOrderError(orderBy: orderBy, idFields: idFields); - } - - final suffix = orderBy - .sublist(orderBy.length - idFields.length) - .map((entry) => entry.field) - .toList(growable: false); - if (_listEquals(suffix, idFields)) { - return; - } - - _throwStableCursorOrderError(orderBy: orderBy, idFields: idFields); - } - - Never _throwStableCursorOrderError({ - required List orderBy, - required List idFields, - }) { - throw runtimeError( - 'PLAN.CURSOR_STABLE_ORDER_REQUIRED', - 'Cursor and page windows require orderBy() to end with the model id fields.', - details: { - 'model': modelName, - 'idFields': idFields, - 'orderBy': orderBy - .map((entry) => entry.toJson()) - .toList(growable: false), - }, - ); - } - - List _expandSelectForExecution({ - required String model, - required List select, - required Map include, - required List distinct, - }) { - if (select.isEmpty) { - return select; - } - - if (include.isEmpty && distinct.isEmpty) { - return select; - } - - final expanded = {...select, ...distinct}; - if (include.isNotEmpty) { - for (final relationName in include.keys) { - final relation = _resolveRelation( - model: model, - relationName: relationName, - ); - expanded.addAll(relation.sourceFields); - } - } - - return expanded.toList(growable: false); + _readPlanCompiler.validateStableCursorOrderBy(orderBy: orderBy); } List _expandSelectForInclude({ @@ -2436,11 +2280,10 @@ class ModelDelegate { required List select, required Map include, }) { - return _expandSelectForExecution( + return _readPlanCompiler.expandSelectForInclude( model: model, select: select, include: include, - distinct: const [], ); } @@ -3302,387 +3145,15 @@ class ModelDelegate { Future _normalizeWhereForExecution({ required String model, required JsonMap where, - }) async { - if (where.isEmpty) { - return const {}; - } - if (_client.contract.target == 'sql-family') { - return where; - } - return _rewriteRelationWhere(model: model, where: where); - } - - Future _rewriteRelationWhere({ - required String model, - required JsonMap where, - }) async { - if (where.isEmpty) { - return const {}; - } - - final modelContract = _client.contract.models[model]; - if (modelContract == null) { - throw ModelNotFoundException(model, _client.contract.models.keys); - } - - final normalizedWhere = {}; - final relationClauses = []; - for (final entry in where.entries) { - final key = entry.key; - if (_whereLogicalKeys.contains(key)) { - normalizedWhere[key] = await _normalizeWhereLogicalOperand( - model: model, - operand: entry.value, - ); - continue; - } - - final relation = modelContract.relations[key]; - if (relation == null) { - normalizedWhere[key] = entry.value; - continue; - } - - final relationWhere = _coerceWhereMap(entry.value); - if (relationWhere == null) { - final supportedOperators = _relationWhereOperatorsFor( - cardinality: relation.cardinality, - ); - throw runtimeError( - 'PLAN.RELATION_WHERE_INVALID', - 'Relation where expects a map of operators.', - details: { - 'model': model, - 'relation': key, - 'expectedOperators': supportedOperators.toList(growable: false), - }, - ); - } - - final clause = await _compileRelationWhereClause( - relationName: key, - relation: relation, - where: relationWhere, - ); - if (clause != null) { - relationClauses.add(clause); - } - } - - for (final clause in relationClauses) { - _appendAndWhereClause(where: normalizedWhere, clause: clause); - } - - return normalizedWhere; - } - - Future _normalizeWhereLogicalOperand({ - required String model, - required Object? operand, - }) async { - final nestedWhere = _coerceWhereMap(operand); - if (nestedWhere != null) { - return _rewriteRelationWhere(model: model, where: nestedWhere); - } - - final nestedWhereList = _coerceWhereList(operand); - if (nestedWhereList == null) { - return operand; - } - - final normalized = []; - for (final entry in nestedWhereList) { - normalized.add(await _rewriteRelationWhere(model: model, where: entry)); - } - return normalized; - } - - Future _compileRelationWhereClause({ - required String relationName, - required ModelRelationContract relation, - required JsonMap where, - }) async { - if (where.isEmpty) { - return null; - } - - final supportedOperators = _relationWhereOperatorsFor( - cardinality: relation.cardinality, - ); - final unknownOperators = where.keys - .where((key) => !supportedOperators.contains(key)) - .toList(growable: false); - if (unknownOperators.isNotEmpty) { - throw runtimeError( - 'PLAN.RELATION_WHERE_OPERATOR_INVALID', - 'Relation where contains unknown operators.', - details: { - 'model': modelName, - 'relation': relationName, - 'unknownOperators': unknownOperators, - 'supportedOperators': supportedOperators.toList(growable: false), - }, - ); - } - - final clauses = []; - if (relation.cardinality == RelationCardinality.many) { - if (where.containsKey('some')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'some', - operand: where['some'], - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: relationWhere, - include: true, - ), - ); - } - - if (where.containsKey('none')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'none', - operand: where['none'], - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: relationWhere, - include: false, - ), - ); - } - - if (where.containsKey('every')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'every', - operand: where['every'], - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: {'NOT': relationWhere}, - include: false, - ), - ); - } - } else { - if (where.containsKey('is')) { - final isOperand = where['is']; - if (isOperand == null) { - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: const {}, - include: false, - ), - ); - } else { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'is', - operand: isOperand, - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: relationWhere, - include: true, - ), - ); - } - } - - if (where.containsKey('isNot')) { - final isNotOperand = where['isNot']; - if (isNotOperand == null) { - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: const {}, - include: true, - ), - ); - } else { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'isNot', - operand: isNotOperand, - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: relationWhere, - include: false, - ), - ); - } - } - } - - if (clauses.isEmpty) { - return null; - } - if (clauses.length == 1) { - return clauses.single; - } - return {'AND': clauses}; - } - - Future _normalizeRelationOperatorWhere({ - required String relationName, - required ModelRelationContract relation, - required String operator, - required Object? operand, - }) async { - if (operand == null) { - return const {}; - } - - final nestedWhere = _coerceWhereMap(operand); - if (nestedWhere == null) { - throw runtimeError( - 'PLAN.RELATION_WHERE_VALUE_INVALID', - 'Relation where operator expects a nested where map.', - details: { - 'model': modelName, - 'relation': relationName, - 'operator': operator, - }, - ); - } - - return _rewriteRelationWhere( - model: relation.relatedModel, - where: nestedWhere, - ); - } - - Future _buildRelationMembershipClause({ - required ModelRelationContract relation, - required JsonMap relatedWhere, - required bool include, - }) async { - final relatedRows = await _runtime - ._resolveDelegate(relation.relatedModel) - ._readAllInternal( - action: OrmAction.read, - where: relatedWhere, - select: relation.targetFields, - includeDepth: 0, - ); - - final keys = <_RelationMergeKey>{}; - for (final row in relatedRows) { - final key = _buildRelationMergeKeyFromRow( - row: row, - fields: relation.targetFields, - ); - if (key != null) { - keys.add(key); - } - } - - return _buildRelationTupleMembershipWhere( - sourceFields: relation.sourceFields, - keys: keys, - include: include, - ); - } - - JsonMap _buildRelationTupleMembershipWhere({ - required List sourceFields, - required Set<_RelationMergeKey> keys, - required bool include, - }) { - if (keys.isEmpty) { - return include - ? const {'OR': []} - : const {'AND': []}; - } - - if (sourceFields.length == 1) { - final field = sourceFields.single; - final values = keys - .map((key) => key.parts.single) - .toList(growable: false); - return { - field: {include ? 'in' : 'notIn': values}, - }; - } - - final tupleClauses = keys - .map((key) { - final where = {}; - for (var index = 0; index < sourceFields.length; index++) { - where[sourceFields[index]] = key.parts[index]; - } - return where; - }) - .toList(growable: false); - - if (include) { - return {'OR': tupleClauses}; - } - - return { - 'NOT': {'OR': tupleClauses}, - }; - } - - void _appendAndWhereClause({ - required JsonMap where, - required JsonMap clause, }) { - if (clause.isEmpty) { - return; - } - - final existing = where['AND']; - if (existing == null) { - where['AND'] = [clause]; - return; - } - - final existingMap = _coerceWhereMap(existing); - if (existingMap != null) { - where['AND'] = [existingMap, clause]; - return; - } - - final existingList = _coerceWhereList(existing); - if (existingList != null) { - where['AND'] = [...existingList, clause]; - return; - } - - where['AND'] = [clause]; - } - - Set _relationWhereOperatorsFor({ - required RelationCardinality cardinality, - }) { - return switch (cardinality) { - RelationCardinality.many => _toManyRelationWhereOperators, - RelationCardinality.one => _toOneRelationWhereOperators, - }; + return _readPlanCompiler.normalizeWhereForExecution( + model: model, + where: where, + ); } Map _normalizeInclude(Map include) { - if (include.isEmpty) { - return const {}; - } - return include; + return _readPlanCompiler.normalizeInclude(include); } Map> _normalizeNestedCreate( diff --git a/pub/orm/lib/src/client/read_plan_compiler.dart b/pub/orm/lib/src/client/read_plan_compiler.dart new file mode 100644 index 00000000..b7cd1aea --- /dev/null +++ b/pub/orm/lib/src/client/read_plan_compiler.dart @@ -0,0 +1,607 @@ +part of 'client.dart'; + +@immutable +final class _PreparedReadPlan { + final OrmPlan plan; + final Map include; + + const _PreparedReadPlan({required this.plan, required this.include}); +} + +final class _OrmReadPlanCompiler { + final ModelDelegate _delegate; + + _OrmReadPlanCompiler(this._delegate); + + Future<_PreparedReadPlan> compile({ + required OrmReadResultMode resultMode, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, + }) async { + if (skip case final offset? when offset < 0) { + throw PlanInvalidPaginationException(key: 'skip', value: offset); + } + if (take case final limit? when limit < 0) { + throw PlanInvalidPaginationException(key: 'take', value: limit); + } + + final normalizedInclude = normalizeInclude(include); + final normalizedWhere = await normalizeWhereForExecution( + model: _delegate.modelName, + where: where, + ); + if ((cursor != null || page != null) && orderBy.isEmpty) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + 'Cursor and page windows require orderBy() first.', + details: { + 'model': _delegate.modelName, + if (cursor != null) 'cursor': cursor, + if (page != null) 'page': page.toJson(), + }, + ); + } + if (cursor != null || page != null) { + validateStableCursorOrderBy(orderBy: orderBy); + } + if ((cursor != null || page != null) && distinct.isNotEmpty) { + throw runtimeError( + 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', + 'Cursor and page windows do not support distinct yet.', + details: { + 'model': _delegate.modelName, + 'distinct': distinct, + if (cursor != null) 'cursor': cursor, + if (page != null) 'page': page.toJson(), + }, + ); + } + if (page != null && resultMode != OrmReadResultMode.all) { + throw runtimeError( + 'PLAN.PAGE_RESULT_MODE_INVALID', + 'Page windows currently compile only to collection read plans.', + details: { + 'model': _delegate.modelName, + 'resultMode': resultMode.name, + 'page': page.toJson(), + }, + ); + } + + final isCollectionRead = resultMode != OrmReadResultMode.oneOrNull; + final resolvedTake = page != null + ? null + : resultMode == OrmReadResultMode.firstOrNull + ? 1 + : take; + final readSelect = switch (resultMode) { + OrmReadResultMode.oneOrNull => expandSelectForInclude( + model: _delegate.modelName, + select: select, + include: normalizedInclude, + ), + OrmReadResultMode.all || + OrmReadResultMode.firstOrNull => expandSelectForExecution( + model: _delegate.modelName, + select: select, + include: normalizedInclude, + distinct: distinct, + ), + }; + + return _PreparedReadPlan( + include: normalizedInclude, + plan: OrmPlan.read( + contractHash: _delegate._client.contract.hash, + target: _delegate._client.contract.target, + storageHash: _delegate._client.contract.markerStorageHash, + profileHash: _delegate._client.contract.profileHash, + lane: 'orm', + annotations: _mergePlanAnnotations( + annotations, + distinct.isEmpty + ? const {} + : { + 'distinct': List.from(distinct, growable: false), + }, + ), + repositoryTrace: repositoryTrace, + model: _delegate.modelName, + where: normalizedWhere, + skip: isCollectionRead && distinct.isEmpty ? skip : null, + take: isCollectionRead && distinct.isEmpty ? resolvedTake : null, + orderBy: isCollectionRead ? orderBy : const [], + distinct: isCollectionRead ? distinct : const [], + select: readSelect, + include: _buildOrmIncludePlanMap(normalizedInclude), + cursor: cursor == null ? null : OrmReadCursorPlan(values: cursor), + page: page, + resultMode: resultMode, + ), + ); + } + + void validateStableCursorOrderBy({required List orderBy}) { + final model = _delegate._client.contract.models[_delegate.modelName]; + if (model == null) { + throw ModelNotFoundException( + _delegate.modelName, + _delegate._client.contract.models.keys, + ); + } + + final idFields = model.idFields; + if (idFields.isEmpty) { + return; + } + if (orderBy.length < idFields.length) { + _throwStableCursorOrderError(orderBy: orderBy, idFields: idFields); + } + + final suffix = orderBy + .sublist(orderBy.length - idFields.length) + .map((entry) => entry.field) + .toList(growable: false); + if (_listEquals(suffix, idFields)) { + return; + } + + _throwStableCursorOrderError(orderBy: orderBy, idFields: idFields); + } + + List expandSelectForExecution({ + required String model, + required List select, + required Map include, + required List distinct, + }) { + if (select.isEmpty) { + return select; + } + + if (include.isEmpty && distinct.isEmpty) { + return select; + } + + final expanded = {...select, ...distinct}; + if (include.isNotEmpty) { + for (final relationName in include.keys) { + final relation = _delegate._resolveRelation( + model: model, + relationName: relationName, + ); + expanded.addAll(relation.sourceFields); + } + } + + return expanded.toList(growable: false); + } + + List expandSelectForInclude({ + required String model, + required List select, + required Map include, + }) { + return expandSelectForExecution( + model: model, + select: select, + include: include, + distinct: const [], + ); + } + + Map normalizeInclude(Map include) { + if (include.isEmpty) { + return const {}; + } + return include; + } + + Future normalizeWhereForExecution({ + required String model, + required JsonMap where, + }) async { + if (where.isEmpty) { + return const {}; + } + if (_delegate._client.contract.target == 'sql-family') { + return where; + } + return _rewriteRelationWhere(model: model, where: where); + } + + Never _throwStableCursorOrderError({ + required List orderBy, + required List idFields, + }) { + throw runtimeError( + 'PLAN.CURSOR_STABLE_ORDER_REQUIRED', + 'Cursor and page windows require orderBy() to end with the model id fields.', + details: { + 'model': _delegate.modelName, + 'idFields': idFields, + 'orderBy': orderBy + .map((entry) => entry.toJson()) + .toList(growable: false), + }, + ); + } + + Future _rewriteRelationWhere({ + required String model, + required JsonMap where, + }) async { + if (where.isEmpty) { + return const {}; + } + + final modelContract = _delegate._client.contract.models[model]; + if (modelContract == null) { + throw ModelNotFoundException( + model, + _delegate._client.contract.models.keys, + ); + } + + final normalizedWhere = {}; + final relationClauses = []; + for (final entry in where.entries) { + final key = entry.key; + if (_whereLogicalKeys.contains(key)) { + normalizedWhere[key] = await _normalizeWhereLogicalOperand( + model: model, + operand: entry.value, + ); + continue; + } + + final relation = modelContract.relations[key]; + if (relation == null) { + normalizedWhere[key] = entry.value; + continue; + } + + final relationWhere = _coerceWhereMap(entry.value); + if (relationWhere == null) { + final supportedOperators = _relationWhereOperatorsFor( + cardinality: relation.cardinality, + ); + throw runtimeError( + 'PLAN.RELATION_WHERE_INVALID', + 'Relation where expects a map of operators.', + details: { + 'model': model, + 'relation': key, + 'expectedOperators': supportedOperators.toList(growable: false), + }, + ); + } + + final clause = await _compileRelationWhereClause( + relationName: key, + relation: relation, + where: relationWhere, + ); + if (clause != null) { + relationClauses.add(clause); + } + } + + for (final clause in relationClauses) { + _appendAndWhereClause(where: normalizedWhere, clause: clause); + } + + return normalizedWhere; + } + + Future _normalizeWhereLogicalOperand({ + required String model, + required Object? operand, + }) async { + final nestedWhere = _coerceWhereMap(operand); + if (nestedWhere != null) { + return _rewriteRelationWhere(model: model, where: nestedWhere); + } + + final nestedWhereList = _coerceWhereList(operand); + if (nestedWhereList == null) { + return operand; + } + + final normalized = []; + for (final entry in nestedWhereList) { + normalized.add(await _rewriteRelationWhere(model: model, where: entry)); + } + return normalized; + } + + Future _compileRelationWhereClause({ + required String relationName, + required ModelRelationContract relation, + required JsonMap where, + }) async { + if (where.isEmpty) { + return null; + } + + final supportedOperators = _relationWhereOperatorsFor( + cardinality: relation.cardinality, + ); + final unknownOperators = where.keys + .where((key) => !supportedOperators.contains(key)) + .toList(growable: false); + if (unknownOperators.isNotEmpty) { + throw runtimeError( + 'PLAN.RELATION_WHERE_OPERATOR_INVALID', + 'Relation where contains unknown operators.', + details: { + 'model': _delegate.modelName, + 'relation': relationName, + 'unknownOperators': unknownOperators, + 'supportedOperators': supportedOperators.toList(growable: false), + }, + ); + } + + final clauses = []; + if (relation.cardinality == RelationCardinality.many) { + if (where.containsKey('some')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'some', + operand: where['some'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } + + if (where.containsKey('none')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'none', + operand: where['none'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } + + if (where.containsKey('every')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'every', + operand: where['every'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: {'NOT': relationWhere}, + include: false, + ), + ); + } + } else { + if (where.containsKey('is')) { + final isOperand = where['is']; + if (isOperand == null) { + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: const {}, + include: false, + ), + ); + } else { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'is', + operand: isOperand, + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } + } + + if (where.containsKey('isNot')) { + final isNotOperand = where['isNot']; + if (isNotOperand == null) { + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: const {}, + include: true, + ), + ); + } else { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'isNot', + operand: isNotOperand, + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } + } + } + + if (clauses.isEmpty) { + return null; + } + if (clauses.length == 1) { + return clauses.single; + } + return {'AND': clauses}; + } + + Future _normalizeRelationOperatorWhere({ + required String relationName, + required ModelRelationContract relation, + required String operator, + required Object? operand, + }) async { + if (operand == null) { + return const {}; + } + + final nestedWhere = _coerceWhereMap(operand); + if (nestedWhere == null) { + throw runtimeError( + 'PLAN.RELATION_WHERE_VALUE_INVALID', + 'Relation where operator expects a nested where map.', + details: { + 'model': _delegate.modelName, + 'relation': relationName, + 'operator': operator, + }, + ); + } + + return _rewriteRelationWhere( + model: relation.relatedModel, + where: nestedWhere, + ); + } + + Future _buildRelationMembershipClause({ + required ModelRelationContract relation, + required JsonMap relatedWhere, + required bool include, + }) async { + final relatedRows = await _delegate._runtime + ._resolveDelegate(relation.relatedModel) + ._readAllInternal( + action: OrmAction.read, + where: relatedWhere, + select: relation.targetFields, + includeDepth: 0, + ); + + final keys = <_RelationMergeKey>{}; + for (final row in relatedRows) { + final key = _delegate._buildRelationMergeKeyFromRow( + row: row, + fields: relation.targetFields, + ); + if (key != null) { + keys.add(key); + } + } + + return _buildRelationTupleMembershipWhere( + sourceFields: relation.sourceFields, + keys: keys, + include: include, + ); + } + + JsonMap _buildRelationTupleMembershipWhere({ + required List sourceFields, + required Set<_RelationMergeKey> keys, + required bool include, + }) { + if (keys.isEmpty) { + return include + ? const {'OR': []} + : const {'AND': []}; + } + + if (sourceFields.length == 1) { + final field = sourceFields.single; + final values = keys + .map((key) => key.parts.single) + .toList(growable: false); + return { + field: {include ? 'in' : 'notIn': values}, + }; + } + + final tupleClauses = keys + .map((key) { + final where = {}; + for (var index = 0; index < sourceFields.length; index++) { + where[sourceFields[index]] = key.parts[index]; + } + return where; + }) + .toList(growable: false); + + if (include) { + return {'OR': tupleClauses}; + } + + return { + 'NOT': {'OR': tupleClauses}, + }; + } + + void _appendAndWhereClause({ + required JsonMap where, + required JsonMap clause, + }) { + if (clause.isEmpty) { + return; + } + + final existing = where['AND']; + if (existing == null) { + where['AND'] = [clause]; + return; + } + + final existingMap = _coerceWhereMap(existing); + if (existingMap != null) { + where['AND'] = [existingMap, clause]; + return; + } + + final existingList = _coerceWhereList(existing); + if (existingList != null) { + where['AND'] = [...existingList, clause]; + return; + } + + where['AND'] = [clause]; + } + + Set _relationWhereOperatorsFor({ + required RelationCardinality cardinality, + }) { + return switch (cardinality) { + RelationCardinality.many => _toManyRelationWhereOperators, + RelationCardinality.one => _toOneRelationWhereOperators, + }; + } +} From 144ae25e476b1990705f23723d624985a4733279 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:25:27 +0800 Subject: [PATCH 118/154] refactor(repository): add prepared read execution pipeline --- pub/orm/lib/src/client/client.dart | 340 ++++++----------- pub/orm/lib/src/client/read_repository.dart | 388 ++++++++++++++++++++ pub/orm/lib/src/generator/writer.dart | 128 ++----- pub/orm/test/generator/generate_test.dart | 20 +- 4 files changed, 529 insertions(+), 347 deletions(-) create mode 100644 pub/orm/lib/src/client/read_repository.dart diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 4be7f2f5..7653487f 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -12,6 +12,7 @@ import '../runtime/types.dart'; part 'include_planner.dart'; part 'mutation_repository.dart'; part 'read_plan_compiler.dart'; +part 'read_repository.dart'; typedef CollectionFactory = ModelDelegate Function({ @@ -1071,6 +1072,9 @@ class ModelDelegate { late final _OrmReadPlanCompiler _readPlanCompiler = _OrmReadPlanCompiler( this, ); + late final _RepositoryReadExecutor _readRepository = _RepositoryReadExecutor( + this, + ); ModelQuery query() => ModelQuery._(this, const ModelQueryState()); @@ -1122,7 +1126,7 @@ class ModelDelegate { IncludeSpec spec = const IncludeSpec(), }) => query().includeRelation(relation, spec: spec); - Future toPlan({ + Future prepareRead({ JsonMap where = const {}, int? skip, int? take, @@ -1132,9 +1136,80 @@ class ModelDelegate { Map include = const {}, JsonMap? cursor, OrmReadPagePlan? page, + }) { + return _prepareReadQuery( + resultMode: OrmReadResultMode.all, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ); + } + + Future _prepareReadQuery({ + required OrmReadResultMode resultMode, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, }) async { final prepared = await _buildReadPlan( - resultMode: OrmReadResultMode.all, + resultMode: resultMode, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + annotations: annotations, + repositoryTrace: repositoryTrace, + ); + return OrmPreparedReadQuery._( + delegate: this, + plan: prepared.plan, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + normalizedInclude: prepared.include, + cursor: cursor, + page: page, + annotations: annotations, + repositoryTrace: repositoryTrace, + resultMode: resultMode, + ); + } + + Future toPlan({ + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + }) async { + final prepared = await prepareRead( where: where, skip: skip, take: take, @@ -1158,9 +1233,8 @@ class ModelDelegate { Map include = const {}, JsonMap? cursor, OrmReadPagePlan? page, - }) { - return _readAllInternal( - action: OrmAction.read, + }) async { + final prepared = await prepareRead( where: where, skip: skip, take: take, @@ -1170,8 +1244,8 @@ class ModelDelegate { include: include, cursor: cursor, page: page, - includeDepth: 0, ); + return prepared.all(); } Future> pageResult({ @@ -1180,16 +1254,15 @@ class ModelDelegate { List select = const [], Map include = const {}, required OrmReadPagePlan page, - }) { - return _readPageResultInternal( - action: OrmAction.read, + }) async { + final prepared = await prepareRead( where: where, orderBy: orderBy, select: select, include: include, page: page, - includeDepth: 0, ); + return prepared.pageResult(); } Stream stream({ @@ -1203,8 +1276,7 @@ class ModelDelegate { JsonMap? cursor, OrmReadPagePlan? page, }) async* { - final prepared = await _buildReadPlan( - resultMode: OrmReadResultMode.all, + final prepared = await prepareRead( where: where, skip: skip, take: take, @@ -1215,55 +1287,20 @@ class ModelDelegate { cursor: cursor, page: page, ); - final normalizedInclude = prepared.include; - final response = await _client.execute(prepared.plan); - - if (normalizedInclude.isEmpty && distinct.isEmpty) { - await for (final row in _streamRows(response, action: 'stream')) { - yield _shapeRow(row, select: select, include: normalizedInclude); - } - return; - } - - final rows = await _collectCollectionRows( - response, - action: 'stream', - distinct: distinct, - skip: skip, - take: take, - ); - if (rows.isEmpty) { - return; - } - - final hydratedRows = await _resolveIncludeRows( - action: OrmAction.read, - rows: rows, - include: normalizedInclude, - depth: 0, - ); - - for (final row in _shapeRows( - hydratedRows, - select: select, - include: normalizedInclude, - )) { - yield row; - } + yield* prepared.stream(); } Future oneOrNull({ JsonMap where = const {}, List select = const [], Map include = const {}, - }) { - return _readOneInternal( - action: OrmAction.read, + }) async { + final prepared = await prepareRead( where: where, select: select, include: include, - includeDepth: 0, ); + return prepared.oneOrNull(); } Future firstOrNull({ @@ -1274,16 +1311,15 @@ class ModelDelegate { List select = const [], Map include = const {}, }) async { - return _readFirstInternal( - action: OrmAction.read, + final prepared = await prepareRead( where: where, skip: skip, orderBy: orderBy, distinct: distinct, select: select, include: include, - includeDepth: 0, ); + return prepared.firstOrNull(); } Future count({ @@ -1329,8 +1365,7 @@ class ModelDelegate { JsonMap? cursor, OrmReadPagePlan? page, }) async { - final prepared = await _buildReadPlan( - resultMode: OrmReadResultMode.all, + final prepared = await prepareRead( where: where, skip: skip, take: take, @@ -1341,18 +1376,7 @@ class ModelDelegate { cursor: cursor, page: page, ); - return Map.unmodifiable({ - ...prepared.plan.toJson(), - 'terminalExecution': _terminalExecutionSummary( - contract: _client.contract, - includeStrategySelector: _client.includeStrategySelector, - modelName: modelName, - distinct: distinct, - include: prepared.include, - cursor: cursor, - page: page, - ), - }); + return prepared.inspectPlan(); } Future explain({ @@ -1366,8 +1390,7 @@ class ModelDelegate { JsonMap? cursor, OrmReadPagePlan? page, }) async { - final prepared = await _buildReadPlan( - resultMode: OrmReadResultMode.all, + final prepared = await prepareRead( where: where, skip: skip, take: take, @@ -1378,19 +1401,7 @@ class ModelDelegate { cursor: cursor, page: page, ); - final explained = await _runtime.explainPlan(prepared.plan); - return Map.unmodifiable({ - ...explained, - 'terminalExecution': _terminalExecutionSummary( - contract: _client.contract, - includeStrategySelector: _client.includeStrategySelector, - modelName: modelName, - distinct: distinct, - include: prepared.include, - cursor: cursor, - page: page, - ), - }); + return prepared.explain(); } Future aggregate({ @@ -1713,7 +1724,7 @@ class ModelDelegate { OrmRepositoryTrace? repositoryTrace, required int includeDepth, }) async { - final prepared = await _buildReadPlan( + final prepared = await _prepareReadQuery( resultMode: OrmReadResultMode.all, where: where, skip: skip, @@ -1727,24 +1738,11 @@ class ModelDelegate { annotations: annotations, repositoryTrace: repositoryTrace, ); - final normalizedInclude = prepared.include; - final response = await _client.execute(prepared.plan); - - final rows = await _collectCollectionRows( - response, - action: 'all', - distinct: distinct, - skip: skip, - take: take, - ); - final hydratedRows = await _resolveIncludeRows( + return _readRepository.all( + prepared: prepared, action: action, - rows: rows, - include: normalizedInclude, - depth: includeDepth, + includeDepth: includeDepth, ); - - return _shapeRows(hydratedRows, select: select, include: normalizedInclude); } Future> _collectCollectionRows( @@ -1762,123 +1760,6 @@ class ModelDelegate { return _sliceRows(rows: rows, skip: skip, take: take); } - Future> _readPageResultInternal({ - required OrmAction action, - JsonMap where = const {}, - List orderBy = const [], - List select = const [], - Map include = const {}, - required OrmReadPagePlan page, - required int includeDepth, - }) async { - final operation = _RepositoryOperation.start(kind: '$modelName.pageResult'); - final pageSelect = _expandSelectForPageExecution( - select: select, - orderBy: orderBy, - ); - final prepared = await _buildReadPlan( - resultMode: OrmReadResultMode.all, - where: where, - orderBy: orderBy, - select: pageSelect, - include: include, - page: OrmReadPagePlan( - size: page.size + 1, - after: page.after, - before: page.before, - ), - repositoryTrace: operation.nextTrace( - phase: 'page.items', - strategy: 'windowPlusOne', - ), - ); - final response = await _client.execute(prepared.plan); - final rawRows = await _collectRows(response, action: 'pageResult'); - final overflowed = rawRows.length > page.size; - final windowRows = _trimPageResultRows(rows: rawRows, page: page); - final hydratedRows = await _resolveIncludeRows( - action: action, - rows: windowRows, - include: prepared.include, - depth: includeDepth, - operation: operation, - ); - final pageInfo = await _buildPageInfo( - where: where, - orderBy: orderBy, - page: page, - rows: windowRows, - overflowed: overflowed, - operation: operation, - ); - - return OrmPageResult( - items: _shapeRows( - hydratedRows, - select: select, - include: prepared.include, - ), - pageInfo: pageInfo, - ); - } - - Future _readFirstInternal({ - required OrmAction action, - JsonMap where = const {}, - int? skip, - List orderBy = const [], - List distinct = const [], - List select = const [], - Map include = const {}, - JsonMap annotations = const {}, - OrmRepositoryTrace? repositoryTrace, - required int includeDepth, - }) async { - final prepared = await _buildReadPlan( - resultMode: distinct.isEmpty - ? OrmReadResultMode.firstOrNull - : OrmReadResultMode.all, - where: where, - skip: skip, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - annotations: annotations, - repositoryTrace: repositoryTrace, - ); - final normalizedInclude = prepared.include; - final response = await _client.execute(prepared.plan); - - final row = distinct.isEmpty - ? await _collectSingleRow(response, action: 'firstOrNull') - : _firstOrNull( - await _collectCollectionRows( - response, - action: 'firstOrNull', - distinct: distinct, - skip: skip, - take: 1, - ), - ); - if (row == null) { - return null; - } - - final hydratedRows = await _resolveIncludeRows( - action: action, - rows: [row], - include: normalizedInclude, - depth: includeDepth, - ); - - return _shapeRows( - hydratedRows, - select: select, - include: normalizedInclude, - ).single; - } - Future _readOneInternal({ required OrmAction action, JsonMap where = const {}, @@ -1888,34 +1769,19 @@ class ModelDelegate { OrmRepositoryTrace? repositoryTrace, required int includeDepth, }) async { - final prepared = await _buildReadPlan( - resultMode: OrmReadResultMode.oneOrNull, + final prepared = await _prepareReadQuery( + resultMode: OrmReadResultMode.all, where: where, select: select, include: include, annotations: annotations, repositoryTrace: repositoryTrace, ); - final normalizedInclude = prepared.include; - final response = await _client.execute(prepared.plan); - - final row = await _collectSingleRow(response, action: 'oneOrNull'); - if (row == null) { - return null; - } - - final hydratedRows = await _resolveIncludeRows( + return _readRepository.oneOrNull( + prepared: prepared, action: action, - rows: [row], - include: normalizedInclude, - depth: includeDepth, + includeDepth: includeDepth, ); - - return _shapeRows( - hydratedRows, - select: select, - include: normalizedInclude, - ).single; } Future> _resolveIncludeRows({ diff --git a/pub/orm/lib/src/client/read_repository.dart b/pub/orm/lib/src/client/read_repository.dart new file mode 100644 index 00000000..ba2c57ed --- /dev/null +++ b/pub/orm/lib/src/client/read_repository.dart @@ -0,0 +1,388 @@ +part of 'client.dart'; + +@immutable +final class OrmPreparedReadQuery { + final ModelDelegate _delegate; + final OrmPlan plan; + final JsonMap _where; + final int? _skip; + final int? _take; + final List _orderBy; + final List _distinct; + final List _select; + final Map _include; + final Map _normalizedInclude; + final JsonMap? _cursor; + final OrmReadPagePlan? _page; + final JsonMap _annotations; + final OrmRepositoryTrace? _repositoryTrace; + final OrmReadResultMode _resultMode; + + OrmPreparedReadQuery._({ + required ModelDelegate delegate, + required this.plan, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + Map normalizedInclude = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, + required OrmReadResultMode resultMode, + }) : _delegate = delegate, + _where = Map.unmodifiable( + Map.from(where), + ), + _skip = skip, + _take = take, + _orderBy = List.unmodifiable(orderBy), + _distinct = List.unmodifiable(distinct), + _select = List.unmodifiable(select), + _include = Map.unmodifiable( + Map.from(include), + ), + _normalizedInclude = Map.unmodifiable( + Map.from(normalizedInclude), + ), + _cursor = cursor == null + ? null + : Map.unmodifiable( + Map.from(cursor), + ), + _page = page, + _annotations = Map.unmodifiable( + Map.from(annotations), + ), + _repositoryTrace = repositoryTrace, + _resultMode = resultMode; + + Future inspectPlan() async { + return Map.unmodifiable({ + ...plan.toJson(), + 'terminalExecution': _terminalExecutionSummary( + contract: _delegate._client.contract, + includeStrategySelector: _delegate._client.includeStrategySelector, + modelName: _delegate.modelName, + distinct: _distinct, + include: _normalizedInclude, + cursor: _cursor, + page: _page, + ), + }); + } + + Future explain() async { + final explained = await _delegate._runtime.explainPlan(plan); + return Map.unmodifiable({ + ...explained, + 'terminalExecution': _terminalExecutionSummary( + contract: _delegate._client.contract, + includeStrategySelector: _delegate._client.includeStrategySelector, + modelName: _delegate.modelName, + distinct: _distinct, + include: _normalizedInclude, + cursor: _cursor, + page: _page, + ), + }); + } + + Future> all({int includeDepth = 0}) { + return _delegate._readRepository.all( + prepared: this, + action: OrmAction.read, + includeDepth: includeDepth, + ); + } + + Future> pageResult({int includeDepth = 0}) { + return _delegate._readRepository.pageResult( + prepared: this, + action: OrmAction.read, + includeDepth: includeDepth, + ); + } + + Future oneOrNull({int includeDepth = 0}) { + return _delegate._readRepository.oneOrNull( + prepared: this, + action: OrmAction.read, + includeDepth: includeDepth, + ); + } + + Future firstOrNull({int includeDepth = 0}) { + return _delegate._readRepository.firstOrNull( + prepared: this, + action: OrmAction.read, + includeDepth: includeDepth, + ); + } + + Stream stream({int includeDepth = 0}) { + return _delegate._readRepository.stream( + prepared: this, + action: OrmAction.read, + includeDepth: includeDepth, + ); + } +} + +final class _RepositoryReadExecutor { + final ModelDelegate _delegate; + + _RepositoryReadExecutor(this._delegate); + + Future> all({ + required OrmPreparedReadQuery prepared, + required OrmAction action, + required int includeDepth, + }) async { + final response = await _delegate._client.execute(prepared.plan); + final rows = await _delegate._collectCollectionRows( + response, + action: 'all', + distinct: prepared._distinct, + skip: prepared._skip, + take: prepared._take, + ); + final hydratedRows = await _delegate._resolveIncludeRows( + action: action, + rows: rows, + include: prepared._normalizedInclude, + depth: includeDepth, + ); + + return _delegate._shapeRows( + hydratedRows, + select: prepared._select, + include: prepared._normalizedInclude, + ); + } + + Future> pageResult({ + required OrmPreparedReadQuery prepared, + required OrmAction action, + required int includeDepth, + }) async { + final page = prepared._page; + if (page == null) { + throw runtimeError( + 'PLAN.PAGE_RESULT_REQUIRES_PAGE_WINDOW', + 'pageResult() requires page() first.', + details: {'model': _delegate.modelName}, + ); + } + + final operation = _RepositoryOperation.start( + kind: '${_delegate.modelName}.pageResult', + ); + final pageSelect = _delegate._expandSelectForPageExecution( + select: prepared._select, + orderBy: prepared._orderBy, + ); + final itemsPrepared = await _delegate._prepareReadQuery( + resultMode: OrmReadResultMode.all, + where: prepared._where, + orderBy: prepared._orderBy, + select: pageSelect, + include: prepared._include, + page: OrmReadPagePlan( + size: page.size + 1, + after: page.after, + before: page.before, + ), + annotations: prepared._annotations, + repositoryTrace: operation.nextTrace( + phase: 'page.items', + strategy: 'windowPlusOne', + ), + ); + final response = await _delegate._client.execute(itemsPrepared.plan); + final rawRows = await _collectRows(response, action: 'pageResult'); + final overflowed = rawRows.length > page.size; + final windowRows = _delegate._trimPageResultRows(rows: rawRows, page: page); + final hydratedRows = await _delegate._resolveIncludeRows( + action: action, + rows: windowRows, + include: itemsPrepared._normalizedInclude, + depth: includeDepth, + operation: operation, + ); + final pageInfo = await _delegate._buildPageInfo( + where: prepared._where, + orderBy: prepared._orderBy, + page: page, + rows: windowRows, + overflowed: overflowed, + operation: operation, + ); + + return OrmPageResult( + items: _delegate._shapeRows( + hydratedRows, + select: prepared._select, + include: itemsPrepared._normalizedInclude, + ), + pageInfo: pageInfo, + ); + } + + Future firstOrNull({ + required OrmPreparedReadQuery prepared, + required OrmAction action, + required int includeDepth, + }) async { + if (prepared._cursor != null || prepared._page != null) { + final rows = await all( + prepared: prepared, + action: action, + includeDepth: includeDepth, + ); + return rows.isEmpty ? null : rows.first; + } + + final effectivePrepared = prepared._distinct.isEmpty + ? await _delegate._prepareReadQuery( + resultMode: OrmReadResultMode.firstOrNull, + where: prepared._where, + skip: prepared._skip, + orderBy: prepared._orderBy, + distinct: prepared._distinct, + select: prepared._select, + include: prepared._include, + annotations: prepared._annotations, + repositoryTrace: prepared._repositoryTrace, + ) + : prepared; + + final response = await _delegate._client.execute(effectivePrepared.plan); + final row = prepared._distinct.isEmpty + ? await _collectSingleRow(response, action: 'firstOrNull') + : _firstOrNull( + await _delegate._collectCollectionRows( + response, + action: 'firstOrNull', + distinct: prepared._distinct, + skip: prepared._skip, + take: 1, + ), + ); + if (row == null) { + return null; + } + + final hydratedRows = await _delegate._resolveIncludeRows( + action: action, + rows: [row], + include: effectivePrepared._normalizedInclude, + depth: includeDepth, + ); + + return _delegate + ._shapeRows( + hydratedRows, + select: prepared._select, + include: effectivePrepared._normalizedInclude, + ) + .single; + } + + Future oneOrNull({ + required OrmPreparedReadQuery prepared, + required OrmAction action, + required int includeDepth, + }) async { + if (prepared._cursor != null || prepared._page != null) { + final rows = await all( + prepared: prepared, + action: action, + includeDepth: includeDepth, + ); + return rows.isEmpty ? null : rows.first; + } + + final effectivePrepared = + prepared._resultMode == OrmReadResultMode.oneOrNull + ? prepared + : await _delegate._prepareReadQuery( + resultMode: OrmReadResultMode.oneOrNull, + where: prepared._where, + select: prepared._select, + include: prepared._include, + annotations: prepared._annotations, + repositoryTrace: prepared._repositoryTrace, + ); + final response = await _delegate._client.execute(effectivePrepared.plan); + + final row = await _collectSingleRow(response, action: 'oneOrNull'); + if (row == null) { + return null; + } + + final hydratedRows = await _delegate._resolveIncludeRows( + action: action, + rows: [row], + include: effectivePrepared._normalizedInclude, + depth: includeDepth, + ); + + return _delegate + ._shapeRows( + hydratedRows, + select: prepared._select, + include: effectivePrepared._normalizedInclude, + ) + .single; + } + + Stream stream({ + required OrmPreparedReadQuery prepared, + required OrmAction action, + required int includeDepth, + }) async* { + final response = await _delegate._client.execute(prepared.plan); + + if (prepared._normalizedInclude.isEmpty && prepared._distinct.isEmpty) { + await for (final row in _streamRows(response, action: 'stream')) { + yield _delegate._shapeRow( + row, + select: prepared._select, + include: prepared._normalizedInclude, + ); + } + return; + } + + final rows = await _delegate._collectCollectionRows( + response, + action: 'stream', + distinct: prepared._distinct, + skip: prepared._skip, + take: prepared._take, + ); + if (rows.isEmpty) { + return; + } + + final hydratedRows = await _delegate._resolveIncludeRows( + action: action, + rows: rows, + include: prepared._normalizedInclude, + depth: includeDepth, + ); + + for (final row in _delegate._shapeRows( + hydratedRows, + select: prepared._select, + include: prepared._normalizedInclude, + )) { + yield row; + } + } +} diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index c70e793c..71e72299 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2820,6 +2820,20 @@ final class TypedClientWriter { buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future _prepareRead() {'); + buffer.writeln(' return _delegate._delegate.prepareRead('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: _runtimeOrderBy,'); + buffer.writeln(' distinct: _runtimeDistinct,'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' cursor: _runtimeCursor,'); + buffer.writeln(' page: _runtimePage,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' void _assertReadExecutionSupported(String terminal) {'); buffer.writeln( @@ -2871,49 +2885,19 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future toPlan() {'); - buffer.writeln(' return _delegate._delegate.toPlan('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _runtimeOrderBy,'); - buffer.writeln(' distinct: _runtimeDistinct,'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); - buffer.writeln(' cursor: _runtimeCursor,'); - buffer.writeln(' page: _runtimePage,'); - buffer.writeln(' );'); + buffer.writeln(' Future toPlan() async {'); + buffer.writeln(' return (await _prepareRead()).plan;'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future inspectPlan() {'); - buffer.writeln(' return _delegate._delegate.inspectPlan('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _runtimeOrderBy,'); - buffer.writeln(' distinct: _runtimeDistinct,'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); - buffer.writeln(' cursor: _runtimeCursor,'); - buffer.writeln(' page: _runtimePage,'); - buffer.writeln(' );'); + buffer.writeln(' Future inspectPlan() async {'); + buffer.writeln(' return (await _prepareRead()).inspectPlan();'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future> all() async {'); buffer.writeln(" _assertReadExecutionSupported('all');"); - buffer.writeln(' final rows = await _delegate._delegate.all('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _runtimeOrderBy,'); - buffer.writeln(' distinct: _runtimeDistinct,'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); - buffer.writeln(' cursor: _runtimeCursor,'); - buffer.writeln(' page: _runtimePage,'); - buffer.writeln(' );'); + buffer.writeln(' final rows = await (await _prepareRead()).all();'); buffer.writeln( ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', ); @@ -2924,23 +2908,9 @@ final class TypedClientWriter { ' Future> pageResult() async {', ); buffer.writeln(" _assertReadExecutionSupported('pageResult');"); - buffer.writeln(' final page = _runtimePage;'); - buffer.writeln(' if (page == null) {'); - buffer.writeln(' throw runtimeError('); - buffer.writeln(" 'PLAN.PAGE_RESULT_REQUIRES_PAGE_WINDOW',"); - buffer.writeln(" 'pageResult() requires page() first.',"); buffer.writeln( - " details: {'model': '$runtimeName'},", + ' final result = await (await _prepareRead()).pageResult();', ); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(' final result = await _delegate._delegate.pageResult('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' orderBy: _runtimeOrderBy,'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); - buffer.writeln(' page: page,'); - buffer.writeln(' );'); buffer.writeln( ' return result.mapItems(${model.dataClassName}.fromJson);', ); @@ -2949,18 +2919,7 @@ final class TypedClientWriter { buffer.writeln(' Future<${model.dataClassName}?> oneOrNull() async {'); buffer.writeln(" _assertReadExecutionSupported('oneOrNull');"); - buffer.writeln(' if (_runtimeCursor != null || _runtimePage != null) {'); - buffer.writeln(' final rows = await all();'); - buffer.writeln(' if (rows.isEmpty) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return rows.first;'); - buffer.writeln(' }'); - buffer.writeln(' final row = await _delegate._delegate.oneOrNull('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); - buffer.writeln(' );'); + buffer.writeln(' final row = await (await _prepareRead()).oneOrNull();'); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); buffer.writeln(' }'); @@ -2970,21 +2929,9 @@ final class TypedClientWriter { buffer.writeln(' Future<${model.dataClassName}?> firstOrNull() async {'); buffer.writeln(" _assertReadExecutionSupported('firstOrNull');"); - buffer.writeln(' if (_runtimeCursor != null || _runtimePage != null) {'); - buffer.writeln(' final rows = await all();'); - buffer.writeln(' if (rows.isEmpty) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return rows.first;'); - buffer.writeln(' }'); - buffer.writeln(' final row = await _delegate._delegate.firstOrNull('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' orderBy: _runtimeOrderBy,'); - buffer.writeln(' distinct: _runtimeDistinct,'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); - buffer.writeln(' );'); + buffer.writeln( + ' final row = await (await _prepareRead()).firstOrNull();', + ); buffer.writeln(' if (row == null) {'); buffer.writeln(' return null;'); buffer.writeln(' }'); @@ -2994,34 +2941,15 @@ final class TypedClientWriter { buffer.writeln(' Stream<${model.dataClassName}> stream() async* {'); buffer.writeln(" _assertReadExecutionSupported('stream');"); - buffer.writeln(' await for (final row in _delegate._delegate.stream('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _runtimeOrderBy,'); - buffer.writeln(' distinct: _runtimeDistinct,'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); - buffer.writeln(' cursor: _runtimeCursor,'); - buffer.writeln(' page: _runtimePage,'); - buffer.writeln(' )) {'); + buffer.writeln(' final prepared = await _prepareRead();'); + buffer.writeln(' await for (final row in prepared.stream()) {'); buffer.writeln(' yield ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future explain() {'); - buffer.writeln(' return _delegate._delegate.explain('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); - buffer.writeln(' orderBy: _runtimeOrderBy,'); - buffer.writeln(' distinct: _runtimeDistinct,'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); - buffer.writeln(' cursor: _runtimeCursor,'); - buffer.writeln(' page: _runtimePage,'); - buffer.writeln(' );'); + buffer.writeln(' Future explain() async {'); + buffer.writeln(' return (await _prepareRead()).explain();'); buffer.writeln(' }'); buffer.writeln(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 9ce24637..63062b97 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -661,11 +661,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?List\s+get\s+_runtimeOrderBy[\s\S]*?Future>\s+all\(\)\s+async\s*\{[\s\S]*?_delegate\._delegate\.all\(', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+_prepareRead\(\)[\s\S]*?Future>\s+all\(\)\s+async\s*\{[\s\S]*?\(await\s+_prepareRead\(\)\)\.all\(\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery read execution to compile typed state straight to delegate execution.', + 'Expected UserQuery read execution to compile typed state through prepared read objects.', ); expect( generatedSource.contains('ModelQuery _runtimeQuery('), @@ -724,19 +724,19 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+toPlan\(\s*\)[\s\S]*?_delegate\._delegate\.toPlan\(', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+toPlan\(\s*\)\s+async[\s\S]*?\(await\s+_prepareRead\(\)\)\.plan', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.toPlan() to compile typed state directly through delegate planning.', + 'Expected UserQuery.toPlan() to compile typed state through prepared read planning.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+inspectPlan\(\s*\)[\s\S]*?_delegate\._delegate\.inspectPlan\(', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+inspectPlan\(\s*\)\s+async[\s\S]*?\(await\s+_prepareRead\(\)\)\.inspectPlan\(\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.inspectPlan() to compile typed state directly through delegate inspection.', + 'Expected UserQuery.inspectPlan() to compile typed state through prepared read inspection.', ); expect( RegExp( @@ -747,11 +747,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+explain\(\s*\)[\s\S]*?_delegate\._delegate\.explain\(', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+explain\(\s*\)\s+async[\s\S]*?\(await\s+_prepareRead\(\)\)\.explain\(\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.explain() to compile typed state directly through delegate explain.', + 'Expected UserQuery.explain() to compile typed state through prepared read explain.', ); expect( RegExp( @@ -762,11 +762,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+pageResult\(\s*\)\s+async[\s\S]*?_delegate\._delegate\.pageResult\(', + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+pageResult\(\s*\)\s+async[\s\S]*?\(await\s+_prepareRead\(\)\)\.pageResult\(\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.pageResult() to expose structured page envelope mapping without runtime query bridge.', + 'Expected UserQuery.pageResult() to expose structured page envelope mapping through prepared reads.', ); expect( RegExp( From 81a83f0ea11d3a17c88749253ddf6e7973a656c2 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:27:56 +0800 Subject: [PATCH 119/154] refactor(client): route model queries through prepared reads --- pub/orm/lib/src/client/client.dart | 112 +++++------------------------ 1 file changed, 19 insertions(+), 93 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 7653487f..9600ec53 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3391,8 +3391,8 @@ final class ModelQuery { ); } - Future toPlan() { - return _delegate.toPlan( + Future _prepareRead() { + return _delegate.prepareRead( where: _state.where, skip: _state.skip, take: _state.take, @@ -3405,102 +3405,38 @@ final class ModelQuery { ); } - Future inspectPlan() { - return _delegate.inspectPlan( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ); + Future toPlan() async { + return (await _prepareRead()).plan; } - Future> all() { + Future inspectPlan() async { + return (await _prepareRead()).inspectPlan(); + } + + Future> all() async { _assertReadExecutionSupported('all'); - return _delegate.all( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ); + return (await _prepareRead()).all(); } - Future> pageResult() { + Future> pageResult() async { _assertReadExecutionSupported('pageResult'); - final page = _state.page; - if (page == null) { - throw runtimeError( - 'PLAN.PAGE_RESULT_REQUIRES_PAGE_WINDOW', - 'pageResult() requires page() first.', - details: {'model': _delegate.modelName}, - ); - } - return _delegate.pageResult( - where: _state.where, - orderBy: _state.orderBy, - select: _state.select, - include: _state.include, - page: page, - ); + return (await _prepareRead()).pageResult(); } - Stream stream() { + Stream stream() async* { _assertReadExecutionSupported('stream'); - return _delegate.stream( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ); + final prepared = await _prepareRead(); + yield* prepared.stream(); } Future oneOrNull() async { _assertReadExecutionSupported('oneOrNull'); - if (_state.cursor != null || _state.page != null) { - final rows = await all(); - if (rows.isEmpty) { - return null; - } - return rows.first; - } - return _delegate.oneOrNull( - where: _state.where, - select: _state.select, - include: _state.include, - ); + return (await _prepareRead()).oneOrNull(); } Future firstOrNull() async { _assertReadExecutionSupported('firstOrNull'); - if (_state.cursor != null || _state.page != null) { - final rows = await all(); - if (rows.isEmpty) { - return null; - } - return rows.first; - } - return _delegate.firstOrNull( - where: _state.where, - skip: _state.skip, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - ); + return (await _prepareRead()).firstOrNull(); } Future count() { @@ -3523,18 +3459,8 @@ final class ModelQuery { ); } - Future explain() { - return _delegate.explain( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ); + Future explain() async { + return (await _prepareRead()).explain(); } Future aggregate({ From 0456ed2976ac6b97da98adebbedbac4c3829e64b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:31:53 +0800 Subject: [PATCH 120/154] refactor(client): collapse prepared read compiler artifacts --- pub/orm/lib/src/client/client.dart | 75 +++---------- .../lib/src/client/read_plan_compiler.dart | 106 ++++++++---------- pub/orm/lib/src/client/read_repository.dart | 66 ++++++++--- 3 files changed, 112 insertions(+), 135 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 9600ec53..f030e0c3 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1165,36 +1165,21 @@ class ModelDelegate { JsonMap annotations = const {}, OrmRepositoryTrace? repositoryTrace, }) async { - final prepared = await _buildReadPlan( - resultMode: resultMode, - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - annotations: annotations, - repositoryTrace: repositoryTrace, - ); - return OrmPreparedReadQuery._( - delegate: this, - plan: prepared.plan, - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - normalizedInclude: prepared.include, - cursor: cursor, - page: page, - annotations: annotations, - repositoryTrace: repositoryTrace, - resultMode: resultMode, + return _readPlanCompiler.compile( + state: _OrmPreparedReadState( + resultMode: resultMode, + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + annotations: annotations, + repositoryTrace: repositoryTrace, + ), ); } @@ -1679,36 +1664,6 @@ class ModelDelegate { this, ).delete(where: where, select: select, include: include); - Future<_PreparedReadPlan> _buildReadPlan({ - required OrmReadResultMode resultMode, - JsonMap where = const {}, - int? skip, - int? take, - List orderBy = const [], - List distinct = const [], - List select = const [], - Map include = const {}, - JsonMap? cursor, - OrmReadPagePlan? page, - JsonMap annotations = const {}, - OrmRepositoryTrace? repositoryTrace, - }) { - return _readPlanCompiler.compile( - resultMode: resultMode, - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - annotations: annotations, - repositoryTrace: repositoryTrace, - ); - } - Future> _readAllInternal({ required OrmAction action, JsonMap where = const {}, diff --git a/pub/orm/lib/src/client/read_plan_compiler.dart b/pub/orm/lib/src/client/read_plan_compiler.dart index b7cd1aea..a439b70c 100644 --- a/pub/orm/lib/src/client/read_plan_compiler.dart +++ b/pub/orm/lib/src/client/read_plan_compiler.dart @@ -1,105 +1,90 @@ part of 'client.dart'; -@immutable -final class _PreparedReadPlan { - final OrmPlan plan; - final Map include; - - const _PreparedReadPlan({required this.plan, required this.include}); -} - final class _OrmReadPlanCompiler { final ModelDelegate _delegate; _OrmReadPlanCompiler(this._delegate); - Future<_PreparedReadPlan> compile({ - required OrmReadResultMode resultMode, - JsonMap where = const {}, - int? skip, - int? take, - List orderBy = const [], - List distinct = const [], - List select = const [], - Map include = const {}, - JsonMap? cursor, - OrmReadPagePlan? page, - JsonMap annotations = const {}, - OrmRepositoryTrace? repositoryTrace, + Future compile({ + required _OrmPreparedReadState state, }) async { - if (skip case final offset? when offset < 0) { + if (state._skip case final offset? when offset < 0) { throw PlanInvalidPaginationException(key: 'skip', value: offset); } - if (take case final limit? when limit < 0) { + if (state._take case final limit? when limit < 0) { throw PlanInvalidPaginationException(key: 'take', value: limit); } - final normalizedInclude = normalizeInclude(include); + final normalizedInclude = normalizeInclude(state._include); final normalizedWhere = await normalizeWhereForExecution( model: _delegate.modelName, - where: where, + where: state._where, ); - if ((cursor != null || page != null) && orderBy.isEmpty) { + if ((state._cursor != null || state._page != null) && + state._orderBy.isEmpty) { throw runtimeError( 'PLAN.CURSOR_ORDER_BY_REQUIRED', 'Cursor and page windows require orderBy() first.', details: { 'model': _delegate.modelName, - if (cursor != null) 'cursor': cursor, - if (page != null) 'page': page.toJson(), + if (state._cursor != null) 'cursor': state._cursor, + if (state._page != null) 'page': state._page!.toJson(), }, ); } - if (cursor != null || page != null) { - validateStableCursorOrderBy(orderBy: orderBy); + if (state._cursor != null || state._page != null) { + validateStableCursorOrderBy(orderBy: state._orderBy); } - if ((cursor != null || page != null) && distinct.isNotEmpty) { + if ((state._cursor != null || state._page != null) && + state._distinct.isNotEmpty) { throw runtimeError( 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', 'Cursor and page windows do not support distinct yet.', details: { 'model': _delegate.modelName, - 'distinct': distinct, - if (cursor != null) 'cursor': cursor, - if (page != null) 'page': page.toJson(), + 'distinct': state._distinct, + if (state._cursor != null) 'cursor': state._cursor, + if (state._page != null) 'page': state._page!.toJson(), }, ); } - if (page != null && resultMode != OrmReadResultMode.all) { + if (state._page != null && state.resultMode != OrmReadResultMode.all) { throw runtimeError( 'PLAN.PAGE_RESULT_MODE_INVALID', 'Page windows currently compile only to collection read plans.', details: { 'model': _delegate.modelName, - 'resultMode': resultMode.name, - 'page': page.toJson(), + 'resultMode': state.resultMode.name, + 'page': state._page!.toJson(), }, ); } - final isCollectionRead = resultMode != OrmReadResultMode.oneOrNull; - final resolvedTake = page != null + final isCollectionRead = state.resultMode != OrmReadResultMode.oneOrNull; + final resolvedTake = state._page != null ? null - : resultMode == OrmReadResultMode.firstOrNull + : state.resultMode == OrmReadResultMode.firstOrNull ? 1 - : take; - final readSelect = switch (resultMode) { + : state._take; + final readSelect = switch (state.resultMode) { OrmReadResultMode.oneOrNull => expandSelectForInclude( model: _delegate.modelName, - select: select, + select: state._select, include: normalizedInclude, ), OrmReadResultMode.all || OrmReadResultMode.firstOrNull => expandSelectForExecution( model: _delegate.modelName, - select: select, + select: state._select, include: normalizedInclude, - distinct: distinct, + distinct: state._distinct, ), }; - return _PreparedReadPlan( - include: normalizedInclude, + return OrmPreparedReadQuery._( + delegate: _delegate, + state: state, + normalizedInclude: normalizedInclude, plan: OrmPlan.read( contractHash: _delegate._client.contract.hash, target: _delegate._client.contract.target, @@ -107,25 +92,30 @@ final class _OrmReadPlanCompiler { profileHash: _delegate._client.contract.profileHash, lane: 'orm', annotations: _mergePlanAnnotations( - annotations, - distinct.isEmpty + state._annotations, + state._distinct.isEmpty ? const {} : { - 'distinct': List.from(distinct, growable: false), + 'distinct': List.from( + state._distinct, + growable: false, + ), }, ), - repositoryTrace: repositoryTrace, + repositoryTrace: state._repositoryTrace, model: _delegate.modelName, where: normalizedWhere, - skip: isCollectionRead && distinct.isEmpty ? skip : null, - take: isCollectionRead && distinct.isEmpty ? resolvedTake : null, - orderBy: isCollectionRead ? orderBy : const [], - distinct: isCollectionRead ? distinct : const [], + skip: isCollectionRead && state._distinct.isEmpty ? state._skip : null, + take: isCollectionRead && state._distinct.isEmpty ? resolvedTake : null, + orderBy: isCollectionRead ? state._orderBy : const [], + distinct: isCollectionRead ? state._distinct : const [], select: readSelect, include: _buildOrmIncludePlanMap(normalizedInclude), - cursor: cursor == null ? null : OrmReadCursorPlan(values: cursor), - page: page, - resultMode: resultMode, + cursor: state._cursor == null + ? null + : OrmReadCursorPlan(values: state._cursor!), + page: state._page, + resultMode: state.resultMode, ), ); } diff --git a/pub/orm/lib/src/client/read_repository.dart b/pub/orm/lib/src/client/read_repository.dart index ba2c57ed..3fdb8b7e 100644 --- a/pub/orm/lib/src/client/read_repository.dart +++ b/pub/orm/lib/src/client/read_repository.dart @@ -1,9 +1,8 @@ part of 'client.dart'; @immutable -final class OrmPreparedReadQuery { - final ModelDelegate _delegate; - final OrmPlan plan; +final class _OrmPreparedReadState { + final OrmReadResultMode resultMode; final JsonMap _where; final int? _skip; final int? _take; @@ -11,16 +10,13 @@ final class OrmPreparedReadQuery { final List _distinct; final List _select; final Map _include; - final Map _normalizedInclude; final JsonMap? _cursor; final OrmReadPagePlan? _page; final JsonMap _annotations; final OrmRepositoryTrace? _repositoryTrace; - final OrmReadResultMode _resultMode; - OrmPreparedReadQuery._({ - required ModelDelegate delegate, - required this.plan, + _OrmPreparedReadState({ + required this.resultMode, JsonMap where = const {}, int? skip, int? take, @@ -28,14 +24,11 @@ final class OrmPreparedReadQuery { List distinct = const [], List select = const [], Map include = const {}, - Map normalizedInclude = const {}, JsonMap? cursor, OrmReadPagePlan? page, JsonMap annotations = const {}, OrmRepositoryTrace? repositoryTrace, - required OrmReadResultMode resultMode, - }) : _delegate = delegate, - _where = Map.unmodifiable( + }) : _where = Map.unmodifiable( Map.from(where), ), _skip = skip, @@ -46,9 +39,6 @@ final class OrmPreparedReadQuery { _include = Map.unmodifiable( Map.from(include), ), - _normalizedInclude = Map.unmodifiable( - Map.from(normalizedInclude), - ), _cursor = cursor == null ? null : Map.unmodifiable( @@ -58,8 +48,50 @@ final class OrmPreparedReadQuery { _annotations = Map.unmodifiable( Map.from(annotations), ), - _repositoryTrace = repositoryTrace, - _resultMode = resultMode; + _repositoryTrace = repositoryTrace; +} + +@immutable +final class OrmPreparedReadQuery { + final ModelDelegate _delegate; + final OrmPlan plan; + final _OrmPreparedReadState _state; + final Map _normalizedInclude; + + OrmPreparedReadQuery._({ + required ModelDelegate delegate, + required this.plan, + required _OrmPreparedReadState state, + Map normalizedInclude = const {}, + }) : _delegate = delegate, + _state = state, + _normalizedInclude = Map.unmodifiable( + Map.from(normalizedInclude), + ); + + JsonMap get _where => _state._where; + + int? get _skip => _state._skip; + + int? get _take => _state._take; + + List get _orderBy => _state._orderBy; + + List get _distinct => _state._distinct; + + List get _select => _state._select; + + Map get _include => _state._include; + + JsonMap? get _cursor => _state._cursor; + + OrmReadPagePlan? get _page => _state._page; + + JsonMap get _annotations => _state._annotations; + + OrmRepositoryTrace? get _repositoryTrace => _state._repositoryTrace; + + OrmReadResultMode get _resultMode => _state.resultMode; Future inspectPlan() async { return Map.unmodifiable({ From 7c9b2572d272876905eb4c4df6704e7cd29fd93c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:43:52 +0800 Subject: [PATCH 121/154] refactor(repository): move relation where rewrite out of read compiler --- pub/orm/lib/src/client/client.dart | 13 +- .../lib/src/client/read_plan_compiler.dart | 392 +----------------- .../src/client/relation_where_rewriter.dart | 389 +++++++++++++++++ 3 files changed, 399 insertions(+), 395 deletions(-) create mode 100644 pub/orm/lib/src/client/relation_where_rewriter.dart diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index f030e0c3..11bc4eec 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -11,6 +11,7 @@ import '../runtime/types.dart'; part 'include_planner.dart'; part 'mutation_repository.dart'; +part 'relation_where_rewriter.dart'; part 'read_plan_compiler.dart'; part 'read_repository.dart'; @@ -1069,6 +1070,8 @@ class ModelDelegate { OrmCollectionContext get client => _client; _OrmDelegateRuntime get _runtime => _client as _OrmDelegateRuntime; + late final _RepositoryRelationWhereRewriter _relationWhereRewriter = + _RepositoryRelationWhereRewriter(this); late final _OrmReadPlanCompiler _readPlanCompiler = _OrmReadPlanCompiler( this, ); @@ -1165,10 +1168,13 @@ class ModelDelegate { JsonMap annotations = const {}, OrmRepositoryTrace? repositoryTrace, }) async { + final normalizedWhere = where.isEmpty + ? const {} + : await _normalizeWhereForExecution(model: modelName, where: where); return _readPlanCompiler.compile( state: _OrmPreparedReadState( resultMode: resultMode, - where: where, + where: normalizedWhere, skip: skip, take: take, orderBy: orderBy, @@ -2967,10 +2973,7 @@ class ModelDelegate { required String model, required JsonMap where, }) { - return _readPlanCompiler.normalizeWhereForExecution( - model: model, - where: where, - ); + return _relationWhereRewriter.rewrite(model: model, where: where); } Map _normalizeInclude(Map include) { diff --git a/pub/orm/lib/src/client/read_plan_compiler.dart b/pub/orm/lib/src/client/read_plan_compiler.dart index a439b70c..a27f17aa 100644 --- a/pub/orm/lib/src/client/read_plan_compiler.dart +++ b/pub/orm/lib/src/client/read_plan_compiler.dart @@ -5,9 +5,7 @@ final class _OrmReadPlanCompiler { _OrmReadPlanCompiler(this._delegate); - Future compile({ - required _OrmPreparedReadState state, - }) async { + OrmPreparedReadQuery compile({required _OrmPreparedReadState state}) { if (state._skip case final offset? when offset < 0) { throw PlanInvalidPaginationException(key: 'skip', value: offset); } @@ -16,10 +14,6 @@ final class _OrmReadPlanCompiler { } final normalizedInclude = normalizeInclude(state._include); - final normalizedWhere = await normalizeWhereForExecution( - model: _delegate.modelName, - where: state._where, - ); if ((state._cursor != null || state._page != null) && state._orderBy.isEmpty) { throw runtimeError( @@ -104,7 +98,7 @@ final class _OrmReadPlanCompiler { ), repositoryTrace: state._repositoryTrace, model: _delegate.modelName, - where: normalizedWhere, + where: state._where, skip: isCollectionRead && state._distinct.isEmpty ? state._skip : null, take: isCollectionRead && state._distinct.isEmpty ? resolvedTake : null, orderBy: isCollectionRead ? state._orderBy : const [], @@ -196,19 +190,6 @@ final class _OrmReadPlanCompiler { return include; } - Future normalizeWhereForExecution({ - required String model, - required JsonMap where, - }) async { - if (where.isEmpty) { - return const {}; - } - if (_delegate._client.contract.target == 'sql-family') { - return where; - } - return _rewriteRelationWhere(model: model, where: where); - } - Never _throwStableCursorOrderError({ required List orderBy, required List idFields, @@ -225,373 +206,4 @@ final class _OrmReadPlanCompiler { }, ); } - - Future _rewriteRelationWhere({ - required String model, - required JsonMap where, - }) async { - if (where.isEmpty) { - return const {}; - } - - final modelContract = _delegate._client.contract.models[model]; - if (modelContract == null) { - throw ModelNotFoundException( - model, - _delegate._client.contract.models.keys, - ); - } - - final normalizedWhere = {}; - final relationClauses = []; - for (final entry in where.entries) { - final key = entry.key; - if (_whereLogicalKeys.contains(key)) { - normalizedWhere[key] = await _normalizeWhereLogicalOperand( - model: model, - operand: entry.value, - ); - continue; - } - - final relation = modelContract.relations[key]; - if (relation == null) { - normalizedWhere[key] = entry.value; - continue; - } - - final relationWhere = _coerceWhereMap(entry.value); - if (relationWhere == null) { - final supportedOperators = _relationWhereOperatorsFor( - cardinality: relation.cardinality, - ); - throw runtimeError( - 'PLAN.RELATION_WHERE_INVALID', - 'Relation where expects a map of operators.', - details: { - 'model': model, - 'relation': key, - 'expectedOperators': supportedOperators.toList(growable: false), - }, - ); - } - - final clause = await _compileRelationWhereClause( - relationName: key, - relation: relation, - where: relationWhere, - ); - if (clause != null) { - relationClauses.add(clause); - } - } - - for (final clause in relationClauses) { - _appendAndWhereClause(where: normalizedWhere, clause: clause); - } - - return normalizedWhere; - } - - Future _normalizeWhereLogicalOperand({ - required String model, - required Object? operand, - }) async { - final nestedWhere = _coerceWhereMap(operand); - if (nestedWhere != null) { - return _rewriteRelationWhere(model: model, where: nestedWhere); - } - - final nestedWhereList = _coerceWhereList(operand); - if (nestedWhereList == null) { - return operand; - } - - final normalized = []; - for (final entry in nestedWhereList) { - normalized.add(await _rewriteRelationWhere(model: model, where: entry)); - } - return normalized; - } - - Future _compileRelationWhereClause({ - required String relationName, - required ModelRelationContract relation, - required JsonMap where, - }) async { - if (where.isEmpty) { - return null; - } - - final supportedOperators = _relationWhereOperatorsFor( - cardinality: relation.cardinality, - ); - final unknownOperators = where.keys - .where((key) => !supportedOperators.contains(key)) - .toList(growable: false); - if (unknownOperators.isNotEmpty) { - throw runtimeError( - 'PLAN.RELATION_WHERE_OPERATOR_INVALID', - 'Relation where contains unknown operators.', - details: { - 'model': _delegate.modelName, - 'relation': relationName, - 'unknownOperators': unknownOperators, - 'supportedOperators': supportedOperators.toList(growable: false), - }, - ); - } - - final clauses = []; - if (relation.cardinality == RelationCardinality.many) { - if (where.containsKey('some')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'some', - operand: where['some'], - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: relationWhere, - include: true, - ), - ); - } - - if (where.containsKey('none')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'none', - operand: where['none'], - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: relationWhere, - include: false, - ), - ); - } - - if (where.containsKey('every')) { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'every', - operand: where['every'], - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: {'NOT': relationWhere}, - include: false, - ), - ); - } - } else { - if (where.containsKey('is')) { - final isOperand = where['is']; - if (isOperand == null) { - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: const {}, - include: false, - ), - ); - } else { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'is', - operand: isOperand, - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: relationWhere, - include: true, - ), - ); - } - } - - if (where.containsKey('isNot')) { - final isNotOperand = where['isNot']; - if (isNotOperand == null) { - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: const {}, - include: true, - ), - ); - } else { - final relationWhere = await _normalizeRelationOperatorWhere( - relationName: relationName, - relation: relation, - operator: 'isNot', - operand: isNotOperand, - ); - clauses.add( - await _buildRelationMembershipClause( - relation: relation, - relatedWhere: relationWhere, - include: false, - ), - ); - } - } - } - - if (clauses.isEmpty) { - return null; - } - if (clauses.length == 1) { - return clauses.single; - } - return {'AND': clauses}; - } - - Future _normalizeRelationOperatorWhere({ - required String relationName, - required ModelRelationContract relation, - required String operator, - required Object? operand, - }) async { - if (operand == null) { - return const {}; - } - - final nestedWhere = _coerceWhereMap(operand); - if (nestedWhere == null) { - throw runtimeError( - 'PLAN.RELATION_WHERE_VALUE_INVALID', - 'Relation where operator expects a nested where map.', - details: { - 'model': _delegate.modelName, - 'relation': relationName, - 'operator': operator, - }, - ); - } - - return _rewriteRelationWhere( - model: relation.relatedModel, - where: nestedWhere, - ); - } - - Future _buildRelationMembershipClause({ - required ModelRelationContract relation, - required JsonMap relatedWhere, - required bool include, - }) async { - final relatedRows = await _delegate._runtime - ._resolveDelegate(relation.relatedModel) - ._readAllInternal( - action: OrmAction.read, - where: relatedWhere, - select: relation.targetFields, - includeDepth: 0, - ); - - final keys = <_RelationMergeKey>{}; - for (final row in relatedRows) { - final key = _delegate._buildRelationMergeKeyFromRow( - row: row, - fields: relation.targetFields, - ); - if (key != null) { - keys.add(key); - } - } - - return _buildRelationTupleMembershipWhere( - sourceFields: relation.sourceFields, - keys: keys, - include: include, - ); - } - - JsonMap _buildRelationTupleMembershipWhere({ - required List sourceFields, - required Set<_RelationMergeKey> keys, - required bool include, - }) { - if (keys.isEmpty) { - return include - ? const {'OR': []} - : const {'AND': []}; - } - - if (sourceFields.length == 1) { - final field = sourceFields.single; - final values = keys - .map((key) => key.parts.single) - .toList(growable: false); - return { - field: {include ? 'in' : 'notIn': values}, - }; - } - - final tupleClauses = keys - .map((key) { - final where = {}; - for (var index = 0; index < sourceFields.length; index++) { - where[sourceFields[index]] = key.parts[index]; - } - return where; - }) - .toList(growable: false); - - if (include) { - return {'OR': tupleClauses}; - } - - return { - 'NOT': {'OR': tupleClauses}, - }; - } - - void _appendAndWhereClause({ - required JsonMap where, - required JsonMap clause, - }) { - if (clause.isEmpty) { - return; - } - - final existing = where['AND']; - if (existing == null) { - where['AND'] = [clause]; - return; - } - - final existingMap = _coerceWhereMap(existing); - if (existingMap != null) { - where['AND'] = [existingMap, clause]; - return; - } - - final existingList = _coerceWhereList(existing); - if (existingList != null) { - where['AND'] = [...existingList, clause]; - return; - } - - where['AND'] = [clause]; - } - - Set _relationWhereOperatorsFor({ - required RelationCardinality cardinality, - }) { - return switch (cardinality) { - RelationCardinality.many => _toManyRelationWhereOperators, - RelationCardinality.one => _toOneRelationWhereOperators, - }; - } } diff --git a/pub/orm/lib/src/client/relation_where_rewriter.dart b/pub/orm/lib/src/client/relation_where_rewriter.dart new file mode 100644 index 00000000..b6a4381c --- /dev/null +++ b/pub/orm/lib/src/client/relation_where_rewriter.dart @@ -0,0 +1,389 @@ +part of 'client.dart'; + +final class _RepositoryRelationWhereRewriter { + final ModelDelegate _delegate; + + const _RepositoryRelationWhereRewriter(this._delegate); + + Future rewrite({ + required String model, + required JsonMap where, + }) async { + if (where.isEmpty) { + return const {}; + } + if (_delegate._client.contract.target == 'sql-family') { + return where; + } + return _rewriteRelationWhere(model: model, where: where); + } + + Future _rewriteRelationWhere({ + required String model, + required JsonMap where, + }) async { + if (where.isEmpty) { + return const {}; + } + + final modelContract = _delegate._client.contract.models[model]; + if (modelContract == null) { + throw ModelNotFoundException( + model, + _delegate._client.contract.models.keys, + ); + } + + final normalizedWhere = {}; + final relationClauses = []; + for (final entry in where.entries) { + final key = entry.key; + if (_whereLogicalKeys.contains(key)) { + normalizedWhere[key] = await _normalizeWhereLogicalOperand( + model: model, + operand: entry.value, + ); + continue; + } + + final relation = modelContract.relations[key]; + if (relation == null) { + normalizedWhere[key] = entry.value; + continue; + } + + final relationWhere = _coerceWhereMap(entry.value); + if (relationWhere == null) { + final supportedOperators = _relationWhereOperatorsFor( + cardinality: relation.cardinality, + ); + throw runtimeError( + 'PLAN.RELATION_WHERE_INVALID', + 'Relation where expects a map of operators.', + details: { + 'model': model, + 'relation': key, + 'expectedOperators': supportedOperators.toList(growable: false), + }, + ); + } + + final clause = await _compileRelationWhereClause( + relationName: key, + relation: relation, + where: relationWhere, + ); + if (clause != null) { + relationClauses.add(clause); + } + } + + for (final clause in relationClauses) { + _appendAndWhereClause(where: normalizedWhere, clause: clause); + } + + return normalizedWhere; + } + + Future _normalizeWhereLogicalOperand({ + required String model, + required Object? operand, + }) async { + final nestedWhere = _coerceWhereMap(operand); + if (nestedWhere != null) { + return _rewriteRelationWhere(model: model, where: nestedWhere); + } + + final nestedWhereList = _coerceWhereList(operand); + if (nestedWhereList == null) { + return operand; + } + + final normalized = []; + for (final entry in nestedWhereList) { + normalized.add(await _rewriteRelationWhere(model: model, where: entry)); + } + return normalized; + } + + Future _compileRelationWhereClause({ + required String relationName, + required ModelRelationContract relation, + required JsonMap where, + }) async { + if (where.isEmpty) { + return null; + } + + final supportedOperators = _relationWhereOperatorsFor( + cardinality: relation.cardinality, + ); + final unknownOperators = where.keys + .where((key) => !supportedOperators.contains(key)) + .toList(growable: false); + if (unknownOperators.isNotEmpty) { + throw runtimeError( + 'PLAN.RELATION_WHERE_OPERATOR_INVALID', + 'Relation where contains unknown operators.', + details: { + 'model': _delegate.modelName, + 'relation': relationName, + 'unknownOperators': unknownOperators, + 'supportedOperators': supportedOperators.toList(growable: false), + }, + ); + } + + final clauses = []; + if (relation.cardinality == RelationCardinality.many) { + if (where.containsKey('some')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'some', + operand: where['some'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } + + if (where.containsKey('none')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'none', + operand: where['none'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } + + if (where.containsKey('every')) { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'every', + operand: where['every'], + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: {'NOT': relationWhere}, + include: false, + ), + ); + } + } else { + if (where.containsKey('is')) { + final isOperand = where['is']; + if (isOperand == null) { + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: const {}, + include: false, + ), + ); + } else { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'is', + operand: isOperand, + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } + } + + if (where.containsKey('isNot')) { + final isNotOperand = where['isNot']; + if (isNotOperand == null) { + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: const {}, + include: true, + ), + ); + } else { + final relationWhere = await _normalizeRelationOperatorWhere( + relationName: relationName, + relation: relation, + operator: 'isNot', + operand: isNotOperand, + ); + clauses.add( + await _buildRelationMembershipClause( + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } + } + } + + if (clauses.isEmpty) { + return null; + } + if (clauses.length == 1) { + return clauses.single; + } + return {'AND': clauses}; + } + + Future _normalizeRelationOperatorWhere({ + required String relationName, + required ModelRelationContract relation, + required String operator, + required Object? operand, + }) async { + if (operand == null) { + return const {}; + } + + final nestedWhere = _coerceWhereMap(operand); + if (nestedWhere == null) { + throw runtimeError( + 'PLAN.RELATION_WHERE_VALUE_INVALID', + 'Relation where operator expects a nested where map.', + details: { + 'model': _delegate.modelName, + 'relation': relationName, + 'operator': operator, + }, + ); + } + + return _rewriteRelationWhere( + model: relation.relatedModel, + where: nestedWhere, + ); + } + + Future _buildRelationMembershipClause({ + required ModelRelationContract relation, + required JsonMap relatedWhere, + required bool include, + }) async { + final relatedRows = await _delegate._runtime + ._resolveDelegate(relation.relatedModel) + ._readAllInternal( + action: OrmAction.read, + where: relatedWhere, + select: relation.targetFields, + includeDepth: 0, + ); + + final keys = <_RelationMergeKey>{}; + for (final row in relatedRows) { + final key = _delegate._buildRelationMergeKeyFromRow( + row: row, + fields: relation.targetFields, + ); + if (key != null) { + keys.add(key); + } + } + + return _buildRelationTupleMembershipWhere( + sourceFields: relation.sourceFields, + keys: keys, + include: include, + ); + } + + JsonMap _buildRelationTupleMembershipWhere({ + required List sourceFields, + required Set<_RelationMergeKey> keys, + required bool include, + }) { + if (keys.isEmpty) { + return include + ? const {'OR': []} + : const {'AND': []}; + } + + if (sourceFields.length == 1) { + final field = sourceFields.single; + final values = keys + .map((key) => key.parts.single) + .toList(growable: false); + return { + field: {include ? 'in' : 'notIn': values}, + }; + } + + final tupleClauses = keys + .map((key) { + final where = {}; + for (var index = 0; index < sourceFields.length; index++) { + where[sourceFields[index]] = key.parts[index]; + } + return where; + }) + .toList(growable: false); + + if (include) { + return {'OR': tupleClauses}; + } + + return { + 'NOT': {'OR': tupleClauses}, + }; + } + + void _appendAndWhereClause({ + required JsonMap where, + required JsonMap clause, + }) { + if (clause.isEmpty) { + return; + } + + final existing = where['AND']; + if (existing == null) { + where['AND'] = [clause]; + return; + } + + final existingMap = _coerceWhereMap(existing); + if (existingMap != null) { + where['AND'] = [existingMap, clause]; + return; + } + + final existingList = _coerceWhereList(existing); + if (existingList != null) { + where['AND'] = [...existingList, clause]; + return; + } + + where['AND'] = [clause]; + } + + Set _relationWhereOperatorsFor({ + required RelationCardinality cardinality, + }) { + return switch (cardinality) { + RelationCardinality.many => _toManyRelationWhereOperators, + RelationCardinality.one => _toOneRelationWhereOperators, + }; + } +} From 8944d11b4f7679d99c477ec4837485fe4fc730d7 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:01:09 +0800 Subject: [PATCH 122/154] refactor(client)!: add read specs and traced relation rewrites BREAKING CHANGE: prepareRead now accepts a single OrmReadQuerySpec instead of individual query arguments. --- pub/orm/lib/src/client/client.dart | 483 ++++++++---------- .../lib/src/client/mutation_repository.dart | 8 +- pub/orm/lib/src/client/read_repository.dart | 154 +++--- .../src/client/relation_where_rewriter.dart | 99 +++- pub/orm/lib/src/generator/writer.dart | 10 +- pub/orm/test/client/client_test.dart | 124 +++++ pub/orm/test/generator/generate_test.dart | 4 +- 7 files changed, 526 insertions(+), 356 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 11bc4eec..9f8293a1 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -33,6 +33,7 @@ typedef IncludeExecutionStrategySelector = }); const int _defaultMaxIncludeDepth = 4; +const Object _stateKeepToken = Object(); const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; const List _filterOperatorOrder = [ 'equals', @@ -305,6 +306,11 @@ final class _RepositoryOperation { ); } + factory _RepositoryOperation.resume({required OrmRepositoryTrace trace}) { + return _RepositoryOperation._(id: trace.operationId, kind: trace.kind) + .._step = trace.step; + } + OrmRepositoryTrace nextTrace({ required String phase, required String strategy, @@ -1079,7 +1085,7 @@ class ModelDelegate { this, ); - ModelQuery query() => ModelQuery._(this, const ModelQueryState()); + ModelQuery query() => ModelQuery._(this, OrmReadQuerySpec()); ModelQuery where(JsonMap where) => query().where(where); @@ -1129,33 +1135,51 @@ class ModelDelegate { IncludeSpec spec = const IncludeSpec(), }) => query().includeRelation(relation, spec: spec); - Future prepareRead({ - JsonMap where = const {}, - int? skip, - int? take, - List orderBy = const [], - List distinct = const [], - List select = const [], - Map include = const {}, - JsonMap? cursor, - OrmReadPagePlan? page, - }) { + Future prepareRead({required OrmReadQuerySpec spec}) { return _prepareReadQuery( - resultMode: OrmReadResultMode.all, - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, + state: _OrmPreparedReadState( + resultMode: OrmReadResultMode.all, + spec: spec, + ), ); } Future _prepareReadQuery({ - required OrmReadResultMode resultMode, + required _OrmPreparedReadState state, + }) async { + final preparedOperation = state._repositoryTrace != null + ? _RepositoryOperation.resume(trace: state._repositoryTrace!) + : state._where.isEmpty + ? null + : _RepositoryOperation.start(kind: '$modelName.read'); + final rewriteResult = state._where.isEmpty + ? const _RelationWhereRewriteResult( + where: {}, + usedLookups: false, + ) + : await _normalizeWhereForExecution( + model: modelName, + where: state._where, + operation: preparedOperation, + ); + final effectiveTrace = rewriteResult.usedLookups + ? preparedOperation!.nextTrace( + phase: state._repositoryTrace?.phase ?? 'read.execute', + strategy: + state._repositoryTrace?.strategy ?? 'relationWhereRewrite', + relation: state._repositoryTrace?.relation, + itemIndex: state._repositoryTrace?.itemIndex, + ) + : state._repositoryTrace; + return _readPlanCompiler.compile( + state: state.copyWith( + spec: state._spec.copyWith(where: rewriteResult.where), + repositoryTrace: effectiveTrace, + ), + ); + } + + Future toPlan({ JsonMap where = const {}, int? skip, int? take, @@ -1165,16 +1189,10 @@ class ModelDelegate { Map include = const {}, JsonMap? cursor, OrmReadPagePlan? page, - JsonMap annotations = const {}, - OrmRepositoryTrace? repositoryTrace, }) async { - final normalizedWhere = where.isEmpty - ? const {} - : await _normalizeWhereForExecution(model: modelName, where: where); - return _readPlanCompiler.compile( - state: _OrmPreparedReadState( - resultMode: resultMode, - where: normalizedWhere, + final prepared = await prepareRead( + spec: OrmReadQuerySpec( + where: where, skip: skip, take: take, orderBy: orderBy, @@ -1183,34 +1201,8 @@ class ModelDelegate { include: include, cursor: cursor, page: page, - annotations: annotations, - repositoryTrace: repositoryTrace, ), ); - } - - Future toPlan({ - JsonMap where = const {}, - int? skip, - int? take, - List orderBy = const [], - List distinct = const [], - List select = const [], - Map include = const {}, - JsonMap? cursor, - OrmReadPagePlan? page, - }) async { - final prepared = await prepareRead( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - ); return prepared.plan; } @@ -1226,15 +1218,17 @@ class ModelDelegate { OrmReadPagePlan? page, }) async { final prepared = await prepareRead( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, + spec: OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), ); return prepared.all(); } @@ -1247,11 +1241,13 @@ class ModelDelegate { required OrmReadPagePlan page, }) async { final prepared = await prepareRead( - where: where, - orderBy: orderBy, - select: select, - include: include, - page: page, + spec: OrmReadQuerySpec( + where: where, + orderBy: orderBy, + select: select, + include: include, + page: page, + ), ); return prepared.pageResult(); } @@ -1268,15 +1264,17 @@ class ModelDelegate { OrmReadPagePlan? page, }) async* { final prepared = await prepareRead( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, + spec: OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), ); yield* prepared.stream(); } @@ -1287,9 +1285,7 @@ class ModelDelegate { Map include = const {}, }) async { final prepared = await prepareRead( - where: where, - select: select, - include: include, + spec: OrmReadQuerySpec(where: where, select: select, include: include), ); return prepared.oneOrNull(); } @@ -1303,12 +1299,14 @@ class ModelDelegate { Map include = const {}, }) async { final prepared = await prepareRead( - where: where, - skip: skip, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, + spec: OrmReadQuerySpec( + where: where, + skip: skip, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + ), ); return prepared.firstOrNull(); } @@ -1357,15 +1355,17 @@ class ModelDelegate { OrmReadPagePlan? page, }) async { final prepared = await prepareRead( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, + spec: OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), ); return prepared.inspectPlan(); } @@ -1382,15 +1382,17 @@ class ModelDelegate { OrmReadPagePlan? page, }) async { final prepared = await prepareRead( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, + spec: OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), ); return prepared.explain(); } @@ -1686,18 +1688,22 @@ class ModelDelegate { required int includeDepth, }) async { final prepared = await _prepareReadQuery( - resultMode: OrmReadResultMode.all, - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - annotations: annotations, - repositoryTrace: repositoryTrace, + state: _OrmPreparedReadState( + resultMode: OrmReadResultMode.all, + spec: OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), + annotations: annotations, + repositoryTrace: repositoryTrace, + ), ); return _readRepository.all( prepared: prepared, @@ -1731,12 +1737,12 @@ class ModelDelegate { required int includeDepth, }) async { final prepared = await _prepareReadQuery( - resultMode: OrmReadResultMode.all, - where: where, - select: select, - include: include, - annotations: annotations, - repositoryTrace: repositoryTrace, + state: _OrmPreparedReadState( + resultMode: OrmReadResultMode.all, + spec: OrmReadQuerySpec(where: where, select: select, include: include), + annotations: annotations, + repositoryTrace: repositoryTrace, + ), ); return _readRepository.oneOrNull( prepared: prepared, @@ -2969,11 +2975,16 @@ class ModelDelegate { return Map.from(data); } - Future _normalizeWhereForExecution({ + Future<_RelationWhereRewriteResult> _normalizeWhereForExecution({ required String model, required JsonMap where, + _RepositoryOperation? operation, }) { - return _relationWhereRewriter.rewrite(model: model, where: where); + return _relationWhereRewriter.rewrite( + model: model, + where: where, + operation: operation, + ); } Map _normalizeInclude(Map include) { @@ -3026,7 +3037,7 @@ class ModelDelegate { } @immutable -final class ModelQueryState { +final class OrmReadQuerySpec { final JsonMap where; final int? skip; final int? take; @@ -3037,23 +3048,64 @@ final class ModelQueryState { final JsonMap? cursor; final OrmReadPagePlan? page; - const ModelQueryState({ - this.where = const {}, + OrmReadQuerySpec({ + JsonMap where = const {}, this.skip, this.take, - this.orderBy = const [], - this.distinct = const [], - this.select = const [], - this.include = const {}, - this.cursor, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, this.page, - }); + }) : where = Map.unmodifiable( + Map.from(where), + ), + orderBy = List.unmodifiable(orderBy), + distinct = List.unmodifiable(distinct), + select = List.unmodifiable(select), + include = Map.unmodifiable( + Map.from(include), + ), + cursor = cursor == null + ? null + : Map.unmodifiable( + Map.from(cursor), + ); + + OrmReadQuerySpec copyWith({ + JsonMap? where, + Object? skip = _stateKeepToken, + Object? take = _stateKeepToken, + List? orderBy, + List? distinct, + List? select, + Map? include, + Object? cursor = _stateKeepToken, + Object? page = _stateKeepToken, + }) { + return OrmReadQuerySpec( + where: where ?? this.where, + skip: identical(skip, _stateKeepToken) ? this.skip : skip as int?, + take: identical(take, _stateKeepToken) ? this.take : take as int?, + orderBy: orderBy ?? this.orderBy, + distinct: distinct ?? this.distinct, + select: select ?? this.select, + include: include ?? this.include, + cursor: identical(cursor, _stateKeepToken) + ? this.cursor + : cursor as JsonMap?, + page: identical(page, _stateKeepToken) + ? this.page + : page as OrmReadPagePlan?, + ); + } } @immutable final class ModelQuery { final ModelDelegate _delegate; - final ModelQueryState _state; + final OrmReadQuerySpec _state; const ModelQuery._(this._delegate, this._state); @@ -3079,19 +3131,7 @@ final class ModelQuery { final nextWhere = merge ? {..._state.where, ...where} : {...where}; - return _next( - ModelQueryState( - where: nextWhere, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ), - ); + return _next(_state.copyWith(where: nextWhere)); } ModelQuery whereWith( @@ -3107,19 +3147,7 @@ final class ModelQuery { final nextOrderBy = append ? [..._state.orderBy, ...orderBy] : [...orderBy]; - return _next( - ModelQueryState( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: nextOrderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ), - ); + return _next(_state.copyWith(orderBy: nextOrderBy)); } ModelQuery orderByField(String field, {SortOrder order = SortOrder.asc}) { @@ -3130,19 +3158,7 @@ final class ModelQuery { final nextDistinct = append ? [..._state.distinct, ...fields] : [...fields]; - return _next( - ModelQueryState( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: nextDistinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ), - ); + return _next(_state.copyWith(distinct: nextDistinct)); } ModelQuery distinctField(String field) { @@ -3153,19 +3169,7 @@ final class ModelQuery { final nextSelect = append ? [..._state.select, ...fields] : [...fields]; - return _next( - ModelQueryState( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: nextSelect, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ), - ); + return _next(_state.copyWith(select: nextSelect)); } ModelQuery selectWith( @@ -3186,19 +3190,7 @@ final class ModelQuery { ? _mergeIncludeSpecMap(_state.include, include) : {...include}; - return _next( - ModelQueryState( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: nextInclude, - cursor: _state.cursor, - page: _state.page, - ), - ); + return _next(_state.copyWith(include: nextInclude)); } ModelQuery includeWith( @@ -3218,35 +3210,11 @@ final class ModelQuery { } ModelQuery skip(int value) { - return _next( - ModelQueryState( - where: _state.where, - skip: value, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: null, - ), - ); + return _next(_state.copyWith(skip: value, page: null)); } ModelQuery take(int value) { - return _next( - ModelQueryState( - where: _state.where, - skip: _state.skip, - take: value, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: null, - ), - ); + return _next(_state.copyWith(take: value, page: null)); } ModelQuery cursor(JsonMap cursor) { @@ -3264,21 +3232,7 @@ final class ModelQuery { details: {'model': _delegate.modelName}, ); } - return _next( - ModelQueryState( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: Map.unmodifiable( - Map.from(cursor), - ), - page: null, - ), - ); + return _next(_state.copyWith(cursor: cursor, page: null)); } ModelQuery page({required int size, JsonMap? after, JsonMap? before}) { @@ -3315,52 +3269,21 @@ final class ModelQuery { ); } return _next( - ModelQueryState( - where: _state.where, + _state.copyWith( skip: null, take: null, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, cursor: null, - page: OrmReadPagePlan( - size: size, - after: after == null ? null : Map.from(after), - before: before == null ? null : Map.from(before), - ), + page: OrmReadPagePlan(size: size, after: after, before: before), ), ); } ModelQuery unbounded() { - return _next( - ModelQueryState( - where: _state.where, - skip: _state.skip, - take: null, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: null, - ), - ); + return _next(_state.copyWith(take: null, page: null)); } Future _prepareRead() { - return _delegate.prepareRead( - where: _state.where, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, - distinct: _state.distinct, - select: _state.select, - include: _state.include, - cursor: _state.cursor, - page: _state.page, - ); + return _delegate.prepareRead(spec: _state); } Future toPlan() async { @@ -3600,7 +3523,7 @@ final class ModelQuery { ); } - ModelQuery _next(ModelQueryState nextState) => + ModelQuery _next(OrmReadQuerySpec nextState) => ModelQuery._(_delegate, nextState); } diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index 0d89c4ae..9d1d15b2 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -45,6 +45,7 @@ final class _RepositoryMutationExecutor { where: const {}, select: select, include: include, + operation: trace, ); final prepared = _composeMutationPlan( action: OrmAction.create, @@ -303,6 +304,7 @@ final class _RepositoryMutationExecutor { where: where, select: select, include: include, + operation: operation, ); final normalizedInclude = normalized.include; final normalizedWhere = normalized.where; @@ -353,14 +355,16 @@ final class _RepositoryMutationExecutor { JsonMap where = const {}, List select = const [], Map include = const {}, + _RepositoryOperation? operation, }) async { final normalizedInclude = _delegate._normalizeInclude(include); final normalizedWhere = where.isEmpty ? const {} - : await _delegate._normalizeWhereForExecution( + : (await _delegate._normalizeWhereForExecution( model: _delegate.modelName, where: where, - ); + operation: operation, + )).where; return _NormalizedMutationInput( where: normalizedWhere, diff --git a/pub/orm/lib/src/client/read_repository.dart b/pub/orm/lib/src/client/read_repository.dart index 3fdb8b7e..af36a63c 100644 --- a/pub/orm/lib/src/client/read_repository.dart +++ b/pub/orm/lib/src/client/read_repository.dart @@ -3,52 +3,54 @@ part of 'client.dart'; @immutable final class _OrmPreparedReadState { final OrmReadResultMode resultMode; - final JsonMap _where; - final int? _skip; - final int? _take; - final List _orderBy; - final List _distinct; - final List _select; - final Map _include; - final JsonMap? _cursor; - final OrmReadPagePlan? _page; + final OrmReadQuerySpec _spec; final JsonMap _annotations; final OrmRepositoryTrace? _repositoryTrace; _OrmPreparedReadState({ required this.resultMode, - JsonMap where = const {}, - int? skip, - int? take, - List orderBy = const [], - List distinct = const [], - List select = const [], - Map include = const {}, - JsonMap? cursor, - OrmReadPagePlan? page, + required OrmReadQuerySpec spec, JsonMap annotations = const {}, OrmRepositoryTrace? repositoryTrace, - }) : _where = Map.unmodifiable( - Map.from(where), - ), - _skip = skip, - _take = take, - _orderBy = List.unmodifiable(orderBy), - _distinct = List.unmodifiable(distinct), - _select = List.unmodifiable(select), - _include = Map.unmodifiable( - Map.from(include), - ), - _cursor = cursor == null - ? null - : Map.unmodifiable( - Map.from(cursor), - ), - _page = page, + }) : _spec = spec.copyWith(), _annotations = Map.unmodifiable( Map.from(annotations), ), _repositoryTrace = repositoryTrace; + + JsonMap get _where => _spec.where; + + int? get _skip => _spec.skip; + + int? get _take => _spec.take; + + List get _orderBy => _spec.orderBy; + + List get _distinct => _spec.distinct; + + List get _select => _spec.select; + + Map get _include => _spec.include; + + JsonMap? get _cursor => _spec.cursor; + + OrmReadPagePlan? get _page => _spec.page; + + _OrmPreparedReadState copyWith({ + OrmReadResultMode? resultMode, + OrmReadQuerySpec? spec, + JsonMap? annotations, + Object? repositoryTrace = _stateKeepToken, + }) { + return _OrmPreparedReadState( + resultMode: resultMode ?? this.resultMode, + spec: spec ?? _spec, + annotations: annotations ?? _annotations, + repositoryTrace: identical(repositoryTrace, _stateKeepToken) + ? _repositoryTrace + : repositoryTrace as OrmRepositoryTrace?, + ); + } } @immutable @@ -219,20 +221,28 @@ final class _RepositoryReadExecutor { orderBy: prepared._orderBy, ); final itemsPrepared = await _delegate._prepareReadQuery( - resultMode: OrmReadResultMode.all, - where: prepared._where, - orderBy: prepared._orderBy, - select: pageSelect, - include: prepared._include, - page: OrmReadPagePlan( - size: page.size + 1, - after: page.after, - before: page.before, - ), - annotations: prepared._annotations, - repositoryTrace: operation.nextTrace( - phase: 'page.items', - strategy: 'windowPlusOne', + state: prepared._state.copyWith( + resultMode: OrmReadResultMode.all, + spec: prepared._state._spec.copyWith( + where: prepared._where, + skip: null, + take: null, + orderBy: prepared._orderBy, + distinct: const [], + select: pageSelect, + include: prepared._include, + cursor: null, + page: OrmReadPagePlan( + size: page.size + 1, + after: page.after, + before: page.before, + ), + ), + annotations: prepared._annotations, + repositoryTrace: operation.nextTrace( + phase: 'page.items', + strategy: 'windowPlusOne', + ), ), ); final response = await _delegate._client.execute(itemsPrepared.plan); @@ -281,15 +291,21 @@ final class _RepositoryReadExecutor { final effectivePrepared = prepared._distinct.isEmpty ? await _delegate._prepareReadQuery( - resultMode: OrmReadResultMode.firstOrNull, - where: prepared._where, - skip: prepared._skip, - orderBy: prepared._orderBy, - distinct: prepared._distinct, - select: prepared._select, - include: prepared._include, - annotations: prepared._annotations, - repositoryTrace: prepared._repositoryTrace, + state: prepared._state.copyWith( + resultMode: OrmReadResultMode.firstOrNull, + spec: prepared._state._spec.copyWith( + where: prepared._where, + skip: prepared._skip, + orderBy: prepared._orderBy, + distinct: prepared._distinct, + select: prepared._select, + include: prepared._include, + cursor: null, + page: null, + ), + annotations: prepared._annotations, + repositoryTrace: prepared._repositoryTrace, + ), ) : prepared; @@ -343,12 +359,22 @@ final class _RepositoryReadExecutor { prepared._resultMode == OrmReadResultMode.oneOrNull ? prepared : await _delegate._prepareReadQuery( - resultMode: OrmReadResultMode.oneOrNull, - where: prepared._where, - select: prepared._select, - include: prepared._include, - annotations: prepared._annotations, - repositoryTrace: prepared._repositoryTrace, + state: prepared._state.copyWith( + resultMode: OrmReadResultMode.oneOrNull, + spec: prepared._state._spec.copyWith( + where: prepared._where, + select: prepared._select, + include: prepared._include, + skip: null, + take: null, + orderBy: const [], + distinct: const [], + cursor: null, + page: null, + ), + annotations: prepared._annotations, + repositoryTrace: prepared._repositoryTrace, + ), ); final response = await _delegate._client.execute(effectivePrepared.plan); diff --git a/pub/orm/lib/src/client/relation_where_rewriter.dart b/pub/orm/lib/src/client/relation_where_rewriter.dart index b6a4381c..f1656058 100644 --- a/pub/orm/lib/src/client/relation_where_rewriter.dart +++ b/pub/orm/lib/src/client/relation_where_rewriter.dart @@ -1,24 +1,68 @@ part of 'client.dart'; +@immutable +final class _RelationWhereRewriteResult { + final JsonMap where; + final bool usedLookups; + + const _RelationWhereRewriteResult({ + required this.where, + required this.usedLookups, + }); +} + +final class _RelationWhereRewriteSession { + final _RepositoryOperation? operation; + final String lookupPhase; + final String lookupStrategy; + var usedLookups = false; + + _RelationWhereRewriteSession({ + required this.operation, + required this.lookupPhase, + required this.lookupStrategy, + }); +} + final class _RepositoryRelationWhereRewriter { final ModelDelegate _delegate; const _RepositoryRelationWhereRewriter(this._delegate); - Future rewrite({ + Future<_RelationWhereRewriteResult> rewrite({ required String model, required JsonMap where, + _RepositoryOperation? operation, + String lookupPhase = 'where.relationLookup', + String lookupStrategy = 'relationWhereLookup', }) async { if (where.isEmpty) { - return const {}; + return const _RelationWhereRewriteResult( + where: {}, + usedLookups: false, + ); } if (_delegate._client.contract.target == 'sql-family') { - return where; + return _RelationWhereRewriteResult(where: where, usedLookups: false); } - return _rewriteRelationWhere(model: model, where: where); + final session = _RelationWhereRewriteSession( + operation: operation, + lookupPhase: lookupPhase, + lookupStrategy: lookupStrategy, + ); + final normalizedWhere = await _rewriteRelationWhere( + session: session, + model: model, + where: where, + ); + return _RelationWhereRewriteResult( + where: normalizedWhere, + usedLookups: session.usedLookups, + ); } Future _rewriteRelationWhere({ + required _RelationWhereRewriteSession session, required String model, required JsonMap where, }) async { @@ -40,6 +84,7 @@ final class _RepositoryRelationWhereRewriter { final key = entry.key; if (_whereLogicalKeys.contains(key)) { normalizedWhere[key] = await _normalizeWhereLogicalOperand( + session: session, model: model, operand: entry.value, ); @@ -69,6 +114,7 @@ final class _RepositoryRelationWhereRewriter { } final clause = await _compileRelationWhereClause( + session: session, relationName: key, relation: relation, where: relationWhere, @@ -86,12 +132,17 @@ final class _RepositoryRelationWhereRewriter { } Future _normalizeWhereLogicalOperand({ + required _RelationWhereRewriteSession session, required String model, required Object? operand, }) async { final nestedWhere = _coerceWhereMap(operand); if (nestedWhere != null) { - return _rewriteRelationWhere(model: model, where: nestedWhere); + return _rewriteRelationWhere( + session: session, + model: model, + where: nestedWhere, + ); } final nestedWhereList = _coerceWhereList(operand); @@ -101,12 +152,19 @@ final class _RepositoryRelationWhereRewriter { final normalized = []; for (final entry in nestedWhereList) { - normalized.add(await _rewriteRelationWhere(model: model, where: entry)); + normalized.add( + await _rewriteRelationWhere( + session: session, + model: model, + where: entry, + ), + ); } return normalized; } Future _compileRelationWhereClause({ + required _RelationWhereRewriteSession session, required String relationName, required ModelRelationContract relation, required JsonMap where, @@ -138,6 +196,7 @@ final class _RepositoryRelationWhereRewriter { if (relation.cardinality == RelationCardinality.many) { if (where.containsKey('some')) { final relationWhere = await _normalizeRelationOperatorWhere( + session: session, relationName: relationName, relation: relation, operator: 'some', @@ -145,6 +204,8 @@ final class _RepositoryRelationWhereRewriter { ); clauses.add( await _buildRelationMembershipClause( + session: session, + relationName: relationName, relation: relation, relatedWhere: relationWhere, include: true, @@ -154,6 +215,7 @@ final class _RepositoryRelationWhereRewriter { if (where.containsKey('none')) { final relationWhere = await _normalizeRelationOperatorWhere( + session: session, relationName: relationName, relation: relation, operator: 'none', @@ -161,6 +223,8 @@ final class _RepositoryRelationWhereRewriter { ); clauses.add( await _buildRelationMembershipClause( + session: session, + relationName: relationName, relation: relation, relatedWhere: relationWhere, include: false, @@ -170,6 +234,7 @@ final class _RepositoryRelationWhereRewriter { if (where.containsKey('every')) { final relationWhere = await _normalizeRelationOperatorWhere( + session: session, relationName: relationName, relation: relation, operator: 'every', @@ -177,6 +242,8 @@ final class _RepositoryRelationWhereRewriter { ); clauses.add( await _buildRelationMembershipClause( + session: session, + relationName: relationName, relation: relation, relatedWhere: {'NOT': relationWhere}, include: false, @@ -189,6 +256,8 @@ final class _RepositoryRelationWhereRewriter { if (isOperand == null) { clauses.add( await _buildRelationMembershipClause( + session: session, + relationName: relationName, relation: relation, relatedWhere: const {}, include: false, @@ -196,6 +265,7 @@ final class _RepositoryRelationWhereRewriter { ); } else { final relationWhere = await _normalizeRelationOperatorWhere( + session: session, relationName: relationName, relation: relation, operator: 'is', @@ -203,6 +273,8 @@ final class _RepositoryRelationWhereRewriter { ); clauses.add( await _buildRelationMembershipClause( + session: session, + relationName: relationName, relation: relation, relatedWhere: relationWhere, include: true, @@ -216,6 +288,8 @@ final class _RepositoryRelationWhereRewriter { if (isNotOperand == null) { clauses.add( await _buildRelationMembershipClause( + session: session, + relationName: relationName, relation: relation, relatedWhere: const {}, include: true, @@ -223,6 +297,7 @@ final class _RepositoryRelationWhereRewriter { ); } else { final relationWhere = await _normalizeRelationOperatorWhere( + session: session, relationName: relationName, relation: relation, operator: 'isNot', @@ -230,6 +305,8 @@ final class _RepositoryRelationWhereRewriter { ); clauses.add( await _buildRelationMembershipClause( + session: session, + relationName: relationName, relation: relation, relatedWhere: relationWhere, include: false, @@ -249,6 +326,7 @@ final class _RepositoryRelationWhereRewriter { } Future _normalizeRelationOperatorWhere({ + required _RelationWhereRewriteSession session, required String relationName, required ModelRelationContract relation, required String operator, @@ -272,22 +350,31 @@ final class _RepositoryRelationWhereRewriter { } return _rewriteRelationWhere( + session: session, model: relation.relatedModel, where: nestedWhere, ); } Future _buildRelationMembershipClause({ + required _RelationWhereRewriteSession session, + required String relationName, required ModelRelationContract relation, required JsonMap relatedWhere, required bool include, }) async { + session.usedLookups = true; final relatedRows = await _delegate._runtime ._resolveDelegate(relation.relatedModel) ._readAllInternal( action: OrmAction.read, where: relatedWhere, select: relation.targetFields, + repositoryTrace: session.operation?.nextTrace( + phase: session.lookupPhase, + strategy: session.lookupStrategy, + relation: relationName, + ), includeDepth: 0, ); diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 71e72299..9d1e6309 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2820,8 +2820,8 @@ final class TypedClientWriter { buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future _prepareRead() {'); - buffer.writeln(' return _delegate._delegate.prepareRead('); + buffer.writeln(' OrmReadQuerySpec _readSpec() {'); + buffer.writeln(' return OrmReadQuerySpec('); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); @@ -2834,6 +2834,12 @@ final class TypedClientWriter { buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future _prepareRead() {'); + buffer.writeln( + ' return _delegate._delegate.prepareRead(spec: _readSpec());', + ); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' void _assertReadExecutionSupported(String terminal) {'); buffer.writeln( diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 71294330..da058a02 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -1568,6 +1568,69 @@ void main() { }, ); + test('records relation where lookup traces for read operations', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient(contract: relationalContract, engine: engine); + await client.connect(); + await _seedRelationalData(client); + engine.reset(); + + final rows = await client.db.orm.model('User').all( + where: { + 'posts': { + 'some': {'title': 'Post A'}, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + + expect(rows.map((row) => row['id']).toList(growable: false), [ + 'u1', + ]); + expect( + engine.executedPlans.map((plan) => plan.model).toList(growable: false), + ['Post', 'User'], + ); + expect( + engine.executedPlans + .map((plan) => plan.action) + .toList(growable: false), + [OrmAction.read, OrmAction.read], + ); + + final traces = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final operationId = traces.first.operationId; + expect(traces.map((trace) => trace.operationId).toSet(), { + operationId, + }); + expect( + traces.map((trace) => trace.kind).toList(growable: false), + ['User.read', 'User.read'], + ); + expect( + traces.map((trace) => trace.phase).toList(growable: false), + ['where.relationLookup', 'read.execute'], + ); + expect( + traces.map((trace) => trace.strategy).toList(growable: false), + ['relationWhereLookup', 'relationWhereRewrite'], + ); + expect( + traces.map((trace) => trace.relation).toList(growable: false), + ['posts', null], + ); + expect( + traces.map((trace) => trace.step).toList(growable: false), + [1, 2], + ); + expect(client.telemetry()?.operationId, operationId); + expect(client.telemetry()?.operationKind, 'User.read'); + expect(client.operationTelemetry(operationId)?.statementCount, 2); + await client.disconnect(); + }); + test('supports relation where is/isNot for to-one relation', () async { final client = OrmClient( contract: relationalContract, @@ -1803,6 +1866,67 @@ void main() { await client.disconnect(); }); + test('records relation where lookup traces on mutation paths', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient(contract: relationalContract, engine: engine); + await client.connect(); + await _seedRelationalData(client); + engine.reset(); + + final updated = await client.db.orm.model('User').update( + where: { + 'posts': { + 'some': {'title': 'Post C'}, + }, + }, + data: {'email': 'u2+updated@example.com'}, + ); + + expect(updated?['id'], 'u2'); + expect( + engine.executedPlans.map((plan) => plan.model).toList(growable: false), + ['Post', 'User'], + ); + expect( + engine.executedPlans + .map((plan) => plan.action) + .toList(growable: false), + [OrmAction.read, OrmAction.update], + ); + + final traces = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final operationId = traces.first.operationId; + expect(traces.map((trace) => trace.operationId).toSet(), { + operationId, + }); + expect( + traces.map((trace) => trace.kind).toList(growable: false), + ['User.update', 'User.update'], + ); + expect( + traces.map((trace) => trace.phase).toList(growable: false), + ['where.relationLookup', 'write'], + ); + expect( + traces.map((trace) => trace.strategy).toList(growable: false), + ['relationWhereLookup', 'singlePlan'], + ); + expect( + traces.map((trace) => trace.relation).toList(growable: false), + ['posts', null], + ); + expect( + traces.map((trace) => trace.step).toList(growable: false), + [1, 2], + ); + expect(client.telemetry()?.operationId, operationId); + expect(client.telemetry()?.operationKind, 'User.update'); + expect(client.operationTelemetry(operationId)?.statementCount, 2); + await client.disconnect(); + }); + test('supports to-one relation where on mutation paths', () async { final client = OrmClient( contract: relationalContract, diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 63062b97..cfae606b 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -661,11 +661,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+_prepareRead\(\)[\s\S]*?Future>\s+all\(\)\s+async\s*\{[\s\S]*?\(await\s+_prepareRead\(\)\)\.all\(\)', + r'class\s+UserQuery\s*\{[\s\S]*?OrmReadQuerySpec\s+_readSpec\(\)[\s\S]*?Future\s+_prepareRead\(\)[\s\S]*?Future>\s+all\(\)\s+async\s*\{[\s\S]*?\(await\s+_prepareRead\(\)\)\.all\(\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery read execution to compile typed state through prepared read objects.', + 'Expected UserQuery read execution to compile typed state through read specs and prepared read objects.', ); expect( generatedSource.contains('ModelQuery _runtimeQuery('), From d35f789f13a2013bda1f873328099bece2b6b7b7 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:14:54 +0800 Subject: [PATCH 123/154] feat(client)!: require exact model names at orm roots BREAKING CHANGE: ORM and SQL root entrypoints now require exact contract model names, and generated typed roots expose exact model-name getters. --- docs/orm-v6-blueprint.md | 2 +- pub/orm/lib/src/client/client.dart | 72 ++------------- pub/orm/lib/src/generator/writer.dart | 18 ++-- pub/orm/test/client/client_test.dart | 105 ++++++++++++++-------- pub/orm/test/generator/generate_test.dart | 12 ++- 5 files changed, 99 insertions(+), 110 deletions(-) diff --git a/docs/orm-v6-blueprint.md b/docs/orm-v6-blueprint.md index cd50d136..70e5159b 100644 --- a/docs/orm-v6-blueprint.md +++ b/docs/orm-v6-blueprint.md @@ -39,7 +39,7 @@ - 示例: ```dart -final users = await db.user +final users = await db.orm.User .where((w) => w.email.equals('a@x.com') & w.active.equals(true)) .orderBy((o) => [o.createdAt.desc()]) .take(20) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 9f8293a1..7ffbcbb8 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -383,7 +383,6 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { final OrmEngine engine; final OrmRuntimeCore _runtime; final Map _delegates = {}; - final Map _modelAliases; final Map _collectionRegistry; late final OrmDbNamespace _db = OrmDbNamespace( sqlContext: this, @@ -414,7 +413,6 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { mode: mode, log: log, ), - _modelAliases = _createModelAliases(contract), _collectionRegistry = _createCollectionRegistry(contract, collections); bool get isConnected => _runtime.isConnected; @@ -434,7 +432,6 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { contract: contract, executePlan: connection.execute, explainPlan: connection.explain, - modelAliases: _modelAliases, collectionRegistry: _collectionRegistry, includeStrategySelector: includeStrategySelector, maxIncludeDepth: maxIncludeDepth, @@ -460,7 +457,6 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { contract: contract, executePlan: openedTransaction.execute, explainPlan: openedTransaction.explain, - modelAliases: _modelAliases, collectionRegistry: _collectionRegistry, includeStrategySelector: includeStrategySelector, maxIncludeDepth: maxIncludeDepth, @@ -531,18 +527,10 @@ final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { } String? _resolveModel(String modelKey) { - final exact = _modelAliases[modelKey]; - if (exact != null) { - return exact; - } - if (modelKey.endsWith('s') && modelKey.length > 1) { - final singular = modelKey.substring(0, modelKey.length - 1); - final singularMatch = _modelAliases[singular]; - if (singularMatch != null) { - return singularMatch; - } + if (contract.models.containsKey(modelKey)) { + return modelKey; } - return contract.resolveModel(modelKey); + return null; } } @@ -551,7 +539,6 @@ final class OrmScopedClient implements OrmDbContext, _OrmDelegateRuntime { final OrmContract contract; final Future Function(OrmPlan plan) _executePlan; final Future Function(OrmPlan plan) _explainPlan; - final Map _modelAliases; final Map _collectionRegistry; final Map _delegates = {}; late final OrmDbNamespace _db = OrmDbNamespace( @@ -567,13 +554,11 @@ final class OrmScopedClient implements OrmDbContext, _OrmDelegateRuntime { required this.contract, required Future Function(OrmPlan plan) executePlan, required Future Function(OrmPlan plan) explainPlan, - required Map modelAliases, required Map collectionRegistry, required this.includeStrategySelector, required this.maxIncludeDepth, }) : _executePlan = executePlan, _explainPlan = explainPlan, - _modelAliases = modelAliases, _collectionRegistry = collectionRegistry; @override @@ -611,18 +596,10 @@ final class OrmScopedClient implements OrmDbContext, _OrmDelegateRuntime { } String? _resolveModel(String modelKey) { - final exact = _modelAliases[modelKey]; - if (exact != null) { - return exact; - } - if (modelKey.endsWith('s') && modelKey.length > 1) { - final singular = modelKey.substring(0, modelKey.length - 1); - final singularMatch = _modelAliases[singular]; - if (singularMatch != null) { - return singularMatch; - } + if (contract.models.containsKey(modelKey)) { + return modelKey; } - return contract.resolveModel(modelKey); + return null; } } @@ -3549,30 +3526,6 @@ final class _RelationMergeKey { int get hashCode => Object.hashAll(parts); } -Map _createModelAliases(OrmContract contract) { - final aliases = {}; - - for (final model in contract.models.values) { - final name = model.name; - final lower = _lowercaseFirst(name); - aliases[name] = name; - aliases[lower] = name; - aliases['${lower}s'] = name; - aliases[model.table] = name; - if (!model.table.endsWith('s')) { - aliases['${model.table}s'] = name; - } - } - - for (final alias in contract.aliases.entries) { - if (contract.models.containsKey(alias.value)) { - aliases[alias.key] = alias.value; - } - } - - return aliases; -} - Map _createCollectionRegistry( OrmContract contract, Map collections, @@ -3581,15 +3534,13 @@ Map _createCollectionRegistry( return const {}; } - final aliases = _createModelAliases(contract); final registry = {}; for (final entry in collections.entries) { - final model = aliases[entry.key] ?? aliases[_lowercaseFirst(entry.key)]; - if (model == null) { + if (!contract.models.containsKey(entry.key)) { throw ModelNotFoundException(entry.key, contract.models.keys); } - registry[model] = entry.value; + registry[entry.key] = entry.value; } return registry; @@ -3696,10 +3647,3 @@ bool _listEquals(List left, List right) { } return true; } - -String _lowercaseFirst(String value) { - if (value.isEmpty) { - return value; - } - return value[0].toLowerCase() + value.substring(1); -} diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 9d1e6309..4a041f99 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -111,7 +111,9 @@ final class TypedClientWriter { } else { buffer.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.'); } - buffer.writeln('// ignore_for_file: unused_element'); + buffer.writeln( + '// ignore_for_file: unused_element, non_constant_identifier_names', + ); buffer.writeln(); buffer.writeln("import '${options.ormImport}';"); @@ -572,7 +574,7 @@ final class TypedClientWriter { for (final model in models) { buffer.writeln( - ' late final ${model.delegateClassName} ${model.getterName} =', + ' late final ${model.delegateClassName} ${model.propertyName} =', ); buffer.writeln( " ${model.delegateClassName}(_orm.model('${_escapeString(model.model.runtimeName)}'));", @@ -592,7 +594,7 @@ final class TypedClientWriter { buffer.writeln(); for (final model in models) { buffer.writeln( - ' late final ${model.sqlClassName} ${model.getterName} =', + ' late final ${model.sqlClassName} ${model.propertyName} =', ); buffer.writeln(' ${model.sqlClassName}(_api);'); buffer.writeln(); @@ -3662,15 +3664,15 @@ final class TypedClientWriter { base: _toUpperCamelIdentifier(model.name, fallback: 'Model'), used: usedClassNames, ); - final getterName = _makeUnique( - base: _toLowerCamelIdentifier(model.name, fallback: 'model'), + final propertyName = _makeUnique( + base: _toUpperCamelIdentifier(model.name, fallback: 'Model'), used: usedGetterNames, ); resolved.add( _ResolvedModel( model: model, classBaseName: classBaseName, - getterName: getterName, + propertyName: propertyName, ), ); } @@ -4098,12 +4100,12 @@ enum _TemplateClassKind { data, where, whereUnique, cursor, create, update } final class _ResolvedModel { final TypedModel model; final String classBaseName; - final String getterName; + final String propertyName; const _ResolvedModel({ required this.model, required this.classBaseName, - required this.getterName, + required this.propertyName, }); String get delegateClassName => '${classBaseName}Delegate'; diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index da058a02..a94bb3d4 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -82,7 +82,7 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.db.orm.model('users'); + final users = client.db.orm.model('User'); final created = await users.create( data: {'id': 'u1', 'email': 'a@example.com'}, ); @@ -126,7 +126,7 @@ void main() { await client.connect(); final insertResult = await client.db.sql - .insertInto('users') + .insertInto('User') .values({'id': 'u1', 'email': 'a@example.com'}) .returning(const ['id', 'email']) .execute(); @@ -175,7 +175,7 @@ void main() { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); - final users = client.db.orm.model('users'); + final users = client.db.orm.model('User'); await users.create( data: {'id': 'u1', 'email': 'a@example.com'}, ); @@ -192,6 +192,22 @@ void main() { await client.disconnect(); }); + test('requires exact model names on orm and sql roots', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + expect( + () => client.db.orm.model('users'), + throwsA(isA()), + ); + expect( + () => client.db.sql.from('users'), + throwsA(isA()), + ); + + await client.disconnect(); + }); + test('rejects plan with mismatched contract hash', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -1575,14 +1591,16 @@ void main() { await _seedRelationalData(client); engine.reset(); - final rows = await client.db.orm.model('User').all( - where: { - 'posts': { - 'some': {'title': 'Post A'}, - }, - }, - orderBy: const [OrmOrderBy('id')], - ); + final rows = await client.db.orm + .model('User') + .all( + where: { + 'posts': { + 'some': {'title': 'Post A'}, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); expect(rows.map((row) => row['id']).toList(growable: false), [ 'u1', @@ -1592,9 +1610,7 @@ void main() { ['Post', 'User'], ); expect( - engine.executedPlans - .map((plan) => plan.action) - .toList(growable: false), + engine.executedPlans.map((plan) => plan.action).toList(growable: false), [OrmAction.read, OrmAction.read], ); @@ -1621,10 +1637,10 @@ void main() { traces.map((trace) => trace.relation).toList(growable: false), ['posts', null], ); - expect( - traces.map((trace) => trace.step).toList(growable: false), - [1, 2], - ); + expect(traces.map((trace) => trace.step).toList(growable: false), [ + 1, + 2, + ]); expect(client.telemetry()?.operationId, operationId); expect(client.telemetry()?.operationKind, 'User.read'); expect(client.operationTelemetry(operationId)?.statementCount, 2); @@ -1873,14 +1889,16 @@ void main() { await _seedRelationalData(client); engine.reset(); - final updated = await client.db.orm.model('User').update( - where: { - 'posts': { - 'some': {'title': 'Post C'}, - }, - }, - data: {'email': 'u2+updated@example.com'}, - ); + final updated = await client.db.orm + .model('User') + .update( + where: { + 'posts': { + 'some': {'title': 'Post C'}, + }, + }, + data: {'email': 'u2+updated@example.com'}, + ); expect(updated?['id'], 'u2'); expect( @@ -1888,9 +1906,7 @@ void main() { ['Post', 'User'], ); expect( - engine.executedPlans - .map((plan) => plan.action) - .toList(growable: false), + engine.executedPlans.map((plan) => plan.action).toList(growable: false), [OrmAction.read, OrmAction.update], ); @@ -1917,10 +1933,10 @@ void main() { traces.map((trace) => trace.relation).toList(growable: false), ['posts', null], ); - expect( - traces.map((trace) => trace.step).toList(growable: false), - [1, 2], - ); + expect(traces.map((trace) => trace.step).toList(growable: false), [ + 1, + 2, + ]); expect(client.telemetry()?.operationId, operationId); expect(client.telemetry()?.operationKind, 'User.update'); expect(client.operationTelemetry(operationId)?.statementCount, 2); @@ -2986,7 +3002,7 @@ void main() { contract: contract, engine: MemoryEngine(), collections: { - 'users': + 'User': ({ required OrmCollectionContext client, required String modelName, @@ -2997,7 +3013,7 @@ void main() { ); await client.connect(); - final first = client.db.orm.model('users'); + final first = client.db.orm.model('User'); final second = client.db.orm.model('User'); expect(first, same(second)); @@ -3005,6 +3021,25 @@ void main() { await client.disconnect(); }); + test('requires exact custom collection keys', () { + expect( + () => OrmClient( + contract: contract, + engine: MemoryEngine(), + collections: { + 'users': + ({ + required OrmCollectionContext client, + required String modelName, + }) { + return _UsersCollection(client: client, modelName: modelName); + }, + }, + ), + throwsA(isA()), + ); + }); + test('supports runtime connection and transaction APIs', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index cfae606b..4d3c4b14 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -474,6 +474,14 @@ typedef Post = ({ reason: 'Expected GeneratedOrmClient to remove direct model delegate getters.', ); + expect( + RegExp( + r'class\s+GeneratedOrmCollections\s*\{[\s\S]*?UserDelegate\s+User\s*=', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected GeneratedOrmCollections to expose exact model-name delegate getters.', + ); expect( RegExp( r'class\s+GeneratedOrmDb\s*\{[\s\S]*?late\s+final\s+GeneratedOrmCollections\s+orm\s*=\s*GeneratedOrmCollections\(_db\.orm\);[\s\S]*?late\s+final\s+GeneratedOrmSql\s+sql\s*=\s*GeneratedOrmSql\(_db\.sql\);', @@ -484,11 +492,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+GeneratedOrmSql\s*\{[\s\S]*?final\s+OrmSqlApi\s+_api;[\s\S]*?UserSql\s+user\s*=', + r'class\s+GeneratedOrmSql\s*\{[\s\S]*?final\s+OrmSqlApi\s+_api;[\s\S]*?UserSql\s+User\s*=', ).hasMatch(generatedSource), isTrue, reason: - 'Expected GeneratedOrmSql to expose typed model sql delegates.', + 'Expected GeneratedOrmSql to expose exact model-name sql delegates.', ); expect( RegExp( From 7bf49e2696a169a26bfe82f20732c05ffa88421c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:22:14 +0800 Subject: [PATCH 124/154] test(client): lock self relation include and nested writes --- pub/orm/test/client/client_test.dart | 258 +++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index a94bb3d4..7cc2375f 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -50,6 +50,35 @@ void main() { }, aliases: {'users': 'User', 'posts': 'Post'}, ); + + final selfRelationalContract = OrmContract( + version: '1', + hash: 'contract-self-rel-v1', + models: { + 'User': ModelContract( + name: 'User', + table: 'users', + fields: {'id', 'email', 'invitedById'}, + relations: { + 'invitedUsers': ModelRelationContract( + name: 'invitedUsers', + relatedModel: 'User', + sourceFields: ['id'], + targetFields: ['invitedById'], + cardinality: RelationCardinality.many, + ), + 'invitedBy': ModelRelationContract( + name: 'invitedBy', + relatedModel: 'User', + sourceFields: ['invitedById'], + targetFields: ['id'], + cardinality: RelationCardinality.one, + ), + }, + ), + }, + aliases: {'users': 'User'}, + ); group('OrmClient + MemoryEngine', () { test('default include strategy selector follows contract capabilities', () { final multi = defaultIncludeExecutionStrategySelector( @@ -2037,6 +2066,115 @@ void main() { await client.disconnect(); }); + test( + 'supports self-relation include for to-many across strategies', + () async { + Future> readWithStrategy( + IncludeExecutionStrategy strategy, + ) async { + final client = OrmClient( + contract: selfRelationalContract, + engine: MemoryEngine(), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => strategy, + ); + await client.connect(); + try { + await _seedSelfRelationalData(client); + return await client.db.orm + .model('User') + .all( + orderBy: const [OrmOrderBy('id')], + include: { + 'invitedUsers': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }, + ); + } finally { + await client.disconnect(); + } + } + + final singleRows = await readWithStrategy( + IncludeExecutionStrategy.singleQuery, + ); + final multiRows = await readWithStrategy( + IncludeExecutionStrategy.multiQuery, + ); + + expect(singleRows, equals(multiRows)); + expect(singleRows, hasLength(4)); + expect( + _readRowsValue(singleRows[0]['invitedUsers']).map((row) => row['id']), + ['u2', 'u3'], + ); + expect( + _readRowsValue(singleRows[1]['invitedUsers']).map((row) => row['id']), + ['u4'], + ); + expect(_readRowsValue(singleRows[2]['invitedUsers']), isEmpty); + expect(_readRowsValue(singleRows[3]['invitedUsers']), isEmpty); + }, + ); + + test( + 'supports self-relation include for to-one across strategies', + () async { + Future> readWithStrategy( + IncludeExecutionStrategy strategy, + ) async { + final client = OrmClient( + contract: selfRelationalContract, + engine: MemoryEngine(), + includeStrategySelector: + ({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, + }) => strategy, + ); + await client.connect(); + try { + await _seedSelfRelationalData(client); + return await client.db.orm + .model('User') + .all( + orderBy: const [OrmOrderBy('id')], + include: { + 'invitedBy': IncludeSpec( + select: const ['id', 'email', 'invitedById'], + ), + }, + ); + } finally { + await client.disconnect(); + } + } + + final singleRows = await readWithStrategy( + IncludeExecutionStrategy.singleQuery, + ); + final multiRows = await readWithStrategy( + IncludeExecutionStrategy.multiQuery, + ); + + expect(singleRows, equals(multiRows)); + expect(_readRowValue(singleRows[0]['invitedBy']), isNull); + expect(_readRowValue(singleRows[1]['invitedBy'])?['id'], 'u1'); + expect(_readRowValue(singleRows[2]['invitedBy'])?['id'], 'u1'); + expect(_readRowValue(singleRows[3]['invitedBy'])?['id'], 'u2'); + }, + ); + test( 'singleQuery include matches multiQuery semantics for one-to-many', () async { @@ -2565,6 +2703,50 @@ void main() { }, ); + test('supports self-relation nested create orchestration', () async { + final client = OrmClient( + contract: selfRelationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + + final created = await client.db.orm + .model('User') + .createNested( + data: {'id': 'u1', 'email': 'u1@example.com'}, + create: >{ + 'invitedUsers': [ + {'id': 'u2', 'email': 'u2@example.com'}, + {'id': 'u3', 'email': 'u3@example.com'}, + ], + }, + ); + + expect(created['id'], 'u1'); + final invitedUsers = _readRowsValue(created['invitedUsers']); + expect(invitedUsers, hasLength(2)); + expect( + invitedUsers.map((row) => row['invitedById']).toList(growable: false), + ['u1', 'u1'], + ); + + final persisted = await client.db.orm + .model('User') + .all(orderBy: const [OrmOrderBy('id')]); + expect( + persisted.map((row) => row['id']).toList(growable: false), + ['u1', 'u2', 'u3'], + ); + expect( + persisted + .skip(1) + .map((row) => row['invitedById']) + .toList(growable: false), + ['u1', 'u1'], + ); + await client.disconnect(); + }); + test('nested create rolls back when child mutation fails', () async { final client = OrmClient( contract: relationalContract, @@ -2640,6 +2822,49 @@ void main() { }, ); + test( + 'updateNested supports self-relation child creation with include payload', + () async { + final client = OrmClient( + contract: selfRelationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedSelfRelationalData(client); + + final updated = await client.db.orm + .model('User') + .updateNested( + where: {'id': 'u1'}, + data: {'email': 'u1+updated@example.com'}, + create: >{ + 'invitedUsers': [ + {'id': 'u5', 'email': 'u5@example.com'}, + ], + }, + include: { + 'invitedUsers': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + ), + }, + ); + + expect(updated?['email'], 'u1+updated@example.com'); + final invitedUsers = _readRowsValue(updated?['invitedUsers']); + expect( + invitedUsers.map((row) => row['id']).toList(growable: false), + ['u2', 'u3', 'u5'], + ); + expect(invitedUsers.last['invitedById'], 'u1'); + + final persistedChild = await client.db.orm + .model('User') + .oneOrNull(where: {'id': 'u5'}); + expect(persistedChild?['invitedById'], 'u1'); + await client.disconnect(); + }, + ); + test('updateNested returns null when parent record is missing', () async { final client = OrmClient( contract: relationalContract, @@ -4306,6 +4531,39 @@ Future _seedRelationalData(OrmClient client) async { ); } +Future _seedSelfRelationalData(OrmClient client) async { + final users = client.db.orm.model('User'); + + await users.create( + data: { + 'id': 'u1', + 'email': 'u1@example.com', + 'invitedById': null, + }, + ); + await users.create( + data: { + 'id': 'u2', + 'email': 'u2@example.com', + 'invitedById': 'u1', + }, + ); + await users.create( + data: { + 'id': 'u3', + 'email': 'u3@example.com', + 'invitedById': 'u1', + }, + ); + await users.create( + data: { + 'id': 'u4', + 'email': 'u4@example.com', + 'invitedById': 'u2', + }, + ); +} + JsonMap? _readRowValue(Object? value) { if (value == null) { return null; From 2abb36f1ed1442939c0bd6fa52ae5e0fad77b42b Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:34:26 +0800 Subject: [PATCH 125/154] fix(generator)!: preserve identifier casing in typed surfaces BREAKING CHANGE: generated typed identifiers now preserve camelCase and UpperCamel relation names instead of collapsing internal capitals. --- pub/orm/lib/src/generator/writer.dart | 21 ++++- pub/orm/test/generator/generate_test.dart | 107 ++++++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 4a041f99..1c115b98 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -4056,14 +4056,27 @@ final class TypedClientWriter { final normalized = []; for (final segment in segments) { - final withPrefix = RegExp(r'^[0-9]').hasMatch(segment) - ? 'n$segment' - : segment; - normalized.add(withPrefix); + for (final part in _splitIdentifierSegment(segment)) { + final withPrefix = RegExp(r'^[0-9]').hasMatch(part) ? 'n$part' : part; + normalized.add(withPrefix); + } } return normalized; } + List _splitIdentifierSegment(String segment) { + final matches = RegExp( + r'[A-Z]+(?=[A-Z][a-z]|[0-9]|$)|[A-Z]?[a-z]+|[0-9]+', + ).allMatches(segment); + if (matches.isEmpty) { + return [segment]; + } + return matches + .map((match) => match.group(0)!) + .where((part) => part.isNotEmpty) + .toList(growable: false); + } + String _capitalize(String value) { if (value.isEmpty) { return value; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 4d3c4b14..7a9af799 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1657,6 +1657,113 @@ typedef Post = ({ ); }); + test('generates typed self-relation include and relation filter surfaces', () async { + final fixtureDir = _copyFixture(fixturesRoot, 'default_output'); + addTearDown(() => fixtureDir.deleteSync(recursive: true)); + + final schemaFile = File( + _path([fixtureDir.path, 'orm.schema.dart']), + ); + schemaFile.writeAsStringSync(''' +class _ModelMarker { + const _ModelMarker(); +} + +const model = _ModelMarker(); + +class Relation { + final Set? fields; + final Set? references; + final String? name; + + const Relation({this.fields, this.references, this.name}); +} + +@model +typedef User = ({ + String id, + String email, + String? invitedById, + @Relation(fields: {'invitedById'}, references: {'id'}) + User? invitedBy, + @Relation(references: {'invitedById'}) + List invitedUsers +}); +'''); + + final run = await _runGenerate( + entryPath: generatorEntry.path, + workingDirectory: fixtureDir.path, + ); + + expect(run.exitCode, 0, reason: run.debugOutput); + + var generatedDartFiles = _findDartFiles( + Directory(_path([fixtureDir.path, 'generated'])), + ); + if (generatedDartFiles.isEmpty) { + generatedDartFiles = _findDartFiles( + Directory(_path([fixtureDir.path, 'lib'])), + ); + } + expect( + generatedDartFiles, + isNotEmpty, + reason: 'Expected generated Dart files to assert self-relation DSL.', + ); + + final generatedSource = generatedDartFiles + .map((file) => file.readAsStringSync()) + .join('\n'); + + expect( + generatedSource.contains('class UserInvitedUsersInclude'), + isTrue, + reason: + 'Expected self to-many include class to generate for invitedUsers.', + ); + expect( + generatedSource.contains('class UserInvitedByInclude'), + isTrue, + reason: + 'Expected self to-one include class to generate for invitedBy.', + ); + expect( + generatedSource.contains('includeInvitedUsers('), + isTrue, + reason: 'Expected typed helpers for self to-many include relation.', + ); + expect( + generatedSource.contains('includeInvitedBy('), + isTrue, + reason: 'Expected typed helpers for self to-one include relation.', + ); + expect( + generatedSource.contains( + "static const UserDistinct invitedById = UserDistinct._('invitedById');", + ), + isTrue, + reason: + 'Expected camelCase scalar identifiers to preserve field casing in generated constants.', + ); + expect( + RegExp( + r'static\s+UserOrderBy\s+invitedById\(\{SortOrder\s+order\s*=\s*SortOrder\.asc\}\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected camelCase scalar identifiers to preserve field casing in generated orderBy helpers.', + ); + expect( + RegExp( + r'class\s+UserWhereInput\s*\{[\s\S]*?final\s+StringWhereFilter\?\s+invitedById;[\s\S]*?final\s+UserInvitedByRelationWhereFilter\?\s+invitedBy;[\s\S]*?final\s+UserInvitedUsersRelationWhereFilter\?\s+invitedUsers;', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected self-relation where fields and camelCase scalar fields to remain typed.', + ); + }); + test('prints actionable error message for invalid config', () async { final fixtureDir = _copyFixture(fixturesRoot, 'missing_config'); addTearDown(() => fixtureDir.deleteSync(recursive: true)); From 7ff610ba2cd5d72e6f549ded183e73329e91ae92 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:39:13 +0800 Subject: [PATCH 126/154] refactor(generator): route typed read delegates through queries --- pub/orm/lib/src/generator/writer.dart | 93 ++++++++--------------- pub/orm/test/generator/generate_test.dart | 32 ++++++++ 2 files changed, 62 insertions(+), 63 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 1c115b98..349578c8 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1719,31 +1719,16 @@ final class TypedClientWriter { ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln( - ' final runtimeDistinct = distinct.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final rows = await _delegate.all('); - buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); - buffer.writeln(' orderBy: runtimeOrderBy,'); - buffer.writeln(' distinct: runtimeDistinct,'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln( - ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', - ); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).all();'); buffer.writeln(' }'); buffer.writeln(); @@ -1751,22 +1736,12 @@ final class TypedClientWriter { buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final row = await _delegate.oneOrNull('); - buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where.toWhereInput(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).oneOrNull();'); buffer.writeln(' }'); buffer.writeln(); @@ -1783,31 +1758,15 @@ final class TypedClientWriter { ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln( - ' final runtimeDistinct = distinct.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final row = await _delegate.firstOrNull('); - buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); buffer.writeln(' skip: skip,'); - buffer.writeln(' orderBy: runtimeOrderBy,'); - buffer.writeln(' distinct: runtimeDistinct,'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).firstOrNull();'); buffer.writeln(' }'); buffer.writeln(); @@ -3420,6 +3379,14 @@ final class TypedClientWriter { } buffer.writeln(' };'); buffer.writeln(' }'); + if (classKind == _TemplateClassKind.whereUnique) { + buffer.writeln(); + buffer.writeln(' ${model.whereInputClassName} toWhereInput() {'); + buffer.writeln( + ' return ${model.whereInputClassName}.fromJson(toJson());', + ); + buffer.writeln(' }'); + } if (includeLogicalWhere) { buffer.writeln(); buffer.writeln(' bool get isEmpty =>'); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 7a9af799..ba0107ca 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -986,6 +986,14 @@ typedef Post = ({ isTrue, reason: 'Expected non-unique all to keep UserWhereInput.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+all\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?distinct:\s*distinct,[\s\S]*?\)\.all\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate all(...) to route through typed query instead of rebuilding runtime read arguments.', + ); expect( RegExp( r'Future>\s+all\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', @@ -1002,6 +1010,30 @@ typedef Post = ({ reason: 'Expected firstOrNull to expose typed distinct parameter in generated delegate.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+firstOrNull\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?distinct:\s*distinct,[\s\S]*?\)\.firstOrNull\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate firstOrNull(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserWhereUniqueInput\s*\{[\s\S]*?UserWhereInput\s+toWhereInput\(\)\s*\{[\s\S]*?return\s+UserWhereInput\.fromJson\(toJson\(\)\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected unique input to expose typed toWhereInput() conversion for read delegate reuse.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+oneOrNull\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where\.toWhereInput\(\),[\s\S]*?\)\.oneOrNull\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate oneOrNull(...) to route through typed query using unique-to-where conversion.', + ); expect( generatedSource.contains('Future> findMany('), isFalse, From c1ce6e27628ce57450b32d9eb6977fd234d74818 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:46:55 +0800 Subject: [PATCH 127/154] refactor(generator): collapse typed delegates onto query paths --- pub/orm/lib/src/generator/writer.dart | 169 ++++++---------------- pub/orm/test/generator/generate_test.dart | 76 +++++++++- 2 files changed, 120 insertions(+), 125 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 349578c8..650d1b19 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1818,23 +1818,11 @@ final class TypedClientWriter { buffer.writeln(' required List<${model.createInputClassName}> data,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final rows = await _delegate.createMany('); - buffer.writeln( - ' data: data.map((entry) => entry.toJson()).toList(growable: false),', - ); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln( - ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', - ); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).createMany(data: data);'); buffer.writeln(' }'); buffer.writeln(); @@ -1873,24 +1861,12 @@ final class TypedClientWriter { ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final row = await _delegate.updateNested('); - buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' data: data.toJson(),'); - buffer.writeln(' create: create.toJson(),'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).updateNested(data: data, create: create);'); buffer.writeln(' }'); buffer.writeln(); @@ -1949,18 +1925,11 @@ final class TypedClientWriter { buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' return _delegate.updateMany('); - buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' data: data.toJson(),'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).updateMany(data: data);'); buffer.writeln(' }'); buffer.writeln(); @@ -1969,7 +1938,7 @@ final class TypedClientWriter { ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); buffer.writeln(' }) {'); - buffer.writeln(' return _delegate.deleteMany(where: where.toJson());'); + buffer.writeln(' return query(where: where).deleteMany();'); buffer.writeln(' }'); buffer.writeln(); @@ -1978,7 +1947,7 @@ final class TypedClientWriter { ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); buffer.writeln(' }) {'); - buffer.writeln(' return _delegate.count(where: where.toJson());'); + buffer.writeln(' return query(where: where).count();'); buffer.writeln(' }'); buffer.writeln(); @@ -1987,7 +1956,7 @@ final class TypedClientWriter { ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); buffer.writeln(' }) {'); - buffer.writeln(' return _delegate.exists(where: where.toJson());'); + buffer.writeln(' return query(where: where).exists();'); buffer.writeln(' }'); buffer.writeln(); @@ -2011,29 +1980,15 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); - buffer.writeln(' }) async {'); - buffer.writeln(' final value = await _delegate.aggregate('); - buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' }) {'); + buffer.writeln(' return query(where: where).aggregate('); buffer.writeln(' countAll: countAll,'); - buffer.writeln( - ' count: count.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' min: min.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' max: max.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' sum: sum.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' avg: avg.map((entry) => entry.value).toList(growable: false),', - ); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); buffer.writeln(' );'); - buffer.writeln( - ' return ${model.aggregateResultClassName}.fromJson(value);', - ); buffer.writeln(' }'); buffer.writeln(); @@ -2066,40 +2021,22 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeOrderBy = groupByOrderBy.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln(' final runtimeHaving = typedHaving.toJson();'); - buffer.writeln(' final rows = await _delegate.groupBy('); - buffer.writeln( - ' by: by.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' having: runtimeHaving,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); - buffer.writeln(' orderBy: runtimeOrderBy,'); + buffer.writeln(' ).groupBy('); + buffer.writeln(' by: by,'); + buffer.writeln(' groupByOrderBy: groupByOrderBy,'); + buffer.writeln(' typedHaving: typedHaving,'); buffer.writeln(' countAll: countAll,'); - buffer.writeln( - ' count: count.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' min: min.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' max: max.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' sum: sum.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' avg: avg.map((entry) => entry.value).toList(growable: false),', - ); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); buffer.writeln(' );'); - buffer.writeln( - ' return rows.map(${model.groupByResultClassName}.fromJson).toList(growable: false);', - ); buffer.writeln(' }'); buffer.writeln(); @@ -2117,30 +2054,16 @@ final class TypedClientWriter { ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async* {'); - buffer.writeln( - ' final runtimeOrderBy = orderBy.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln( - ' final runtimeDistinct = distinct.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' await for (final row in _delegate.stream('); - buffer.writeln(' where: where.toJson(),'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); buffer.writeln(' skip: skip,'); buffer.writeln(' take: take,'); - buffer.writeln(' orderBy: runtimeOrderBy,'); - buffer.writeln(' distinct: runtimeDistinct,'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' )) {'); - buffer.writeln(' yield ${model.dataClassName}.fromJson(row);'); - buffer.writeln(' }'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).stream();'); buffer.writeln(' }'); buffer.writeln('}'); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index ba0107ca..ecce7454 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1034,6 +1034,78 @@ typedef Post = ({ reason: 'Expected generated delegate oneOrNull(...) to route through typed query using unique-to-where conversion.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Stream\s+stream\(\{[\s\S]*?return\s+query\([\s\S]*?distinct:\s*distinct,[\s\S]*?\)\.stream\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate stream(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+createMany\(\{[\s\S]*?return\s+query\([\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.createMany\(data:\s*data\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate createMany(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateNested\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.updateNested\(data:\s*data,\s*create:\s*create\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate updateNested(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateMany\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.updateMany\(data:\s*data\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate updateMany(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+deleteMany\(\{[\s\S]*?return\s+query\(where:\s*where\)\.deleteMany\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate deleteMany(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+count\(\{[\s\S]*?return\s+query\(where:\s*where\)\.count\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate count(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+exists\(\{[\s\S]*?return\s+query\(where:\s*where\)\.exists\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate exists(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+aggregate\(\{[\s\S]*?return\s+query\(where:\s*where\)\.aggregate\([\s\S]*?count:\s*count,[\s\S]*?avg:\s*avg,[\s\S]*?\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate aggregate(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?skip:\s*skip,[\s\S]*?take:\s*take,[\s\S]*?\)\.groupBy\([\s\S]*?groupByOrderBy:\s*groupByOrderBy,[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate groupBy(...) to route through typed query.', + ); expect( generatedSource.contains('Future> findMany('), isFalse, @@ -1265,11 +1337,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?final\s+runtimeHaving\s*=\s*typedHaving\.toJson\(\);', + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?skip:\s*skip,[\s\S]*?take:\s*take,[\s\S]*?\)\.groupBy\([\s\S]*?groupByOrderBy:\s*groupByOrderBy,[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.groupBy(...) to resolve runtime having from typedHaving only.', + 'Expected UserDelegate.groupBy(...) to route typed groupBy execution through query.', ); expect( RegExp( From 09849672065e37c801a885ae7ee76c4873a4baff Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:51:09 +0800 Subject: [PATCH 128/154] feat(generator): add typed mutation query terminals --- pub/orm/lib/src/generator/writer.dart | 184 +++++++++++++--------- pub/orm/test/generator/generate_test.dart | 75 +++++++++ 2 files changed, 184 insertions(+), 75 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 650d1b19..8bc8c2f3 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1774,19 +1774,11 @@ final class TypedClientWriter { buffer.writeln(' required ${model.createInputClassName} data,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final row = await _delegate.create('); - buffer.writeln(' data: data.toJson(),'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).create(data: data);'); buffer.writeln(' }'); buffer.writeln(); @@ -1797,20 +1789,11 @@ final class TypedClientWriter { ); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final row = await _delegate.createNested('); - buffer.writeln(' data: data.toJson(),'); - buffer.writeln(' create: create.toJson(),'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).createNested(data: data, create: create);'); buffer.writeln(' }'); buffer.writeln(); @@ -1831,23 +1814,12 @@ final class TypedClientWriter { buffer.writeln(' required ${model.updateInputClassName} data,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final row = await _delegate.update('); - buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' data: data.toJson(),'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where.toWhereInput(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).update(data: data);'); buffer.writeln(' }'); buffer.writeln(); @@ -1874,22 +1846,12 @@ final class TypedClientWriter { buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final row = await _delegate.delete('); - buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where.toWhereInput(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).delete();'); buffer.writeln(' }'); buffer.writeln(); @@ -1899,21 +1861,12 @@ final class TypedClientWriter { buffer.writeln(' required ${model.updateInputClassName} update,'); buffer.writeln(' ${model.selectClassName}? select,'); buffer.writeln(' ${model.includeClassName}? include,'); - buffer.writeln(' }) async {'); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); - buffer.writeln( - ' final runtimeInclude = include?.toIncludeMap() ?? const {};', - ); - buffer.writeln(' final row = await _delegate.upsert('); - buffer.writeln(' where: where.toJson(),'); - buffer.writeln(' create: create.toJson(),'); - buffer.writeln(' update: update.toJson(),'); - buffer.writeln(' select: runtimeSelect,'); - buffer.writeln(' include: runtimeInclude,'); - buffer.writeln(' );'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where.toWhereInput(),'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).upsert(create: create, update: update);'); buffer.writeln(' }'); buffer.writeln(); @@ -2949,6 +2902,40 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}> create({'); + buffer.writeln(' required ${model.createInputClassName} data,'); + buffer.writeln(' }) async {'); + buffer.writeln( + " _assertMutationQueryState(action: 'create', allowWhere: false);", + ); + buffer.writeln(' final row = await _delegate._delegate.create('); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}> createNested({'); + buffer.writeln(' required ${model.createInputClassName} data,'); + buffer.writeln( + ' ${model.nestedCreateInputClassName} create = const ${model.nestedCreateInputClassName}(),', + ); + buffer.writeln(' }) async {'); + buffer.writeln( + " _assertMutationQueryState(action: 'createNested', allowWhere: false);", + ); + buffer.writeln(' final row = await _delegate._delegate.createNested('); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' create: create.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( ' Future> createMany({required List<${model.createInputClassName}> data}) async {', ); @@ -2989,6 +2976,23 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> update({'); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln(' }) async {'); + buffer.writeln(" _assertMutationQueryState(action: 'update');"); + buffer.writeln(' final row = await _delegate._delegate.update('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( ' Future updateMany({required ${model.updateInputClassName} data}) {', ); @@ -3010,6 +3014,36 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> delete() async {'); + buffer.writeln(" _assertMutationQueryState(action: 'delete');"); + buffer.writeln(' final row = await _delegate._delegate.delete('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}> upsert({'); + buffer.writeln(' required ${model.createInputClassName} create,'); + buffer.writeln(' required ${model.updateInputClassName} update,'); + buffer.writeln(' }) async {'); + buffer.writeln(" _assertMutationQueryState(action: 'upsert');"); + buffer.writeln(' final row = await _delegate._delegate.upsert('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' create: create.toJson(),'); + buffer.writeln(' update: update.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future count() {'); buffer.writeln(" _assertReadExecutionSupported('count');"); buffer.writeln(' return _delegate._delegate.count('); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index ecce7454..adfd2a32 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -947,6 +947,20 @@ typedef Post = ({ reason: 'Expected UserDelegate.createNested(...) to expose typed nested create input.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+create\(\{\s*required\s+UserCreateInput\s+data', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.create(...) to exist.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+createNested\(\{\s*required\s+UserCreateInput\s+data,\s*UserNestedCreateInput\s+create\s*=\s*const\s+UserNestedCreateInput\(\),', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.createNested(...) to exist.', + ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateNested\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),\s*required\s+UserUpdateInput\s+data,\s*UserNestedCreateInput\s+create\s*=\s*const\s+UserNestedCreateInput\(\),', @@ -963,6 +977,13 @@ typedef Post = ({ reason: 'Expected UserQuery.updateNested(...) to expose typed nested create input.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+update\(\{\s*required\s+UserUpdateInput\s+data', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.update(...) to exist.', + ); expect( RegExp( r'Future\s+delete\(\{\s*required\s+UserWhereUniqueInput\s+where,', @@ -979,6 +1000,20 @@ typedef Post = ({ reason: 'Expected upsert where parameter to use UserWhereUniqueInput.', ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+delete\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.delete() to exist.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+upsert\(\{\s*required\s+UserCreateInput\s+create,\s*required\s+UserUpdateInput\s+update,', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.upsert(...) to exist.', + ); expect( RegExp( r'Future>\s+all\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),', @@ -1050,6 +1085,22 @@ typedef Post = ({ reason: 'Expected generated delegate createMany(...) to route through typed query.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+create\(\{[\s\S]*?return\s+query\([\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.create\(data:\s*data\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate create(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+createNested\(\{[\s\S]*?return\s+query\([\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.createNested\(data:\s*data,\s*create:\s*create\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate createNested(...) to route through typed query.', + ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateNested\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.updateNested\(data:\s*data,\s*create:\s*create\);', @@ -1066,6 +1117,14 @@ typedef Post = ({ reason: 'Expected generated delegate updateMany(...) to route through typed query.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+update\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where\.toWhereInput\(\),[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.update\(data:\s*data\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate update(...) to route through typed query.', + ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+deleteMany\(\{[\s\S]*?return\s+query\(where:\s*where\)\.deleteMany\(\);', @@ -1074,6 +1133,22 @@ typedef Post = ({ reason: 'Expected generated delegate deleteMany(...) to route through typed query.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+delete\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where\.toWhereInput\(\),[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.delete\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate delete(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+upsert\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where\.toWhereInput\(\),[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.upsert\(create:\s*create,\s*update:\s*update\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate upsert(...) to route through typed query.', + ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+count\(\{[\s\S]*?return\s+query\(where:\s*where\)\.count\(\);', From ec2996193ce9ecb16ba065664134397981f365bd Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:54:34 +0800 Subject: [PATCH 129/154] refactor(generator): build unique filters without json bridging --- pub/orm/lib/src/generator/writer.dart | 11 ++++++++--- pub/orm/test/generator/generate_test.dart | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 8bc8c2f3..e0826329 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -3339,9 +3339,14 @@ final class TypedClientWriter { if (classKind == _TemplateClassKind.whereUnique) { buffer.writeln(); buffer.writeln(' ${model.whereInputClassName} toWhereInput() {'); - buffer.writeln( - ' return ${model.whereInputClassName}.fromJson(toJson());', - ); + buffer.writeln(' return ${model.whereInputClassName}('); + for (final field in fields) { + final filterClass = _whereFilterClassName(field.field.scalarType); + buffer.writeln( + ' ${field.memberName}: ${field.memberName} == null ? null : $filterClass(equals: ${field.memberName}),', + ); + } + buffer.writeln(' );'); buffer.writeln(' }'); } if (includeLogicalWhere) { diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index adfd2a32..3897355b 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1055,11 +1055,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserWhereUniqueInput\s*\{[\s\S]*?UserWhereInput\s+toWhereInput\(\)\s*\{[\s\S]*?return\s+UserWhereInput\.fromJson\(toJson\(\)\);', + r'class\s+UserWhereUniqueInput\s*\{[\s\S]*?UserWhereInput\s+toWhereInput\(\)\s*\{[\s\S]*?return\s+UserWhereInput\([\s\S]*?id:\s*id\s*==\s*null\s*\?\s*null\s*:\s*IntWhereFilter\(equals:\s*id\),', ).hasMatch(generatedSource), isTrue, reason: - 'Expected unique input to expose typed toWhereInput() conversion for read delegate reuse.', + 'Expected unique input to expose direct typed toWhereInput() conversion without JSON round-tripping.', ); expect( RegExp( From 8eedc863f1099eb59a0b4f6aeeaf708a22e08072 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:07:37 +0800 Subject: [PATCH 130/154] refactor(client): route dynamic delegates through query terminals --- pub/orm/lib/src/client/client.dart | 618 ++++++++++++++++------------- 1 file changed, 342 insertions(+), 276 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 7ffbcbb8..a04bdf15 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1062,7 +1062,9 @@ class ModelDelegate { this, ); - ModelQuery query() => ModelQuery._(this, OrmReadQuerySpec()); + ModelQuery query() => _queryFromSpec(OrmReadQuerySpec()); + + ModelQuery _queryFromSpec(OrmReadQuerySpec spec) => ModelQuery._(this, spec); ModelQuery where(JsonMap where) => query().where(where); @@ -1166,22 +1168,19 @@ class ModelDelegate { Map include = const {}, JsonMap? cursor, OrmReadPagePlan? page, - }) async { - final prepared = await prepareRead( - spec: OrmReadQuerySpec( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - ), - ); - return prepared.plan; - } + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), + ).toPlan(); Future> all({ JsonMap where = const {}, @@ -1193,22 +1192,19 @@ class ModelDelegate { Map include = const {}, JsonMap? cursor, OrmReadPagePlan? page, - }) async { - final prepared = await prepareRead( - spec: OrmReadQuerySpec( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - ), - ); - return prepared.all(); - } + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), + ).all(); Future> pageResult({ JsonMap where = const {}, @@ -1216,18 +1212,15 @@ class ModelDelegate { List select = const [], Map include = const {}, required OrmReadPagePlan page, - }) async { - final prepared = await prepareRead( - spec: OrmReadQuerySpec( - where: where, - orderBy: orderBy, - select: select, - include: include, - page: page, - ), - ); - return prepared.pageResult(); - } + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + orderBy: orderBy, + select: select, + include: include, + page: page, + ), + ).pageResult(); Stream stream({ JsonMap where = const {}, @@ -1239,33 +1232,27 @@ class ModelDelegate { Map include = const {}, JsonMap? cursor, OrmReadPagePlan? page, - }) async* { - final prepared = await prepareRead( - spec: OrmReadQuerySpec( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - ), - ); - yield* prepared.stream(); - } + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), + ).stream(); Future oneOrNull({ JsonMap where = const {}, List select = const [], Map include = const {}, - }) async { - final prepared = await prepareRead( - spec: OrmReadQuerySpec(where: where, select: select, include: include), - ); - return prepared.oneOrNull(); - } + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).oneOrNull(); Future firstOrNull({ JsonMap where = const {}, @@ -1274,51 +1261,44 @@ class ModelDelegate { List distinct = const [], List select = const [], Map include = const {}, - }) async { - final prepared = await prepareRead( - spec: OrmReadQuerySpec( - where: where, - skip: skip, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - ), - ); - return prepared.firstOrNull(); - } + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + skip: skip, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + ), + ).firstOrNull(); Future count({ JsonMap where = const {}, List orderBy = const [], JsonMap? cursor, OrmReadPagePlan? page, - }) async { - final rows = await _readAllInternal( - action: OrmAction.read, + }) => _queryFromSpec( + OrmReadQuerySpec( where: where, orderBy: orderBy, cursor: cursor, page: page, - includeDepth: 0, - ); - return rows.length; - } + ), + ).count(); Future exists({ JsonMap where = const {}, List orderBy = const [], JsonMap? cursor, OrmReadPagePlan? page, - }) async { - final rowCount = await count( + }) => _queryFromSpec( + OrmReadQuerySpec( where: where, orderBy: orderBy, cursor: cursor, page: page, - ); - return rowCount > 0; - } + ), + ).exists(); Future inspectPlan({ JsonMap where = const {}, @@ -1330,22 +1310,19 @@ class ModelDelegate { Map include = const {}, JsonMap? cursor, OrmReadPagePlan? page, - }) async { - final prepared = await prepareRead( - spec: OrmReadQuerySpec( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - ), - ); - return prepared.inspectPlan(); - } + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), + ).inspectPlan(); Future explain({ JsonMap where = const {}, @@ -1357,22 +1334,19 @@ class ModelDelegate { Map include = const {}, JsonMap? cursor, OrmReadPagePlan? page, - }) async { - final prepared = await prepareRead( - spec: OrmReadQuerySpec( - where: where, - skip: skip, - take: take, - orderBy: orderBy, - distinct: distinct, - select: select, - include: include, - cursor: cursor, - page: page, - ), - ); - return prepared.explain(); - } + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + ), + ).explain(); Future aggregate({ JsonMap where = const {}, @@ -1385,6 +1359,158 @@ class ModelDelegate { List max = const [], List sum = const [], List avg = const [], + }) => + _queryFromSpec( + OrmReadQuerySpec( + where: where, + orderBy: orderBy, + cursor: cursor, + page: page, + ), + ).aggregate( + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + + Future> groupBy({ + required List by, + JsonMap where = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + JsonMap having = const {}, + int? skip, + int? take, + List orderBy = const [], + bool countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) => + _queryFromSpec( + OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + cursor: cursor, + page: page, + ), + ).groupBy( + by: by, + having: having, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); + + Future create({ + required JsonMap data, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(select: select, include: include), + ).create(data: data); + + Future createNested({ + required JsonMap data, + Map> create = const >{}, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(select: select, include: include), + ).createNested(data: data, create: create); + + Future updateNested({ + JsonMap where = const {}, + required JsonMap data, + Map> create = const >{}, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).updateNested(data: data, create: create); + + Future> createMany({ + required List data, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(select: select, include: include), + ).createMany(data: data); + + Future updateMany({ + JsonMap where = const {}, + required JsonMap data, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).updateMany(data: data); + + Future deleteMany({JsonMap where = const {}}) => + _queryFromSpec(OrmReadQuerySpec(where: where)).deleteMany(); + + Future upsert({ + required JsonMap where, + required JsonMap create, + required JsonMap update, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).upsert(create: create, update: update); + + Future update({ + JsonMap where = const {}, + required JsonMap data, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).update(data: data); + + Future delete({ + JsonMap where = const {}, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).delete(); + + Future _count({required OrmReadQuerySpec spec}) async { + final rows = await _readAllInternal( + action: OrmAction.read, + where: spec.where, + orderBy: spec.orderBy, + cursor: spec.cursor, + page: spec.page, + includeDepth: 0, + ); + return rows.length; + } + + Future _exists({required OrmReadQuerySpec spec}) async { + final rowCount = await _count(spec: spec); + return rowCount > 0; + } + + Future _aggregate({ + required OrmReadQuerySpec spec, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, }) async { _assertKnownAggregateFields(fields: count, source: 'aggregate.count'); _assertKnownAggregateFields(fields: min, source: 'aggregate.min'); @@ -1394,10 +1520,10 @@ class ModelDelegate { final rows = await _readAllInternal( action: OrmAction.read, - where: where, - orderBy: orderBy, - cursor: cursor, - page: page, + where: spec.where, + orderBy: spec.orderBy, + cursor: spec.cursor, + page: spec.page, select: _buildAggregateSelect( count: count, min: min, @@ -1419,21 +1545,16 @@ class ModelDelegate { ); } - Future> groupBy({ + Future> _groupBy({ + required OrmReadQuerySpec spec, required List by, - JsonMap where = const {}, - JsonMap? cursor, - OrmReadPagePlan? page, - JsonMap having = const {}, - int? skip, - int? take, - List orderBy = const [], - bool countAll = false, - List count = const [], - List min = const [], - List max = const [], - List sum = const [], - List avg = const [], + required JsonMap having, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, }) async { if (by.isEmpty) { throw runtimeError( @@ -1442,20 +1563,20 @@ class ModelDelegate { details: {'model': modelName}, ); } - if (skip case final offset? when offset < 0) { + if (spec.skip case final offset? when offset < 0) { throw PlanInvalidPaginationException(key: 'skip', value: offset); } - if (take case final limit? when limit < 0) { + if (spec.take case final limit? when limit < 0) { throw PlanInvalidPaginationException(key: 'take', value: limit); } - if (cursor != null || page != null) { + if (spec.cursor != null || spec.page != null) { throw runtimeError( 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', 'GroupBy does not support cursor or page windows yet.', details: { 'model': modelName, - if (cursor != null) 'cursor': cursor, - if (page != null) 'page': page.toJson(), + if (spec.cursor != null) 'cursor': spec.cursor, + if (spec.page != null) 'page': spec.page!.toJson(), }, ); } @@ -1467,7 +1588,7 @@ class ModelDelegate { _assertKnownAggregateFields(fields: sum, source: 'groupBy.sum'); _assertKnownAggregateFields(fields: avg, source: 'groupBy.avg'); _assertGroupByOrderByFields( - orderBy: orderBy, + orderBy: spec.orderBy, by: by, countAll: countAll, count: count, @@ -1489,7 +1610,7 @@ class ModelDelegate { final rows = await _readAllInternal( action: OrmAction.read, - where: where, + where: spec.where, select: _buildAggregateSelect( count: by.followedBy(count).toList(growable: false), min: min, @@ -1542,112 +1663,101 @@ class ModelDelegate { .toList(growable: false); } - if (orderBy.isNotEmpty) { + if (spec.orderBy.isNotEmpty) { results.sort( (left, right) => _compareRowsForGroupByOrderBy( left: left, right: right, - orderBy: orderBy, + orderBy: spec.orderBy, ), ); } - return _sliceRows(rows: results, skip: skip, take: take); + return _sliceRows(rows: results, skip: spec.skip, take: spec.take); } - Future create({ + Future _create({ required JsonMap data, - List select = const [], - Map include = const {}, + required OrmReadQuerySpec spec, }) => _RepositoryMutationExecutor( this, - ).create(data: data, select: select, include: include); + ).create(data: data, select: spec.select, include: spec.include); - Future createNested({ + Future _createNested({ required JsonMap data, - Map> create = const >{}, - List select = const [], - Map include = const {}, + required Map> create, + required OrmReadQuerySpec spec, }) => _RepositoryMutationExecutor(this).createNested( data: data, nestedCreate: create, - select: select, - include: include, - ); - - Future updateNested({ - JsonMap where = const {}, - required JsonMap data, - Map> create = const >{}, - List select = const [], - Map include = const {}, - }) => _RepositoryMutationExecutor(this).updateNested( - where: where, - data: data, - nestedCreate: create, - select: select, - include: include, + select: spec.select, + include: spec.include, ); - Future> createMany({ + Future> _createMany({ required List data, - List select = const [], - Map include = const {}, + required OrmReadQuerySpec spec, }) => _RepositoryMutationExecutor( this, - ).createMany(data: data, select: select, include: include); + ).createMany(data: data, select: spec.select, include: spec.include); - Future updateMany({ - JsonMap where = const {}, + Future _updateMany({ required JsonMap data, - List select = const [], - Map include = const {}, + required OrmReadQuerySpec spec, }) async { _throwApiNotImplemented( 'orm.updateMany', details: { 'model': modelName, - 'where': where, + 'where': spec.where, 'data': data, - 'select': select, - 'include': include.keys.toList(growable: false), + 'select': spec.select, + 'include': spec.include.keys.toList(growable: false), }, ); } - Future deleteMany({JsonMap where = const {}}) => - _RepositoryMutationExecutor(this).deleteMany(where: where); + Future _deleteMany({required OrmReadQuerySpec spec}) => + _RepositoryMutationExecutor(this).deleteMany(where: spec.where); - Future upsert({ - required JsonMap where, + Future _upsert({ required JsonMap create, required JsonMap update, - List select = const [], - Map include = const {}, + required OrmReadQuerySpec spec, }) => _RepositoryMutationExecutor(this).upsert( - where: where, + where: spec.where, create: create, update: update, - select: select, - include: include, + select: spec.select, + include: spec.include, ); - Future update({ - JsonMap where = const {}, + Future _update({ required JsonMap data, - List select = const [], - Map include = const {}, - }) => _RepositoryMutationExecutor( - this, - ).update(where: where, data: data, select: select, include: include); + required OrmReadQuerySpec spec, + }) => _RepositoryMutationExecutor(this).update( + where: spec.where, + data: data, + select: spec.select, + include: spec.include, + ); - Future delete({ - JsonMap where = const {}, - List select = const [], - Map include = const {}, - }) => _RepositoryMutationExecutor( - this, - ).delete(where: where, select: select, include: include); + Future _updateNested({ + required JsonMap data, + required Map> create, + required OrmReadQuerySpec spec, + }) => _RepositoryMutationExecutor(this).updateNested( + where: spec.where, + data: data, + nestedCreate: create, + select: spec.select, + include: spec.include, + ); + + Future _delete({required OrmReadQuerySpec spec}) => + _RepositoryMutationExecutor( + this, + ).delete(where: spec.where, select: spec.select, include: spec.include); Future> _readAllInternal({ required OrmAction action, @@ -3299,22 +3409,12 @@ final class ModelQuery { Future count() { _assertReadExecutionSupported('count'); - return _delegate.count( - where: _state.where, - orderBy: _state.orderBy, - cursor: _state.cursor, - page: _state.page, - ); + return _delegate._count(spec: _state); } Future exists() { _assertReadExecutionSupported('exists'); - return _delegate.exists( - where: _state.where, - orderBy: _state.orderBy, - cursor: _state.cursor, - page: _state.page, - ); + return _delegate._exists(spec: _state); } Future explain() async { @@ -3330,11 +3430,8 @@ final class ModelQuery { List avg = const [], }) { _assertReadExecutionSupported('aggregate'); - return _delegate.aggregate( - where: _state.where, - orderBy: _state.orderBy, - cursor: _state.cursor, - page: _state.page, + return _delegate._aggregate( + spec: _state, countAll: countAll, count: count, min: min, @@ -3366,15 +3463,10 @@ final class ModelQuery { }, ); } - return _delegate.groupBy( + return _delegate._groupBy( + spec: _state, by: by, - where: _state.where, - cursor: _state.cursor, - page: _state.page, having: having, - skip: _state.skip, - take: _state.take, - orderBy: _state.orderBy, countAll: countAll, count: count, min: min, @@ -3425,56 +3517,40 @@ final class ModelQuery { Future create({required JsonMap data}) { _assertMutationQueryState(action: 'create', allowWhere: false); - return _delegate.create( - data: data, - select: _state.select, - include: _state.include, - ); + return _delegate._create(data: data, spec: _state); + } + + Future createNested({ + required JsonMap data, + Map> create = const >{}, + }) { + _assertMutationQueryState(action: 'createNested', allowWhere: false); + return _delegate._createNested(data: data, create: create, spec: _state); } Future> createMany({required List data}) { _assertMutationQueryState(action: 'createMany', allowWhere: false); - return _delegate.createMany( - data: data, - select: _state.select, - include: _state.include, - ); + return _delegate._createMany(data: data, spec: _state); } Future updateMany({required JsonMap data}) { _assertMutationQueryState(action: 'updateMany'); - return _delegate.updateMany( - where: _state.where, - data: data, - select: _state.select, - include: _state.include, - ); + return _delegate._updateMany(data: data, spec: _state); } Future deleteMany() { _assertMutationQueryState(action: 'deleteMany'); - return _delegate.deleteMany(where: _state.where); + return _delegate._deleteMany(spec: _state); } Future upsert({required JsonMap create, required JsonMap update}) { _assertMutationQueryState(action: 'upsert'); - return _delegate.upsert( - where: _state.where, - create: create, - update: update, - select: _state.select, - include: _state.include, - ); + return _delegate._upsert(create: create, update: update, spec: _state); } Future update({required JsonMap data}) { _assertMutationQueryState(action: 'update'); - return _delegate.update( - where: _state.where, - data: data, - select: _state.select, - include: _state.include, - ); + return _delegate._update(data: data, spec: _state); } Future updateNested({ @@ -3482,22 +3558,12 @@ final class ModelQuery { Map> create = const >{}, }) { _assertMutationQueryState(action: 'updateNested'); - return _delegate.updateNested( - where: _state.where, - data: data, - create: create, - select: _state.select, - include: _state.include, - ); + return _delegate._updateNested(data: data, create: create, spec: _state); } Future delete() { _assertMutationQueryState(action: 'delete'); - return _delegate.delete( - where: _state.where, - select: _state.select, - include: _state.include, - ); + return _delegate._delete(spec: _state); } ModelQuery _next(OrmReadQuerySpec nextState) => From f2c1ac8d783f457ab634b452d54c683c827e3b67 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:07:42 +0800 Subject: [PATCH 131/154] refactor(generator): shrink typed sql helper conversion layers --- pub/orm/lib/src/generator/writer.dart | 75 ++++++++++++----------- pub/orm/test/generator/generate_test.dart | 72 ++++++++++++++++++++++ 2 files changed, 111 insertions(+), 36 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index e0826329..f0b10482 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2035,6 +2035,35 @@ final class TypedClientWriter { buffer.writeln(' const ${model.sqlClassName}(this._sql);'); buffer.writeln(); + buffer.writeln( + ' List _fields(${model.selectClassName}? select) {', + ); + buffer.writeln(' return select?.toFields() ?? const [];'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.dataClassName} _decodeRow(JsonMap row) => ${model.dataClassName}.fromJson(row);', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.dataClassName}? _decodeOptionalRow(JsonMap? row) {', + ); + buffer.writeln(' if (row == null) {'); + buffer.writeln(' return null;'); + buffer.writeln(' }'); + buffer.writeln(' return _decodeRow(row);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' List<${model.dataClassName}> _decodeRows(List rows) {', + ); + buffer.writeln(' return rows.map(_decodeRow).toList(growable: false);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' OrmSqlSelectBuilder _selectBuilder({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -2055,9 +2084,7 @@ final class TypedClientWriter { buffer.writeln( ' final runtimeDistinct = distinct.map((entry) => entry.value).toList(growable: false);', ); - buffer.writeln( - ' final runtimeSelect = select?.toFields() ?? const [];', - ); + buffer.writeln(' final runtimeSelect = _fields(select);'); buffer.writeln(" return _sql.from('$runtimeName')"); buffer.writeln(' .where(where.toJson())'); buffer.writeln(' .skip(skip)'); @@ -2115,9 +2142,7 @@ final class TypedClientWriter { buffer.writeln(' distinct: distinct,'); buffer.writeln(' select: select,'); buffer.writeln(' ).all();'); - buffer.writeln( - ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', - ); + buffer.writeln(' return _decodeRows(rows);'); buffer.writeln(' }'); buffer.writeln(); @@ -2143,7 +2168,7 @@ final class TypedClientWriter { buffer.writeln(' distinct: distinct,'); buffer.writeln(' select: select,'); buffer.writeln(' ).stream()) {'); - buffer.writeln(' yield ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' yield _decodeRow(row);'); buffer.writeln(' }'); buffer.writeln(' }'); buffer.writeln(); @@ -2164,15 +2189,11 @@ final class TypedClientWriter { buffer.writeln(' final row = await _selectBuilder('); buffer.writeln(' where: where,'); buffer.writeln(' skip: skip,'); - buffer.writeln(' take: 1,'); buffer.writeln(' orderBy: orderBy,'); buffer.writeln(' distinct: distinct,'); buffer.writeln(' select: select,'); buffer.writeln(' ).firstOrNull();'); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + buffer.writeln(' return _decodeOptionalRow(row);'); buffer.writeln(' }'); buffer.writeln(); @@ -2180,9 +2201,7 @@ final class TypedClientWriter { buffer.writeln(' required ${model.createInputClassName} data,'); buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) {'); - buffer.writeln( - ' final runtimeReturning = returning?.toFields() ?? const [];', - ); + buffer.writeln(' final runtimeReturning = _fields(returning);'); buffer.writeln( " return _sql.insertInto('$runtimeName').values(data.toJson()).returning(runtimeReturning);", ); @@ -2204,12 +2223,8 @@ final class TypedClientWriter { buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) async {'); buffer.writeln( - ' final row = (await insertResult(data: data, returning: returning)).row;', + ' return _decodeOptionalRow(await insertPlan(data: data, returning: returning).one());', ); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); buffer.writeln(); @@ -2218,9 +2233,7 @@ final class TypedClientWriter { buffer.writeln(' required ${model.updateInputClassName} data,'); buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) {'); - buffer.writeln( - ' final runtimeReturning = returning?.toFields() ?? const [];', - ); + buffer.writeln(' final runtimeReturning = _fields(returning);'); buffer.writeln( " return _sql.update('$runtimeName').where(where.toJson()).set(data.toJson()).returning(runtimeReturning);", ); @@ -2244,12 +2257,8 @@ final class TypedClientWriter { buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) async {'); buffer.writeln( - ' final row = (await updateResult(where: where, data: data, returning: returning)).row;', + ' return _decodeOptionalRow(await updatePlan(where: where, data: data, returning: returning).one());', ); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); buffer.writeln(); @@ -2257,9 +2266,7 @@ final class TypedClientWriter { buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) {'); - buffer.writeln( - ' final runtimeReturning = returning?.toFields() ?? const [];', - ); + buffer.writeln(' final runtimeReturning = _fields(returning);'); buffer.writeln( " return _sql.deleteFrom('$runtimeName').where(where.toJson()).returning(runtimeReturning);", ); @@ -2281,12 +2288,8 @@ final class TypedClientWriter { buffer.writeln(' ${model.selectClassName}? returning,'); buffer.writeln(' }) async {'); buffer.writeln( - ' final row = (await deleteResult(where: where, returning: returning)).row;', + ' return _decodeOptionalRow(await deletePlan(where: where, returning: returning).one());', ); - buffer.writeln(' if (row == null) {'); - buffer.writeln(' return null;'); - buffer.writeln(' }'); - buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); buffer.writeln(' }'); buffer.writeln('}'); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 3897355b..7d8b7bb0 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -844,6 +844,78 @@ typedef Post = ({ isTrue, reason: 'Expected UserSql to expose deleteResult helper.', ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?List\s+_fields\(\s*UserSelect\?\s+select\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserSql to centralize typed field-list conversion in a private helper.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?UserData\s+_decodeRow\(\s*JsonMap\s+row\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserSql to centralize typed row decoding in a private helper.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?UserData\?\s+_decodeOptionalRow\(\s*JsonMap\?\s+row\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserSql to expose private optional row decoding helper for mutation terminals.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?List\s+_decodeRows\(\s*List\s+rows\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserSql to centralize typed list decoding for read terminals.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future>\s+all\([\s\S]*?return\s+_decodeRows\(rows\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserSql.all(...) to decode rows through a shared helper instead of repeating fromJson mapping.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+insert\([\s\S]*?return\s+_decodeOptionalRow\(await\s+insertPlan\([\s\S]*?\.one\(\)\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserSql.insert(...) to decode rows directly from insertPlan(...).one().', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+update\([\s\S]*?return\s+_decodeOptionalRow\(await\s+updatePlan\([\s\S]*?\.one\(\)\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserSql.update(...) to decode rows directly from updatePlan(...).one().', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+delete\([\s\S]*?return\s+_decodeOptionalRow\(await\s+deletePlan\([\s\S]*?\.one\(\)\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserSql.delete(...) to decode rows directly from deletePlan(...).one().', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future\s+firstOrNull\([\s\S]*?_selectBuilder\([\s\S]*?take:\s*1', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserSql.firstOrNull(...) to rely on builder.firstOrNull() without duplicating take: 1.', + ); expect( RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), isTrue, From 64bce033bfbd23f84c72e78275b7b5a9f4b91e14 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:23:02 +0800 Subject: [PATCH 132/154] feat(client): add structured aggregate and groupBy specs --- pub/orm/lib/src/client/client.dart | 294 +++++++++++++------ pub/orm/test/client/source_surface_test.dart | 115 ++++++++ 2 files changed, 321 insertions(+), 88 deletions(-) create mode 100644 pub/orm/test/client/source_surface_test.dart diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index a04bdf15..f4fa0846 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1376,6 +1376,21 @@ class ModelDelegate { avg: avg, ); + Future aggregateWith({ + JsonMap where = const {}, + List orderBy = const [], + JsonMap? cursor, + OrmReadPagePlan? page, + required OrmAggregateSpec aggregate, + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + orderBy: orderBy, + cursor: cursor, + page: page, + ), + ).aggregateWith(aggregate); + Future> groupBy({ required List by, JsonMap where = const {}, @@ -1397,13 +1412,13 @@ class ModelDelegate { where: where, skip: skip, take: take, - orderBy: orderBy, cursor: cursor, page: page, ), ).groupBy( by: by, having: having, + orderBy: orderBy, countAll: countAll, count: count, min: min, @@ -1412,6 +1427,23 @@ class ModelDelegate { avg: avg, ); + Future> groupByWith({ + JsonMap where = const {}, + int? skip, + int? take, + JsonMap? cursor, + OrmReadPagePlan? page, + required OrmGroupBySpec groupBy, + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + skip: skip, + take: take, + cursor: cursor, + page: page, + ), + ).groupByWith(groupBy); + Future create({ required JsonMap data, List select = const [], @@ -1505,18 +1537,16 @@ class ModelDelegate { Future _aggregate({ required OrmReadQuerySpec spec, - required bool countAll, - required List count, - required List min, - required List max, - required List sum, - required List avg, + required OrmAggregateSpec aggregate, }) async { - _assertKnownAggregateFields(fields: count, source: 'aggregate.count'); - _assertKnownAggregateFields(fields: min, source: 'aggregate.min'); - _assertKnownAggregateFields(fields: max, source: 'aggregate.max'); - _assertKnownAggregateFields(fields: sum, source: 'aggregate.sum'); - _assertKnownAggregateFields(fields: avg, source: 'aggregate.avg'); + _assertKnownAggregateFields( + fields: aggregate.count, + source: 'aggregate.count', + ); + _assertKnownAggregateFields(fields: aggregate.min, source: 'aggregate.min'); + _assertKnownAggregateFields(fields: aggregate.max, source: 'aggregate.max'); + _assertKnownAggregateFields(fields: aggregate.sum, source: 'aggregate.sum'); + _assertKnownAggregateFields(fields: aggregate.avg, source: 'aggregate.avg'); final rows = await _readAllInternal( action: OrmAction.read, @@ -1525,38 +1555,31 @@ class ModelDelegate { cursor: spec.cursor, page: spec.page, select: _buildAggregateSelect( - count: count, - min: min, - max: max, - sum: sum, - avg: avg, + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + sum: aggregate.sum, + avg: aggregate.avg, ), includeDepth: 0, ); return _buildAggregateResult( rows: rows, - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, + countAll: aggregate.countAll, + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + sum: aggregate.sum, + avg: aggregate.avg, ); } Future> _groupBy({ required OrmReadQuerySpec spec, - required List by, - required JsonMap having, - required bool countAll, - required List count, - required List min, - required List max, - required List sum, - required List avg, + required OrmGroupBySpec groupBy, }) async { - if (by.isEmpty) { + if (groupBy.by.isEmpty) { throw runtimeError( 'PLAN.GROUP_BY_FIELDS_EMPTY', 'GroupBy requires at least one field in by.', @@ -1581,42 +1604,42 @@ class ModelDelegate { ); } - _assertKnownAggregateFields(fields: by, source: 'groupBy.by'); - _assertKnownAggregateFields(fields: count, source: 'groupBy.count'); - _assertKnownAggregateFields(fields: min, source: 'groupBy.min'); - _assertKnownAggregateFields(fields: max, source: 'groupBy.max'); - _assertKnownAggregateFields(fields: sum, source: 'groupBy.sum'); - _assertKnownAggregateFields(fields: avg, source: 'groupBy.avg'); + _assertKnownAggregateFields(fields: groupBy.by, source: 'groupBy.by'); + _assertKnownAggregateFields(fields: groupBy.count, source: 'groupBy.count'); + _assertKnownAggregateFields(fields: groupBy.min, source: 'groupBy.min'); + _assertKnownAggregateFields(fields: groupBy.max, source: 'groupBy.max'); + _assertKnownAggregateFields(fields: groupBy.sum, source: 'groupBy.sum'); + _assertKnownAggregateFields(fields: groupBy.avg, source: 'groupBy.avg'); _assertGroupByOrderByFields( - orderBy: spec.orderBy, - by: by, - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, + orderBy: groupBy.orderBy, + by: groupBy.by, + countAll: groupBy.countAll, + count: groupBy.count, + min: groupBy.min, + max: groupBy.max, + sum: groupBy.sum, + avg: groupBy.avg, ); _assertGroupByHavingFields( - having: having, - by: by, - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, + having: groupBy.having, + by: groupBy.by, + countAll: groupBy.countAll, + count: groupBy.count, + min: groupBy.min, + max: groupBy.max, + sum: groupBy.sum, + avg: groupBy.avg, ); final rows = await _readAllInternal( action: OrmAction.read, where: spec.where, select: _buildAggregateSelect( - count: by.followedBy(count).toList(growable: false), - min: min, - max: max, - sum: sum, - avg: avg, + count: groupBy.by.followedBy(groupBy.count).toList(growable: false), + min: groupBy.min, + max: groupBy.max, + sum: groupBy.sum, + avg: groupBy.avg, ), includeDepth: 0, ); @@ -1624,7 +1647,7 @@ class ModelDelegate { final groupedRows = <_RelationMergeKey, List>{}; for (final row in rows) { final key = _RelationMergeKey( - by + groupBy.by .map((field) => row.containsKey(field) ? row[field] : null) .toList(growable: false), ); @@ -1640,35 +1663,37 @@ class ModelDelegate { final groupResult = {}; final first = groupRows.first; - for (final field in by) { + for (final field in groupBy.by) { groupResult[field] = first[field]; } groupResult.addAll( _buildAggregateResult( rows: groupRows, - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, + countAll: groupBy.countAll, + count: groupBy.count, + min: groupBy.min, + max: groupBy.max, + sum: groupBy.sum, + avg: groupBy.avg, ), ); results.add(groupResult); } - if (having.isNotEmpty) { + if (groupBy.having.isNotEmpty) { results = results - .where((row) => _matchesGroupByHaving(row: row, having: having)) + .where( + (row) => _matchesGroupByHaving(row: row, having: groupBy.having), + ) .toList(growable: false); } - if (spec.orderBy.isNotEmpty) { + if (groupBy.orderBy.isNotEmpty) { results.sort( (left, right) => _compareRowsForGroupByOrderBy( left: left, right: right, - orderBy: spec.orderBy, + orderBy: groupBy.orderBy, ), ); } @@ -3189,6 +3214,87 @@ final class OrmReadQuerySpec { } } +@immutable +final class OrmAggregateSpec { + final bool countAll; + final List count; + final List min; + final List max; + final List sum; + final List avg; + + OrmAggregateSpec({ + this.countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) : count = List.unmodifiable(count), + min = List.unmodifiable(min), + max = List.unmodifiable(max), + sum = List.unmodifiable(sum), + avg = List.unmodifiable(avg); +} + +@immutable +final class OrmGroupBySpec { + final List by; + final JsonMap having; + final List orderBy; + final bool countAll; + final List count; + final List min; + final List max; + final List sum; + final List avg; + + OrmGroupBySpec({ + required List by, + JsonMap having = const {}, + List orderBy = const [], + this.countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) : by = List.unmodifiable(by), + having = Map.unmodifiable( + Map.from(having), + ), + orderBy = List.unmodifiable(orderBy), + count = List.unmodifiable(count), + min = List.unmodifiable(min), + max = List.unmodifiable(max), + sum = List.unmodifiable(sum), + avg = List.unmodifiable(avg); + + OrmGroupBySpec copyWith({ + List? by, + JsonMap? having, + List? orderBy, + bool? countAll, + List? count, + List? min, + List? max, + List? sum, + List? avg, + }) { + return OrmGroupBySpec( + by: by ?? this.by, + having: having ?? this.having, + orderBy: orderBy ?? this.orderBy, + countAll: countAll ?? this.countAll, + count: count ?? this.count, + min: min ?? this.min, + max: max ?? this.max, + sum: sum ?? this.sum, + avg: avg ?? this.avg, + ); + } +} + @immutable final class ModelQuery { final ModelDelegate _delegate; @@ -3428,29 +3534,47 @@ final class ModelQuery { List max = const [], List sum = const [], List avg = const [], - }) { - _assertReadExecutionSupported('aggregate'); - return _delegate._aggregate( - spec: _state, + }) => aggregateWith( + OrmAggregateSpec( countAll: countAll, count: count, min: min, max: max, sum: sum, avg: avg, - ); + ), + ); + + Future aggregateWith(OrmAggregateSpec aggregate) { + _assertReadExecutionSupported('aggregate'); + return _delegate._aggregate(spec: _state, aggregate: aggregate); } Future> groupBy({ required List by, JsonMap having = const {}, + List orderBy = const [], bool countAll = false, List count = const [], List min = const [], List max = const [], List sum = const [], List avg = const [], - }) { + }) => groupByWith( + OrmGroupBySpec( + by: by, + having: having, + orderBy: orderBy, + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ), + ); + + Future> groupByWith(OrmGroupBySpec groupBy) { _assertReadExecutionSupported('groupBy'); if (_state.cursor != null || _state.page != null) { throw runtimeError( @@ -3463,17 +3587,11 @@ final class ModelQuery { }, ); } - return _delegate._groupBy( - spec: _state, - by: by, - having: having, - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ); + final effectiveGroupBy = + groupBy.orderBy.isEmpty && _state.orderBy.isNotEmpty + ? groupBy.copyWith(orderBy: _state.orderBy) + : groupBy; + return _delegate._groupBy(spec: _state, groupBy: effectiveGroupBy); } void _assertReadExecutionSupported(String terminal) { diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart new file mode 100644 index 00000000..7ab9ced9 --- /dev/null +++ b/pub/orm/test/client/source_surface_test.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + final source = File( + '/Users/seven/workspace/dart-orm/pub/orm/lib/src/client/client.dart', + ).readAsStringSync(); + + group('dynamic client source surface', () { + test('delegate routes public terminals through query specs', () { + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?ModelQuery\s+query\(\)\s*=>\s*_queryFromSpec\(OrmReadQuerySpec\(\)\);', + ).hasMatch(source), + isTrue, + reason: 'Expected ModelDelegate.query() to centralize query creation.', + ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future>\s+all\(\{[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\.all\(\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelDelegate.all(...) to route through _queryFromSpec(...).all().', + ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future\s+count\(\{[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\.count\(\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelDelegate.count(...) to route through _queryFromSpec(...).count().', + ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future\s+create\(\{[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\.create\(data:\s*data\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelDelegate.create(...) to route through _queryFromSpec(...).create(...).', + ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(\{[\s\S]*?required\s+OrmAggregateSpec\s+aggregate,[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\.aggregateWith\(aggregate\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelDelegate.aggregateWith(...) to route structured aggregate execution through query terminals.', + ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(\{[\s\S]*?required\s+OrmGroupBySpec\s+groupBy,[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\.groupByWith\(groupBy\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelDelegate.groupByWith(...) to route structured groupBy execution through query terminals.', + ); + }); + + test( + 'query terminals route aggregate groupBy and mutations to private helpers', + () { + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregate\(\{[\s\S]*?\)\s*=>\s*aggregateWith\(\s*OrmAggregateSpec\(', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.aggregate(...) to compile convenience arguments into OrmAggregateSpec.', + ); + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregateWith\(OrmAggregateSpec\s+aggregate\)\s*\{[\s\S]*?return\s+_delegate\._aggregate\(spec:\s*_state,\s*aggregate:\s*aggregate\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.aggregateWith(...) to terminate through the private aggregate helper.', + ); + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?\)\s*=>\s*groupByWith\(\s*OrmGroupBySpec\(', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.groupBy(...) to compile convenience arguments into OrmGroupBySpec.', + ); + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupByWith\(OrmGroupBySpec\s+groupBy\)\s*\{[\s\S]*?final\s+effectiveGroupBy\s*=[\s\S]*?groupBy\.copyWith\(orderBy:\s*_state\.orderBy\)[\s\S]*?return\s+_delegate\._groupBy\(spec:\s*_state,\s*groupBy:\s*effectiveGroupBy\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.groupByWith(...) to terminate through the private groupBy helper.', + ); + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+createMany\(\{required\s+List\s+data\}\)\s*\{[\s\S]*?return\s+_delegate\._createMany\(data:\s*data,\s*spec:\s*_state\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.createMany(...) to terminate through the private mutation helper.', + ); + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+deleteMany\(\)\s*\{[\s\S]*?return\s+_delegate\._deleteMany\(spec:\s*_state\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.deleteMany(...) to terminate through the private mutation helper.', + ); + }, + ); + }); +} From fecba8a35ed4d0d9a75c939830c5c34bbbc9693c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:23:07 +0800 Subject: [PATCH 133/154] feat(generator)!: add typed aggregate specs and trim sql mutation api --- pub/orm/lib/src/generator/writer.dart | 251 ++++++++++++++++------ pub/orm/test/generator/generate_test.dart | 86 +++++++- 2 files changed, 269 insertions(+), 68 deletions(-) diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index f0b10482..8e60e624 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -978,6 +978,103 @@ final class TypedClientWriter { ); } + buffer.writeln('class ${model.aggregateSpecClassName} {'); + buffer.writeln(' final bool countAll;'); + buffer.writeln(' final List<${model.distinctClassName}> count;'); + buffer.writeln(' final List<${model.distinctClassName}> min;'); + buffer.writeln(' final List<${model.distinctClassName}> max;'); + buffer.writeln(' final List<${model.distinctClassName}> sum;'); + buffer.writeln(' final List<${model.distinctClassName}> avg;'); + buffer.writeln(); + buffer.writeln(' const ${model.aggregateSpecClassName}({'); + buffer.writeln(' this.countAll = false,'); + buffer.writeln(' this.count = const <${model.distinctClassName}>[],'); + buffer.writeln(' this.min = const <${model.distinctClassName}>[],'); + buffer.writeln(' this.max = const <${model.distinctClassName}>[],'); + buffer.writeln(' this.sum = const <${model.distinctClassName}>[],'); + buffer.writeln(' this.avg = const <${model.distinctClassName}>[],'); + buffer.writeln(' });'); + buffer.writeln(); + buffer.writeln(' OrmAggregateSpec toRuntimeSpec() {'); + buffer.writeln(' return OrmAggregateSpec('); + buffer.writeln(' countAll: countAll,'); + buffer.writeln( + ' count: count.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' min: min.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' max: max.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' sum: sum.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' avg: avg.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class ${model.groupBySpecClassName} {'); + buffer.writeln(' final List<${model.distinctClassName}> by;'); + buffer.writeln(' final ${model.groupByHavingClassName} having;'); + buffer.writeln(' final List<${model.groupByOrderByClassName}> orderBy;'); + buffer.writeln(' final bool countAll;'); + buffer.writeln(' final List<${model.distinctClassName}> count;'); + buffer.writeln(' final List<${model.distinctClassName}> min;'); + buffer.writeln(' final List<${model.distinctClassName}> max;'); + buffer.writeln(' final List<${model.distinctClassName}> sum;'); + buffer.writeln(' final List<${model.distinctClassName}> avg;'); + buffer.writeln(); + buffer.writeln(' const ${model.groupBySpecClassName}({'); + buffer.writeln(' required this.by,'); + buffer.writeln( + ' this.having = const ${model.groupByHavingClassName}(),', + ); + buffer.writeln( + ' this.orderBy = const <${model.groupByOrderByClassName}>[],', + ); + buffer.writeln(' this.countAll = false,'); + buffer.writeln(' this.count = const <${model.distinctClassName}>[],'); + buffer.writeln(' this.min = const <${model.distinctClassName}>[],'); + buffer.writeln(' this.max = const <${model.distinctClassName}>[],'); + buffer.writeln(' this.sum = const <${model.distinctClassName}>[],'); + buffer.writeln(' this.avg = const <${model.distinctClassName}>[],'); + buffer.writeln(' });'); + buffer.writeln(); + buffer.writeln(' OrmGroupBySpec toRuntimeSpec() {'); + buffer.writeln(' return OrmGroupBySpec('); + buffer.writeln( + ' by: by.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' having: having.toJson(),'); + buffer.writeln( + ' orderBy: orderBy.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' countAll: countAll,'); + buffer.writeln( + ' count: count.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' min: min.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' max: max.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' sum: sum.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' avg: avg.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('class ${model.aggregateResultClassName} {'); buffer.writeln(' final Map _value;'); buffer.writeln(); @@ -1945,6 +2042,18 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' Future<${model.aggregateResultClassName}> aggregateWith({', + ); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' required ${model.aggregateSpecClassName} aggregate,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query(where: where).aggregateWith(aggregate);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future> groupBy({'); buffer.writeln(' required List<${model.distinctClassName}> by,'); buffer.writeln( @@ -1993,6 +2102,24 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' Future> groupByWith({', + ); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln(' required ${model.groupBySpecClassName} groupBy,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); + buffer.writeln(' ).groupByWith(groupBy);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Stream<${model.dataClassName}> stream({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -2208,16 +2335,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future insertResult({'); - buffer.writeln(' required ${model.createInputClassName} data,'); - buffer.writeln(' ${model.selectClassName}? returning,'); - buffer.writeln(' }) {'); - buffer.writeln( - ' return insertPlan(data: data, returning: returning).execute();', - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> insert({'); buffer.writeln(' required ${model.createInputClassName} data,'); buffer.writeln(' ${model.selectClassName}? returning,'); @@ -2240,17 +2357,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future updateResult({'); - buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); - buffer.writeln(' required ${model.updateInputClassName} data,'); - buffer.writeln(' ${model.selectClassName}? returning,'); - buffer.writeln(' }) {'); - buffer.writeln( - ' return updatePlan(where: where, data: data, returning: returning).execute();', - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> update({'); buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' required ${model.updateInputClassName} data,'); @@ -2273,16 +2379,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future deleteResult({'); - buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); - buffer.writeln(' ${model.selectClassName}? returning,'); - buffer.writeln(' }) {'); - buffer.writeln( - ' return deletePlan(where: where, returning: returning).execute();', - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}?> delete({'); buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); buffer.writeln(' ${model.selectClassName}? returning,'); @@ -2818,27 +2914,29 @@ final class TypedClientWriter { ); buffer.writeln(' }) {'); buffer.writeln(" _assertReadExecutionSupported('aggregate');"); - buffer.writeln(' return _delegate._delegate.aggregate('); + buffer.writeln(' return aggregateWith('); + buffer.writeln(' ${model.aggregateSpecClassName}('); + buffer.writeln(' countAll: countAll,'); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future<${model.aggregateResultClassName}> aggregateWith(${model.aggregateSpecClassName} aggregate) {', + ); + buffer.writeln(" _assertReadExecutionSupported('aggregate');"); + buffer.writeln(' return _delegate._delegate.aggregateWith('); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' orderBy: _runtimeOrderBy,'); buffer.writeln(' cursor: _runtimeCursor,'); buffer.writeln(' page: _runtimePage,'); - buffer.writeln(' countAll: countAll,'); - buffer.writeln( - ' count: count.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' min: min.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' max: max.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' sum: sum.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln( - ' avg: avg.map((entry) => entry.value).toList(growable: false),', - ); + buffer.writeln(' aggregate: aggregate.toRuntimeSpec(),'); buffer.writeln(' ).then(${model.aggregateResultClassName}.fromJson);'); buffer.writeln(' }'); buffer.writeln(); @@ -2886,22 +2984,51 @@ final class TypedClientWriter { buffer.writeln(' },'); buffer.writeln(' );'); buffer.writeln(' }'); - buffer.writeln(' return _delegate.groupBy('); + buffer.writeln(' return groupByWith('); + buffer.writeln(' ${model.groupBySpecClassName}('); + buffer.writeln(' by: by,'); + buffer.writeln(' having: typedHaving,'); + buffer.writeln(' orderBy: groupByOrderBy,'); + buffer.writeln(' countAll: countAll,'); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( - ' by: by.map((entry) => entry.value).toList(growable: false),', + ' Future> groupByWith(${model.groupBySpecClassName} groupBy) {', ); - buffer.writeln(' where: _where,'); + buffer.writeln(" _assertReadExecutionSupported('groupBy');"); + buffer.writeln(' if (_cursor != null || _pageSize != null) {'); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED',"); + buffer.writeln( + " 'GroupBy does not support cursor or page windows yet.',", + ); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln( + " if (_runtimeCursor != null) 'cursor': _runtimeCursor,", + ); + buffer.writeln( + " if (_runtimePage != null) 'page': _runtimePage!.toJson(),", + ); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' return _delegate._delegate.groupByWith('); + buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' skip: _skip,'); buffer.writeln(' take: _take,'); - buffer.writeln(' typedHaving: typedHaving,'); - buffer.writeln(' groupByOrderBy: groupByOrderBy,'); - buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' );'); + buffer.writeln(' groupBy: groupBy.toRuntimeSpec(),'); + buffer.writeln( + ' ).then((rows) => rows.map(${model.groupByResultClassName}.fromJson).toList(growable: false));', + ); buffer.writeln(' }'); buffer.writeln(); @@ -4084,8 +4211,12 @@ final class _ResolvedModel { String get groupByHavingConditionClassName => '${classBaseName}GroupByHavingCondition'; + String get aggregateSpecClassName => '${classBaseName}AggregateSpec'; + String get aggregateResultClassName => '${classBaseName}AggregateResult'; + String get groupBySpecClassName => '${classBaseName}GroupBySpec'; + String get groupByResultClassName => '${classBaseName}GroupByResult'; String get selectClassName => '${classBaseName}Select'; diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 7d8b7bb0..075273ec 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -827,22 +827,25 @@ typedef Post = ({ RegExp( r'class\s+UserSql\s*\{[\s\S]*?Future\s+insertResult\(', ).hasMatch(generatedSource), - isTrue, - reason: 'Expected UserSql to expose insertResult helper.', + isFalse, + reason: + 'Expected UserSql to stop exposing insertResult helper and use insertPlan().execute() for raw mutation results.', ); expect( RegExp( r'class\s+UserSql\s*\{[\s\S]*?Future\s+updateResult\(', ).hasMatch(generatedSource), - isTrue, - reason: 'Expected UserSql to expose updateResult helper.', + isFalse, + reason: + 'Expected UserSql to stop exposing updateResult helper and use updatePlan().execute() for raw mutation results.', ); expect( RegExp( r'class\s+UserSql\s*\{[\s\S]*?Future\s+deleteResult\(', ).hasMatch(generatedSource), - isTrue, - reason: 'Expected UserSql to expose deleteResult helper.', + isFalse, + reason: + 'Expected UserSql to stop exposing deleteResult helper and use deletePlan().execute() for raw mutation results.', ); expect( RegExp( @@ -916,6 +919,33 @@ typedef Post = ({ reason: 'Expected UserSql.firstOrNull(...) to rely on builder.firstOrNull() without duplicating take: 1.', ); + expect( + RegExp(r'\bclass UserAggregateSpec\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed UserAggregateSpec.', + ); + expect( + RegExp(r'\bclass UserGroupBySpec\b').hasMatch(generatedSource), + isTrue, + reason: 'Expected generated source to include typed UserGroupBySpec.', + ); + expect( + RegExp( + r'class\s+UserAggregateSpec\s*\{[\s\S]*?OrmAggregateSpec\s+toRuntimeSpec\(\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateSpec to compile to the runtime aggregate spec.', + ); + expect( + RegExp( + r'class\s+UserGroupBySpec\s*\{[\s\S]*?OrmGroupBySpec\s+toRuntimeSpec\(\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupBySpec to compile to the runtime groupBy spec.', + ); expect( RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), isTrue, @@ -1245,6 +1275,14 @@ typedef Post = ({ reason: 'Expected generated delegate aggregate(...) to route through typed query.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(\{[\s\S]*?required\s+UserAggregateSpec\s+aggregate,[\s\S]*?return\s+query\(where:\s*where\)\.aggregateWith\(aggregate\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate aggregateWith(...) to route structured aggregate specs through typed query.', + ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?skip:\s*skip,[\s\S]*?take:\s*take,[\s\S]*?\)\.groupBy\([\s\S]*?groupByOrderBy:\s*groupByOrderBy,[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?\);', @@ -1253,6 +1291,14 @@ typedef Post = ({ reason: 'Expected generated delegate groupBy(...) to route through typed query.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(\{[\s\S]*?required\s+UserGroupBySpec\s+groupBy,[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?skip:\s*skip,[\s\S]*?take:\s*take,[\s\S]*?\)\.groupByWith\(groupBy\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate groupByWith(...) to route structured groupBy specs through typed query.', + ); expect( generatedSource.contains('Future> findMany('), isFalse, @@ -1492,11 +1538,35 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?List\s+groupByOrderBy\s*=\s*const\s+\[\],[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?groupByOrderBy:\s*groupByOrderBy,', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregate\(\{[\s\S]*?return\s+aggregateWith\(\s*UserAggregateSpec\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.aggregate(...) to compile convenience arguments into a structured typed aggregate spec.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregateWith\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?aggregate:\s*aggregate\.toRuntimeSpec\(\),[\s\S]*?then\(UserAggregateResult\.fromJson\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.aggregateWith(...) to route through runtime structured aggregate specs.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?List\s+groupByOrderBy\s*=\s*const\s+\[\],[\s\S]*?return\s+groupByWith\(\s*UserGroupBySpec\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.groupBy(...) to compile convenience arguments into a structured typed groupBy spec.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupByWith\(UserGroupBySpec\s+groupBy\)\s*\{[\s\S]*?groupBy:\s*groupBy\.toRuntimeSpec\(\),[\s\S]*?UserGroupByResult\.fromJson', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.groupBy(...) to expose and forward typed groupBy helpers.', + 'Expected UserQuery.groupByWith(...) to route through runtime structured groupBy specs.', ); expect( RegExp( From 3e7794f77b8b1d6eff8c5d08610d6e7ee6eed17e Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:46:58 +0800 Subject: [PATCH 134/154] feat(orm)!: split groupBy onto grouped query builders BREAKING CHANGE: groupedBy now requires a where-only base query surface. orderBy, skip, take, distinct, select, include, cursor, and page must be applied on the grouped builder or via structured groupBy specs instead of row query state. --- pub/orm/lib/src/client/client.dart | 284 +++++++++++--- pub/orm/lib/src/generator/writer.dart | 371 +++++++++++++++---- pub/orm/test/client/client_test.dart | 48 ++- pub/orm/test/client/source_surface_test.dart | 44 ++- pub/orm/test/generator/generate_test.dart | 56 ++- 5 files changed, 637 insertions(+), 166 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index f4fa0846..d7a38898 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1391,11 +1391,14 @@ class ModelDelegate { ), ).aggregateWith(aggregate); + ModelGroupedQuery groupedBy( + List by, { + JsonMap where = const {}, + }) => _queryFromSpec(OrmReadQuerySpec(where: where)).groupedBy(by); + Future> groupBy({ required List by, JsonMap where = const {}, - JsonMap? cursor, - OrmReadPagePlan? page, JsonMap having = const {}, int? skip, int? take, @@ -1406,19 +1409,12 @@ class ModelDelegate { List max = const [], List sum = const [], List avg = const [], - }) => - _queryFromSpec( - OrmReadQuerySpec( - where: where, - skip: skip, - take: take, - cursor: cursor, - page: page, - ), - ).groupBy( - by: by, - having: having, - orderBy: orderBy, + }) => groupedBy(by, where: where) + .having(having, merge: false) + .orderBy(orderBy, append: false) + .skip(skip) + .take(take) + .aggregate( countAll: countAll, count: count, min: min, @@ -1429,20 +1425,8 @@ class ModelDelegate { Future> groupByWith({ JsonMap where = const {}, - int? skip, - int? take, - JsonMap? cursor, - OrmReadPagePlan? page, required OrmGroupBySpec groupBy, - }) => _queryFromSpec( - OrmReadQuerySpec( - where: where, - skip: skip, - take: take, - cursor: cursor, - page: page, - ), - ).groupByWith(groupBy); + }) => groupedBy(groupBy.by, where: where).configure(groupBy)._execute(); Future create({ required JsonMap data, @@ -1586,10 +1570,10 @@ class ModelDelegate { details: {'model': modelName}, ); } - if (spec.skip case final offset? when offset < 0) { + if (groupBy.skip case final offset? when offset < 0) { throw PlanInvalidPaginationException(key: 'skip', value: offset); } - if (spec.take case final limit? when limit < 0) { + if (groupBy.take case final limit? when limit < 0) { throw PlanInvalidPaginationException(key: 'take', value: limit); } if (spec.cursor != null || spec.page != null) { @@ -1698,7 +1682,7 @@ class ModelDelegate { ); } - return _sliceRows(rows: results, skip: spec.skip, take: spec.take); + return _sliceRows(rows: results, skip: groupBy.skip, take: groupBy.take); } Future _create({ @@ -3242,6 +3226,8 @@ final class OrmGroupBySpec { final List by; final JsonMap having; final List orderBy; + final int? skip; + final int? take; final bool countAll; final List count; final List min; @@ -3253,6 +3239,8 @@ final class OrmGroupBySpec { required List by, JsonMap having = const {}, List orderBy = const [], + this.skip, + this.take, this.countAll = false, List count = const [], List min = const [], @@ -3274,6 +3262,8 @@ final class OrmGroupBySpec { List? by, JsonMap? having, List? orderBy, + Object? skip = _stateKeepToken, + Object? take = _stateKeepToken, bool? countAll, List? count, List? min, @@ -3285,6 +3275,8 @@ final class OrmGroupBySpec { by: by ?? this.by, having: having ?? this.having, orderBy: orderBy ?? this.orderBy, + skip: identical(skip, _stateKeepToken) ? this.skip : skip as int?, + take: identical(take, _stateKeepToken) ? this.take : take as int?, countAll: countAll ?? this.countAll, count: count ?? this.count, min: min ?? this.min, @@ -3550,9 +3542,20 @@ final class ModelQuery { return _delegate._aggregate(spec: _state, aggregate: aggregate); } + ModelGroupedQuery groupedBy(List by) { + _assertGroupedQueryBaseState(); + return ModelGroupedQuery._( + _delegate, + _state.copyWith(), + OrmGroupBySpec(by: by), + ); + } + Future> groupBy({ required List by, JsonMap having = const {}, + int? skip, + int? take, List orderBy = const [], bool countAll = false, List count = const [], @@ -3560,40 +3563,30 @@ final class ModelQuery { List max = const [], List sum = const [], List avg = const [], - }) => groupByWith( - OrmGroupBySpec( - by: by, - having: having, - orderBy: orderBy, + }) { + var grouped = groupedBy(by).having(having, merge: false); + if (orderBy.isNotEmpty) { + grouped = grouped.orderBy(orderBy, append: false); + } + if (skip != null) { + grouped = grouped.skip(skip); + } + if (take != null) { + grouped = grouped.take(take); + } + return grouped.aggregate( countAll: countAll, count: count, min: min, max: max, sum: sum, avg: avg, - ), - ); - - Future> groupByWith(OrmGroupBySpec groupBy) { - _assertReadExecutionSupported('groupBy'); - if (_state.cursor != null || _state.page != null) { - throw runtimeError( - 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', - 'GroupBy does not support cursor or page windows yet.', - details: { - 'model': _delegate.modelName, - if (_state.cursor != null) 'cursor': _state.cursor, - if (_state.page != null) 'page': _state.page!.toJson(), - }, - ); - } - final effectiveGroupBy = - groupBy.orderBy.isEmpty && _state.orderBy.isNotEmpty - ? groupBy.copyWith(orderBy: _state.orderBy) - : groupBy; - return _delegate._groupBy(spec: _state, groupBy: effectiveGroupBy); + ); } + Future> groupByWith(OrmGroupBySpec groupBy) => + groupedBy(groupBy.by).configure(groupBy)._execute(); + void _assertReadExecutionSupported(String terminal) { if ((_state.cursor != null || _state.page != null) && _state.distinct.isNotEmpty) { @@ -3609,6 +3602,31 @@ final class ModelQuery { } } + void _assertGroupedQueryBaseState() { + final invalidKeys = [ + if (_state.skip != null) 'skip', + if (_state.take != null) 'take', + if (_state.orderBy.isNotEmpty) 'orderBy', + if (_state.distinct.isNotEmpty) 'distinct', + if (_state.select.isNotEmpty) 'select', + if (_state.include.isNotEmpty) 'include', + if (_state.cursor != null) 'cursor', + if (_state.page != null) 'page', + ]; + if (invalidKeys.isEmpty) { + return; + } + + throw runtimeError( + 'PLAN.GROUP_BY_QUERY_STATE_INVALID', + 'groupedBy() does not allow query state keys: ${invalidKeys.join(', ')}.', + details: { + 'model': _delegate.modelName, + 'invalidKeys': invalidKeys, + }, + ); + } + void _assertMutationQueryState({ required String action, bool allowWhere = true, @@ -3688,6 +3706,140 @@ final class ModelQuery { ModelQuery._(_delegate, nextState); } +@immutable +final class ModelGroupedQuery { + final ModelDelegate _delegate; + final OrmReadQuerySpec _baseState; + final OrmGroupBySpec _groupBy; + + const ModelGroupedQuery._(this._delegate, this._baseState, this._groupBy); + + List get byFields => _groupBy.by; + + JsonMap get havingClause => _groupBy.having; + + List get orderByValues => _groupBy.orderBy; + + int? get skipValue => _groupBy.skip; + + int? get takeValue => _groupBy.take; + + ModelGroupedQuery configure(OrmGroupBySpec groupBy) { + if (!_sameStringList(left: _groupBy.by, right: groupBy.by)) { + throw runtimeError( + 'PLAN.GROUP_BY_FIELDS_MISMATCH', + 'groupByWith() cannot replace the grouped fields after groupedBy().', + details: { + 'model': _delegate.modelName, + 'currentBy': _groupBy.by, + 'nextBy': groupBy.by, + }, + ); + } + return _next(groupBy); + } + + ModelGroupedQuery having(JsonMap having, {bool merge = true}) { + final nextHaving = merge + ? {..._groupBy.having, ...having} + : {...having}; + return _next(_groupBy.copyWith(having: nextHaving)); + } + + ModelGroupedQuery havingWith( + JsonMap Function(JsonMap having) build, { + bool merge = true, + }) { + final current = Map.from(_groupBy.having); + final next = build(Map.unmodifiable(current)); + return having(next, merge: merge); + } + + ModelGroupedQuery orderBy(List orderBy, {bool append = true}) { + final nextOrderBy = append + ? [..._groupBy.orderBy, ...orderBy] + : [...orderBy]; + return _next(_groupBy.copyWith(orderBy: nextOrderBy)); + } + + ModelGroupedQuery orderByField( + String field, { + SortOrder order = SortOrder.asc, + }) { + return orderBy([OrmOrderBy(field, order: order)]); + } + + ModelGroupedQuery skip(int? value) { + return _next(_groupBy.copyWith(skip: value)); + } + + ModelGroupedQuery take(int? value) { + return _next(_groupBy.copyWith(take: value)); + } + + Future> aggregate({ + bool countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) => aggregateWith( + OrmAggregateSpec( + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ), + ); + + Future> aggregateWith(OrmAggregateSpec aggregate) { + _assertExecutionSupported('aggregate'); + return _delegate._groupBy( + spec: _baseState, + groupBy: _groupBy.copyWith( + countAll: aggregate.countAll, + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + sum: aggregate.sum, + avg: aggregate.avg, + ), + ); + } + + void _assertExecutionSupported(String terminal) { + if (_baseState.cursor != null || _baseState.page != null) { + throw runtimeError( + 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', + 'Grouped queries do not support cursor or page windows yet.', + details: { + 'model': _delegate.modelName, + 'terminal': terminal, + if (_baseState.cursor != null) 'cursor': _baseState.cursor, + if (_baseState.page != null) 'page': _baseState.page!.toJson(), + }, + ); + } + } + + Future> _execute() => aggregateWith( + OrmAggregateSpec( + countAll: _groupBy.countAll, + count: _groupBy.count, + min: _groupBy.min, + max: _groupBy.max, + sum: _groupBy.sum, + avg: _groupBy.avg, + ), + ); + + ModelGroupedQuery _next(OrmGroupBySpec nextGroupBy) => + ModelGroupedQuery._(_delegate, _baseState, nextGroupBy); +} + @immutable final class _RelationMergeKey { final List parts; @@ -3710,6 +3862,24 @@ final class _RelationMergeKey { int get hashCode => Object.hashAll(parts); } +bool _sameStringList({ + required List left, + required List right, +}) { + if (identical(left, right)) { + return true; + } + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index++) { + if (left[index] != right[index]) { + return false; + } + } + return true; +} + Map _createCollectionRegistry( OrmContract contract, Map collections, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 8e60e624..4c67f408 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -118,6 +118,8 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln("import '${options.ormImport}';"); buffer.writeln(); + buffer.writeln('const Object _typedStateKeepToken = Object();'); + buffer.writeln(); } void _writeWhereFilterClasses(StringBuffer buffer) { @@ -1022,6 +1024,8 @@ final class TypedClientWriter { buffer.writeln(' final List<${model.distinctClassName}> by;'); buffer.writeln(' final ${model.groupByHavingClassName} having;'); buffer.writeln(' final List<${model.groupByOrderByClassName}> orderBy;'); + buffer.writeln(' final int? skip;'); + buffer.writeln(' final int? take;'); buffer.writeln(' final bool countAll;'); buffer.writeln(' final List<${model.distinctClassName}> count;'); buffer.writeln(' final List<${model.distinctClassName}> min;'); @@ -1037,6 +1041,8 @@ final class TypedClientWriter { buffer.writeln( ' this.orderBy = const <${model.groupByOrderByClassName}>[],', ); + buffer.writeln(' this.skip,'); + buffer.writeln(' this.take,'); buffer.writeln(' this.countAll = false,'); buffer.writeln(' this.count = const <${model.distinctClassName}>[],'); buffer.writeln(' this.min = const <${model.distinctClassName}>[],'); @@ -1045,6 +1051,38 @@ final class TypedClientWriter { buffer.writeln(' this.avg = const <${model.distinctClassName}>[],'); buffer.writeln(' });'); buffer.writeln(); + buffer.writeln(' ${model.groupBySpecClassName} copyWith({'); + buffer.writeln(' List<${model.distinctClassName}>? by,'); + buffer.writeln(' ${model.groupByHavingClassName}? having,'); + buffer.writeln(' List<${model.groupByOrderByClassName}>? orderBy,'); + buffer.writeln(' Object? skip = _typedStateKeepToken,'); + buffer.writeln(' Object? take = _typedStateKeepToken,'); + buffer.writeln(' bool? countAll,'); + buffer.writeln(' List<${model.distinctClassName}>? count,'); + buffer.writeln(' List<${model.distinctClassName}>? min,'); + buffer.writeln(' List<${model.distinctClassName}>? max,'); + buffer.writeln(' List<${model.distinctClassName}>? sum,'); + buffer.writeln(' List<${model.distinctClassName}>? avg,'); + buffer.writeln(' }) {'); + buffer.writeln(' return ${model.groupBySpecClassName}('); + buffer.writeln(' by: by ?? this.by,'); + buffer.writeln(' having: having ?? this.having,'); + buffer.writeln(' orderBy: orderBy ?? this.orderBy,'); + buffer.writeln( + ' skip: identical(skip, _typedStateKeepToken) ? this.skip : skip as int?,', + ); + buffer.writeln( + ' take: identical(take, _typedStateKeepToken) ? this.take : take as int?,', + ); + buffer.writeln(' countAll: countAll ?? this.countAll,'); + buffer.writeln(' count: count ?? this.count,'); + buffer.writeln(' min: min ?? this.min,'); + buffer.writeln(' max: max ?? this.max,'); + buffer.writeln(' sum: sum ?? this.sum,'); + buffer.writeln(' avg: avg ?? this.avg,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' OrmGroupBySpec toRuntimeSpec() {'); buffer.writeln(' return OrmGroupBySpec('); buffer.writeln( @@ -1054,6 +1092,8 @@ final class TypedClientWriter { buffer.writeln( ' orderBy: orderBy.map((entry) => entry.value).toList(growable: false),', ); + buffer.writeln(' skip: skip,'); + buffer.writeln(' take: take,'); buffer.writeln(' countAll: countAll,'); buffer.writeln( ' count: count.map((entry) => entry.value).toList(growable: false),', @@ -2054,6 +2094,16 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' ${model.groupedQueryClassName} groupedBy('); + buffer.writeln(' List<${model.distinctClassName}> by, {'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return query(where: where).groupedBy(by);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future> groupBy({'); buffer.writeln(' required List<${model.distinctClassName}> by,'); buffer.writeln( @@ -2084,21 +2134,19 @@ final class TypedClientWriter { ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); buffer.writeln(' }) {'); - buffer.writeln(' return query('); - buffer.writeln(' where: where,'); - buffer.writeln(' skip: skip,'); - buffer.writeln(' take: take,'); - buffer.writeln(' ).groupBy('); - buffer.writeln(' by: by,'); - buffer.writeln(' groupByOrderBy: groupByOrderBy,'); - buffer.writeln(' typedHaving: typedHaving,'); - buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' );'); + buffer.writeln(' return groupedBy(by, where: where)'); + buffer.writeln(' .having(typedHaving, merge: false)'); + buffer.writeln(' .orderBy(groupByOrderBy, append: false)'); + buffer.writeln(' .skip(skip)'); + buffer.writeln(' .take(take)'); + buffer.writeln(' .aggregate('); + buffer.writeln(' countAll: countAll,'); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); + buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -2108,15 +2156,11 @@ final class TypedClientWriter { buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); - buffer.writeln(' int? skip,'); - buffer.writeln(' int? take,'); buffer.writeln(' required ${model.groupBySpecClassName} groupBy,'); buffer.writeln(' }) {'); - buffer.writeln(' return query('); - buffer.writeln(' where: where,'); - buffer.writeln(' skip: skip,'); - buffer.writeln(' take: take,'); - buffer.writeln(' ).groupByWith(groupBy);'); + buffer.writeln(' return groupedBy(groupBy.by, where: where)'); + buffer.writeln(' .configure(groupBy)'); + buffer.writeln(' ._execute();'); buffer.writeln(' }'); buffer.writeln(); @@ -2149,6 +2193,7 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); _writeTypedQueryClass(buffer: buffer, model: model); + _writeTypedGroupedQueryClass(buffer: buffer, model: model); } void _writeTypedSqlClass({ @@ -2796,6 +2841,34 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' void _assertGroupedQueryBaseState() {'); + buffer.writeln(' final invalidKeys = ['); + buffer.writeln(" if (_skip != null) 'skip',"); + buffer.writeln(" if (_take != null) 'take',"); + buffer.writeln(" if (_orderBy.isNotEmpty) 'orderBy',"); + buffer.writeln(" if (_distinct.isNotEmpty) 'distinct',"); + buffer.writeln(" if (_select != null) 'select',"); + buffer.writeln(" if (_include != null) 'include',"); + buffer.writeln(" if (_cursor != null) 'cursor',"); + buffer.writeln(" if (_pageSize != null) 'page',"); + buffer.writeln(' ];'); + buffer.writeln(' if (invalidKeys.isEmpty) {'); + buffer.writeln(' return;'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.GROUP_BY_QUERY_STATE_INVALID',"); + buffer.writeln( + " 'groupedBy() does not allow query state keys: \${invalidKeys.join(', ')}.',", + ); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'invalidKeys': invalidKeys,"); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' void _assertMutationQueryState({'); buffer.writeln(' required String action,'); buffer.writeln(' bool allowWhere = true,'); @@ -2941,6 +3014,18 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' ${model.groupedQueryClassName} groupedBy(List<${model.distinctClassName}> by) {', + ); + buffer.writeln(' _assertGroupedQueryBaseState();'); + buffer.writeln(' return ${model.groupedQueryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' groupBy: ${model.groupBySpecClassName}(by: by),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future> groupBy({'); buffer.writeln(' required List<${model.distinctClassName}> by,'); buffer.writeln( @@ -2949,6 +3034,8 @@ final class TypedClientWriter { buffer.writeln( ' List<${model.groupByOrderByClassName}> groupByOrderBy = const <${model.groupByOrderByClassName}>[],', ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); buffer.writeln(' bool countAll = false,'); buffer.writeln( ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', @@ -2966,36 +3053,22 @@ final class TypedClientWriter { ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); buffer.writeln(' }) {'); - buffer.writeln(" _assertReadExecutionSupported('groupBy');"); - buffer.writeln(' if (_cursor != null || _pageSize != null) {'); - buffer.writeln(' throw runtimeError('); - buffer.writeln(" 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED',"); - buffer.writeln( - " 'GroupBy does not support cursor or page windows yet.',", - ); - buffer.writeln(' details: {'); - buffer.writeln(" 'model': '$runtimeName',"); - buffer.writeln( - " if (_runtimeCursor != null) 'cursor': _runtimeCursor,", - ); + buffer.writeln(' var grouped = groupedBy(by)'); + buffer.writeln(' .having(typedHaving, merge: false)'); + buffer.writeln(' .skip(skip)'); + buffer.writeln(' .take(take);'); + buffer.writeln(' if (groupByOrderBy.isNotEmpty) {'); buffer.writeln( - " if (_runtimePage != null) 'page': _runtimePage!.toJson(),", + ' grouped = grouped.orderBy(groupByOrderBy, append: false);', ); - buffer.writeln(' },'); - buffer.writeln(' );'); buffer.writeln(' }'); - buffer.writeln(' return groupByWith('); - buffer.writeln(' ${model.groupBySpecClassName}('); - buffer.writeln(' by: by,'); - buffer.writeln(' having: typedHaving,'); - buffer.writeln(' orderBy: groupByOrderBy,'); - buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' ),'); + buffer.writeln(' return grouped.aggregate('); + buffer.writeln(' countAll: countAll,'); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -3003,31 +3076,8 @@ final class TypedClientWriter { buffer.writeln( ' Future> groupByWith(${model.groupBySpecClassName} groupBy) {', ); - buffer.writeln(" _assertReadExecutionSupported('groupBy');"); - buffer.writeln(' if (_cursor != null || _pageSize != null) {'); - buffer.writeln(' throw runtimeError('); - buffer.writeln(" 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED',"); - buffer.writeln( - " 'GroupBy does not support cursor or page windows yet.',", - ); - buffer.writeln(' details: {'); - buffer.writeln(" 'model': '$runtimeName',"); - buffer.writeln( - " if (_runtimeCursor != null) 'cursor': _runtimeCursor,", - ); - buffer.writeln( - " if (_runtimePage != null) 'page': _runtimePage!.toJson(),", - ); - buffer.writeln(' },'); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(' return _delegate._delegate.groupByWith('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' skip: _skip,'); - buffer.writeln(' take: _take,'); - buffer.writeln(' groupBy: groupBy.toRuntimeSpec(),'); buffer.writeln( - ' ).then((rows) => rows.map(${model.groupByResultClassName}.fromJson).toList(growable: false));', + ' return groupedBy(groupBy.by).configure(groupBy)._execute();', ); buffer.writeln(' }'); buffer.writeln(); @@ -3198,6 +3248,179 @@ final class TypedClientWriter { buffer.writeln(); } + void _writeTypedGroupedQueryClass({ + required StringBuffer buffer, + required _ResolvedModel model, + }) { + final runtimeName = _escapeString(model.model.runtimeName); + buffer.writeln('class ${model.groupedQueryClassName} {'); + buffer.writeln(' final ${model.delegateClassName} _delegate;'); + buffer.writeln(' final ${model.whereInputClassName} _where;'); + buffer.writeln(' final ${model.groupBySpecClassName} _groupBy;'); + buffer.writeln(); + buffer.writeln(' const ${model.groupedQueryClassName}._({'); + buffer.writeln(' required ${model.delegateClassName} delegate,'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.groupBySpecClassName} groupBy,'); + buffer.writeln(' }) : _delegate = delegate,'); + buffer.writeln(' _where = where,'); + buffer.writeln(' _groupBy = groupBy;'); + buffer.writeln(); + + buffer.writeln( + ' ${model.groupedQueryClassName} configure(${model.groupBySpecClassName} groupBy) {', + ); + buffer.writeln( + ' final currentBy = _groupBy.by.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln( + ' final nextBy = groupBy.by.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln( + ' final sameBy = currentBy.length == nextBy.length && Iterable.generate(currentBy.length).every((index) => currentBy[index] == nextBy[index]);', + ); + buffer.writeln(' if (!sameBy) {'); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.GROUP_BY_FIELDS_MISMATCH',"); + buffer.writeln( + " 'groupByWith() cannot replace the grouped fields after groupedBy().',", + ); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'currentBy': currentBy,"); + buffer.writeln(" 'nextBy': nextBy,"); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' return _next(groupBy);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.groupedQueryClassName} having(${model.groupByHavingClassName} having, {bool merge = true}) {', + ); + buffer.writeln(' return _next('); + buffer.writeln(' _groupBy.copyWith('); + buffer.writeln( + ' having: merge ? _groupBy.having.merge(having) : having,', + ); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.groupedQueryClassName} havingWith(${model.groupByHavingClassName} Function(${model.groupByHavingClassName} having) build, {bool merge = true}) {', + ); + buffer.writeln(' final next = build(_groupBy.having);'); + buffer.writeln(' return having(next, merge: merge);'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.groupedQueryClassName} orderBy(List<${model.groupByOrderByClassName}> orderBy, {bool append = true}) {', + ); + buffer.writeln(' return _next('); + buffer.writeln(' _groupBy.copyWith('); + buffer.writeln(' orderBy: append'); + buffer.writeln( + ' ? <${model.groupByOrderByClassName}>[..._groupBy.orderBy, ...orderBy]', + ); + buffer.writeln(' : orderBy,'); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.groupedQueryClassName} skip(int? skip) => _next(_groupBy.copyWith(skip: skip));', + ); + buffer.writeln(); + + buffer.writeln( + ' ${model.groupedQueryClassName} take(int? take) => _next(_groupBy.copyWith(take: take));', + ); + buffer.writeln(); + + buffer.writeln( + ' Future> aggregate({', + ); + buffer.writeln(' bool countAll = false,'); + buffer.writeln( + ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', + ); + buffer.writeln( + ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return aggregateWith('); + buffer.writeln(' ${model.aggregateSpecClassName}('); + buffer.writeln(' countAll: countAll,'); + buffer.writeln(' count: count,'); + buffer.writeln(' min: min,'); + buffer.writeln(' max: max,'); + buffer.writeln(' sum: sum,'); + buffer.writeln(' avg: avg,'); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future> aggregateWith(${model.aggregateSpecClassName} aggregate) {', + ); + buffer.writeln(' return _executeSpec('); + buffer.writeln(' _groupBy.copyWith('); + buffer.writeln(' countAll: aggregate.countAll,'); + buffer.writeln(' count: aggregate.count,'); + buffer.writeln(' min: aggregate.min,'); + buffer.writeln(' max: aggregate.max,'); + buffer.writeln(' sum: aggregate.sum,'); + buffer.writeln(' avg: aggregate.avg,'); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future> _execute() => _executeSpec(_groupBy);', + ); + buffer.writeln(); + + buffer.writeln( + ' Future> _executeSpec(${model.groupBySpecClassName} groupBy) {', + ); + buffer.writeln(' return _delegate._delegate.groupByWith('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' groupBy: groupBy.toRuntimeSpec(),'); + buffer.writeln( + ' ).then((rows) => rows.map(${model.groupByResultClassName}.fromJson).toList(growable: false));', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.groupedQueryClassName} _next(${model.groupBySpecClassName} groupBy) {', + ); + buffer.writeln(' return ${model.groupedQueryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: _where,'); + buffer.writeln(' groupBy: groupBy,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + } + void _writeRelationWhereFilterClasses({ required StringBuffer buffer, required _ResolvedModel model, @@ -4219,6 +4442,8 @@ final class _ResolvedModel { String get groupByResultClassName => '${classBaseName}GroupByResult'; + String get groupedQueryClassName => '${classBaseName}GroupedQuery'; + String get selectClassName => '${classBaseName}Select'; String get includeClassName => '${classBaseName}Include'; diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 7cc2375f..bbe9628a 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -588,9 +588,9 @@ void main() { final grouped = await users .query() + .groupedBy(const ['email']) .orderByField('email') - .groupBy( - by: const ['email'], + .aggregate( countAll: true, sum: const ['id'], avg: const ['id'], @@ -633,17 +633,14 @@ void main() { final grouped = await users .query() - .orderByField('_sum.id', order: SortOrder.desc) - .groupBy( - by: const ['email'], - having: { - '_count': { - 'all': {'gte': 2}, - }, + .groupedBy(const ['email']) + .having({ + '_count': { + 'all': {'gte': 2}, }, - countAll: true, - sum: const ['id'], - ); + }, merge: false) + .orderByField('_sum.id', order: SortOrder.desc) + .aggregate(countAll: true, sum: const ['id']); expect(grouped, hasLength(2)); expect( @@ -668,12 +665,9 @@ void main() { await expectLater( users .query() + .groupedBy(const ['email']) .orderByField('sum.email') - .groupBy( - by: const ['email'], - countAll: true, - sum: const ['id'], - ), + .aggregate(countAll: true, sum: const ['id']), throwsA( isA().having( (error) => error.code, @@ -685,6 +679,26 @@ void main() { await client.disconnect(); }); + test('rejects groupedBy when row-query state is already present', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + expect( + () => users.query().orderByField('email').groupedBy(const [ + 'email', + ]), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.GROUP_BY_QUERY_STATE_INVALID', + ), + ), + ); + await client.disconnect(); + }); + test('rejects invalid groupBy having aggregate fields', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 7ab9ced9..621b9d1f 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -50,11 +50,19 @@ void main() { ); expect( RegExp( - r'class\s+ModelDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(\{[\s\S]*?required\s+OrmGroupBySpec\s+groupBy,[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\.groupByWith\(groupBy\);', + r'class\s+ModelDelegate\s*\{[\s\S]*?ModelGroupedQuery\s+groupedBy\(\s*List\s+by,\s*\{[\s\S]*?JsonMap\s+where\s*=\s*const\s+\{\},[\s\S]*?\)\s*=>\s*_queryFromSpec\(OrmReadQuerySpec\(where:\s*where\)\)\.groupedBy\(by\);', ).hasMatch(source), isTrue, reason: - 'Expected ModelDelegate.groupByWith(...) to route structured groupBy execution through query terminals.', + 'Expected ModelDelegate.groupedBy(...) to create grouped queries from where-only query specs.', + ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(\{[\s\S]*?required\s+OrmGroupBySpec\s+groupBy,[\s\S]*?\)\s*=>\s*groupedBy\(groupBy\.by,\s*where:\s*where\)\.configure\(groupBy\)\._execute\(\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelDelegate.groupByWith(...) to route structured groupBy execution through the grouped builder.', ); }); @@ -79,19 +87,43 @@ void main() { ); expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?\)\s*=>\s*groupByWith\(\s*OrmGroupBySpec\(', + r'class\s+ModelQuery\s*\{[\s\S]*?ModelGroupedQuery\s+groupedBy\(List\s+by\)\s*\{[\s\S]*?_assertGroupedQueryBaseState\(\);[\s\S]*?return\s+ModelGroupedQuery\._\([\s\S]*?OrmGroupBySpec\(by:\s*by\),', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.groupedBy(...) to construct a distinct grouped builder.', + ); + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?var\s+grouped\s*=\s*groupedBy\(by\)\.having\(having,\s*merge:\s*false\);[\s\S]*?return\s+grouped\.aggregate\(', ).hasMatch(source), isTrue, reason: - 'Expected ModelQuery.groupBy(...) to compile convenience arguments into OrmGroupBySpec.', + 'Expected ModelQuery.groupBy(...) to route convenience arguments through the grouped builder.', ); expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupByWith\(OrmGroupBySpec\s+groupBy\)\s*\{[\s\S]*?final\s+effectiveGroupBy\s*=[\s\S]*?groupBy\.copyWith\(orderBy:\s*_state\.orderBy\)[\s\S]*?return\s+_delegate\._groupBy\(spec:\s*_state,\s*groupBy:\s*effectiveGroupBy\);', + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupByWith\(OrmGroupBySpec\s+groupBy\)\s*=>\s*groupedBy\(groupBy\.by\)\.configure\(groupBy\)\._execute\(\);', ).hasMatch(source), isTrue, reason: - 'Expected ModelQuery.groupByWith(...) to terminate through the private groupBy helper.', + 'Expected ModelQuery.groupByWith(...) to execute via the grouped builder.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(OrmAggregateSpec\s+aggregate\)\s*\{[\s\S]*?return\s+_delegate\._groupBy\(\s*spec:\s*_baseState,\s*groupBy:\s*_groupBy\.copyWith\(', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelGroupedQuery.aggregateWith(...) to terminate through the private groupBy helper.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+all\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to avoid row-query all() terminals.', ); expect( RegExp( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 075273ec..365e6bc2 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -946,6 +946,14 @@ typedef Post = ({ reason: 'Expected UserGroupBySpec to compile to the runtime groupBy spec.', ); + expect( + RegExp( + r'class\s+UserGroupBySpec\s*\{[\s\S]*?final\s+int\?\s+skip;[\s\S]*?final\s+int\?\s+take;[\s\S]*?UserGroupBySpec\s+copyWith\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupBySpec to carry grouped pagination state and copyWith helpers.', + ); expect( RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), isTrue, @@ -1285,19 +1293,27 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?skip:\s*skip,[\s\S]*?take:\s*take,[\s\S]*?\)\.groupBy\([\s\S]*?groupByOrderBy:\s*groupByOrderBy,[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?\);', + r'class\s+UserDelegate\s*\{[\s\S]*?UserGroupedQuery\s+groupedBy\([\s\S]*?return\s+query\(where:\s*where\)\.groupedBy\(by\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate groupedBy(...) to expose the typed grouped builder.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+groupedBy\(by,\s*where:\s*where\)[\s\S]*?\.aggregate\(', ).hasMatch(generatedSource), isTrue, reason: - 'Expected generated delegate groupBy(...) to route through typed query.', + 'Expected generated delegate groupBy(...) to route through the typed grouped builder.', ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(\{[\s\S]*?required\s+UserGroupBySpec\s+groupBy,[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?skip:\s*skip,[\s\S]*?take:\s*take,[\s\S]*?\)\.groupByWith\(groupBy\);', + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(\{[\s\S]*?required\s+UserGroupBySpec\s+groupBy,[\s\S]*?return\s+groupedBy\(groupBy\.by,\s*where:\s*where\)[\s\S]*?\.configure\(groupBy\)[\s\S]*?\._execute\(\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected generated delegate groupByWith(...) to route structured groupBy specs through typed query.', + 'Expected generated delegate groupByWith(...) to route structured groupBy specs through the typed grouped builder.', ); expect( generatedSource.contains('Future> findMany('), @@ -1530,11 +1546,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?skip:\s*skip,[\s\S]*?take:\s*take,[\s\S]*?\)\.groupBy\([\s\S]*?groupByOrderBy:\s*groupByOrderBy,[\s\S]*?typedHaving:\s*typedHaving,[\s\S]*?\);', + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+groupedBy\(by,\s*where:\s*where\)[\s\S]*?typedHaving[\s\S]*?\.aggregate\(', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.groupBy(...) to route typed groupBy execution through query.', + 'Expected UserDelegate.groupBy(...) to route typed groupBy execution through the grouped builder.', ); expect( RegExp( @@ -1554,27 +1570,41 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?List\s+groupByOrderBy\s*=\s*const\s+\[\],[\s\S]*?return\s+groupByWith\(\s*UserGroupBySpec\(', + r'class\s+UserQuery\s*\{[\s\S]*?UserGroupedQuery\s+groupedBy\(List\s+by\)\s*\{[\s\S]*?_assertGroupedQueryBaseState\(\);[\s\S]*?return\s+UserGroupedQuery\._\(', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.groupBy(...) to compile convenience arguments into a structured typed groupBy spec.', + 'Expected UserQuery.groupedBy(...) to construct a distinct typed grouped builder.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupByWith\(UserGroupBySpec\s+groupBy\)\s*\{[\s\S]*?groupBy:\s*groupBy\.toRuntimeSpec\(\),[\s\S]*?UserGroupByResult\.fromJson', + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?List\s+groupByOrderBy\s*=\s*const\s+\[\],[\s\S]*?var\s+grouped\s*=\s*groupedBy\(by\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.groupByWith(...) to route through runtime structured groupBy specs.', + 'Expected UserQuery.groupBy(...) to route convenience arguments through the typed grouped builder.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?orderBy:\s*_orderBy,', + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupByWith\(UserGroupBySpec\s+groupBy\)\s*\{?[\s\S]*?return\s+groupedBy\(groupBy\.by\)\.configure\(groupBy\)\._execute\(\);', ).hasMatch(generatedSource), - isFalse, + isTrue, + reason: + 'Expected UserQuery.groupByWith(...) to route through the typed grouped builder.', + ); + expect( + RegExp(r'\bclass UserGroupedQuery\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include a distinct UserGroupedQuery builder.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?groupBy:\s*groupBy\.toRuntimeSpec\(\),[\s\S]*?UserGroupByResult\.fromJson', + ).hasMatch(generatedSource), + isTrue, reason: - 'Expected UserQuery.groupBy(...) to stop forwarding query orderBy state.', + 'Expected UserGroupedQuery.aggregateWith(...) to terminate through runtime structured groupBy specs.', ); expect( RegExp( From cf00b9445c030704bd8057b527d2a2773d2f3905 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:06:38 +0800 Subject: [PATCH 135/154] feat(plan)!: add structured aggregate read shapes --- pub/orm/lib/src/client/client.dart | 312 +++++++++++++++---- pub/orm/lib/src/client/read_repository.dart | 46 +++ pub/orm/lib/src/generator/writer.dart | 50 ++- pub/orm/lib/src/runtime/core.dart | 118 +++++++ pub/orm/lib/src/runtime/plan.dart | 90 +++++- pub/orm/test/client/api_surface_test.dart | 26 ++ pub/orm/test/client/client_test.dart | 62 ++++ pub/orm/test/client/source_surface_test.dart | 16 +- pub/orm/test/generator/generate_test.dart | 28 +- pub/orm/test/runtime/plan_surface_test.dart | 69 +++- 10 files changed, 723 insertions(+), 94 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index d7a38898..2dd85e2b 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1123,6 +1123,135 @@ class ModelDelegate { ); } + Future _prepareAggregateQuery({ + required OrmReadQuerySpec spec, + required OrmAggregateSpec aggregate, + }) async { + _validateAggregateSpec(aggregate: aggregate, source: 'aggregate'); + final prepared = await _prepareReadQuery( + state: _OrmPreparedReadState( + resultMode: OrmReadResultMode.all, + spec: spec.copyWith( + select: _buildAggregateSelect( + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + sum: aggregate.sum, + avg: aggregate.avg, + ), + include: const {}, + ), + ), + ); + final basePlan = prepared.plan; + final read = basePlan.read!; + return OrmPreparedAggregateQuery._( + delegate: this, + plan: OrmPlan.read( + contractHash: basePlan.contractHash, + target: basePlan.target, + storageHash: basePlan.storageHash, + profileHash: basePlan.profileHash, + lane: basePlan.lane, + annotations: basePlan.annotations, + repositoryTrace: basePlan.repositoryTrace, + model: modelName, + where: read.where, + skip: read.skip, + take: read.take, + orderBy: read.orderBy, + distinct: read.distinct, + select: read.select, + include: read.include, + cursor: read.cursor, + page: read.page, + resultMode: read.resultMode, + shape: OrmReadShape.aggregate, + aggregate: OrmReadAggregatePlan( + countAll: aggregate.countAll, + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + sum: aggregate.sum, + avg: aggregate.avg, + ), + ), + spec: prepared._state._spec, + aggregate: aggregate, + ); + } + + Future _prepareGroupedQuery({ + required OrmReadQuerySpec baseSpec, + required OrmGroupBySpec groupBy, + }) async { + _validateGroupBySpec(spec: baseSpec, groupBy: groupBy); + final prepared = await _prepareReadQuery( + state: _OrmPreparedReadState( + resultMode: OrmReadResultMode.all, + spec: baseSpec.copyWith( + skip: null, + take: null, + orderBy: const [], + distinct: const [], + select: _buildAggregateSelect( + count: groupBy.by.followedBy(groupBy.count).toList(growable: false), + min: groupBy.min, + max: groupBy.max, + sum: groupBy.sum, + avg: groupBy.avg, + ), + include: const {}, + cursor: null, + page: null, + ), + ), + ); + final basePlan = prepared.plan; + final read = basePlan.read!; + return OrmPreparedGroupedQuery._( + delegate: this, + plan: OrmPlan.read( + contractHash: basePlan.contractHash, + target: basePlan.target, + storageHash: basePlan.storageHash, + profileHash: basePlan.profileHash, + lane: basePlan.lane, + annotations: basePlan.annotations, + repositoryTrace: basePlan.repositoryTrace, + model: modelName, + where: read.where, + skip: read.skip, + take: read.take, + orderBy: read.orderBy, + distinct: read.distinct, + select: read.select, + include: read.include, + cursor: read.cursor, + page: read.page, + resultMode: read.resultMode, + shape: OrmReadShape.groupedAggregate, + aggregate: OrmReadAggregatePlan( + countAll: groupBy.countAll, + count: groupBy.count, + min: groupBy.min, + max: groupBy.max, + sum: groupBy.sum, + avg: groupBy.avg, + ), + groupBy: OrmReadGroupByPlan( + by: groupBy.by, + having: groupBy.having, + orderBy: groupBy.orderBy, + skip: groupBy.skip, + take: groupBy.take, + ), + ), + baseSpec: prepared._state._spec, + groupBy: groupBy, + ); + } + Future _prepareReadQuery({ required _OrmPreparedReadState state, }) async { @@ -1523,14 +1652,7 @@ class ModelDelegate { required OrmReadQuerySpec spec, required OrmAggregateSpec aggregate, }) async { - _assertKnownAggregateFields( - fields: aggregate.count, - source: 'aggregate.count', - ); - _assertKnownAggregateFields(fields: aggregate.min, source: 'aggregate.min'); - _assertKnownAggregateFields(fields: aggregate.max, source: 'aggregate.max'); - _assertKnownAggregateFields(fields: aggregate.sum, source: 'aggregate.sum'); - _assertKnownAggregateFields(fields: aggregate.avg, source: 'aggregate.avg'); + _validateAggregateSpec(aggregate: aggregate, source: 'aggregate'); final rows = await _readAllInternal( action: OrmAction.read, @@ -1563,57 +1685,7 @@ class ModelDelegate { required OrmReadQuerySpec spec, required OrmGroupBySpec groupBy, }) async { - if (groupBy.by.isEmpty) { - throw runtimeError( - 'PLAN.GROUP_BY_FIELDS_EMPTY', - 'GroupBy requires at least one field in by.', - details: {'model': modelName}, - ); - } - if (groupBy.skip case final offset? when offset < 0) { - throw PlanInvalidPaginationException(key: 'skip', value: offset); - } - if (groupBy.take case final limit? when limit < 0) { - throw PlanInvalidPaginationException(key: 'take', value: limit); - } - if (spec.cursor != null || spec.page != null) { - throw runtimeError( - 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', - 'GroupBy does not support cursor or page windows yet.', - details: { - 'model': modelName, - if (spec.cursor != null) 'cursor': spec.cursor, - if (spec.page != null) 'page': spec.page!.toJson(), - }, - ); - } - - _assertKnownAggregateFields(fields: groupBy.by, source: 'groupBy.by'); - _assertKnownAggregateFields(fields: groupBy.count, source: 'groupBy.count'); - _assertKnownAggregateFields(fields: groupBy.min, source: 'groupBy.min'); - _assertKnownAggregateFields(fields: groupBy.max, source: 'groupBy.max'); - _assertKnownAggregateFields(fields: groupBy.sum, source: 'groupBy.sum'); - _assertKnownAggregateFields(fields: groupBy.avg, source: 'groupBy.avg'); - _assertGroupByOrderByFields( - orderBy: groupBy.orderBy, - by: groupBy.by, - countAll: groupBy.countAll, - count: groupBy.count, - min: groupBy.min, - max: groupBy.max, - sum: groupBy.sum, - avg: groupBy.avg, - ); - _assertGroupByHavingFields( - having: groupBy.having, - by: groupBy.by, - countAll: groupBy.countAll, - count: groupBy.count, - min: groupBy.min, - max: groupBy.max, - sum: groupBy.sum, - avg: groupBy.avg, - ); + _validateGroupBySpec(spec: spec, groupBy: groupBy); final rows = await _readAllInternal( action: OrmAction.read, @@ -2241,6 +2313,77 @@ class ModelDelegate { } } + void _validateAggregateSpec({ + required OrmAggregateSpec aggregate, + required String source, + }) { + _assertKnownAggregateFields( + fields: aggregate.count, + source: '$source.count', + ); + _assertKnownAggregateFields(fields: aggregate.min, source: '$source.min'); + _assertKnownAggregateFields(fields: aggregate.max, source: '$source.max'); + _assertKnownAggregateFields(fields: aggregate.sum, source: '$source.sum'); + _assertKnownAggregateFields(fields: aggregate.avg, source: '$source.avg'); + } + + void _validateGroupBySpec({ + required OrmReadQuerySpec spec, + required OrmGroupBySpec groupBy, + }) { + if (groupBy.by.isEmpty) { + throw runtimeError( + 'PLAN.GROUP_BY_FIELDS_EMPTY', + 'GroupBy requires at least one field in by.', + details: {'model': modelName}, + ); + } + if (groupBy.skip case final offset? when offset < 0) { + throw PlanInvalidPaginationException(key: 'skip', value: offset); + } + if (groupBy.take case final limit? when limit < 0) { + throw PlanInvalidPaginationException(key: 'take', value: limit); + } + if (spec.cursor != null || spec.page != null) { + throw runtimeError( + 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', + 'GroupBy does not support cursor or page windows yet.', + details: { + 'model': modelName, + if (spec.cursor != null) 'cursor': spec.cursor, + if (spec.page != null) 'page': spec.page!.toJson(), + }, + ); + } + + _assertKnownAggregateFields(fields: groupBy.by, source: 'groupBy.by'); + _assertKnownAggregateFields(fields: groupBy.count, source: 'groupBy.count'); + _assertKnownAggregateFields(fields: groupBy.min, source: 'groupBy.min'); + _assertKnownAggregateFields(fields: groupBy.max, source: 'groupBy.max'); + _assertKnownAggregateFields(fields: groupBy.sum, source: 'groupBy.sum'); + _assertKnownAggregateFields(fields: groupBy.avg, source: 'groupBy.avg'); + _assertGroupByOrderByFields( + orderBy: groupBy.orderBy, + by: groupBy.by, + countAll: groupBy.countAll, + count: groupBy.count, + min: groupBy.min, + max: groupBy.max, + sum: groupBy.sum, + avg: groupBy.avg, + ); + _assertGroupByHavingFields( + having: groupBy.having, + by: groupBy.by, + countAll: groupBy.countAll, + count: groupBy.count, + min: groupBy.min, + max: groupBy.max, + sum: groupBy.sum, + avg: groupBy.avg, + ); + } + void _assertGroupByOrderByFields({ required List orderBy, required List by, @@ -3538,8 +3681,10 @@ final class ModelQuery { ); Future aggregateWith(OrmAggregateSpec aggregate) { - _assertReadExecutionSupported('aggregate'); - return _delegate._aggregate(spec: _state, aggregate: aggregate); + _assertAggregateQueryState(); + return _delegate + ._prepareAggregateQuery(spec: _state, aggregate: aggregate) + .then((prepared) => prepared.execute()); } ModelGroupedQuery groupedBy(List by) { @@ -3627,6 +3772,28 @@ final class ModelQuery { ); } + void _assertAggregateQueryState() { + final invalidKeys = [ + if (_state.skip != null) 'skip', + if (_state.take != null) 'take', + if (_state.distinct.isNotEmpty) 'distinct', + if (_state.select.isNotEmpty) 'select', + if (_state.include.isNotEmpty) 'include', + ]; + if (invalidKeys.isEmpty) { + return; + } + + throw runtimeError( + 'PLAN.AGGREGATE_QUERY_STATE_INVALID', + 'aggregate() does not allow query state keys: ${invalidKeys.join(', ')}.', + details: { + 'model': _delegate.modelName, + 'invalidKeys': invalidKeys, + }, + ); + } + void _assertMutationQueryState({ required String action, bool allowWhere = true, @@ -3797,8 +3964,7 @@ final class ModelGroupedQuery { Future> aggregateWith(OrmAggregateSpec aggregate) { _assertExecutionSupported('aggregate'); - return _delegate._groupBy( - spec: _baseState, + return _prepareGrouped( groupBy: _groupBy.copyWith( countAll: aggregate.countAll, count: aggregate.count, @@ -3807,7 +3973,7 @@ final class ModelGroupedQuery { sum: aggregate.sum, avg: aggregate.avg, ), - ); + ).then((prepared) => prepared.execute()); } void _assertExecutionSupported(String terminal) { @@ -3825,6 +3991,14 @@ final class ModelGroupedQuery { } } + Future toPlan() async { + return (await _prepareGrouped(groupBy: _groupBy)).plan; + } + + Future inspectPlan() async { + return (await _prepareGrouped(groupBy: _groupBy)).inspectPlan(); + } + Future> _execute() => aggregateWith( OrmAggregateSpec( countAll: _groupBy.countAll, @@ -3836,6 +4010,16 @@ final class ModelGroupedQuery { ), ); + Future _prepareGrouped({ + required OrmGroupBySpec groupBy, + }) { + _assertExecutionSupported('aggregate'); + return _delegate._prepareGroupedQuery( + baseSpec: _baseState, + groupBy: groupBy, + ); + } + ModelGroupedQuery _next(OrmGroupBySpec nextGroupBy) => ModelGroupedQuery._(_delegate, _baseState, nextGroupBy); } diff --git a/pub/orm/lib/src/client/read_repository.dart b/pub/orm/lib/src/client/read_repository.dart index af36a63c..6ce49772 100644 --- a/pub/orm/lib/src/client/read_repository.dart +++ b/pub/orm/lib/src/client/read_repository.dart @@ -167,6 +167,52 @@ final class OrmPreparedReadQuery { } } +@immutable +final class OrmPreparedAggregateQuery { + final ModelDelegate _delegate; + final OrmPlan plan; + final OrmReadQuerySpec _spec; + final OrmAggregateSpec _aggregate; + + const OrmPreparedAggregateQuery._({ + required ModelDelegate delegate, + required this.plan, + required OrmReadQuerySpec spec, + required OrmAggregateSpec aggregate, + }) : _delegate = delegate, + _spec = spec, + _aggregate = aggregate; + + Future inspectPlan() async => + Map.unmodifiable(plan.toJson()); + + Future execute() => + _delegate._aggregate(spec: _spec, aggregate: _aggregate); +} + +@immutable +final class OrmPreparedGroupedQuery { + final ModelDelegate _delegate; + final OrmPlan plan; + final OrmReadQuerySpec _baseSpec; + final OrmGroupBySpec _groupBy; + + const OrmPreparedGroupedQuery._({ + required ModelDelegate delegate, + required this.plan, + required OrmReadQuerySpec baseSpec, + required OrmGroupBySpec groupBy, + }) : _delegate = delegate, + _baseSpec = baseSpec, + _groupBy = groupBy; + + Future inspectPlan() async => + Map.unmodifiable(plan.toJson()); + + Future> execute() => + _delegate._groupBy(spec: _baseSpec, groupBy: _groupBy); +} + final class _RepositoryReadExecutor { final ModelDelegate _delegate; diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 4c67f408..f2475b3e 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -3342,6 +3342,16 @@ final class TypedClientWriter { ); buffer.writeln(); + buffer.writeln(' Future toPlan() {'); + buffer.writeln(' return _runtimeGrouped(_groupBy).toPlan();'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future inspectPlan() {'); + buffer.writeln(' return _runtimeGrouped(_groupBy).inspectPlan();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( ' Future> aggregate({', ); @@ -3399,12 +3409,44 @@ final class TypedClientWriter { buffer.writeln( ' Future> _executeSpec(${model.groupBySpecClassName} groupBy) {', ); - buffer.writeln(' return _delegate._delegate.groupByWith('); - buffer.writeln(' where: _where.toJson(),'); - buffer.writeln(' groupBy: groupBy.toRuntimeSpec(),'); + buffer.writeln(' return _runtimeGrouped(groupBy)'); + buffer.writeln(' .aggregateWith('); + buffer.writeln(' OrmAggregateSpec('); + buffer.writeln(' countAll: groupBy.countAll,'); + buffer.writeln( + ' count: groupBy.count.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' min: groupBy.min.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' max: groupBy.max.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' sum: groupBy.sum.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln( + ' avg: groupBy.avg.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' ),'); + buffer.writeln(' )'); + buffer.writeln( + ' .then((rows) => rows.map(${model.groupByResultClassName}.fromJson).toList(growable: false));', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ModelGroupedQuery _runtimeGrouped(${model.groupBySpecClassName} groupBy) {', + ); + buffer.writeln(' return _delegate._delegate'); + buffer.writeln(' .groupedBy('); buffer.writeln( - ' ).then((rows) => rows.map(${model.groupByResultClassName}.fromJson).toList(growable: false));', + ' by: groupBy.by.map((entry) => entry.value).toList(growable: false),', ); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' )'); + buffer.writeln(' .configure(groupBy.toRuntimeSpec());'); buffer.writeln(' }'); buffer.writeln(); diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 8b80be6e..abf26829 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -54,6 +54,9 @@ int? _estimatedRowsForExplain(OrmPlan plan) { if (read == null) { return null; } + if (read.shape == OrmReadShape.aggregate) { + return 1; + } if (read.page case final page?) { return page.size; } @@ -78,6 +81,7 @@ JsonMap _buildExplainResult(OrmPlan plan) { 'executionMode': 'deferred', 'executionSource': 'notExecuted', if (read != null) 'readResultMode': read.resultMode.name, + if (read != null) 'readShape': read.shape.name, if (mutation != null) 'mutationResultMode': mutation.resultMode.name, if (read != null) 'selectedFieldCount': read.select.length, if (mutation != null) 'selectedFieldCount': mutation.select.length, @@ -937,6 +941,7 @@ final class OrmRuntimeCore implements RuntimeCore { required ModelContract model, required OrmReadPlan plan, }) { + _assertReadShape(model: model, plan: plan); _assertWhereFields(model: model, where: plan.where, source: 'where'); _assertKnownFields( model: model, @@ -1090,6 +1095,119 @@ final class OrmRuntimeCore implements RuntimeCore { } } + void _assertReadShape({ + required ModelContract model, + required OrmReadPlan plan, + }) { + switch (plan.shape) { + case OrmReadShape.rows: + if (plan.aggregate != null || plan.groupBy != null) { + throw runtimeError( + 'PLAN.READ_SHAPE_INVALID', + 'Row read plans cannot include aggregate or grouped metadata.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + }, + ); + } + case OrmReadShape.aggregate: + final aggregate = plan.aggregate; + if (aggregate == null || plan.groupBy != null) { + throw runtimeError( + 'PLAN.READ_SHAPE_INVALID', + 'Aggregate read plans require aggregate metadata and forbid groupBy metadata.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + }, + ); + } + _assertKnownFields( + model: model, + fields: [ + ...aggregate.count, + ...aggregate.min, + ...aggregate.max, + ...aggregate.sum, + ...aggregate.avg, + ], + source: 'aggregate', + ); + throw runtimeError( + 'PLAN.READ_SHAPE_UNSUPPORTED', + 'Aggregate read plans are not executable through runtime yet.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + }, + ); + case OrmReadShape.groupedAggregate: + final aggregate = plan.aggregate; + final groupBy = plan.groupBy; + if (aggregate == null || groupBy == null) { + throw runtimeError( + 'PLAN.READ_SHAPE_INVALID', + 'Grouped aggregate plans require both aggregate and groupBy metadata.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + }, + ); + } + _assertKnownFields( + model: model, + fields: groupBy.by, + source: 'groupBy.by', + ); + _assertKnownFields( + model: model, + fields: [ + ...aggregate.count, + ...aggregate.min, + ...aggregate.max, + ...aggregate.sum, + ...aggregate.avg, + ], + source: 'groupBy.aggregate', + ); + if (groupBy.skip case final skip? when skip < 0) { + throw PlanInvalidPaginationException( + key: 'groupBy.skip', + value: skip, + ); + } + if (groupBy.take case final take? when take < 0) { + throw PlanInvalidPaginationException( + key: 'groupBy.take', + value: take, + ); + } + if (groupBy.by.isEmpty) { + throw runtimeError( + 'PLAN.GROUP_BY_FIELDS_EMPTY', + 'GroupBy requires at least one field in by.', + details: {'model': model.name}, + ); + } + if (plan.cursor != null || plan.page != null) { + throw runtimeError( + 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', + 'Grouped aggregate plans do not support cursor or page windows.', + details: {'model': model.name}, + ); + } + throw runtimeError( + 'PLAN.READ_SHAPE_UNSUPPORTED', + 'Grouped aggregate plans are not executable through runtime yet.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + }, + ); + } + } + bool _matchesBoundaryFields({ required List orderBy, required Iterable boundaryFields, diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index ea2e6468..bc4f4f55 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -9,13 +9,14 @@ enum OrmReadResultMode { all, firstOrNull, oneOrNull } enum OrmMutationResultMode { row, rowOrNull } +enum OrmReadShape { rows, aggregate, groupedAggregate } + @immutable final class OrmReadCursorPlan { final JsonMap values; - OrmReadCursorPlan({ - JsonMap values = const {}, - }) : values = Map.unmodifiable(values); + OrmReadCursorPlan({JsonMap values = const {}}) + : values = Map.unmodifiable(values); JsonMap toJson() => {'values': values}; } @@ -26,12 +27,9 @@ final class OrmReadPagePlan { final JsonMap? after; final JsonMap? before; - OrmReadPagePlan({ - required this.size, - JsonMap? after, - JsonMap? before, - }) : after = after == null ? null : Map.unmodifiable(after), - before = before == null ? null : Map.unmodifiable(before); + OrmReadPagePlan({required this.size, JsonMap? after, JsonMap? before}) + : after = after == null ? null : Map.unmodifiable(after), + before = before == null ? null : Map.unmodifiable(before); JsonMap toJson() => { 'size': size, @@ -126,6 +124,9 @@ final class OrmReadPlan { final OrmReadCursorPlan? cursor; final OrmReadPagePlan? page; final OrmReadResultMode resultMode; + final OrmReadShape shape; + final OrmReadAggregatePlan? aggregate; + final OrmReadGroupByPlan? groupBy; OrmReadPlan({ JsonMap where = const {}, @@ -138,6 +139,9 @@ final class OrmReadPlan { this.cursor, this.page, required this.resultMode, + this.shape = OrmReadShape.rows, + this.aggregate, + this.groupBy, }) : where = Map.unmodifiable(where), orderBy = List.unmodifiable(orderBy), distinct = List.unmodifiable(distinct), @@ -157,6 +161,68 @@ final class OrmReadPlan { if (cursor != null) 'cursor': cursor!.toJson(), if (page != null) 'page': page!.toJson(), 'resultMode': resultMode.name, + 'shape': shape.name, + if (aggregate != null) 'aggregate': aggregate!.toJson(), + if (groupBy != null) 'groupBy': groupBy!.toJson(), + }; +} + +@immutable +final class OrmReadAggregatePlan { + final bool countAll; + final List count; + final List min; + final List max; + final List sum; + final List avg; + + OrmReadAggregatePlan({ + this.countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) : count = List.unmodifiable(count), + min = List.unmodifiable(min), + max = List.unmodifiable(max), + sum = List.unmodifiable(sum), + avg = List.unmodifiable(avg); + + JsonMap toJson() => { + 'countAll': countAll, + 'count': count, + 'min': min, + 'max': max, + 'sum': sum, + 'avg': avg, + }; +} + +@immutable +final class OrmReadGroupByPlan { + final List by; + final JsonMap having; + final List orderBy; + final int? skip; + final int? take; + + OrmReadGroupByPlan({ + required List by, + JsonMap having = const {}, + List orderBy = const [], + this.skip, + this.take, + }) : by = List.unmodifiable(by), + having = Map.unmodifiable(having), + orderBy = List.unmodifiable(orderBy); + + JsonMap toJson() => { + 'by': by, + 'having': having, + 'orderBy': orderBy.map((entry) => entry.toJson()).toList(growable: false), + if (skip != null) 'skip': skip, + if (take != null) 'take': take, }; } @@ -231,6 +297,9 @@ final class OrmPlan { OrmReadCursorPlan? cursor, OrmReadPagePlan? page, required OrmReadResultMode resultMode, + OrmReadShape shape = OrmReadShape.rows, + OrmReadAggregatePlan? aggregate, + OrmReadGroupByPlan? groupBy, }) { return OrmPlan( contractHash: contractHash, @@ -253,6 +322,9 @@ final class OrmPlan { cursor: cursor, page: page, resultMode: resultMode, + shape: shape, + aggregate: aggregate, + groupBy: groupBy, ), ); } diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 3d544317..4a590b18 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -144,6 +144,32 @@ void main() { }, ); + test( + 'runtime rejects direct execution of grouped aggregate plans', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final plan = await client.db.orm.model('User').query().groupedBy( + const ['email'], + ).toPlan(); + + await expectLater( + client.execute(plan), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.READ_SHAPE_UNSUPPORTED', + ), + ), + ); + } finally { + await client.disconnect(); + } + }, + ); + test('explain requires an active runtime connection', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index bbe9628a..0563fa97 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -699,6 +699,68 @@ void main() { await client.disconnect(); }); + test( + 'grouped builder compiles to structured grouped aggregate plan', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + final plan = await users + .query() + .groupedBy(const ['email']) + .configure( + OrmGroupBySpec( + by: const ['email'], + countAll: true, + having: const { + '_count': { + 'all': {'gte': 2}, + }, + }, + orderBy: const [ + OrmOrderBy('_sum.id', order: SortOrder.desc), + ], + take: 5, + sum: const ['id'], + ), + ) + .toPlan(); + + expect(plan.read?.shape, OrmReadShape.groupedAggregate); + expect(plan.read?.groupBy?.by, ['email']); + expect(plan.read?.groupBy?.take, 5); + expect( + plan.read?.groupBy?.orderBy.map((entry) => entry.field).toList(), + ['_sum.id'], + ); + expect(plan.read?.groupBy?.having, { + '_count': { + 'all': {'gte': 2}, + }, + }); + expect(plan.read?.aggregate, isNotNull); + }, + ); + + test('aggregate rejects unsupported row-query state keys', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users + .query() + .select(const ['id']) + .aggregate(countAll: true), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.AGGREGATE_QUERY_STATE_INVALID', + ), + ), + ); + }); + test('rejects invalid groupBy having aggregate fields', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 621b9d1f..e1255d77 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -79,11 +79,11 @@ void main() { ); expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregateWith\(OrmAggregateSpec\s+aggregate\)\s*\{[\s\S]*?return\s+_delegate\._aggregate\(spec:\s*_state,\s*aggregate:\s*aggregate\);', + r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregateWith\(OrmAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_assertAggregateQueryState\(\);[\s\S]*?_prepareAggregateQuery\(spec:\s*_state,\s*aggregate:\s*aggregate\)[\s\S]*?prepared\.execute\(\)', ).hasMatch(source), isTrue, reason: - 'Expected ModelQuery.aggregateWith(...) to terminate through the private aggregate helper.', + 'Expected ModelQuery.aggregateWith(...) to prepare an aggregate plan before execution.', ); expect( RegExp( @@ -111,11 +111,19 @@ void main() { ); expect( RegExp( - r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(OrmAggregateSpec\s+aggregate\)\s*\{[\s\S]*?return\s+_delegate\._groupBy\(\s*spec:\s*_baseState,\s*groupBy:\s*_groupBy\.copyWith\(', + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(OrmAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_prepareGrouped\([\s\S]*?groupBy:\s*_groupBy\.copyWith\([\s\S]*?prepared\.execute\(\)', ).hasMatch(source), isTrue, reason: - 'Expected ModelGroupedQuery.aggregateWith(...) to terminate through the private groupBy helper.', + 'Expected ModelGroupedQuery.aggregateWith(...) to prepare a grouped aggregate plan before execution.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future\s+toPlan\(\)\s+async\s*\{[\s\S]*?_prepareGrouped\(groupBy:\s*_groupBy\)\)\.plan;', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelGroupedQuery.toPlan() to expose the grouped aggregate plan surface.', ); expect( RegExp( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 365e6bc2..bb6bb513 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1600,11 +1600,35 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?groupBy:\s*groupBy\.toRuntimeSpec\(\),[\s\S]*?UserGroupByResult\.fromJson', + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future\s+toPlan\(\)\s*\{[\s\S]*?_runtimeGrouped\(_groupBy\)\.toPlan\(\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserGroupedQuery.aggregateWith(...) to terminate through runtime structured groupBy specs.', + 'Expected UserGroupedQuery.toPlan() to reuse the runtime grouped builder plan path.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future\s+inspectPlan\(\)\s*\{[\s\S]*?_runtimeGrouped\(_groupBy\)\.inspectPlan\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupedQuery.inspectPlan() to reuse the runtime grouped builder inspection path.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_runtimeGrouped\(groupBy\)[\s\S]*?OrmAggregateSpec\([\s\S]*?countAll:\s*groupBy\.countAll,[\s\S]*?UserGroupByResult\.fromJson', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupedQuery.aggregateWith(...) to reuse the runtime grouped builder execution path.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+_runtimeGrouped\(UserGroupBySpec\s+groupBy\)\s*\{[\s\S]*?\.groupedBy\([\s\S]*?where:\s*_where\.toJson\(\),[\s\S]*?\.configure\(groupBy\.toRuntimeSpec\(\)\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupedQuery to keep a single runtime grouped builder bridge.', ); expect( RegExp( diff --git a/pub/orm/test/runtime/plan_surface_test.dart b/pub/orm/test/runtime/plan_surface_test.dart index 978d2c14..f24c8d1d 100644 --- a/pub/orm/test/runtime/plan_surface_test.dart +++ b/pub/orm/test/runtime/plan_surface_test.dart @@ -26,18 +26,65 @@ void main() { expect(encoded['lane'], 'orm'); expect(encoded['action'], 'read'); final read = encoded['read'] as Map; - expect( - read['cursor'], - { - 'values': {'id': 'u1'}, - }, + expect(read['cursor'], { + 'values': {'id': 'u1'}, + }); + expect(read['page'], { + 'size': 20, + 'after': {'id': 'u2'}, + }); + }); + + test('read plan serializes aggregate and grouped aggregate metadata', () { + final plan = OrmPlan.read( + contractHash: 'contract-v1', + lane: 'orm', + model: 'User', + where: const {'email': 'a@x.com'}, + select: const ['email', 'id'], + resultMode: OrmReadResultMode.all, + shape: OrmReadShape.groupedAggregate, + aggregate: OrmReadAggregatePlan( + countAll: true, + sum: const ['id'], + ), + groupBy: OrmReadGroupByPlan( + by: const ['email'], + having: const { + '_count': { + 'all': {'gte': 2}, + }, + }, + orderBy: const [ + OrmOrderBy('_sum.id', order: SortOrder.desc), + ], + take: 5, + ), ); - expect( - read['page'], - { - 'size': 20, - 'after': {'id': 'u2'}, + + expect(plan.read?.shape, OrmReadShape.groupedAggregate); + final encoded = plan.toJson(); + final read = encoded['read'] as Map; + expect(read['shape'], 'groupedAggregate'); + expect(read['aggregate'], { + 'countAll': true, + 'count': const [], + 'min': const [], + 'max': const [], + 'sum': const ['id'], + 'avg': const [], + }); + expect(read['groupBy'], { + 'by': const ['email'], + 'having': const { + '_count': { + 'all': {'gte': 2}, + }, }, - ); + 'orderBy': const [ + {'field': '_sum.id', 'order': 'desc'}, + ], + 'take': 5, + }); }); } From 9a50e0a539c86523492ed11c4e748a9c02f8316e Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:26:23 +0800 Subject: [PATCH 136/154] feat(runtime)!: execute aggregate and grouped read shapes --- pub/orm/lib/src/client/read_repository.dart | 31 +- pub/orm/lib/src/engine/memory_engine.dart | 416 ++++++++++++++ pub/orm/lib/src/runtime/core.dart | 16 - pub/orm/lib/src/sql/adapter.dart | 589 +++++++++++++++++++- pub/orm/test/client/api_surface_test.dart | 76 ++- pub/orm/test/sql/sql_adapter_test.dart | 127 +++++ 6 files changed, 1212 insertions(+), 43 deletions(-) diff --git a/pub/orm/lib/src/client/read_repository.dart b/pub/orm/lib/src/client/read_repository.dart index 6ce49772..619f623e 100644 --- a/pub/orm/lib/src/client/read_repository.dart +++ b/pub/orm/lib/src/client/read_repository.dart @@ -171,46 +171,38 @@ final class OrmPreparedReadQuery { final class OrmPreparedAggregateQuery { final ModelDelegate _delegate; final OrmPlan plan; - final OrmReadQuerySpec _spec; - final OrmAggregateSpec _aggregate; const OrmPreparedAggregateQuery._({ required ModelDelegate delegate, required this.plan, required OrmReadQuerySpec spec, required OrmAggregateSpec aggregate, - }) : _delegate = delegate, - _spec = spec, - _aggregate = aggregate; + }) : _delegate = delegate; Future inspectPlan() async => Map.unmodifiable(plan.toJson()); Future execute() => - _delegate._aggregate(spec: _spec, aggregate: _aggregate); + _delegate._readRepository.aggregate(prepared: this); } @immutable final class OrmPreparedGroupedQuery { final ModelDelegate _delegate; final OrmPlan plan; - final OrmReadQuerySpec _baseSpec; - final OrmGroupBySpec _groupBy; const OrmPreparedGroupedQuery._({ required ModelDelegate delegate, required this.plan, required OrmReadQuerySpec baseSpec, required OrmGroupBySpec groupBy, - }) : _delegate = delegate, - _baseSpec = baseSpec, - _groupBy = groupBy; + }) : _delegate = delegate; Future inspectPlan() async => Map.unmodifiable(plan.toJson()); Future> execute() => - _delegate._groupBy(spec: _baseSpec, groupBy: _groupBy); + _delegate._readRepository.grouped(prepared: this); } final class _RepositoryReadExecutor { @@ -218,6 +210,21 @@ final class _RepositoryReadExecutor { _RepositoryReadExecutor(this._delegate); + Future aggregate({ + required OrmPreparedAggregateQuery prepared, + }) async { + final response = await _delegate._client.execute(prepared.plan); + return (await _collectSingleRow(response, action: 'aggregate')) ?? + const {}; + } + + Future> grouped({ + required OrmPreparedGroupedQuery prepared, + }) async { + final response = await _delegate._client.execute(prepared.plan); + return _collectRows(response, action: 'groupBy'); + } + Future> all({ required OrmPreparedReadQuery prepared, required OrmAction action, diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index 1e589b3e..c58a51f6 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -92,6 +92,14 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { EngineResponse _read(List bucket, OrmPlan plan) { final read = plan.read!; + return switch (read.shape) { + OrmReadShape.rows => _readRows(bucket, read), + OrmReadShape.aggregate => _readAggregate(bucket, read), + OrmReadShape.groupedAggregate => _readGroupedAggregate(bucket, read), + }; + } + + EngineResponse _readRows(List bucket, OrmReadPlan read) { var rows = bucket.where((row) => _matches(row, read.where)).toList(); if (read.orderBy.isNotEmpty) { @@ -111,6 +119,102 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { }; } + EngineResponse _readAggregate(List bucket, OrmReadPlan read) { + final aggregate = read.aggregate!; + var rows = bucket.where((row) => _matches(row, read.where)).toList(); + + if (read.orderBy.isNotEmpty) { + rows.sort((left, right) => _compareRows(left, right, read.orderBy)); + } + + rows = _applyReadWindow(rows, read); + final projected = rows + .map((row) => _projectRow(row, read.select)) + .toList(growable: false); + + return EngineResponse.buffered( + _buildAggregateResult( + rows: projected, + countAll: aggregate.countAll, + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + sum: aggregate.sum, + avg: aggregate.avg, + ), + ); + } + + EngineResponse _readGroupedAggregate(List bucket, OrmReadPlan read) { + final aggregate = read.aggregate!; + final groupBy = read.groupBy!; + final projected = bucket + .where((row) => _matches(row, read.where)) + .map((row) => _projectRow(row, read.select)) + .toList(growable: false); + + final groupedRows = <_MemoryGroupKey, List>{}; + for (final row in projected) { + final key = _MemoryGroupKey( + groupBy.by.map((field) => row[field]).toList(growable: false), + ); + groupedRows.putIfAbsent(key, () => []).add(row); + } + + var results = []; + for (final entry in groupedRows.entries) { + final rows = entry.value; + if (rows.isEmpty) { + continue; + } + + final result = {}; + final first = rows.first; + for (final field in groupBy.by) { + result[field] = first[field]; + } + result.addAll( + _buildAggregateResult( + rows: rows, + countAll: aggregate.countAll, + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + sum: aggregate.sum, + avg: aggregate.avg, + ), + ); + results.add(Map.unmodifiable(result)); + } + + if (groupBy.having.isNotEmpty) { + results = results + .where( + (row) => _matchesGroupByHaving(row: row, having: groupBy.having), + ) + .toList(growable: false); + } + + if (groupBy.orderBy.isNotEmpty) { + results.sort( + (left, right) => _compareRowsForGroupByOrderBy( + left: left, + right: right, + orderBy: groupBy.orderBy, + ), + ); + } + + if (groupBy.skip case final skip?) { + results = skip >= results.length ? [] : results.sublist(skip); + } + if (groupBy.take case final take?) { + results = take >= results.length ? results : results.sublist(0, take); + } + + return EngineResponse.buffered(results); + } + List _applyReadWindow(List rows, OrmReadPlan read) { var next = rows; @@ -539,6 +643,318 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { } return left.toString().compareTo(right.toString()); } + + JsonMap _buildAggregateResult({ + required List rows, + required bool countAll, + required List count, + required List min, + required List max, + required List sum, + required List avg, + }) { + final result = {}; + + if (countAll || count.isNotEmpty) { + final countResult = {}; + if (countAll) { + countResult['all'] = rows.length; + } + for (final field in count) { + countResult[field] = rows.where((row) => row[field] != null).length; + } + result['count'] = countResult; + } + + if (min.isNotEmpty) { + result['min'] = { + for (final field in min) field: _aggregateMin(rows: rows, field: field), + }; + } + if (max.isNotEmpty) { + result['max'] = { + for (final field in max) field: _aggregateMax(rows: rows, field: field), + }; + } + if (sum.isNotEmpty) { + result['sum'] = { + for (final field in sum) field: _aggregateSum(rows: rows, field: field), + }; + } + if (avg.isNotEmpty) { + result['avg'] = { + for (final field in avg) field: _aggregateAvg(rows: rows, field: field), + }; + } + + return Map.unmodifiable(result); + } + + Object? _aggregateMin({required List rows, required String field}) { + Object? current; + for (final row in rows) { + final value = row[field]; + if (value == null) { + continue; + } + if (current == null || + _compareAggregateValues(left: value, right: current) < 0) { + current = value; + } + } + return current; + } + + Object? _aggregateMax({required List rows, required String field}) { + Object? current; + for (final row in rows) { + final value = row[field]; + if (value == null) { + continue; + } + if (current == null || + _compareAggregateValues(left: value, right: current) > 0) { + current = value; + } + } + return current; + } + + num? _aggregateSum({required List rows, required String field}) { + num? sum; + for (final row in rows) { + final value = row[field]; + if (value is! num) { + continue; + } + sum = (sum ?? 0) + value; + } + return sum; + } + + double? _aggregateAvg({required List rows, required String field}) { + var count = 0; + var sum = 0.0; + for (final row in rows) { + final value = row[field]; + if (value is! num) { + continue; + } + sum += value.toDouble(); + count += 1; + } + return count == 0 ? null : sum / count; + } + + int _compareAggregateValues({required Object left, required Object right}) { + if (left is num && right is num) { + return left.compareTo(right); + } + if (left is DateTime && right is DateTime) { + return left.compareTo(right); + } + if (left is Comparable && left.runtimeType == right.runtimeType) { + return left.compareTo(right); + } + return left.toString().compareTo(right.toString()); + } + + bool _matchesGroupByHaving({required JsonMap row, required JsonMap having}) { + for (final entry in having.entries) { + final key = entry.key; + final value = entry.value; + if (key == 'AND' || key == 'OR' || key == 'NOT') { + if (!_matchesGroupByHavingLogical( + row: row, + operator: key, + operand: value, + )) { + return false; + } + continue; + } + + final bucket = _normalizeAggregateBucket(key); + if (bucket != null) { + final aggregateFilters = _coerceWhereMap(value); + if (aggregateFilters == null) { + return false; + } + for (final aggregateEntry in aggregateFilters.entries) { + final aggregateValue = _readGroupByAggregateValue( + row: row, + bucket: bucket, + field: aggregateEntry.key, + ); + if (!_matchesGroupByHavingCondition( + actual: aggregateValue, + condition: aggregateEntry.value, + )) { + return false; + } + } + continue; + } + + if (!_matchesGroupByHavingCondition(actual: row[key], condition: value)) { + return false; + } + } + return true; + } + + bool _matchesGroupByHavingLogical({ + required JsonMap row, + required String operator, + required Object? operand, + }) { + final nestedMap = _coerceWhereMap(operand); + if (nestedMap != null) { + final matched = _matchesGroupByHaving(row: row, having: nestedMap); + return operator == 'NOT' ? !matched : matched; + } + final nestedList = _coerceWhereList(operand); + if (nestedList == null) { + return false; + } + return switch (operator) { + 'AND' => nestedList.every( + (clause) => _matchesGroupByHaving(row: row, having: clause), + ), + 'OR' => nestedList.any( + (clause) => _matchesGroupByHaving(row: row, having: clause), + ), + 'NOT' => nestedList.every( + (clause) => !_matchesGroupByHaving(row: row, having: clause), + ), + _ => false, + }; + } + + bool _matchesGroupByHavingCondition({ + required Object? actual, + required Object? condition, + }) { + final conditionMap = _coerceOperatorMap(condition); + if (conditionMap == null || conditionMap.isEmpty) { + return actual == condition; + } + for (final operator in _whereOperatorOrder) { + if (!conditionMap.containsKey(operator)) { + continue; + } + final operand = conditionMap[operator]; + final matched = switch (operator) { + 'equals' => actual == operand, + 'not' => + operand is Map + ? !_matchesGroupByHavingCondition( + actual: actual, + condition: operand, + ) + : actual != operand, + 'in' => _matchIn(actual, operand), + 'notIn' => _matchNotIn(actual, operand), + 'contains' => _matchStringOperation(actual, operand, operator), + 'startsWith' => _matchStringOperation(actual, operand, operator), + 'endsWith' => _matchStringOperation(actual, operand, operator), + 'gt' => _matchComparison(actual, operand, operator), + 'gte' => _matchComparison(actual, operand, operator), + 'lt' => _matchComparison(actual, operand, operator), + 'lte' => _matchComparison(actual, operand, operator), + _ => false, + }; + if (!matched) { + return false; + } + } + return true; + } + + String? _normalizeAggregateBucket(String bucket) { + return switch (bucket) { + 'count' || '_count' => 'count', + 'min' || '_min' => 'min', + 'max' || '_max' => 'max', + 'sum' || '_sum' => 'sum', + 'avg' || '_avg' => 'avg', + _ => null, + }; + } + + Object? _readGroupByAggregateValue({ + required JsonMap row, + required String bucket, + required String field, + }) { + final bucketValue = row[bucket]; + if (bucketValue is! Map) { + return null; + } + return bucketValue[field]; + } + + Object? _readGroupByOrderByValue({ + required JsonMap row, + required String field, + }) { + final fieldPath = field.split('.'); + if (fieldPath.length != 2) { + return row[field]; + } + final bucket = _normalizeAggregateBucket(fieldPath[0]); + if (bucket == null) { + return row[field]; + } + return _readGroupByAggregateValue( + row: row, + bucket: bucket, + field: fieldPath[1], + ); + } + + int _compareRowsForGroupByOrderBy({ + required JsonMap left, + required JsonMap right, + required List orderBy, + }) { + for (final clause in orderBy) { + final compared = _compareValues( + _readGroupByOrderByValue(row: left, field: clause.field), + _readGroupByOrderByValue(row: right, field: clause.field), + ); + if (compared == 0) { + continue; + } + return clause.order == SortOrder.desc ? -compared : compared; + } + return 0; + } +} + +final class _MemoryGroupKey { + final List values; + + const _MemoryGroupKey(this.values); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! _MemoryGroupKey || values.length != other.values.length) { + return false; + } + for (var index = 0; index < values.length; index++) { + if (values[index] != other.values[index]) { + return false; + } + } + return true; + } + + @override + int get hashCode => Object.hashAll(values); } JsonMap _cloneRow(JsonMap source) => Map.unmodifiable(source); diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index abf26829..62c267e5 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -1134,14 +1134,6 @@ final class OrmRuntimeCore implements RuntimeCore { ], source: 'aggregate', ); - throw runtimeError( - 'PLAN.READ_SHAPE_UNSUPPORTED', - 'Aggregate read plans are not executable through runtime yet.', - details: { - 'model': model.name, - 'shape': plan.shape.name, - }, - ); case OrmReadShape.groupedAggregate: final aggregate = plan.aggregate; final groupBy = plan.groupBy; @@ -1197,14 +1189,6 @@ final class OrmRuntimeCore implements RuntimeCore { details: {'model': model.name}, ); } - throw runtimeError( - 'PLAN.READ_SHAPE_UNSUPPORTED', - 'Grouped aggregate plans are not executable through runtime yet.', - details: { - 'model': model.name, - 'shape': plan.shape.name, - }, - ); } } diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 994435dc..bd72eaae 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:meta/meta.dart'; + import '../core/sort_order.dart'; import '../contract/contract.dart'; import '../engine/engine.dart'; @@ -52,6 +54,8 @@ const Set _toManyRelationWhereOperators = { }; const Set _toOneRelationWhereOperators = {'is', 'isNot'}; const String _relationWhereAlias = '_rel'; +const String _aggregateEmptyAlias = '__empty'; +const String _aggregateRowAlias = '__row'; final class SqlAdapter implements @@ -122,6 +126,30 @@ final class SqlAdapter required OrmPlan plan, required String table, required String model, + }) { + return switch (plan.read!.shape) { + OrmReadShape.rows => _lowerRowRead( + plan: plan, + table: table, + model: model, + ), + OrmReadShape.aggregate => _lowerAggregateRead( + plan: plan, + table: table, + model: model, + ), + OrmReadShape.groupedAggregate => _lowerGroupedAggregateRead( + plan: plan, + table: table, + model: model, + ), + }; + } + + SqlStatement _lowerRowRead({ + required OrmPlan plan, + required String table, + required String model, }) { final read = plan.read!; final whereParams = []; @@ -166,6 +194,80 @@ final class SqlAdapter ); } + SqlStatement _lowerAggregateRead({ + required OrmPlan plan, + required String table, + required String model, + }) { + final read = plan.read!; + final aggregate = read.aggregate!; + final selectors = _buildAggregateSelectExpressions( + aggregate: aggregate, + rowRef: _id('_agg'), + ); + if (selectors.isEmpty) { + return SqlStatement( + action: plan.action, + text: 'SELECT 1 AS ${_id(_aggregateEmptyAlias)}', + parameters: const [], + ); + } + + final baseFields = _aggregateBaseFields(aggregate); + final inner = _buildReadSourceQuery( + table: table, + model: model, + read: read, + selectColumns: baseFields.isEmpty + ? '1 AS ${_id(_aggregateRowAlias)}' + : baseFields.map(_id).join(', '), + ); + return SqlStatement( + action: plan.action, + text: + 'SELECT ${selectors.join(', ')} FROM (${inner.text}) AS ${_id('_agg')}', + parameters: inner.parameters, + ); + } + + SqlStatement _lowerGroupedAggregateRead({ + required OrmPlan plan, + required String table, + required String model, + }) { + final read = plan.read!; + final aggregate = read.aggregate!; + final groupBy = read.groupBy!; + final params = []; + final whereClause = _buildWhereClause( + model: model, + where: read.where, + params: params, + ); + final selectClauses = [ + ...groupBy.by.map((field) => _id(field)), + ..._buildAggregateSelectExpressions(aggregate: aggregate, rowRef: null), + ]; + final groupByClause = groupBy.by.map(_id).join(', '); + final havingClause = _buildGroupedHavingClause( + having: groupBy.having, + params: params, + ); + final orderByClause = _buildGroupedOrderByClause(groupBy.orderBy); + final paginationClause = _buildGroupedLimitOffsetClause( + skip: groupBy.skip, + take: groupBy.take, + params: params, + ); + return SqlStatement( + action: plan.action, + text: + 'SELECT ${selectClauses.join(', ')} FROM ${_id(table)}' + '$whereClause GROUP BY $groupByClause$havingClause$orderByClause$paginationClause', + parameters: params, + ); + } + @override EngineResponse decode(SqlResult response, OrmPlan plan) { final resolver = codecResolver; @@ -278,10 +380,26 @@ final class SqlAdapter required int affectedRows, required OrmPlan plan, }) { - return switch (plan.read!.resultMode) { - OrmReadResultMode.firstOrNull || OrmReadResultMode.oneOrNull => - EngineResponse.buffered(_firstOrNull(rows), affectedRows: affectedRows), - _ => EngineResponse.buffered(rows, affectedRows: affectedRows), + final read = plan.read!; + return switch (read.shape) { + OrmReadShape.rows => switch (read.resultMode) { + OrmReadResultMode.firstOrNull || + OrmReadResultMode.oneOrNull => EngineResponse.buffered( + _firstOrNull(rows), + affectedRows: affectedRows, + ), + _ => EngineResponse.buffered(rows, affectedRows: affectedRows), + }, + OrmReadShape.aggregate => EngineResponse.buffered( + _decodeAggregateResult(read: read, row: _firstOrNull(rows)), + affectedRows: affectedRows, + ), + OrmReadShape.groupedAggregate => EngineResponse.buffered( + rows + .map((row) => _decodeGroupedAggregateRow(read: read, row: row)) + .toList(growable: false), + affectedRows: affectedRows, + ), }; } @@ -314,6 +432,74 @@ final class SqlAdapter return select.map(_id).join(', '); } + List _aggregateBaseFields(OrmReadAggregatePlan aggregate) { + final fields = { + ...aggregate.count, + ...aggregate.min, + ...aggregate.max, + ...aggregate.sum, + ...aggregate.avg, + }; + return fields.toList(growable: false); + } + + List _buildAggregateSelectExpressions({ + required OrmReadAggregatePlan aggregate, + required String? rowRef, + }) { + final expressions = []; + if (aggregate.countAll) { + expressions.add( + 'COUNT(*) AS ${_id(_aggregateAlias(bucket: 'count', field: 'all'))}', + ); + } + for (final field in aggregate.count) { + expressions.add( + 'COUNT(${_aggregateFieldReference(field: field, rowRef: rowRef)}) ' + 'AS ${_id(_aggregateAlias(bucket: 'count', field: field))}', + ); + } + for (final field in aggregate.min) { + expressions.add( + 'MIN(${_aggregateFieldReference(field: field, rowRef: rowRef)}) ' + 'AS ${_id(_aggregateAlias(bucket: 'min', field: field))}', + ); + } + for (final field in aggregate.max) { + expressions.add( + 'MAX(${_aggregateFieldReference(field: field, rowRef: rowRef)}) ' + 'AS ${_id(_aggregateAlias(bucket: 'max', field: field))}', + ); + } + for (final field in aggregate.sum) { + expressions.add( + 'SUM(${_aggregateFieldReference(field: field, rowRef: rowRef)}) ' + 'AS ${_id(_aggregateAlias(bucket: 'sum', field: field))}', + ); + } + for (final field in aggregate.avg) { + expressions.add( + 'AVG(${_aggregateFieldReference(field: field, rowRef: rowRef)}) ' + 'AS ${_id(_aggregateAlias(bucket: 'avg', field: field))}', + ); + } + return expressions; + } + + String _aggregateFieldReference({ + required String field, + required String? rowRef, + }) { + if (rowRef == null) { + return _id(field); + } + return '$rowRef.${_id(field)}'; + } + + String _aggregateAlias({required String bucket, required String field}) { + return '__${bucket}_$field'; + } + String _buildMutationReturningClause(List select) { if (!contract.capabilities.mutationReturning) { return ''; @@ -322,6 +508,52 @@ final class SqlAdapter return ' RETURNING ${_buildSelectColumns(select)}'; } + SqlStatement _buildReadSourceQuery({ + required String table, + required String model, + required OrmReadPlan read, + required String selectColumns, + }) { + final whereParams = []; + final whereClause = _buildWhereClause( + model: model, + where: read.where, + params: whereParams, + ); + final windowParams = []; + final windowPredicate = _buildCursorWindowPredicate( + read: read, + params: windowParams, + ); + final mergedWhereClause = _mergeWhereClauses(whereClause, windowPredicate); + final orderByClause = _buildOrderByClause(read.orderBy); + if (read.page?.before != null) { + final limitParams = []; + final innerOrderByClause = _buildOrderByClause( + _reverseOrderBy(read.orderBy), + ); + final innerLimitClause = _buildReadLimitOffsetClause(read, limitParams); + return SqlStatement( + action: OrmAction.read, + text: + 'SELECT $selectColumns FROM (' + 'SELECT * FROM ${_id(table)}' + '$mergedWhereClause$innerOrderByClause$innerLimitClause' + ') AS ${_id('_page')}$orderByClause', + parameters: [...whereParams, ...windowParams, ...limitParams], + ); + } + + final params = [...whereParams, ...windowParams]; + return SqlStatement( + action: OrmAction.read, + text: + 'SELECT $selectColumns FROM ${_id(table)}' + '$mergedWhereClause$orderByClause${_buildReadLimitOffsetClause(read, params)}', + parameters: params, + ); + } + String _buildWhereClause({ required String model, required JsonMap where, @@ -982,6 +1214,284 @@ final class SqlAdapter return ' ORDER BY ${clauses.join(', ')}'; } + String _buildGroupedOrderByClause(List orderBy) { + if (orderBy.isEmpty) { + return ''; + } + final clauses = orderBy.map((entry) { + final direction = entry.order.name.toUpperCase(); + return '${_groupedOrderByExpression(entry.field)} $direction'; + }); + return ' ORDER BY ${clauses.join(', ')}'; + } + + String _groupedOrderByExpression(String field) { + final metric = _parseGroupedMetricField(field); + if (metric == null) { + return _id(field); + } + return _id(_aggregateAlias(bucket: metric.bucket, field: metric.field)); + } + + String _buildGroupedHavingClause({ + required JsonMap having, + required List params, + }) { + if (having.isEmpty) { + return ''; + } + return ' HAVING ${_buildGroupedHavingExpression(having: having, params: params)}'; + } + + String _buildGroupedHavingExpression({ + required JsonMap having, + required List params, + }) { + final predicates = []; + for (final entry in having.entries) { + final key = entry.key; + if (_whereLogicalKeys.contains(key)) { + predicates.add( + _buildGroupedHavingLogicalPredicate( + key: key, + operand: entry.value, + params: params, + ), + ); + continue; + } + + final metricBucket = _normalizeGroupedMetricBucket(key); + if (metricBucket != null) { + final metricFilters = _coerceWhereMap(entry.value); + if (metricFilters == null || metricFilters.isEmpty) { + continue; + } + for (final metricEntry in metricFilters.entries) { + predicates.add( + _buildGroupedHavingConditionPredicate( + leftOperand: _aggregateFunctionExpression( + bucket: metricBucket, + field: metricEntry.key, + rowRef: null, + ), + field: metricEntry.key, + condition: metricEntry.value, + params: params, + ), + ); + } + continue; + } + + predicates.add( + _buildGroupedHavingConditionPredicate( + leftOperand: _id(key), + field: key, + condition: entry.value, + params: params, + ), + ); + } + if (predicates.isEmpty) { + return '1 = 1'; + } + return predicates.join(' AND '); + } + + String _buildGroupedHavingLogicalPredicate({ + required String key, + required Object? operand, + required List params, + }) { + final nestedMap = _coerceWhereMap(operand); + if (nestedMap != null) { + final predicate = _buildGroupedHavingExpression( + having: nestedMap, + params: params, + ); + return key == 'NOT' ? 'NOT ($predicate)' : '($predicate)'; + } + final nestedList = _coerceWhereList(operand); + if (nestedList == null || nestedList.isEmpty) { + return key == 'OR' ? '0 = 1' : '1 = 1'; + } + final joiner = key == 'OR' ? ' OR ' : ' AND '; + final clauses = nestedList + .map( + (clause) => + _buildGroupedHavingExpression(having: clause, params: params), + ) + .map((clause) => '($clause)') + .join(joiner); + return key == 'NOT' ? 'NOT ($clauses)' : clauses; + } + + String _buildGroupedHavingConditionPredicate({ + required String leftOperand, + required String field, + required Object? condition, + required List params, + }) { + final operatorMap = _coerceOperatorMap(condition); + if (operatorMap == null) { + params.add(condition); + return '$leftOperand = ?'; + } + + final predicates = []; + for (final operator in _whereOperatorOrder) { + if (!operatorMap.containsKey(operator)) { + continue; + } + final operand = operatorMap[operator]; + predicates.add( + _buildGroupedHavingOperatorPredicate( + leftOperand: leftOperand, + field: field, + operator: operator, + operand: operand, + params: params, + ), + ); + } + if (predicates.isEmpty) { + return '1 = 1'; + } + return predicates.join(' AND '); + } + + String _buildGroupedHavingOperatorPredicate({ + required String leftOperand, + required String field, + required String operator, + required Object? operand, + required List params, + }) { + switch (operator) { + case 'equals': + params.add(operand); + return '$leftOperand = ?'; + case 'not': + final nested = _coerceOperatorMap(operand); + if (nested != null) { + final predicate = _buildGroupedHavingConditionPredicate( + leftOperand: leftOperand, + field: field, + condition: operand, + params: params, + ); + return 'NOT ($predicate)'; + } + params.add(operand); + return '$leftOperand <> ?'; + case 'in': + final values = _coerceListOperand(operand); + if (values.isEmpty) { + return '0 = 1'; + } + params.addAll(values); + return '$leftOperand IN (${List.filled(values.length, '?').join(', ')})'; + case 'notIn': + final values = _coerceListOperand(operand); + if (values.isEmpty) { + return '1 = 1'; + } + params.addAll(values); + return '$leftOperand NOT IN (${List.filled(values.length, '?').join(', ')})'; + case 'contains': + case 'startsWith': + case 'endsWith': + if (operand is! String) { + return '0 = 1'; + } + final escaped = _escapeLikePattern(operand); + final pattern = switch (operator) { + 'contains' => '%$escaped%', + 'startsWith' => '$escaped%', + 'endsWith' => '%$escaped', + _ => escaped, + }; + params.add(pattern); + return "$leftOperand LIKE ? ESCAPE '\\'"; + case 'gt': + params.add(operand); + return '$leftOperand > ?'; + case 'gte': + params.add(operand); + return '$leftOperand >= ?'; + case 'lt': + params.add(operand); + return '$leftOperand < ?'; + case 'lte': + params.add(operand); + return '$leftOperand <= ?'; + default: + return '1 = 1'; + } + } + + String _buildGroupedLimitOffsetClause({ + required int? skip, + required int? take, + required List params, + }) { + final clauses = []; + if (take case final limit?) { + clauses.add(' LIMIT ?'); + params.add(limit); + } + if (skip case final offset?) { + if (take == null) { + clauses.add(' LIMIT -1'); + } + clauses.add(' OFFSET ?'); + params.add(offset); + } + return clauses.join(); + } + + _GroupedMetricField? _parseGroupedMetricField(String field) { + final parts = field.split('.'); + if (parts.length != 2) { + return null; + } + final bucket = _normalizeGroupedMetricBucket(parts[0]); + if (bucket == null) { + return null; + } + return _GroupedMetricField(bucket: bucket, field: parts[1]); + } + + String? _normalizeGroupedMetricBucket(String bucket) { + return switch (bucket) { + 'count' || '_count' => 'count', + 'min' || '_min' => 'min', + 'max' || '_max' => 'max', + 'sum' || '_sum' => 'sum', + 'avg' || '_avg' => 'avg', + _ => null, + }; + } + + String _aggregateFunctionExpression({ + required String bucket, + required String field, + required String? rowRef, + }) { + final fieldRef = field == 'all' && bucket == 'count' + ? '*' + : _aggregateFieldReference(field: field, rowRef: rowRef); + return switch (bucket) { + 'count' => field == 'all' ? 'COUNT(*)' : 'COUNT($fieldRef)', + 'min' => 'MIN($fieldRef)', + 'max' => 'MAX($fieldRef)', + 'sum' => 'SUM($fieldRef)', + 'avg' => 'AVG($fieldRef)', + _ => throw StateError('Unsupported aggregate bucket: $bucket'), + }; + } + String _mergeWhereClauses(String whereClause, String predicate) { if (predicate.isEmpty) { return whereClause; @@ -1140,6 +1650,69 @@ final class SqlAdapter return decoded; } + JsonMap _decodeAggregateResult({ + required OrmReadPlan read, + required JsonMap? row, + }) { + final aggregate = read.aggregate!; + if (!aggregate.countAll && + aggregate.count.isEmpty && + aggregate.min.isEmpty && + aggregate.max.isEmpty && + aggregate.sum.isEmpty && + aggregate.avg.isEmpty) { + return const {}; + } + + final source = row ?? const {}; + final result = {}; + if (aggregate.countAll || aggregate.count.isNotEmpty) { + result['count'] = { + if (aggregate.countAll) + 'all': source[_aggregateAlias(bucket: 'count', field: 'all')] ?? 0, + for (final field in aggregate.count) + field: source[_aggregateAlias(bucket: 'count', field: field)] ?? 0, + }; + } + if (aggregate.min.isNotEmpty) { + result['min'] = { + for (final field in aggregate.min) + field: source[_aggregateAlias(bucket: 'min', field: field)], + }; + } + if (aggregate.max.isNotEmpty) { + result['max'] = { + for (final field in aggregate.max) + field: source[_aggregateAlias(bucket: 'max', field: field)], + }; + } + if (aggregate.sum.isNotEmpty) { + result['sum'] = { + for (final field in aggregate.sum) + field: source[_aggregateAlias(bucket: 'sum', field: field)], + }; + } + if (aggregate.avg.isNotEmpty) { + result['avg'] = { + for (final field in aggregate.avg) + field: source[_aggregateAlias(bucket: 'avg', field: field)], + }; + } + return Map.unmodifiable(result); + } + + JsonMap _decodeGroupedAggregateRow({ + required OrmReadPlan read, + required JsonMap row, + }) { + final groupBy = read.groupBy!; + final result = { + for (final field in groupBy.by) field: row[field], + }; + result.addAll(_decodeAggregateResult(read: read, row: row)); + return Map.unmodifiable(result); + } + Object? _encodeValue({ required String model, required String field, @@ -1165,6 +1738,14 @@ final class SqlAdapter } } +@immutable +final class _GroupedMetricField { + final String bucket; + final String field; + + const _GroupedMetricField({required this.bucket, required this.field}); +} + T? _firstOrNull(List values) { if (values.isEmpty) { return null; diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 4a590b18..e73bbf02 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -145,25 +145,79 @@ void main() { ); test( - 'runtime rejects direct execution of grouped aggregate plans', + 'runtime executes direct aggregate and grouped aggregate plans', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); try { - final plan = await client.db.orm.model('User').query().groupedBy( - const ['email'], - ).toPlan(); + final users = client.db.orm.model('User'); + await users.create( + data: {'id': 1, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 4, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 3, 'email': 'b@x.com'}, + ); - await expectLater( - client.execute(plan), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'PLAN.READ_SHAPE_UNSUPPORTED', + final aggregateResponse = await client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + shape: OrmReadShape.aggregate, + select: const ['id'], + aggregate: OrmReadAggregatePlan( + countAll: true, + sum: ['id'], ), ), ); + final aggregateRows = await aggregateResponse.rows + .map((row) => row as Map) + .toList(); + expect(aggregateRows, >[ + { + 'count': {'all': 3}, + 'sum': {'id': 8}, + }, + ]); + + final groupedResponse = await client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + shape: OrmReadShape.groupedAggregate, + select: const ['email', 'id'], + aggregate: OrmReadAggregatePlan( + countAll: true, + sum: ['id'], + ), + groupBy: OrmReadGroupByPlan( + by: ['email'], + orderBy: [ + OrmOrderBy('_sum.id', order: SortOrder.desc), + ], + ), + ), + ); + final groupedRows = await groupedResponse.rows + .map((row) => row as Map) + .toList(); + expect(groupedRows, >[ + { + 'email': 'a@x.com', + 'count': {'all': 2}, + 'sum': {'id': 5}, + }, + { + 'email': 'b@x.com', + 'count': {'all': 1}, + 'sum': {'id': 3}, + }, + ]); } finally { await client.disconnect(); } diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 25adda1d..12831f9b 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -69,6 +69,9 @@ void main() { OrmReadCursorPlan? cursor, OrmReadPagePlan? page, OrmReadResultMode resultMode = OrmReadResultMode.all, + OrmReadShape shape = OrmReadShape.rows, + OrmReadAggregatePlan? aggregate, + OrmReadGroupByPlan? groupBy, }) { return OrmPlan( contractHash: contract.hash, @@ -84,6 +87,9 @@ void main() { cursor: cursor, page: page, resultMode: resultMode, + shape: shape, + aggregate: aggregate, + groupBy: groupBy, ), ); } @@ -185,6 +191,127 @@ void main() { expect(statement.parameters, [4, 2]); }); + test('lowers aggregate read shapes through a windowed subquery', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + where: const {'email': 'a@example.com'}, + orderBy: const [OrmOrderBy('id')], + take: 2, + shape: OrmReadShape.aggregate, + select: const ['id'], + aggregate: OrmReadAggregatePlan(countAll: true, sum: ['id']), + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT COUNT(*) AS "__count_all", SUM("_agg"."id") AS "__sum_id" ' + 'FROM (SELECT "id" FROM "users" WHERE "email" = ? ORDER BY "id" ASC LIMIT ?) AS "_agg"', + ); + expect(statement.parameters, ['a@example.com', 2]); + }); + + test('lowers grouped aggregate read shapes with having and orderBy', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + shape: OrmReadShape.groupedAggregate, + aggregate: OrmReadAggregatePlan(countAll: true, sum: ['id']), + groupBy: OrmReadGroupByPlan( + by: ['email'], + having: { + '_count': { + 'all': {'gte': 2}, + }, + }, + orderBy: [OrmOrderBy('_sum.id', order: SortOrder.desc)], + take: 3, + skip: 1, + ), + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT "email", COUNT(*) AS "__count_all", SUM("id") AS "__sum_id" ' + 'FROM "users" GROUP BY "email" HAVING COUNT(*) >= ? ' + 'ORDER BY "__sum_id" DESC LIMIT ? OFFSET ?', + ); + expect(statement.parameters, [2, 3, 1]); + }); + + test( + 'decodes aggregate and grouped aggregate rows into structured payloads', + () { + final adapter = SqlAdapter(contract: contract); + + final aggregatePlan = readPlan( + contract: contract, + model: 'User', + shape: OrmReadShape.aggregate, + aggregate: OrmReadAggregatePlan( + countAll: true, + min: ['id'], + sum: ['id'], + ), + ); + final aggregateResponse = adapter.decode( + SqlResult( + rows: const [ + {'__count_all': 3, '__min_id': 1, '__sum_id': 8}, + ], + ), + aggregatePlan, + ); + + final groupedPlan = readPlan( + contract: contract, + model: 'User', + shape: OrmReadShape.groupedAggregate, + aggregate: OrmReadAggregatePlan(countAll: true, sum: ['id']), + groupBy: OrmReadGroupByPlan(by: ['email']), + ); + final groupedResponse = adapter.decode( + SqlResult( + rows: const [ + { + 'email': 'a@example.com', + '__count_all': 2, + '__sum_id': 5, + }, + ], + ), + groupedPlan, + ); + + expect( + aggregateResponse.rows, + emitsInOrder([ + { + 'count': {'all': 3}, + 'min': {'id': 1}, + 'sum': {'id': 8}, + }, + emitsDone, + ]), + ); + expect( + groupedResponse.rows, + emitsInOrder([ + { + 'email': 'a@example.com', + 'count': {'all': 2}, + 'sum': {'id': 5}, + }, + emitsDone, + ]), + ); + }, + ); + test('lowers where operators with deterministic SQL and parameters', () { final adapter = SqlAdapter(contract: contract); final plan = readPlan( From eaec88b92afd672aa2dd100b62f5564fb7e33fec Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:46:54 +0800 Subject: [PATCH 137/154] feat(orm)!: narrow grouped query surface --- pub/orm/lib/src/client/client.dart | 690 +------------------ pub/orm/lib/src/generator/writer.dart | 121 +--- pub/orm/test/client/client_test.dart | 112 +-- pub/orm/test/client/source_surface_test.dart | 26 +- pub/orm/test/generator/generate_test.dart | 32 +- 5 files changed, 96 insertions(+), 885 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 2dd85e2b..f084fc01 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -35,19 +35,6 @@ typedef IncludeExecutionStrategySelector = const int _defaultMaxIncludeDepth = 4; const Object _stateKeepToken = Object(); const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; -const List _filterOperatorOrder = [ - 'equals', - 'not', - 'in', - 'notIn', - 'contains', - 'startsWith', - 'endsWith', - 'gt', - 'gte', - 'lt', - 'lte', -]; const Set _filterOperators = { 'equals', 'not', @@ -1239,13 +1226,7 @@ class ModelDelegate { sum: groupBy.sum, avg: groupBy.avg, ), - groupBy: OrmReadGroupByPlan( - by: groupBy.by, - having: groupBy.having, - orderBy: groupBy.orderBy, - skip: groupBy.skip, - take: groupBy.take, - ), + groupBy: OrmReadGroupByPlan(by: groupBy.by, having: groupBy.having), ), baseSpec: prepared._state._spec, groupBy: groupBy, @@ -1529,9 +1510,6 @@ class ModelDelegate { required List by, JsonMap where = const {}, JsonMap having = const {}, - int? skip, - int? take, - List orderBy = const [], bool countAll = false, List count = const [], List min = const [], @@ -1540,9 +1518,6 @@ class ModelDelegate { List avg = const [], }) => groupedBy(by, where: where) .having(having, merge: false) - .orderBy(orderBy, append: false) - .skip(skip) - .take(take) .aggregate( countAll: countAll, count: count, @@ -1648,115 +1623,6 @@ class ModelDelegate { return rowCount > 0; } - Future _aggregate({ - required OrmReadQuerySpec spec, - required OrmAggregateSpec aggregate, - }) async { - _validateAggregateSpec(aggregate: aggregate, source: 'aggregate'); - - final rows = await _readAllInternal( - action: OrmAction.read, - where: spec.where, - orderBy: spec.orderBy, - cursor: spec.cursor, - page: spec.page, - select: _buildAggregateSelect( - count: aggregate.count, - min: aggregate.min, - max: aggregate.max, - sum: aggregate.sum, - avg: aggregate.avg, - ), - includeDepth: 0, - ); - - return _buildAggregateResult( - rows: rows, - countAll: aggregate.countAll, - count: aggregate.count, - min: aggregate.min, - max: aggregate.max, - sum: aggregate.sum, - avg: aggregate.avg, - ); - } - - Future> _groupBy({ - required OrmReadQuerySpec spec, - required OrmGroupBySpec groupBy, - }) async { - _validateGroupBySpec(spec: spec, groupBy: groupBy); - - final rows = await _readAllInternal( - action: OrmAction.read, - where: spec.where, - select: _buildAggregateSelect( - count: groupBy.by.followedBy(groupBy.count).toList(growable: false), - min: groupBy.min, - max: groupBy.max, - sum: groupBy.sum, - avg: groupBy.avg, - ), - includeDepth: 0, - ); - - final groupedRows = <_RelationMergeKey, List>{}; - for (final row in rows) { - final key = _RelationMergeKey( - groupBy.by - .map((field) => row.containsKey(field) ? row[field] : null) - .toList(growable: false), - ); - groupedRows.putIfAbsent(key, () => []).add(row); - } - - var results = []; - for (final entry in groupedRows.entries) { - final groupRows = entry.value; - if (groupRows.isEmpty) { - continue; - } - - final groupResult = {}; - final first = groupRows.first; - for (final field in groupBy.by) { - groupResult[field] = first[field]; - } - groupResult.addAll( - _buildAggregateResult( - rows: groupRows, - countAll: groupBy.countAll, - count: groupBy.count, - min: groupBy.min, - max: groupBy.max, - sum: groupBy.sum, - avg: groupBy.avg, - ), - ); - results.add(groupResult); - } - - if (groupBy.having.isNotEmpty) { - results = results - .where( - (row) => _matchesGroupByHaving(row: row, having: groupBy.having), - ) - .toList(growable: false); - } - - if (groupBy.orderBy.isNotEmpty) { - results.sort( - (left, right) => _compareRowsForGroupByOrderBy( - left: left, - right: right, - orderBy: groupBy.orderBy, - ), - ); - } - - return _sliceRows(rows: results, skip: groupBy.skip, take: groupBy.take); - } - Future _create({ required JsonMap data, required OrmReadQuerySpec spec, @@ -2338,12 +2204,6 @@ class ModelDelegate { details: {'model': modelName}, ); } - if (groupBy.skip case final offset? when offset < 0) { - throw PlanInvalidPaginationException(key: 'skip', value: offset); - } - if (groupBy.take case final limit? when limit < 0) { - throw PlanInvalidPaginationException(key: 'take', value: limit); - } if (spec.cursor != null || spec.page != null) { throw runtimeError( 'PLAN.GROUP_BY_CURSOR_WINDOW_UNSUPPORTED', @@ -2362,16 +2222,6 @@ class ModelDelegate { _assertKnownAggregateFields(fields: groupBy.max, source: 'groupBy.max'); _assertKnownAggregateFields(fields: groupBy.sum, source: 'groupBy.sum'); _assertKnownAggregateFields(fields: groupBy.avg, source: 'groupBy.avg'); - _assertGroupByOrderByFields( - orderBy: groupBy.orderBy, - by: groupBy.by, - countAll: groupBy.countAll, - count: groupBy.count, - min: groupBy.min, - max: groupBy.max, - sum: groupBy.sum, - avg: groupBy.avg, - ); _assertGroupByHavingFields( having: groupBy.having, by: groupBy.by, @@ -2384,45 +2234,6 @@ class ModelDelegate { ); } - void _assertGroupByOrderByFields({ - required List orderBy, - required List by, - required bool countAll, - required List count, - required List min, - required List max, - required List sum, - required List avg, - }) { - if (orderBy.isEmpty) { - return; - } - - final allowedFields = _groupByOrderableFields( - by: by, - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ); - for (final clause in orderBy) { - if (allowedFields.contains(clause.field)) { - continue; - } - throw runtimeError( - 'PLAN.GROUP_BY_ORDER_BY_INVALID', - 'GroupBy orderBy field is not available in grouped results.', - details: { - 'model': modelName, - 'field': clause.field, - 'allowedFields': allowedFields.toList(growable: false), - }, - ); - } - } - void _assertGroupByHavingFields({ required JsonMap having, required List by, @@ -2624,34 +2435,6 @@ class ModelDelegate { } } - Set _groupByOrderableFields({ - required List by, - required bool countAll, - required List count, - required List min, - required List max, - required List sum, - required List avg, - }) { - final fields = {...by}; - for (final bucket in _groupByAggregateBuckets) { - final bucketFields = _groupByAggregateBucketFields( - bucket: bucket, - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ); - for (final field in bucketFields) { - fields.add('$bucket.$field'); - fields.addAll(_groupByAggregateBucketAliasFieldPaths(bucket, field)); - } - } - return fields; - } - Set _groupByAggregateBucketFields({ required String bucket, required bool countAll, @@ -2678,276 +2461,6 @@ class ModelDelegate { return _groupByAggregateBucketAliases[bucket]; } - Set _groupByAggregateBucketAliasFieldPaths( - String bucket, - String field, - ) { - final paths = {}; - for (final alias in _groupByAggregateBucketAliases.entries) { - if (alias.value != bucket) { - continue; - } - paths.add('${alias.key}.$field'); - } - return paths; - } - - bool _matchesGroupByHaving({required JsonMap row, required JsonMap having}) { - for (final entry in having.entries) { - final key = entry.key; - final value = entry.value; - - if (_whereLogicalKeys.contains(key)) { - final logicalMatches = _matchesGroupByHavingLogical( - row: row, - operator: key, - operand: value, - ); - if (!logicalMatches) { - return false; - } - continue; - } - - final aggregateBucket = _normalizeGroupByAggregateBucket(key); - if (aggregateBucket != null) { - final aggregateFilters = _coerceWhereMap(value); - if (aggregateFilters == null) { - return false; - } - for (final aggregateEntry in aggregateFilters.entries) { - final aggregateValue = _readGroupByAggregateValue( - row: row, - bucket: aggregateBucket, - field: aggregateEntry.key, - ); - if (!_matchesGroupByHavingCondition( - actual: aggregateValue, - condition: aggregateEntry.value, - )) { - return false; - } - } - continue; - } - - if (!_matchesGroupByHavingCondition(actual: row[key], condition: value)) { - return false; - } - } - - return true; - } - - bool _matchesGroupByHavingLogical({ - required JsonMap row, - required String operator, - required Object? operand, - }) { - final nestedMap = _coerceWhereMap(operand); - if (nestedMap != null) { - final matched = _matchesGroupByHaving(row: row, having: nestedMap); - return operator == 'NOT' ? !matched : matched; - } - - final nestedList = _coerceWhereList(operand); - if (nestedList == null) { - return false; - } - - return switch (operator) { - 'AND' => nestedList.every( - (clause) => _matchesGroupByHaving(row: row, having: clause), - ), - 'OR' => nestedList.any( - (clause) => _matchesGroupByHaving(row: row, having: clause), - ), - 'NOT' => nestedList.every( - (clause) => !_matchesGroupByHaving(row: row, having: clause), - ), - _ => false, - }; - } - - bool _matchesGroupByHavingCondition({ - required Object? actual, - required Object? condition, - }) { - final conditionMap = _coerceWhereMap(condition); - if (conditionMap == null || conditionMap.isEmpty) { - return actual == condition; - } - - if (conditionMap.keys.any( - (operator) => !_filterOperators.contains(operator), - )) { - return false; - } - - for (final operator in _filterOperatorOrder) { - if (!conditionMap.containsKey(operator)) { - continue; - } - final operand = conditionMap[operator]; - if (!_matchesGroupByHavingOperator( - actual: actual, - operator: operator, - operand: operand, - )) { - return false; - } - } - return true; - } - - bool _matchesGroupByHavingOperator({ - required Object? actual, - required String operator, - required Object? operand, - }) { - return switch (operator) { - 'equals' => actual == operand, - 'not' => - operand is Map - ? !_matchesGroupByHavingCondition( - actual: actual, - condition: operand, - ) - : actual != operand, - 'in' => _matchInList(actual: actual, operand: operand), - 'notIn' => _matchNotInList(actual: actual, operand: operand), - 'contains' => - actual is String && operand is String && actual.contains(operand), - 'startsWith' => - actual is String && operand is String && actual.startsWith(operand), - 'endsWith' => - actual is String && operand is String && actual.endsWith(operand), - 'gt' => _matchesGroupByHavingComparison( - actual: actual, - operand: operand, - predicate: (comparison) => comparison > 0, - ), - 'gte' => _matchesGroupByHavingComparison( - actual: actual, - operand: operand, - predicate: (comparison) => comparison >= 0, - ), - 'lt' => _matchesGroupByHavingComparison( - actual: actual, - operand: operand, - predicate: (comparison) => comparison < 0, - ), - 'lte' => _matchesGroupByHavingComparison( - actual: actual, - operand: operand, - predicate: (comparison) => comparison <= 0, - ), - _ => false, - }; - } - - bool _matchInList({required Object? actual, required Object? operand}) { - if (operand is! List) { - return false; - } - return List.from(operand).contains(actual); - } - - bool _matchNotInList({required Object? actual, required Object? operand}) { - if (operand is! List) { - return false; - } - return !List.from(operand).contains(actual); - } - - bool _matchesGroupByHavingComparison({ - required Object? actual, - required Object? operand, - required bool Function(int comparison) predicate, - }) { - final comparison = _compareGroupByHavingValues(actual, operand); - if (comparison == null) { - return false; - } - return predicate(comparison); - } - - int? _compareGroupByHavingValues(Object? left, Object? right) { - if (left == null || right == null) { - return null; - } - if (left is num && right is num) { - return left.compareTo(right); - } - if (left is String && right is String) { - return left.compareTo(right); - } - if (left is DateTime && right is DateTime) { - return left.compareTo(right); - } - if (left is bool && right is bool) { - final leftValue = left ? 1 : 0; - final rightValue = right ? 1 : 0; - return leftValue.compareTo(rightValue); - } - if (left is Comparable && left.runtimeType == right.runtimeType) { - return left.compareTo(right); - } - return null; - } - - Object? _readGroupByAggregateValue({ - required JsonMap row, - required String bucket, - required String field, - }) { - final bucketValue = row[bucket]; - if (bucketValue is! Map) { - return null; - } - return bucketValue[field]; - } - - Object? _readGroupByOrderByValue({ - required JsonMap row, - required String field, - }) { - if (row.containsKey(field)) { - return row[field]; - } - final fieldPath = field.split('.'); - if (fieldPath.length != 2) { - return row[field]; - } - final normalizedBucket = _normalizeGroupByAggregateBucket(fieldPath[0]); - if (normalizedBucket == null) { - return row[field]; - } - return _readGroupByAggregateValue( - row: row, - bucket: normalizedBucket, - field: fieldPath[1], - ); - } - - int _compareRowsForGroupByOrderBy({ - required JsonMap left, - required JsonMap right, - required List orderBy, - }) { - for (final clause in orderBy) { - final compared = _compareOrderByValues( - _readGroupByOrderByValue(row: left, field: clause.field), - _readGroupByOrderByValue(row: right, field: clause.field), - ); - if (compared == 0) { - continue; - } - return clause.order == SortOrder.desc ? -compared : compared; - } - return 0; - } - List _buildAggregateSelect({ required List count, required List min, @@ -2962,135 +2475,6 @@ class ModelDelegate { return fields.toList(growable: false); } - JsonMap _buildAggregateResult({ - required List rows, - required bool countAll, - required List count, - required List min, - required List max, - required List sum, - required List avg, - }) { - final result = {}; - - if (countAll || count.isNotEmpty) { - final countResult = {}; - if (countAll) { - countResult['all'] = rows.length; - } - for (final field in count) { - countResult[field] = rows.where((row) => row[field] != null).length; - } - result['count'] = countResult; - } - - if (min.isNotEmpty) { - final minResult = {}; - for (final field in min) { - minResult[field] = _aggregateMin(rows: rows, field: field); - } - result['min'] = minResult; - } - - if (max.isNotEmpty) { - final maxResult = {}; - for (final field in max) { - maxResult[field] = _aggregateMax(rows: rows, field: field); - } - result['max'] = maxResult; - } - - if (sum.isNotEmpty) { - final sumResult = {}; - for (final field in sum) { - sumResult[field] = _aggregateSum(rows: rows, field: field); - } - result['sum'] = sumResult; - } - - if (avg.isNotEmpty) { - final avgResult = {}; - for (final field in avg) { - avgResult[field] = _aggregateAvg(rows: rows, field: field); - } - result['avg'] = avgResult; - } - - return result; - } - - Object? _aggregateMin({required List rows, required String field}) { - Object? current; - for (final row in rows) { - final value = row[field]; - if (value == null) { - continue; - } - if (current == null || - _compareAggregateValues(left: value, right: current) < 0) { - current = value; - } - } - return current; - } - - Object? _aggregateMax({required List rows, required String field}) { - Object? current; - for (final row in rows) { - final value = row[field]; - if (value == null) { - continue; - } - if (current == null || - _compareAggregateValues(left: value, right: current) > 0) { - current = value; - } - } - return current; - } - - num? _aggregateSum({required List rows, required String field}) { - num? sum; - for (final row in rows) { - final value = row[field]; - if (value is! num) { - continue; - } - sum = (sum ?? 0) + value; - } - return sum; - } - - double? _aggregateAvg({required List rows, required String field}) { - var count = 0; - var sum = 0.0; - for (final row in rows) { - final value = row[field]; - if (value is! num) { - continue; - } - sum += value.toDouble(); - count += 1; - } - if (count == 0) { - return null; - } - return sum / count; - } - - int _compareAggregateValues({required Object left, required Object right}) { - if (left is num && right is num) { - return left.compareTo(right); - } - if (left is DateTime && right is DateTime) { - return left.compareTo(right); - } - if (left is Comparable && left.runtimeType == right.runtimeType) { - return left.compareTo(right); - } - return left.toString().compareTo(right.toString()); - } - List _expandSelectForNestedCreate({ required String model, required List select, @@ -3368,9 +2752,6 @@ final class OrmAggregateSpec { final class OrmGroupBySpec { final List by; final JsonMap having; - final List orderBy; - final int? skip; - final int? take; final bool countAll; final List count; final List min; @@ -3381,9 +2762,6 @@ final class OrmGroupBySpec { OrmGroupBySpec({ required List by, JsonMap having = const {}, - List orderBy = const [], - this.skip, - this.take, this.countAll = false, List count = const [], List min = const [], @@ -3394,7 +2772,6 @@ final class OrmGroupBySpec { having = Map.unmodifiable( Map.from(having), ), - orderBy = List.unmodifiable(orderBy), count = List.unmodifiable(count), min = List.unmodifiable(min), max = List.unmodifiable(max), @@ -3404,9 +2781,6 @@ final class OrmGroupBySpec { OrmGroupBySpec copyWith({ List? by, JsonMap? having, - List? orderBy, - Object? skip = _stateKeepToken, - Object? take = _stateKeepToken, bool? countAll, List? count, List? min, @@ -3417,9 +2791,6 @@ final class OrmGroupBySpec { return OrmGroupBySpec( by: by ?? this.by, having: having ?? this.having, - orderBy: orderBy ?? this.orderBy, - skip: identical(skip, _stateKeepToken) ? this.skip : skip as int?, - take: identical(take, _stateKeepToken) ? this.take : take as int?, countAll: countAll ?? this.countAll, count: count ?? this.count, min: min ?? this.min, @@ -3699,9 +3070,6 @@ final class ModelQuery { Future> groupBy({ required List by, JsonMap having = const {}, - int? skip, - int? take, - List orderBy = const [], bool countAll = false, List count = const [], List min = const [], @@ -3709,24 +3077,16 @@ final class ModelQuery { List sum = const [], List avg = const [], }) { - var grouped = groupedBy(by).having(having, merge: false); - if (orderBy.isNotEmpty) { - grouped = grouped.orderBy(orderBy, append: false); - } - if (skip != null) { - grouped = grouped.skip(skip); - } - if (take != null) { - grouped = grouped.take(take); - } - return grouped.aggregate( - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ); + return groupedBy(by) + .having(having, merge: false) + .aggregate( + countAll: countAll, + count: count, + min: min, + max: max, + sum: sum, + avg: avg, + ); } Future> groupByWith(OrmGroupBySpec groupBy) => @@ -3885,12 +3245,6 @@ final class ModelGroupedQuery { JsonMap get havingClause => _groupBy.having; - List get orderByValues => _groupBy.orderBy; - - int? get skipValue => _groupBy.skip; - - int? get takeValue => _groupBy.take; - ModelGroupedQuery configure(OrmGroupBySpec groupBy) { if (!_sameStringList(left: _groupBy.by, right: groupBy.by)) { throw runtimeError( @@ -3922,28 +3276,6 @@ final class ModelGroupedQuery { return having(next, merge: merge); } - ModelGroupedQuery orderBy(List orderBy, {bool append = true}) { - final nextOrderBy = append - ? [..._groupBy.orderBy, ...orderBy] - : [...orderBy]; - return _next(_groupBy.copyWith(orderBy: nextOrderBy)); - } - - ModelGroupedQuery orderByField( - String field, { - SortOrder order = SortOrder.asc, - }) { - return orderBy([OrmOrderBy(field, order: order)]); - } - - ModelGroupedQuery skip(int? value) { - return _next(_groupBy.copyWith(skip: value)); - } - - ModelGroupedQuery take(int? value) { - return _next(_groupBy.copyWith(take: value)); - } - Future> aggregate({ bool countAll = false, List count = const [], diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index f2475b3e..cc3009df 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -920,57 +920,6 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); - buffer.writeln('class ${model.groupByOrderByClassName} {'); - buffer.writeln(' final OrmOrderBy value;'); - buffer.writeln(); - buffer.writeln(' const ${model.groupByOrderByClassName}._(this.value);'); - buffer.writeln(); - buffer.writeln( - ' static ${model.groupByOrderByClassName} by(' - '${model.distinctClassName} field, ' - '{SortOrder order = SortOrder.asc}' - ') {', - ); - buffer.writeln( - ' return ${model.groupByOrderByClassName}._(OrmOrderBy(field.value, order: order));', - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( - ' static ${model.groupByOrderByClassName} count(' - '${model.distinctClassName} field, ' - '{SortOrder order = SortOrder.asc}' - ') {', - ); - buffer.writeln( - " return ${model.groupByOrderByClassName}._(OrmOrderBy('_count.\${field.value}', order: order));", - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( - ' static ${model.groupByOrderByClassName} countAll({SortOrder order = SortOrder.asc}) {', - ); - buffer.writeln( - " return ${model.groupByOrderByClassName}._(OrmOrderBy('_count.all', order: order));", - ); - buffer.writeln(' }'); - buffer.writeln(); - for (final bucket in const ['min', 'max', 'sum', 'avg']) { - buffer.writeln( - ' static ${model.groupByOrderByClassName} $bucket(' - '${model.distinctClassName} field, ' - '{SortOrder order = SortOrder.asc}' - ') {', - ); - buffer.writeln( - " return ${model.groupByOrderByClassName}._(OrmOrderBy('_$bucket.\${field.value}', order: order));", - ); - buffer.writeln(' }'); - buffer.writeln(); - } - buffer.writeln('}'); - buffer.writeln(); - for (final entry in aggregateBucketClassNames.entries) { _writeAggregateBucketClass( buffer: buffer, @@ -1023,9 +972,6 @@ final class TypedClientWriter { buffer.writeln('class ${model.groupBySpecClassName} {'); buffer.writeln(' final List<${model.distinctClassName}> by;'); buffer.writeln(' final ${model.groupByHavingClassName} having;'); - buffer.writeln(' final List<${model.groupByOrderByClassName}> orderBy;'); - buffer.writeln(' final int? skip;'); - buffer.writeln(' final int? take;'); buffer.writeln(' final bool countAll;'); buffer.writeln(' final List<${model.distinctClassName}> count;'); buffer.writeln(' final List<${model.distinctClassName}> min;'); @@ -1038,11 +984,6 @@ final class TypedClientWriter { buffer.writeln( ' this.having = const ${model.groupByHavingClassName}(),', ); - buffer.writeln( - ' this.orderBy = const <${model.groupByOrderByClassName}>[],', - ); - buffer.writeln(' this.skip,'); - buffer.writeln(' this.take,'); buffer.writeln(' this.countAll = false,'); buffer.writeln(' this.count = const <${model.distinctClassName}>[],'); buffer.writeln(' this.min = const <${model.distinctClassName}>[],'); @@ -1054,9 +995,6 @@ final class TypedClientWriter { buffer.writeln(' ${model.groupBySpecClassName} copyWith({'); buffer.writeln(' List<${model.distinctClassName}>? by,'); buffer.writeln(' ${model.groupByHavingClassName}? having,'); - buffer.writeln(' List<${model.groupByOrderByClassName}>? orderBy,'); - buffer.writeln(' Object? skip = _typedStateKeepToken,'); - buffer.writeln(' Object? take = _typedStateKeepToken,'); buffer.writeln(' bool? countAll,'); buffer.writeln(' List<${model.distinctClassName}>? count,'); buffer.writeln(' List<${model.distinctClassName}>? min,'); @@ -1067,13 +1005,6 @@ final class TypedClientWriter { buffer.writeln(' return ${model.groupBySpecClassName}('); buffer.writeln(' by: by ?? this.by,'); buffer.writeln(' having: having ?? this.having,'); - buffer.writeln(' orderBy: orderBy ?? this.orderBy,'); - buffer.writeln( - ' skip: identical(skip, _typedStateKeepToken) ? this.skip : skip as int?,', - ); - buffer.writeln( - ' take: identical(take, _typedStateKeepToken) ? this.take : take as int?,', - ); buffer.writeln(' countAll: countAll ?? this.countAll,'); buffer.writeln(' count: count ?? this.count,'); buffer.writeln(' min: min ?? this.min,'); @@ -1089,11 +1020,6 @@ final class TypedClientWriter { ' by: by.map((entry) => entry.value).toList(growable: false),', ); buffer.writeln(' having: having.toJson(),'); - buffer.writeln( - ' orderBy: orderBy.map((entry) => entry.value).toList(growable: false),', - ); - buffer.writeln(' skip: skip,'); - buffer.writeln(' take: take,'); buffer.writeln(' countAll: countAll,'); buffer.writeln( ' count: count.map((entry) => entry.value).toList(growable: false),', @@ -2109,11 +2035,6 @@ final class TypedClientWriter { buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); - buffer.writeln(' int? skip,'); - buffer.writeln(' int? take,'); - buffer.writeln( - ' List<${model.groupByOrderByClassName}> groupByOrderBy = const <${model.groupByOrderByClassName}>[],', - ); buffer.writeln( ' ${model.groupByHavingClassName} typedHaving = const ${model.groupByHavingClassName}(),', ); @@ -2136,9 +2057,6 @@ final class TypedClientWriter { buffer.writeln(' }) {'); buffer.writeln(' return groupedBy(by, where: where)'); buffer.writeln(' .having(typedHaving, merge: false)'); - buffer.writeln(' .orderBy(groupByOrderBy, append: false)'); - buffer.writeln(' .skip(skip)'); - buffer.writeln(' .take(take)'); buffer.writeln(' .aggregate('); buffer.writeln(' countAll: countAll,'); buffer.writeln(' count: count,'); @@ -3031,11 +2949,6 @@ final class TypedClientWriter { buffer.writeln( ' ${model.groupByHavingClassName} typedHaving = const ${model.groupByHavingClassName}(),', ); - buffer.writeln( - ' List<${model.groupByOrderByClassName}> groupByOrderBy = const <${model.groupByOrderByClassName}>[],', - ); - buffer.writeln(' int? skip,'); - buffer.writeln(' int? take,'); buffer.writeln(' bool countAll = false,'); buffer.writeln( ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', @@ -3053,16 +2966,9 @@ final class TypedClientWriter { ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', ); buffer.writeln(' }) {'); - buffer.writeln(' var grouped = groupedBy(by)'); - buffer.writeln(' .having(typedHaving, merge: false)'); - buffer.writeln(' .skip(skip)'); - buffer.writeln(' .take(take);'); - buffer.writeln(' if (groupByOrderBy.isNotEmpty) {'); buffer.writeln( - ' grouped = grouped.orderBy(groupByOrderBy, append: false);', + ' return groupedBy(by).having(typedHaving, merge: false).aggregate(', ); - buffer.writeln(' }'); - buffer.writeln(' return grouped.aggregate('); buffer.writeln(' countAll: countAll,'); buffer.writeln(' count: count,'); buffer.writeln(' min: min,'); @@ -3317,31 +3223,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln( - ' ${model.groupedQueryClassName} orderBy(List<${model.groupByOrderByClassName}> orderBy, {bool append = true}) {', - ); - buffer.writeln(' return _next('); - buffer.writeln(' _groupBy.copyWith('); - buffer.writeln(' orderBy: append'); - buffer.writeln( - ' ? <${model.groupByOrderByClassName}>[..._groupBy.orderBy, ...orderBy]', - ); - buffer.writeln(' : orderBy,'); - buffer.writeln(' ),'); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(); - - buffer.writeln( - ' ${model.groupedQueryClassName} skip(int? skip) => _next(_groupBy.copyWith(skip: skip));', - ); - buffer.writeln(); - - buffer.writeln( - ' ${model.groupedQueryClassName} take(int? take) => _next(_groupBy.copyWith(take: take));', - ); - buffer.writeln(); - buffer.writeln(' Future toPlan() {'); buffer.writeln(' return _runtimeGrouped(_groupBy).toPlan();'); buffer.writeln(' }'); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 0563fa97..d4567119 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -589,7 +589,6 @@ void main() { final grouped = await users .query() .groupedBy(const ['email']) - .orderByField('email') .aggregate( countAll: true, sum: const ['id'], @@ -597,85 +596,47 @@ void main() { ); expect(grouped, hasLength(2)); - expect(grouped.first['email'], 'a@x.com'); - expect(grouped.first['count'], {'all': 2}); - expect(grouped.first['sum'], {'id': 3}); - expect(grouped.first['avg'], {'id': 1.5}); - expect(grouped.last['email'], 'b@x.com'); - expect(grouped.last['count'], {'all': 1}); - expect(grouped.last['sum'], {'id': 4}); - expect(grouped.last['avg'], {'id': 4.0}); + final groupedByEmail = { + for (final row in grouped) row['email']! as String: row, + }; + expect(groupedByEmail['a@x.com']?['count'], {'all': 2}); + expect(groupedByEmail['a@x.com']?['sum'], {'id': 3}); + expect(groupedByEmail['a@x.com']?['avg'], {'id': 1.5}); + expect(groupedByEmail['b@x.com']?['count'], {'all': 1}); + expect(groupedByEmail['b@x.com']?['sum'], {'id': 4}); + expect(groupedByEmail['b@x.com']?['avg'], {'id': 4.0}); await client.disconnect(); }); - test( - 'supports groupBy having filters and aggregate orderBy in memory engine', - () async { - final client = OrmClient(contract: contract, engine: MemoryEngine()); - await client.connect(); - final users = client.db.orm.model('User'); - - await users.create( - data: {'id': 1, 'email': 'a@x.com'}, - ); - await users.create( - data: {'id': 2, 'email': 'a@x.com'}, - ); - await users.create( - data: {'id': 10, 'email': 'b@x.com'}, - ); - await users.create( - data: {'id': 20, 'email': 'b@x.com'}, - ); - await users.create( - data: {'id': 5, 'email': 'c@x.com'}, - ); - - final grouped = await users - .query() - .groupedBy(const ['email']) - .having({ - '_count': { - 'all': {'gte': 2}, - }, - }, merge: false) - .orderByField('_sum.id', order: SortOrder.desc) - .aggregate(countAll: true, sum: const ['id']); - - expect(grouped, hasLength(2)); - expect( - grouped.map((row) => row['email']).toList(growable: false), - ['b@x.com', 'a@x.com'], - ); - expect(grouped.first['count'], {'all': 2}); - expect(grouped.first['sum'], {'id': 30}); - expect(grouped.last['count'], {'all': 2}); - expect(grouped.last['sum'], {'id': 3}); - await client.disconnect(); - }, - ); - - test('rejects invalid groupBy aggregate orderBy fields', () async { + test('supports groupBy having filters in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); final users = client.db.orm.model('User'); await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': 'a@x.com'}); + await users.create(data: {'id': 10, 'email': 'b@x.com'}); + await users.create(data: {'id': 20, 'email': 'b@x.com'}); + await users.create(data: {'id': 5, 'email': 'c@x.com'}); - await expectLater( - users - .query() - .groupedBy(const ['email']) - .orderByField('sum.email') - .aggregate(countAll: true, sum: const ['id']), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'PLAN.GROUP_BY_ORDER_BY_INVALID', - ), - ), - ); + final grouped = await users + .query() + .groupedBy(const ['email']) + .having({ + '_count': { + 'all': {'gte': 2}, + }, + }, merge: false) + .aggregate(countAll: true, sum: const ['id']); + + expect(grouped, hasLength(2)); + final groupedByEmail = { + for (final row in grouped) row['email']! as String: row, + }; + expect(groupedByEmail['a@x.com']?['count'], {'all': 2}); + expect(groupedByEmail['a@x.com']?['sum'], {'id': 3}); + expect(groupedByEmail['b@x.com']?['count'], {'all': 2}); + expect(groupedByEmail['b@x.com']?['sum'], {'id': 30}); await client.disconnect(); }); @@ -717,10 +678,6 @@ void main() { 'all': {'gte': 2}, }, }, - orderBy: const [ - OrmOrderBy('_sum.id', order: SortOrder.desc), - ], - take: 5, sum: const ['id'], ), ) @@ -728,11 +685,6 @@ void main() { expect(plan.read?.shape, OrmReadShape.groupedAggregate); expect(plan.read?.groupBy?.by, ['email']); - expect(plan.read?.groupBy?.take, 5); - expect( - plan.read?.groupBy?.orderBy.map((entry) => entry.field).toList(), - ['_sum.id'], - ); expect(plan.read?.groupBy?.having, { '_count': { 'all': {'gte': 2}, diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index e1255d77..85a1699e 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -95,7 +95,7 @@ void main() { ); expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?var\s+grouped\s*=\s*groupedBy\(by\)\.having\(having,\s*merge:\s*false\);[\s\S]*?return\s+grouped\.aggregate\(', + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+groupedBy\(by\)\s*\.having\(having,\s*merge:\s*false\)\s*\.aggregate\(', ).hasMatch(source), isTrue, reason: @@ -125,6 +125,30 @@ void main() { reason: 'Expected ModelGroupedQuery.toPlan() to expose the grouped aggregate plan surface.', ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+orderBy\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to not expose grouped orderBy state.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+skip\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to not expose grouped skip state.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+take\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to not expose grouped take state.', + ); expect( RegExp( r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+all\(', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index bb6bb513..13be4141 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -948,11 +948,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserGroupBySpec\s*\{[\s\S]*?final\s+int\?\s+skip;[\s\S]*?final\s+int\?\s+take;[\s\S]*?UserGroupBySpec\s+copyWith\(', + r'class\s+UserGroupBySpec\s*\{[\s\S]*?final\s+UserGroupByHaving\s+having;[\s\S]*?UserGroupBySpec\s+copyWith\(', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserGroupBySpec to carry grouped pagination state and copyWith helpers.', + 'Expected UserGroupBySpec to keep grouped fields, having state, and copyWith helpers.', ); expect( RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), @@ -1578,7 +1578,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?List\s+groupByOrderBy\s*=\s*const\s+\[\],[\s\S]*?var\s+grouped\s*=\s*groupedBy\(by\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?return\s+groupedBy\(by\)\s*\.having\(typedHaving,\s*merge:\s*false\)\s*\.aggregate\(', ).hasMatch(generatedSource), isTrue, reason: @@ -1654,9 +1654,31 @@ typedef Post = ({ ); expect( RegExp(r'\bclass UserGroupByOrderBy\b').hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected generated source to include typed groupBy orderBy helper.', + 'Expected generated source to keep grouped orderBy out of the typed public surface.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?UserGroupedQuery\s+orderBy\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupedQuery to not expose grouped orderBy state.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?UserGroupedQuery\s+skip\(', + ).hasMatch(generatedSource), + isFalse, + reason: 'Expected UserGroupedQuery to not expose grouped skip state.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?UserGroupedQuery\s+take\(', + ).hasMatch(generatedSource), + isFalse, + reason: 'Expected UserGroupedQuery to not expose grouped take state.', ); expect( RegExp( From 91780863156b4b498ad788fb18d9b3ac0e359f80 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:52:40 +0800 Subject: [PATCH 138/154] refactor(orm)!: remove grouped convenience terminals --- pub/orm/lib/src/client/client.dart | 64 +------------ pub/orm/lib/src/generator/writer.dart | 98 +------------------- pub/orm/test/client/client_test.dart | 17 ++-- pub/orm/test/client/source_surface_test.dart | 18 ++-- pub/orm/test/generator/generate_test.dart | 28 +++--- 5 files changed, 33 insertions(+), 192 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index f084fc01..405b93e9 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1506,32 +1506,6 @@ class ModelDelegate { JsonMap where = const {}, }) => _queryFromSpec(OrmReadQuerySpec(where: where)).groupedBy(by); - Future> groupBy({ - required List by, - JsonMap where = const {}, - JsonMap having = const {}, - bool countAll = false, - List count = const [], - List min = const [], - List max = const [], - List sum = const [], - List avg = const [], - }) => groupedBy(by, where: where) - .having(having, merge: false) - .aggregate( - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ); - - Future> groupByWith({ - JsonMap where = const {}, - required OrmGroupBySpec groupBy, - }) => groupedBy(groupBy.by, where: where).configure(groupBy)._execute(); - Future create({ required JsonMap data, List select = const [], @@ -3067,31 +3041,6 @@ final class ModelQuery { ); } - Future> groupBy({ - required List by, - JsonMap having = const {}, - bool countAll = false, - List count = const [], - List min = const [], - List max = const [], - List sum = const [], - List avg = const [], - }) { - return groupedBy(by) - .having(having, merge: false) - .aggregate( - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ); - } - - Future> groupByWith(OrmGroupBySpec groupBy) => - groupedBy(groupBy.by).configure(groupBy)._execute(); - void _assertReadExecutionSupported(String terminal) { if ((_state.cursor != null || _state.page != null) && _state.distinct.isNotEmpty) { @@ -3249,7 +3198,7 @@ final class ModelGroupedQuery { if (!_sameStringList(left: _groupBy.by, right: groupBy.by)) { throw runtimeError( 'PLAN.GROUP_BY_FIELDS_MISMATCH', - 'groupByWith() cannot replace the grouped fields after groupedBy().', + 'configure() cannot replace the grouped fields after groupedBy().', details: { 'model': _delegate.modelName, 'currentBy': _groupBy.by, @@ -3331,17 +3280,6 @@ final class ModelGroupedQuery { return (await _prepareGrouped(groupBy: _groupBy)).inspectPlan(); } - Future> _execute() => aggregateWith( - OrmAggregateSpec( - countAll: _groupBy.countAll, - count: _groupBy.count, - min: _groupBy.min, - max: _groupBy.max, - sum: _groupBy.sum, - avg: _groupBy.avg, - ), - ); - Future _prepareGrouped({ required OrmGroupBySpec groupBy, }) { diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index cc3009df..4b5f949d 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2030,58 +2030,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future> groupBy({'); - buffer.writeln(' required List<${model.distinctClassName}> by,'); - buffer.writeln( - ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', - ); - buffer.writeln( - ' ${model.groupByHavingClassName} typedHaving = const ${model.groupByHavingClassName}(),', - ); - buffer.writeln(' bool countAll = false,'); - buffer.writeln( - ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', - ); - buffer.writeln(' }) {'); - buffer.writeln(' return groupedBy(by, where: where)'); - buffer.writeln(' .having(typedHaving, merge: false)'); - buffer.writeln(' .aggregate('); - buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(); - - buffer.writeln( - ' Future> groupByWith({', - ); - buffer.writeln( - ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', - ); - buffer.writeln(' required ${model.groupBySpecClassName} groupBy,'); - buffer.writeln(' }) {'); - buffer.writeln(' return groupedBy(groupBy.by, where: where)'); - buffer.writeln(' .configure(groupBy)'); - buffer.writeln(' ._execute();'); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln(' Stream<${model.dataClassName}> stream({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -2944,50 +2892,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future> groupBy({'); - buffer.writeln(' required List<${model.distinctClassName}> by,'); - buffer.writeln( - ' ${model.groupByHavingClassName} typedHaving = const ${model.groupByHavingClassName}(),', - ); - buffer.writeln(' bool countAll = false,'); - buffer.writeln( - ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', - ); - buffer.writeln(' }) {'); - buffer.writeln( - ' return groupedBy(by).having(typedHaving, merge: false).aggregate(', - ); - buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(); - - buffer.writeln( - ' Future> groupByWith(${model.groupBySpecClassName} groupBy) {', - ); - buffer.writeln( - ' return groupedBy(groupBy.by).configure(groupBy)._execute();', - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln(' Future<${model.dataClassName}> create({'); buffer.writeln(' required ${model.createInputClassName} data,'); buffer.writeln(' }) async {'); @@ -3189,7 +3093,7 @@ final class TypedClientWriter { buffer.writeln(' throw runtimeError('); buffer.writeln(" 'PLAN.GROUP_BY_FIELDS_MISMATCH',"); buffer.writeln( - " 'groupByWith() cannot replace the grouped fields after groupedBy().',", + " 'configure() cannot replace the grouped fields after groupedBy().',", ); buffer.writeln(' details: {'); buffer.writeln(" 'model': '$runtimeName',"); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index d4567119..6b2602e5 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -721,15 +721,14 @@ void main() { await users.create(data: {'id': 1, 'email': 'a@x.com'}); await expectLater( - users.groupBy( - by: const ['email'], - having: { - '_sum': { - 'email': {'gte': 1}, - }, - }, - sum: const ['id'], - ), + users + .groupedBy(const ['email']) + .having({ + '_sum': { + 'email': {'gte': 1}, + }, + }, merge: false) + .aggregate(sum: const ['id']), throwsA( isA().having( (error) => error.code, diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 85a1699e..1ed8cb49 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -58,11 +58,11 @@ void main() { ); expect( RegExp( - r'class\s+ModelDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(\{[\s\S]*?required\s+OrmGroupBySpec\s+groupBy,[\s\S]*?\)\s*=>\s*groupedBy\(groupBy\.by,\s*where:\s*where\)\.configure\(groupBy\)\._execute\(\);', + r'class\s+ModelDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(', ).hasMatch(source), - isTrue, + isFalse, reason: - 'Expected ModelDelegate.groupByWith(...) to route structured groupBy execution through the grouped builder.', + 'Expected ModelDelegate to avoid redundant groupByWith(...) wrappers.', ); }); @@ -95,19 +95,19 @@ void main() { ); expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+groupedBy\(by\)\s*\.having\(having,\s*merge:\s*false\)\s*\.aggregate\(', + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupBy\(', ).hasMatch(source), - isTrue, + isFalse, reason: - 'Expected ModelQuery.groupBy(...) to route convenience arguments through the grouped builder.', + 'Expected ModelQuery to avoid redundant groupBy(...) convenience terminals.', ); expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupByWith\(OrmGroupBySpec\s+groupBy\)\s*=>\s*groupedBy\(groupBy\.by\)\.configure\(groupBy\)\._execute\(\);', + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupByWith\(', ).hasMatch(source), - isTrue, + isFalse, reason: - 'Expected ModelQuery.groupByWith(...) to execute via the grouped builder.', + 'Expected ModelQuery to avoid redundant groupByWith(...) terminals.', ); expect( RegExp( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 13be4141..90d1bd88 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1303,17 +1303,17 @@ typedef Post = ({ RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+groupedBy\(by,\s*where:\s*where\)[\s\S]*?\.aggregate\(', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected generated delegate groupBy(...) to route through the typed grouped builder.', + 'Expected generated delegate to avoid redundant groupBy(...) wrappers.', ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(\{[\s\S]*?required\s+UserGroupBySpec\s+groupBy,[\s\S]*?return\s+groupedBy\(groupBy\.by,\s*where:\s*where\)[\s\S]*?\.configure\(groupBy\)[\s\S]*?\._execute\(\);', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected generated delegate groupByWith(...) to route structured groupBy specs through the typed grouped builder.', + 'Expected generated delegate to avoid redundant groupByWith(...) wrappers.', ); expect( generatedSource.contains('Future> findMany('), @@ -1486,9 +1486,9 @@ typedef Post = ({ RegExp( r'Future>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected generated delegate/query to expose typed groupBy helper.', + 'Expected generated source to avoid redundant typed groupBy convenience helpers.', ); expect( RegExp(r'\bclass UserGroupByResult\b').hasMatch(generatedSource), @@ -1540,17 +1540,17 @@ typedef Post = ({ RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected UserDelegate.groupBy(...) to expose typedHaving parameter.', + 'Expected UserDelegate to avoid redundant groupBy(...) terminals.', ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?return\s+groupedBy\(by,\s*where:\s*where\)[\s\S]*?typedHaving[\s\S]*?\.aggregate\(', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected UserDelegate.groupBy(...) to route typed groupBy execution through the grouped builder.', + 'Expected UserDelegate to route grouped aggregation only through groupedBy(...).aggregate(...).', ); expect( RegExp( @@ -1580,17 +1580,17 @@ typedef Post = ({ RegExp( r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),[\s\S]*?return\s+groupedBy\(by\)\s*\.having\(typedHaving,\s*merge:\s*false\)\s*\.aggregate\(', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected UserQuery.groupBy(...) to route convenience arguments through the typed grouped builder.', + 'Expected UserQuery to avoid redundant groupBy(...) terminals.', ); expect( RegExp( r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+groupByWith\(UserGroupBySpec\s+groupBy\)\s*\{?[\s\S]*?return\s+groupedBy\(groupBy\.by\)\.configure\(groupBy\)\._execute\(\);', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected UserQuery.groupByWith(...) to route through the typed grouped builder.', + 'Expected UserQuery to avoid redundant groupByWith(...) terminals.', ); expect( RegExp(r'\bclass UserGroupedQuery\b').hasMatch(generatedSource), From 4477620d15a5f6c6fb21b68b4a50040c46d4e1c7 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:10:20 +0800 Subject: [PATCH 139/154] feat(plan)!: structure grouped having expressions --- pub/orm/lib/src/client/client.dart | 22 +- pub/orm/lib/src/engine/memory_engine.dart | 166 +++++------ pub/orm/lib/src/generator/writer.dart | 6 +- pub/orm/lib/src/runtime/plan.dart | 297 +++++++++++++++++++- pub/orm/lib/src/sql/adapter.dart | 130 +++++---- pub/orm/test/client/client_test.dart | 6 +- pub/orm/test/runtime/plan_surface_test.dart | 4 +- pub/orm/test/sql/sql_adapter_test.dart | 4 +- 8 files changed, 464 insertions(+), 171 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 405b93e9..1a58edae 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -2209,7 +2209,7 @@ class ModelDelegate { } void _assertGroupByHavingFields({ - required JsonMap having, + required OrmGroupByHaving having, required List by, required bool countAll, required List count, @@ -2222,7 +2222,7 @@ class ModelDelegate { return; } _assertGroupByHavingClause( - clause: having, + clause: having.toJson(), source: 'groupBy.having', by: by, countAll: countAll, @@ -2725,7 +2725,7 @@ final class OrmAggregateSpec { @immutable final class OrmGroupBySpec { final List by; - final JsonMap having; + final OrmGroupByHaving having; final bool countAll; final List count; final List min; @@ -2735,7 +2735,7 @@ final class OrmGroupBySpec { OrmGroupBySpec({ required List by, - JsonMap having = const {}, + this.having = const OrmGroupByHaving.empty(), this.countAll = false, List count = const [], List min = const [], @@ -2743,9 +2743,6 @@ final class OrmGroupBySpec { List sum = const [], List avg = const [], }) : by = List.unmodifiable(by), - having = Map.unmodifiable( - Map.from(having), - ), count = List.unmodifiable(count), min = List.unmodifiable(min), max = List.unmodifiable(max), @@ -2754,7 +2751,7 @@ final class OrmGroupBySpec { OrmGroupBySpec copyWith({ List? by, - JsonMap? having, + OrmGroupByHaving? having, bool? countAll, List? count, List? min, @@ -3192,7 +3189,7 @@ final class ModelGroupedQuery { List get byFields => _groupBy.by; - JsonMap get havingClause => _groupBy.having; + JsonMap get havingClause => _groupBy.having.toJson(); ModelGroupedQuery configure(OrmGroupBySpec groupBy) { if (!_sameStringList(left: _groupBy.by, right: groupBy.by)) { @@ -3210,9 +3207,8 @@ final class ModelGroupedQuery { } ModelGroupedQuery having(JsonMap having, {bool merge = true}) { - final nextHaving = merge - ? {..._groupBy.having, ...having} - : {...having}; + final parsed = OrmGroupByHaving.parse({...having}); + final nextHaving = merge ? _groupBy.having.merge(parsed) : parsed; return _next(_groupBy.copyWith(having: nextHaving)); } @@ -3220,7 +3216,7 @@ final class ModelGroupedQuery { JsonMap Function(JsonMap having) build, { bool merge = true, }) { - final current = Map.from(_groupBy.having); + final current = Map.from(_groupBy.having.toJson()); final next = build(Map.unmodifiable(current)); return having(next, merge: merge); } diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index c58a51f6..6766d852 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -759,45 +759,30 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { return left.toString().compareTo(right.toString()); } - bool _matchesGroupByHaving({required JsonMap row, required JsonMap having}) { - for (final entry in having.entries) { - final key = entry.key; - final value = entry.value; - if (key == 'AND' || key == 'OR' || key == 'NOT') { - if (!_matchesGroupByHavingLogical( - row: row, - operator: key, - operand: value, - )) { - return false; - } - continue; - } - - final bucket = _normalizeAggregateBucket(key); - if (bucket != null) { - final aggregateFilters = _coerceWhereMap(value); - if (aggregateFilters == null) { - return false; - } - for (final aggregateEntry in aggregateFilters.entries) { - final aggregateValue = _readGroupByAggregateValue( - row: row, - bucket: bucket, - field: aggregateEntry.key, - ); + bool _matchesGroupByHaving({ + required JsonMap row, + required OrmGroupByHaving having, + }) { + for (final node in having.nodes) { + switch (node) { + case OrmGroupByHavingLogicalNode(): + if (!_matchesGroupByHavingLogical(row: row, node: node)) { + return false; + } + case OrmGroupByHavingPredicateNode(): + final actual = node.bucket == null + ? row[node.field] + : _readGroupByAggregateValue( + row: row, + bucket: _groupByHavingBucketName(node.bucket!), + field: node.field, + ); if (!_matchesGroupByHavingCondition( - actual: aggregateValue, - condition: aggregateEntry.value, + actual: actual, + condition: node.condition, )) { return false; } - } - continue; - } - - if (!_matchesGroupByHavingCondition(actual: row[key], condition: value)) { - return false; } } return true; @@ -805,72 +790,93 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { bool _matchesGroupByHavingLogical({ required JsonMap row, - required String operator, - required Object? operand, + required OrmGroupByHavingLogicalNode node, }) { - final nestedMap = _coerceWhereMap(operand); - if (nestedMap != null) { - final matched = _matchesGroupByHaving(row: row, having: nestedMap); - return operator == 'NOT' ? !matched : matched; - } - final nestedList = _coerceWhereList(operand); - if (nestedList == null) { - return false; - } - return switch (operator) { - 'AND' => nestedList.every( + final clauses = node.clauses; + return switch (node.operator) { + OrmGroupByHavingLogicalOperator.and => clauses.every( (clause) => _matchesGroupByHaving(row: row, having: clause), ), - 'OR' => nestedList.any( + OrmGroupByHavingLogicalOperator.or => clauses.any( (clause) => _matchesGroupByHaving(row: row, having: clause), ), - 'NOT' => nestedList.every( + OrmGroupByHavingLogicalOperator.not => clauses.every( (clause) => !_matchesGroupByHaving(row: row, having: clause), ), - _ => false, }; } bool _matchesGroupByHavingCondition({ required Object? actual, - required Object? condition, + required OrmGroupByHavingCondition condition, }) { - final conditionMap = _coerceOperatorMap(condition); - if (conditionMap == null || conditionMap.isEmpty) { - return actual == condition; + if (condition.shorthand != null) { + return actual == condition.shorthand; } - for (final operator in _whereOperatorOrder) { - if (!conditionMap.containsKey(operator)) { - continue; - } - final operand = conditionMap[operator]; - final matched = switch (operator) { - 'equals' => actual == operand, - 'not' => - operand is Map - ? !_matchesGroupByHavingCondition( - actual: actual, - condition: operand, - ) - : actual != operand, - 'in' => _matchIn(actual, operand), - 'notIn' => _matchNotIn(actual, operand), - 'contains' => _matchStringOperation(actual, operand, operator), - 'startsWith' => _matchStringOperation(actual, operand, operator), - 'endsWith' => _matchStringOperation(actual, operand, operator), - 'gt' => _matchComparison(actual, operand, operator), - 'gte' => _matchComparison(actual, operand, operator), - 'lt' => _matchComparison(actual, operand, operator), - 'lte' => _matchComparison(actual, operand, operator), - _ => false, - }; + if (condition.isEmpty) { + return true; + } + if (condition.equals != null && actual != condition.equals) { + return false; + } + final notOperand = condition.not; + if (notOperand != null) { + final matched = notOperand is Map + ? !_matchesGroupByHavingCondition( + actual: actual, + condition: OrmGroupByHavingCondition.parse(notOperand), + ) + : actual != notOperand; if (!matched) { return false; } } + if (condition.inValues != null && !_matchIn(actual, condition.inValues)) { + return false; + } + if (condition.notInValues != null && + !_matchNotIn(actual, condition.notInValues)) { + return false; + } + if (condition.contains != null && + !_matchStringOperation(actual, condition.contains, 'contains')) { + return false; + } + if (condition.startsWith != null && + !_matchStringOperation(actual, condition.startsWith, 'startsWith')) { + return false; + } + if (condition.endsWith != null && + !_matchStringOperation(actual, condition.endsWith, 'endsWith')) { + return false; + } + if (condition.gt != null && !_matchComparison(actual, condition.gt, 'gt')) { + return false; + } + if (condition.gte != null && + !_matchComparison(actual, condition.gte, 'gte')) { + return false; + } + if (condition.lt != null && !_matchComparison(actual, condition.lt, 'lt')) { + return false; + } + if (condition.lte != null && + !_matchComparison(actual, condition.lte, 'lte')) { + return false; + } return true; } + String _groupByHavingBucketName(OrmGroupByHavingMetricBucket bucket) { + return switch (bucket) { + OrmGroupByHavingMetricBucket.count => 'count', + OrmGroupByHavingMetricBucket.min => 'min', + OrmGroupByHavingMetricBucket.max => 'max', + OrmGroupByHavingMetricBucket.sum => 'sum', + OrmGroupByHavingMetricBucket.avg => 'avg', + }; + } + String? _normalizeAggregateBucket(String bucket) { return switch (bucket) { 'count' || '_count' => 'count', diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 4b5f949d..3581107e 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -912,6 +912,10 @@ final class TypedClientWriter { ); buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' OrmGroupByHaving toRuntimeHaving() {'); + buffer.writeln(' return OrmGroupByHaving.parse(toJson());'); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' Map toJson() {'); buffer.writeln(' return Map.from(value);'); buffer.writeln(' }'); @@ -1019,7 +1023,7 @@ final class TypedClientWriter { buffer.writeln( ' by: by.map((entry) => entry.value).toList(growable: false),', ); - buffer.writeln(' having: having.toJson(),'); + buffer.writeln(' having: having.toRuntimeHaving(),'); buffer.writeln(' countAll: countAll,'); buffer.writeln( ' count: count.map((entry) => entry.value).toList(growable: false),', diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index bc4f4f55..78f6213a 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -11,6 +11,10 @@ enum OrmMutationResultMode { row, rowOrNull } enum OrmReadShape { rows, aggregate, groupedAggregate } +enum OrmGroupByHavingLogicalOperator { and, or, not } + +enum OrmGroupByHavingMetricBucket { count, min, max, sum, avg } + @immutable final class OrmReadCursorPlan { final JsonMap values; @@ -199,33 +203,318 @@ final class OrmReadAggregatePlan { }; } +@immutable +final class OrmGroupByHavingCondition { + final Object? shorthand; + final Object? equals; + final Object? not; + final List? inValues; + final List? notInValues; + final String? contains; + final String? startsWith; + final String? endsWith; + final Object? gt; + final Object? gte; + final Object? lt; + final Object? lte; + + const OrmGroupByHavingCondition({ + this.shorthand, + this.equals, + this.not, + this.inValues, + this.notInValues, + this.contains, + this.startsWith, + this.endsWith, + this.gt, + this.gte, + this.lt, + this.lte, + }); + + factory OrmGroupByHavingCondition.parse(Object? value) { + if (value is! Map) { + return OrmGroupByHavingCondition(shorthand: value); + } + + return OrmGroupByHavingCondition( + equals: value['equals'], + not: value['not'], + inValues: _coerceObjectList(value['in']), + notInValues: _coerceObjectList(value['notIn']), + contains: value['contains'] as String?, + startsWith: value['startsWith'] as String?, + endsWith: value['endsWith'] as String?, + gt: value['gt'], + gte: value['gte'], + lt: value['lt'], + lte: value['lte'], + ); + } + + bool get isEmpty => + shorthand == null && + equals == null && + not == null && + inValues == null && + notInValues == null && + contains == null && + startsWith == null && + endsWith == null && + gt == null && + gte == null && + lt == null && + lte == null; + + Object? toJsonValue() { + if (shorthand != null) { + return shorthand; + } + + final map = {}; + if (equals != null) { + map['equals'] = equals; + } + if (not != null) { + map['not'] = not; + } + if (inValues != null) { + map['in'] = inValues; + } + if (notInValues != null) { + map['notIn'] = notInValues; + } + if (contains != null) { + map['contains'] = contains; + } + if (startsWith != null) { + map['startsWith'] = startsWith; + } + if (endsWith != null) { + map['endsWith'] = endsWith; + } + if (gt != null) { + map['gt'] = gt; + } + if (gte != null) { + map['gte'] = gte; + } + if (lt != null) { + map['lt'] = lt; + } + if (lte != null) { + map['lte'] = lte; + } + return map; + } +} + +sealed class OrmGroupByHavingNode { + const OrmGroupByHavingNode(); + + JsonMap toJson(); +} + +@immutable +final class OrmGroupByHavingLogicalNode extends OrmGroupByHavingNode { + final OrmGroupByHavingLogicalOperator operator; + final List clauses; + + OrmGroupByHavingLogicalNode({ + required this.operator, + List clauses = const [], + }) : clauses = List.unmodifiable(clauses); + + @override + JsonMap toJson() => { + _logicalOperatorName(operator): clauses + .map((clause) => clause.toJson()) + .toList(growable: false), + }; +} + +@immutable +final class OrmGroupByHavingPredicateNode extends OrmGroupByHavingNode { + final String field; + final OrmGroupByHavingCondition condition; + final OrmGroupByHavingMetricBucket? bucket; + + const OrmGroupByHavingPredicateNode({ + required this.field, + required this.condition, + this.bucket, + }); + + @override + JsonMap toJson() { + final value = condition.toJsonValue(); + if (bucket == null) { + return {field: value}; + } + return { + _metricBucketName(bucket!): {field: value}, + }; + } +} + +@immutable +final class OrmGroupByHaving { + final List nodes; + + const OrmGroupByHaving.empty() : nodes = const []; + + OrmGroupByHaving([List nodes = const []]) + : nodes = List.unmodifiable(nodes); + + factory OrmGroupByHaving.parse(JsonMap having) { + final nodes = []; + for (final entry in having.entries) { + final key = entry.key; + final value = entry.value; + final logicalOperator = _parseLogicalOperator(key); + if (logicalOperator != null) { + final clauses = _parseLogicalClauses(value); + nodes.add( + OrmGroupByHavingLogicalNode( + operator: logicalOperator, + clauses: clauses, + ), + ); + continue; + } + + final bucket = _parseMetricBucket(key); + if (bucket != null) { + if (value is! Map) { + continue; + } + final bucketMap = Map.from(value); + for (final bucketEntry in bucketMap.entries) { + nodes.add( + OrmGroupByHavingPredicateNode( + field: bucketEntry.key, + condition: OrmGroupByHavingCondition.parse(bucketEntry.value), + bucket: bucket, + ), + ); + } + continue; + } + + nodes.add( + OrmGroupByHavingPredicateNode( + field: key, + condition: OrmGroupByHavingCondition.parse(value), + ), + ); + } + return OrmGroupByHaving(nodes); + } + + bool get isEmpty => nodes.isEmpty; + bool get isNotEmpty => nodes.isNotEmpty; + + OrmGroupByHaving merge(OrmGroupByHaving other) => + OrmGroupByHaving.parse({...toJson(), ...other.toJson()}); + + JsonMap toJson() { + final map = {}; + for (final node in nodes) { + map.addAll(node.toJson()); + } + return map; + } +} + @immutable final class OrmReadGroupByPlan { final List by; - final JsonMap having; + final OrmGroupByHaving having; final List orderBy; final int? skip; final int? take; OrmReadGroupByPlan({ required List by, - JsonMap having = const {}, + this.having = const OrmGroupByHaving.empty(), List orderBy = const [], this.skip, this.take, }) : by = List.unmodifiable(by), - having = Map.unmodifiable(having), orderBy = List.unmodifiable(orderBy); JsonMap toJson() => { 'by': by, - 'having': having, + 'having': having.toJson(), 'orderBy': orderBy.map((entry) => entry.toJson()).toList(growable: false), if (skip != null) 'skip': skip, if (take != null) 'take': take, }; } +List? _coerceObjectList(Object? value) { + if (value is! List) { + return null; + } + return List.unmodifiable(value.cast()); +} + +OrmGroupByHavingLogicalOperator? _parseLogicalOperator(String key) { + return switch (key) { + 'AND' => OrmGroupByHavingLogicalOperator.and, + 'OR' => OrmGroupByHavingLogicalOperator.or, + 'NOT' => OrmGroupByHavingLogicalOperator.not, + _ => null, + }; +} + +String _logicalOperatorName(OrmGroupByHavingLogicalOperator operator) { + return switch (operator) { + OrmGroupByHavingLogicalOperator.and => 'AND', + OrmGroupByHavingLogicalOperator.or => 'OR', + OrmGroupByHavingLogicalOperator.not => 'NOT', + }; +} + +OrmGroupByHavingMetricBucket? _parseMetricBucket(String key) { + return switch (key) { + '_count' => OrmGroupByHavingMetricBucket.count, + '_min' => OrmGroupByHavingMetricBucket.min, + '_max' => OrmGroupByHavingMetricBucket.max, + '_sum' => OrmGroupByHavingMetricBucket.sum, + '_avg' => OrmGroupByHavingMetricBucket.avg, + _ => null, + }; +} + +String _metricBucketName(OrmGroupByHavingMetricBucket bucket) { + return switch (bucket) { + OrmGroupByHavingMetricBucket.count => '_count', + OrmGroupByHavingMetricBucket.min => '_min', + OrmGroupByHavingMetricBucket.max => '_max', + OrmGroupByHavingMetricBucket.sum => '_sum', + OrmGroupByHavingMetricBucket.avg => '_avg', + }; +} + +List _parseLogicalClauses(Object? operand) { + if (operand is Map) { + return [ + OrmGroupByHaving.parse(Map.from(operand)), + ]; + } + if (operand is List) { + return operand + .whereType() + .map( + (entry) => OrmGroupByHaving.parse(Map.from(entry)), + ) + .toList(growable: false); + } + return const []; +} + @immutable final class OrmMutationPlan { final JsonMap where; diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index bd72eaae..beb28391 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -1234,7 +1234,7 @@ final class SqlAdapter } String _buildGroupedHavingClause({ - required JsonMap having, + required OrmGroupByHaving having, required List params, }) { if (having.isEmpty) { @@ -1244,54 +1244,32 @@ final class SqlAdapter } String _buildGroupedHavingExpression({ - required JsonMap having, + required OrmGroupByHaving having, required List params, }) { final predicates = []; - for (final entry in having.entries) { - final key = entry.key; - if (_whereLogicalKeys.contains(key)) { - predicates.add( - _buildGroupedHavingLogicalPredicate( - key: key, - operand: entry.value, - params: params, - ), - ); - continue; - } - - final metricBucket = _normalizeGroupedMetricBucket(key); - if (metricBucket != null) { - final metricFilters = _coerceWhereMap(entry.value); - if (metricFilters == null || metricFilters.isEmpty) { - continue; - } - for (final metricEntry in metricFilters.entries) { + for (final node in having.nodes) { + switch (node) { + case OrmGroupByHavingLogicalNode(): + predicates.add( + _buildGroupedHavingLogicalPredicate(node: node, params: params), + ); + case OrmGroupByHavingPredicateNode(): predicates.add( _buildGroupedHavingConditionPredicate( - leftOperand: _aggregateFunctionExpression( - bucket: metricBucket, - field: metricEntry.key, - rowRef: null, - ), - field: metricEntry.key, - condition: metricEntry.value, + leftOperand: node.bucket == null + ? _id(node.field) + : _aggregateFunctionExpression( + bucket: _groupByMetricBucketName(node.bucket!), + field: node.field, + rowRef: null, + ), + field: node.field, + condition: node.condition, params: params, ), ); - } - continue; } - - predicates.add( - _buildGroupedHavingConditionPredicate( - leftOperand: _id(key), - field: key, - condition: entry.value, - params: params, - ), - ); } if (predicates.isEmpty) { return '1 = 1'; @@ -1300,51 +1278,62 @@ final class SqlAdapter } String _buildGroupedHavingLogicalPredicate({ - required String key, - required Object? operand, + required OrmGroupByHavingLogicalNode node, required List params, }) { - final nestedMap = _coerceWhereMap(operand); - if (nestedMap != null) { - final predicate = _buildGroupedHavingExpression( - having: nestedMap, - params: params, - ); - return key == 'NOT' ? 'NOT ($predicate)' : '($predicate)'; - } - final nestedList = _coerceWhereList(operand); - if (nestedList == null || nestedList.isEmpty) { - return key == 'OR' ? '0 = 1' : '1 = 1'; - } - final joiner = key == 'OR' ? ' OR ' : ' AND '; - final clauses = nestedList + if (node.clauses.isEmpty) { + return node.operator == OrmGroupByHavingLogicalOperator.or + ? '0 = 1' + : '1 = 1'; + } + final joiner = node.operator == OrmGroupByHavingLogicalOperator.or + ? ' OR ' + : ' AND '; + final clauses = node.clauses .map( (clause) => _buildGroupedHavingExpression(having: clause, params: params), ) .map((clause) => '($clause)') .join(joiner); - return key == 'NOT' ? 'NOT ($clauses)' : clauses; + return node.operator == OrmGroupByHavingLogicalOperator.not + ? 'NOT ($clauses)' + : clauses; } String _buildGroupedHavingConditionPredicate({ required String leftOperand, required String field, - required Object? condition, + required OrmGroupByHavingCondition condition, required List params, }) { - final operatorMap = _coerceOperatorMap(condition); - if (operatorMap == null) { - params.add(condition); + if (condition.shorthand != null) { + params.add(condition.shorthand); return '$leftOperand = ?'; } + if (condition.isEmpty) { + return '1 = 1'; + } final predicates = []; for (final operator in _whereOperatorOrder) { - if (!operatorMap.containsKey(operator)) { + final operand = switch (operator) { + 'equals' => condition.equals, + 'not' => condition.not, + 'in' => condition.inValues, + 'notIn' => condition.notInValues, + 'contains' => condition.contains, + 'startsWith' => condition.startsWith, + 'endsWith' => condition.endsWith, + 'gt' => condition.gt, + 'gte' => condition.gte, + 'lt' => condition.lt, + 'lte' => condition.lte, + _ => null, + }; + if (operand == null) { continue; } - final operand = operatorMap[operator]; predicates.add( _buildGroupedHavingOperatorPredicate( leftOperand: leftOperand, @@ -1373,12 +1362,11 @@ final class SqlAdapter params.add(operand); return '$leftOperand = ?'; case 'not': - final nested = _coerceOperatorMap(operand); - if (nested != null) { + if (operand is Map) { final predicate = _buildGroupedHavingConditionPredicate( leftOperand: leftOperand, field: field, - condition: operand, + condition: OrmGroupByHavingCondition.parse(operand), params: params, ); return 'NOT ($predicate)'; @@ -1431,6 +1419,16 @@ final class SqlAdapter } } + String _groupByMetricBucketName(OrmGroupByHavingMetricBucket bucket) { + return switch (bucket) { + OrmGroupByHavingMetricBucket.count => 'count', + OrmGroupByHavingMetricBucket.min => 'min', + OrmGroupByHavingMetricBucket.max => 'max', + OrmGroupByHavingMetricBucket.sum => 'sum', + OrmGroupByHavingMetricBucket.avg => 'avg', + }; + } + String _buildGroupedLimitOffsetClause({ required int? skip, required int? take, diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 6b2602e5..353d67c9 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -673,11 +673,11 @@ void main() { OrmGroupBySpec( by: const ['email'], countAll: true, - having: const { + having: OrmGroupByHaving.parse(const { '_count': { 'all': {'gte': 2}, }, - }, + }), sum: const ['id'], ), ) @@ -685,7 +685,7 @@ void main() { expect(plan.read?.shape, OrmReadShape.groupedAggregate); expect(plan.read?.groupBy?.by, ['email']); - expect(plan.read?.groupBy?.having, { + expect(plan.read?.groupBy?.having.toJson(), { '_count': { 'all': {'gte': 2}, }, diff --git a/pub/orm/test/runtime/plan_surface_test.dart b/pub/orm/test/runtime/plan_surface_test.dart index f24c8d1d..f78875d5 100644 --- a/pub/orm/test/runtime/plan_surface_test.dart +++ b/pub/orm/test/runtime/plan_surface_test.dart @@ -50,11 +50,11 @@ void main() { ), groupBy: OrmReadGroupByPlan( by: const ['email'], - having: const { + having: OrmGroupByHaving.parse(const { '_count': { 'all': {'gte': 2}, }, - }, + }), orderBy: const [ OrmOrderBy('_sum.id', order: SortOrder.desc), ], diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 12831f9b..7ebf1f16 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -222,11 +222,11 @@ void main() { aggregate: OrmReadAggregatePlan(countAll: true, sum: ['id']), groupBy: OrmReadGroupByPlan( by: ['email'], - having: { + having: OrmGroupByHaving.parse({ '_count': { 'all': {'gte': 2}, }, - }, + }), orderBy: [OrmOrderBy('_sum.id', order: SortOrder.desc)], take: 3, skip: 1, From a856a4b1a052551f371c80e0fa5332264c705e93 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:19:33 +0800 Subject: [PATCH 140/154] feat(orm)!: add builder-style grouped having --- docs/orm-v6-api-surface.md | 8 +- pub/orm/lib/src/client/client.dart | 125 +++++++++++++++++++ pub/orm/lib/src/generator/writer.dart | 115 +++++++++++++++++ pub/orm/lib/src/runtime/core.dart | 22 ++++ pub/orm/test/client/api_surface_test.dart | 52 ++++++++ pub/orm/test/client/client_test.dart | 31 ++++- pub/orm/test/client/source_surface_test.dart | 14 +++ pub/orm/test/generator/generate_test.dart | 16 +++ 8 files changed, 377 insertions(+), 6 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 115aeff4..16b26392 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -86,7 +86,7 @@ Read terminals: | `count()` | implemented | | `exists()` | implemented | | `aggregate(...)` | implemented | -| `groupBy(...)` | implemented | +| `groupedBy(...).aggregate(...)` | implemented | | `explain()` | implemented | Rules: @@ -109,6 +109,12 @@ Rules: - `include(...)` or `distinct(...)` force `stream()` to `bufferedYield`, with reasons and include strategy surfaced in `terminalExecution.stream`. +8. Grouped aggregation is a dedicated surface: + - `groupedBy(...)` only accepts a where-only base query. + - `having(...)`, `havingWith(...)`, and `havingExpr(...)` refine the grouped + builder before `aggregate(...)`. +9. `include(...)` is unsupported on `aggregate(...)` and + `groupedBy(...).aggregate(...)`, including direct plan execution. ## ORM Mutation Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 1a58edae..72130a14 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -2772,6 +2772,121 @@ final class OrmGroupBySpec { } } +@immutable +final class OrmGroupByHavingBuilder { + const OrmGroupByHavingBuilder(); + + OrmGroupByHavingPredicateBuilder by(String field) => + OrmGroupByHavingPredicateBuilder._(field: field); + + OrmGroupByHavingPredicateBuilder count(String field) => + OrmGroupByHavingPredicateBuilder._( + field: field, + bucket: OrmGroupByHavingMetricBucket.count, + ); + + OrmGroupByHavingPredicateBuilder countAll() => + OrmGroupByHavingPredicateBuilder._( + field: 'all', + bucket: OrmGroupByHavingMetricBucket.count, + ); + + OrmGroupByHavingPredicateBuilder min(String field) => + OrmGroupByHavingPredicateBuilder._( + field: field, + bucket: OrmGroupByHavingMetricBucket.min, + ); + + OrmGroupByHavingPredicateBuilder max(String field) => + OrmGroupByHavingPredicateBuilder._( + field: field, + bucket: OrmGroupByHavingMetricBucket.max, + ); + + OrmGroupByHavingPredicateBuilder sum(String field) => + OrmGroupByHavingPredicateBuilder._( + field: field, + bucket: OrmGroupByHavingMetricBucket.sum, + ); + + OrmGroupByHavingPredicateBuilder avg(String field) => + OrmGroupByHavingPredicateBuilder._( + field: field, + bucket: OrmGroupByHavingMetricBucket.avg, + ); + + OrmGroupByHaving and(List clauses) => OrmGroupByHaving([ + OrmGroupByHavingLogicalNode( + operator: OrmGroupByHavingLogicalOperator.and, + clauses: clauses, + ), + ]); + + OrmGroupByHaving or(List clauses) => OrmGroupByHaving([ + OrmGroupByHavingLogicalNode( + operator: OrmGroupByHavingLogicalOperator.or, + clauses: clauses, + ), + ]); + + OrmGroupByHaving not(List clauses) => OrmGroupByHaving([ + OrmGroupByHavingLogicalNode( + operator: OrmGroupByHavingLogicalOperator.not, + clauses: clauses, + ), + ]); +} + +@immutable +final class OrmGroupByHavingPredicateBuilder { + final String field; + final OrmGroupByHavingMetricBucket? bucket; + + const OrmGroupByHavingPredicateBuilder._({required this.field, this.bucket}); + + OrmGroupByHaving equals(Object? value) => + _condition(OrmGroupByHavingCondition(equals: value)); + + OrmGroupByHaving notEquals(Object? value) => + _condition(OrmGroupByHavingCondition(not: value)); + + OrmGroupByHaving inList(List values) => + _condition(OrmGroupByHavingCondition(inValues: values)); + + OrmGroupByHaving notInList(List values) => + _condition(OrmGroupByHavingCondition(notInValues: values)); + + OrmGroupByHaving contains(String value) => + _condition(OrmGroupByHavingCondition(contains: value)); + + OrmGroupByHaving startsWith(String value) => + _condition(OrmGroupByHavingCondition(startsWith: value)); + + OrmGroupByHaving endsWith(String value) => + _condition(OrmGroupByHavingCondition(endsWith: value)); + + OrmGroupByHaving gt(Object? value) => + _condition(OrmGroupByHavingCondition(gt: value)); + + OrmGroupByHaving gte(Object? value) => + _condition(OrmGroupByHavingCondition(gte: value)); + + OrmGroupByHaving lt(Object? value) => + _condition(OrmGroupByHavingCondition(lt: value)); + + OrmGroupByHaving lte(Object? value) => + _condition(OrmGroupByHavingCondition(lte: value)); + + OrmGroupByHaving _condition(OrmGroupByHavingCondition condition) => + OrmGroupByHaving([ + OrmGroupByHavingPredicateNode( + field: field, + condition: condition, + bucket: bucket, + ), + ]); +} + @immutable final class ModelQuery { final ModelDelegate _delegate; @@ -3221,6 +3336,16 @@ final class ModelGroupedQuery { return having(next, merge: merge); } + ModelGroupedQuery havingExpr( + OrmGroupByHaving Function(OrmGroupByHavingBuilder having) build, { + bool merge = true, + }) { + final next = build(const OrmGroupByHavingBuilder()); + return _next( + _groupBy.copyWith(having: merge ? _groupBy.having.merge(next) : next), + ); + } + Future> aggregate({ bool countAll = false, List count = const [], diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 3581107e..097e7a96 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -924,6 +924,111 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); + final havingBuilderClassName = '${model.groupByHavingClassName}Builder'; + final havingPredicateBuilderClassName = + '${model.groupByHavingClassName}PredicateBuilder'; + + buffer.writeln('class $havingBuilderClassName {'); + buffer.writeln(' const $havingBuilderClassName();'); + buffer.writeln(); + buffer.writeln( + ' $havingPredicateBuilderClassName by(${model.distinctClassName} field) =>', + ); + buffer.writeln( + ' $havingPredicateBuilderClassName._((condition) => ${model.groupByHavingClassName}.by(field, condition));', + ); + buffer.writeln(); + buffer.writeln( + ' $havingPredicateBuilderClassName count(${model.distinctClassName} field) =>', + ); + buffer.writeln( + ' $havingPredicateBuilderClassName._((condition) => ${model.groupByHavingClassName}.count(field, condition));', + ); + buffer.writeln(); + buffer.writeln(' $havingPredicateBuilderClassName countAll() =>'); + buffer.writeln( + ' $havingPredicateBuilderClassName._((condition) => ${model.groupByHavingClassName}.countAll(condition));', + ); + buffer.writeln(); + for (final bucket in const ['min', 'max', 'sum', 'avg']) { + buffer.writeln( + ' $havingPredicateBuilderClassName $bucket(${model.distinctClassName} field) =>', + ); + buffer.writeln( + ' $havingPredicateBuilderClassName._((condition) => ${model.groupByHavingClassName}.$bucket(field, condition));', + ); + buffer.writeln(); + } + buffer.writeln( + ' ${model.groupByHavingClassName} and(List<${model.groupByHavingClassName}> clauses) =>', + ); + buffer.writeln(' ${model.groupByHavingClassName}.and(clauses);'); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} or(List<${model.groupByHavingClassName}> clauses) =>', + ); + buffer.writeln(' ${model.groupByHavingClassName}.or(clauses);'); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} not(List<${model.groupByHavingClassName}> clauses) =>', + ); + buffer.writeln(' ${model.groupByHavingClassName}.not(clauses);'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class $havingPredicateBuilderClassName {'); + buffer.writeln( + ' final ${model.groupByHavingClassName} Function(${model.groupByHavingConditionClassName} condition) _build;', + ); + buffer.writeln(); + buffer.writeln(' $havingPredicateBuilderClassName._(this._build);'); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} equals(Object? value) => _build(${model.groupByHavingConditionClassName}.equals(value));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} notEquals(Object? value) => _build(${model.groupByHavingConditionClassName}.notEquals(value));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} inList(List values) => _build(${model.groupByHavingConditionClassName}.inList(values));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} notInList(List values) => _build(${model.groupByHavingConditionClassName}.notInList(values));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} contains(String value) => _build(${model.groupByHavingConditionClassName}.contains(value));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} startsWith(String value) => _build(${model.groupByHavingConditionClassName}.startsWith(value));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} endsWith(String value) => _build(${model.groupByHavingConditionClassName}.endsWith(value));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} gt(Object? value) => _build(${model.groupByHavingConditionClassName}.gt(value));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} gte(Object? value) => _build(${model.groupByHavingConditionClassName}.gte(value));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} lt(Object? value) => _build(${model.groupByHavingConditionClassName}.lt(value));', + ); + buffer.writeln(); + buffer.writeln( + ' ${model.groupByHavingClassName} lte(Object? value) => _build(${model.groupByHavingConditionClassName}.lte(value));', + ); + buffer.writeln('}'); + buffer.writeln(); + for (final entry in aggregateBucketClassNames.entries) { _writeAggregateBucketClass( buffer: buffer, @@ -3131,6 +3236,16 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' ${model.groupedQueryClassName} havingExpr(${model.groupByHavingClassName} Function(${model.groupByHavingClassName}Builder having) build, {bool merge = true}) {', + ); + buffer.writeln( + ' final next = build(const ${model.groupByHavingClassName}Builder());', + ); + buffer.writeln(' return having(next, merge: merge);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future toPlan() {'); buffer.writeln(' return _runtimeGrouped(_groupBy).toPlan();'); buffer.writeln(' }'); diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index 62c267e5..a842cf91 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -1123,6 +1123,17 @@ final class OrmRuntimeCore implements RuntimeCore { }, ); } + if (plan.include.isNotEmpty) { + throw runtimeError( + 'PLAN.READ_INCLUDE_UNSUPPORTED', + 'Aggregate read plans do not support include.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + 'include': plan.include.keys.toList(growable: false), + }, + ); + } _assertKnownFields( model: model, fields: [ @@ -1147,6 +1158,17 @@ final class OrmRuntimeCore implements RuntimeCore { }, ); } + if (plan.include.isNotEmpty) { + throw runtimeError( + 'PLAN.READ_INCLUDE_UNSUPPORTED', + 'Grouped aggregate plans do not support include.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + 'include': plan.include.keys.toList(growable: false), + }, + ); + } _assertKnownFields( model: model, fields: groupBy.by, diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index e73bbf02..409f5e03 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -224,6 +224,58 @@ void main() { }, ); + test( + 'runtime rejects include on aggregate and grouped aggregate plans', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + await expectLater( + () => client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + shape: OrmReadShape.aggregate, + include: {'posts': OrmIncludePlan()}, + aggregate: OrmReadAggregatePlan(countAll: true), + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.READ_INCLUDE_UNSUPPORTED', + ), + ), + ); + + await expectLater( + () => client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + shape: OrmReadShape.groupedAggregate, + include: {'posts': OrmIncludePlan()}, + aggregate: OrmReadAggregatePlan(countAll: true), + groupBy: OrmReadGroupByPlan(by: const ['email']), + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.READ_INCLUDE_UNSUPPORTED', + ), + ), + ); + } finally { + await client.disconnect(); + } + }, + ); + test('explain requires an active runtime connection', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 353d67c9..34665ccc 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -622,11 +622,7 @@ void main() { final grouped = await users .query() .groupedBy(const ['email']) - .having({ - '_count': { - 'all': {'gte': 2}, - }, - }, merge: false) + .havingExpr((having) => having.countAll().gte(2), merge: false) .aggregate(countAll: true, sum: const ['id']); expect(grouped, hasLength(2)); @@ -640,6 +636,31 @@ void main() { await client.disconnect(); }); + test('supports builder-style groupBy having expressions', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': 'a@x.com'}); + await users.create(data: {'id': 3, 'email': 'b@x.com'}); + + final grouped = await users + .query() + .groupedBy(const ['email']) + .havingExpr( + (having) => having.or([ + having.countAll().gte(2), + having.sum('id').gte(3), + ]), + merge: false, + ) + .aggregate(countAll: true, sum: const ['id']); + + expect(grouped, hasLength(2)); + await client.disconnect(); + }); + test('rejects groupedBy when row-query state is already present', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 1ed8cb49..8de3e9be 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -125,6 +125,20 @@ void main() { reason: 'Expected ModelGroupedQuery.toPlan() to expose the grouped aggregate plan surface.', ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+havingExpr\(\s*OrmGroupByHaving\s+Function\(OrmGroupByHavingBuilder\s+having\)\s+build,\s*\{\s*bool\s+merge\s*=\s*true,\s*\}\s*\)', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelGroupedQuery to expose a builder-style havingExpr(...) surface.', + ); + expect( + RegExp(r'\bclass\s+OrmGroupByHavingBuilder\b').hasMatch(source), + isTrue, + reason: + 'Expected dynamic client source to include OrmGroupByHavingBuilder.', + ); expect( RegExp( r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+orderBy\(', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 90d1bd88..efd4142c 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1652,6 +1652,22 @@ typedef Post = ({ reason: 'Expected generated source to include typed groupBy having helper.', ); + expect( + RegExp( + r'\bclass UserGroupByHavingBuilder\b', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed groupBy having builder helper.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?UserGroupedQuery\s+havingExpr\(UserGroupByHaving\s+Function\(UserGroupByHavingBuilder\s+having\)\s+build,\s*\{bool\s+merge\s*=\s*true\}\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupedQuery to expose builder-style havingExpr(...).', + ); expect( RegExp(r'\bclass UserGroupByOrderBy\b').hasMatch(generatedSource), isFalse, From 6a98758fc0e91a91589534f0e117ecd0c4611c7d Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:36:58 +0800 Subject: [PATCH 141/154] feat(orm)!: move aggregate terminals to callback builders --- pub/orm/lib/src/client/client.dart | 182 +++++++++++++------ pub/orm/lib/src/generator/writer.dart | 172 ++++++++++-------- pub/orm/lib/src/runtime/core.dart | 30 +++ pub/orm/test/client/api_surface_test.dart | 50 +++++ pub/orm/test/client/client_test.dart | 43 +++-- pub/orm/test/client/source_surface_test.dart | 4 +- pub/orm/test/generator/generate_test.dart | 12 +- 7 files changed, 344 insertions(+), 149 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 72130a14..6b607980 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1463,28 +1463,15 @@ class ModelDelegate { List orderBy = const [], JsonMap? cursor, OrmReadPagePlan? page, - bool countAll = false, - List count = const [], - List min = const [], - List max = const [], - List sum = const [], - List avg = const [], - }) => - _queryFromSpec( - OrmReadQuerySpec( - where: where, - orderBy: orderBy, - cursor: cursor, - page: page, - ), - ).aggregate( - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ); + required OrmAggregateBuilder Function(OrmAggregateBuilder aggregate) build, + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + orderBy: orderBy, + cursor: cursor, + page: page, + ), + ).aggregate(build); Future aggregateWith({ JsonMap where = const {}, @@ -2153,10 +2140,25 @@ class ModelDelegate { } } + void _assertAggregateSpecRequested( + OrmAggregateSpec aggregate, { + required String terminal, + }) { + if (!aggregate.isEmpty) { + return; + } + throw runtimeError( + 'PLAN.AGGREGATE_FIELDS_EMPTY', + '$terminal requires at least one aggregation selector.', + details: {'model': modelName, 'terminal': terminal}, + ); + } + void _validateAggregateSpec({ required OrmAggregateSpec aggregate, required String source, }) { + _assertAggregateSpecRequested(aggregate, terminal: source); _assertKnownAggregateFields( fields: aggregate.count, source: '$source.count', @@ -2720,6 +2722,97 @@ final class OrmAggregateSpec { max = List.unmodifiable(max), sum = List.unmodifiable(sum), avg = List.unmodifiable(avg); + + OrmAggregateSpec copyWith({ + bool? countAll, + List? count, + List? min, + List? max, + List? sum, + List? avg, + }) { + return OrmAggregateSpec( + countAll: countAll ?? this.countAll, + count: count ?? this.count, + min: min ?? this.min, + max: max ?? this.max, + sum: sum ?? this.sum, + avg: avg ?? this.avg, + ); + } + + bool get isEmpty => + !countAll && + count.isEmpty && + min.isEmpty && + max.isEmpty && + sum.isEmpty && + avg.isEmpty; +} + +@immutable +final class OrmAggregateBuilder { + final OrmAggregateSpec _spec; + + OrmAggregateBuilder._(this._spec); + + OrmAggregateBuilder() : _spec = OrmAggregateSpec(); + + OrmAggregateBuilder countAll() => + OrmAggregateBuilder._(_spec.copyWith(countAll: true)); + + OrmAggregateBuilder count(String field) => OrmAggregateBuilder._( + _spec.copyWith(count: _appendUnique(_spec.count, field)), + ); + + OrmAggregateBuilder min(String field) => OrmAggregateBuilder._( + _spec.copyWith(min: _appendUnique(_spec.min, field)), + ); + + OrmAggregateBuilder max(String field) => OrmAggregateBuilder._( + _spec.copyWith(max: _appendUnique(_spec.max, field)), + ); + + OrmAggregateBuilder sum(String field) => OrmAggregateBuilder._( + _spec.copyWith(sum: _appendUnique(_spec.sum, field)), + ); + + OrmAggregateBuilder avg(String field) => OrmAggregateBuilder._( + _spec.copyWith(avg: _appendUnique(_spec.avg, field)), + ); + + OrmAggregateBuilder merge(OrmAggregateSpec spec) => OrmAggregateBuilder._( + _spec.copyWith( + countAll: _spec.countAll || spec.countAll, + count: _appendUniqueMany(_spec.count, spec.count), + min: _appendUniqueMany(_spec.min, spec.min), + max: _appendUniqueMany(_spec.max, spec.max), + sum: _appendUniqueMany(_spec.sum, spec.sum), + avg: _appendUniqueMany(_spec.avg, spec.avg), + ), + ); + + OrmAggregateSpec toSpec() => _spec; +} + +List _appendUnique(List current, String field) { + if (current.contains(field)) { + return current; + } + return List.unmodifiable([...current, field]); +} + +List _appendUniqueMany(List current, List next) { + if (next.isEmpty) { + return current; + } + final merged = [...current]; + for (final field in next) { + if (!merged.contains(field)) { + merged.add(field); + } + } + return List.unmodifiable(merged); } @immutable @@ -3119,26 +3212,16 @@ final class ModelQuery { return (await _prepareRead()).explain(); } - Future aggregate({ - bool countAll = false, - List count = const [], - List min = const [], - List max = const [], - List sum = const [], - List avg = const [], - }) => aggregateWith( - OrmAggregateSpec( - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ), - ); + Future aggregate( + OrmAggregateBuilder Function(OrmAggregateBuilder aggregate) build, + ) { + _assertReadExecutionSupported('aggregate'); + return aggregateWith(build(OrmAggregateBuilder()).toSpec()); + } Future aggregateWith(OrmAggregateSpec aggregate) { _assertAggregateQueryState(); + _delegate._assertAggregateSpecRequested(aggregate, terminal: 'aggregate'); return _delegate ._prepareAggregateQuery(spec: _state, aggregate: aggregate) .then((prepared) => prepared.execute()); @@ -3346,26 +3429,13 @@ final class ModelGroupedQuery { ); } - Future> aggregate({ - bool countAll = false, - List count = const [], - List min = const [], - List max = const [], - List sum = const [], - List avg = const [], - }) => aggregateWith( - OrmAggregateSpec( - countAll: countAll, - count: count, - min: min, - max: max, - sum: sum, - avg: avg, - ), - ); + Future> aggregate( + OrmAggregateBuilder Function(OrmAggregateBuilder aggregate) build, + ) => aggregateWith(build(OrmAggregateBuilder()).toSpec()); Future> aggregateWith(OrmAggregateSpec aggregate) { _assertExecutionSupported('aggregate'); + _delegate._assertAggregateSpecRequested(aggregate, terminal: 'aggregate'); return _prepareGrouped( groupBy: _groupBy.copyWith( countAll: aggregate.countAll, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 097e7a96..7d2465ba 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -1055,6 +1055,24 @@ final class TypedClientWriter { buffer.writeln(' this.avg = const <${model.distinctClassName}>[],'); buffer.writeln(' });'); buffer.writeln(); + buffer.writeln(' ${model.aggregateSpecClassName} copyWith({'); + buffer.writeln(' bool? countAll,'); + buffer.writeln(' List<${model.distinctClassName}>? count,'); + buffer.writeln(' List<${model.distinctClassName}>? min,'); + buffer.writeln(' List<${model.distinctClassName}>? max,'); + buffer.writeln(' List<${model.distinctClassName}>? sum,'); + buffer.writeln(' List<${model.distinctClassName}>? avg,'); + buffer.writeln(' }) {'); + buffer.writeln(' return ${model.aggregateSpecClassName}('); + buffer.writeln(' countAll: countAll ?? this.countAll,'); + buffer.writeln(' count: count ?? this.count,'); + buffer.writeln(' min: min ?? this.min,'); + buffer.writeln(' max: max ?? this.max,'); + buffer.writeln(' sum: sum ?? this.sum,'); + buffer.writeln(' avg: avg ?? this.avg,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' OrmAggregateSpec toRuntimeSpec() {'); buffer.writeln(' return OrmAggregateSpec('); buffer.writeln(' countAll: countAll,'); @@ -1078,6 +1096,77 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); + final aggregateBuilderClassName = '${model.classBaseName}AggregateBuilder'; + buffer.writeln('class $aggregateBuilderClassName {'); + buffer.writeln(' final ${model.aggregateSpecClassName} _spec;'); + buffer.writeln(); + buffer.writeln(' const $aggregateBuilderClassName._(this._spec);'); + buffer.writeln(); + buffer.writeln( + ' const $aggregateBuilderClassName() : _spec = const ${model.aggregateSpecClassName}();', + ); + buffer.writeln(); + buffer.writeln( + ' $aggregateBuilderClassName countAll() => $aggregateBuilderClassName._(_spec.copyWith(countAll: true));', + ); + buffer.writeln(); + for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { + buffer.writeln( + ' $aggregateBuilderClassName $bucket(${model.distinctClassName} field) =>', + ); + buffer.writeln(' $aggregateBuilderClassName._('); + buffer.writeln(' _spec.copyWith('); + buffer.writeln(' $bucket: _appendUnique(_spec.$bucket, field),'); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(); + } + buffer.writeln( + ' $aggregateBuilderClassName merge(${model.aggregateSpecClassName} spec) =>', + ); + buffer.writeln(' $aggregateBuilderClassName._('); + buffer.writeln(' _spec.copyWith('); + buffer.writeln(' countAll: _spec.countAll || spec.countAll,'); + for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { + buffer.writeln( + ' $bucket: _appendUniqueMany(_spec.$bucket, spec.$bucket),', + ); + } + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(); + buffer.writeln(' ${model.aggregateSpecClassName} toSpec() => _spec;'); + buffer.writeln(); + buffer.writeln( + ' List<${model.distinctClassName}> _appendUnique(List<${model.distinctClassName}> current, ${model.distinctClassName} field) {', + ); + buffer.writeln(' if (current.contains(field)) {'); + buffer.writeln(' return current;'); + buffer.writeln(' }'); + buffer.writeln( + ' return <${model.distinctClassName}>[...current, field];', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' List<${model.distinctClassName}> _appendUniqueMany(List<${model.distinctClassName}> current, List<${model.distinctClassName}> next) {', + ); + buffer.writeln(' if (next.isEmpty) {'); + buffer.writeln(' return current;'); + buffer.writeln(' }'); + buffer.writeln( + ' final merged = <${model.distinctClassName}>[...current];', + ); + buffer.writeln(' for (final field in next) {'); + buffer.writeln(' if (!merged.contains(field)) {'); + buffer.writeln(' merged.add(field);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(' return merged;'); + buffer.writeln(' }'); + buffer.writeln('}'); + buffer.writeln(); + buffer.writeln('class ${model.groupBySpecClassName} {'); buffer.writeln(' final List<${model.distinctClassName}> by;'); buffer.writeln(' final ${model.groupByHavingClassName} having;'); @@ -2085,35 +2174,16 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + final aggregateBuilderClassName = '${model.classBaseName}AggregateBuilder'; buffer.writeln(' Future<${model.aggregateResultClassName}> aggregate({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); - buffer.writeln(' bool countAll = false,'); - buffer.writeln( - ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', - ); buffer.writeln( - ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', + ' required $aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build,', ); buffer.writeln(' }) {'); - buffer.writeln(' return query(where: where).aggregate('); - buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' );'); + buffer.writeln(' return query(where: where).aggregate(build);'); buffer.writeln(' }'); buffer.writeln(); @@ -2417,6 +2487,7 @@ final class TypedClientWriter { required _ResolvedModel model, }) { final runtimeName = _escapeString(model.model.runtimeName); + final aggregateBuilderClassName = '${model.classBaseName}AggregateBuilder'; final relationFields = model.model.fields .where((field) => field.isRelation) .toList(growable: false); @@ -2943,35 +3014,13 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future<${model.aggregateResultClassName}> aggregate({'); - buffer.writeln(' bool countAll = false,'); buffer.writeln( - ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', + ' Future<${model.aggregateResultClassName}> aggregate($aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build) {', ); + buffer.writeln(" _assertReadExecutionSupported('aggregate');"); buffer.writeln( - ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', + ' return aggregateWith(build($aggregateBuilderClassName()).toSpec());', ); - buffer.writeln(' }) {'); - buffer.writeln(" _assertReadExecutionSupported('aggregate');"); - buffer.writeln(' return aggregateWith('); - buffer.writeln(' ${model.aggregateSpecClassName}('); - buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' ),'); - buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -3172,6 +3221,7 @@ final class TypedClientWriter { required _ResolvedModel model, }) { final runtimeName = _escapeString(model.model.runtimeName); + final aggregateBuilderClassName = '${model.classBaseName}AggregateBuilder'; buffer.writeln('class ${model.groupedQueryClassName} {'); buffer.writeln(' final ${model.delegateClassName} _delegate;'); buffer.writeln(' final ${model.whereInputClassName} _where;'); @@ -3257,35 +3307,11 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' Future> aggregate({', + ' Future> aggregate($aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build) {', ); - buffer.writeln(' bool countAll = false,'); buffer.writeln( - ' List<${model.distinctClassName}> count = const <${model.distinctClassName}>[],', + ' return aggregateWith(build($aggregateBuilderClassName()).toSpec());', ); - buffer.writeln( - ' List<${model.distinctClassName}> min = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> max = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> sum = const <${model.distinctClassName}>[],', - ); - buffer.writeln( - ' List<${model.distinctClassName}> avg = const <${model.distinctClassName}>[],', - ); - buffer.writeln(' }) {'); - buffer.writeln(' return aggregateWith('); - buffer.writeln(' ${model.aggregateSpecClassName}('); - buffer.writeln(' countAll: countAll,'); - buffer.writeln(' count: count,'); - buffer.writeln(' min: min,'); - buffer.writeln(' max: max,'); - buffer.writeln(' sum: sum,'); - buffer.writeln(' avg: avg,'); - buffer.writeln(' ),'); - buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index a842cf91..ad1dc74f 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -1123,6 +1123,21 @@ final class OrmRuntimeCore implements RuntimeCore { }, ); } + if (!aggregate.countAll && + aggregate.count.isEmpty && + aggregate.min.isEmpty && + aggregate.max.isEmpty && + aggregate.sum.isEmpty && + aggregate.avg.isEmpty) { + throw runtimeError( + 'PLAN.AGGREGATE_FIELDS_EMPTY', + 'aggregate requires at least one aggregation selector.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + }, + ); + } if (plan.include.isNotEmpty) { throw runtimeError( 'PLAN.READ_INCLUDE_UNSUPPORTED', @@ -1158,6 +1173,21 @@ final class OrmRuntimeCore implements RuntimeCore { }, ); } + if (!aggregate.countAll && + aggregate.count.isEmpty && + aggregate.min.isEmpty && + aggregate.max.isEmpty && + aggregate.sum.isEmpty && + aggregate.avg.isEmpty) { + throw runtimeError( + 'PLAN.AGGREGATE_FIELDS_EMPTY', + 'grouped aggregate requires at least one aggregation selector.', + details: { + 'model': model.name, + 'shape': plan.shape.name, + }, + ); + } if (plan.include.isNotEmpty) { throw runtimeError( 'PLAN.READ_INCLUDE_UNSUPPORTED', diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 409f5e03..4953e92c 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -276,6 +276,56 @@ void main() { }, ); + test( + 'runtime rejects empty aggregate and grouped aggregate plans', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + await expectLater( + () => client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + shape: OrmReadShape.aggregate, + aggregate: OrmReadAggregatePlan(), + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.AGGREGATE_FIELDS_EMPTY', + ), + ), + ); + + await expectLater( + () => client.execute( + OrmPlan.read( + contractHash: contract.hash, + model: 'User', + resultMode: OrmReadResultMode.all, + shape: OrmReadShape.groupedAggregate, + aggregate: OrmReadAggregatePlan(), + groupBy: OrmReadGroupByPlan(by: const ['email']), + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.AGGREGATE_FIELDS_EMPTY', + ), + ), + ); + } finally { + await client.disconnect(); + } + }, + ); + test('explain requires an active runtime connection', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 34665ccc..11c023bb 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -561,12 +561,13 @@ void main() { await users.create(data: {'id': 3, 'email': 'b@x.com'}); final aggregate = await users.aggregate( - countAll: true, - count: const ['email'], - min: const ['id'], - max: const ['id'], - sum: const ['id'], - avg: const ['id'], + build: (aggregate) => aggregate + .countAll() + .count('email') + .min('id') + .max('id') + .sum('id') + .avg('id'), ); expect(aggregate['count'], {'all': 3, 'email': 2}); @@ -589,11 +590,7 @@ void main() { final grouped = await users .query() .groupedBy(const ['email']) - .aggregate( - countAll: true, - sum: const ['id'], - avg: const ['id'], - ); + .aggregate((aggregate) => aggregate.countAll().sum('id').avg('id')); expect(grouped, hasLength(2)); final groupedByEmail = { @@ -623,7 +620,7 @@ void main() { .query() .groupedBy(const ['email']) .havingExpr((having) => having.countAll().gte(2), merge: false) - .aggregate(countAll: true, sum: const ['id']); + .aggregate((aggregate) => aggregate.countAll().sum('id')); expect(grouped, hasLength(2)); final groupedByEmail = { @@ -655,7 +652,7 @@ void main() { ]), merge: false, ) - .aggregate(countAll: true, sum: const ['id']); + .aggregate((aggregate) => aggregate.countAll().sum('id')); expect(grouped, hasLength(2)); await client.disconnect(); @@ -723,7 +720,7 @@ void main() { () => users .query() .select(const ['id']) - .aggregate(countAll: true), + .aggregate((aggregate) => aggregate.countAll()), throwsA( isA().having( (error) => error.code, @@ -749,7 +746,7 @@ void main() { 'email': {'gte': 1}, }, }, merge: false) - .aggregate(sum: const ['id']), + .aggregate((aggregate) => aggregate.sum('id')), throwsA( isA().having( (error) => error.code, @@ -761,6 +758,22 @@ void main() { await client.disconnect(); }); + test('aggregate rejects empty aggregate builder', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users.query().aggregate((aggregate) => aggregate), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.AGGREGATE_FIELDS_EMPTY', + ), + ), + ); + }); + test('supports where operators gt/in/notIn in memory engine', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 8de3e9be..80eb7758 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -71,11 +71,11 @@ void main() { () { expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregate\(\{[\s\S]*?\)\s*=>\s*aggregateWith\(\s*OrmAggregateSpec\(', + r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregate\(\s*OrmAggregateBuilder\s+Function\(OrmAggregateBuilder\s+aggregate\)\s+build,\s*\)\s*\{[\s\S]*?return\s+aggregateWith\(build\(OrmAggregateBuilder\(\)\)\.toSpec\(\)\);', ).hasMatch(source), isTrue, reason: - 'Expected ModelQuery.aggregate(...) to compile convenience arguments into OrmAggregateSpec.', + 'Expected ModelQuery.aggregate(...) to route through the aggregate builder callback.', ); expect( RegExp( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index efd4142c..e56184ed 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1277,7 +1277,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+aggregate\(\{[\s\S]*?return\s+query\(where:\s*where\)\.aggregate\([\s\S]*?count:\s*count,[\s\S]*?avg:\s*avg,[\s\S]*?\);', + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+aggregate\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),[\s\S]*?required\s+UserAggregateBuilder\s+Function\(UserAggregateBuilder\s+aggregate\)\s+build,[\s\S]*?return\s+query\(where:\s*where\)\.aggregate\(build\);', ).hasMatch(generatedSource), isTrue, reason: @@ -1554,11 +1554,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregate\(\{[\s\S]*?return\s+aggregateWith\(\s*UserAggregateSpec\(', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregate\(UserAggregateBuilder\s+Function\(UserAggregateBuilder\s+aggregate\)\s+build\)\s*\{[\s\S]*?return\s+aggregateWith\(build\(UserAggregateBuilder\(\)\)\.toSpec\(\)\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.aggregate(...) to compile convenience arguments into a structured typed aggregate spec.', + 'Expected UserQuery.aggregate(...) to route through the typed aggregate builder callback.', ); expect( RegExp( @@ -1652,6 +1652,12 @@ typedef Post = ({ reason: 'Expected generated source to include typed groupBy having helper.', ); + expect( + RegExp(r'\bclass UserAggregateBuilder\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include typed aggregate builder helper.', + ); expect( RegExp( r'\bclass UserGroupByHavingBuilder\b', From b88a9d81ea73a417c3afff764e942f1a724abf43 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:48:59 +0800 Subject: [PATCH 142/154] feat(orm)!: remove raw grouped having maps --- docs/orm-v6-api-surface.md | 5 +++-- pub/orm/lib/src/client/client.dart | 10 ++++------ pub/orm/test/client/client_test.dart | 6 +----- pub/orm/test/client/source_surface_test.dart | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 16b26392..fc749b07 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -111,8 +111,9 @@ Rules: `terminalExecution.stream`. 8. Grouped aggregation is a dedicated surface: - `groupedBy(...)` only accepts a where-only base query. - - `having(...)`, `havingWith(...)`, and `havingExpr(...)` refine the grouped - builder before `aggregate(...)`. + - `having(...)` and `havingWith(...)` accept structured grouped predicates. + - `havingExpr(...)` is the primary builder-style entrypoint before + `aggregate(...)`. 9. `include(...)` is unsupported on `aggregate(...)` and `groupedBy(...).aggregate(...)`, including direct plan execution. diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 6b607980..a58aed46 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3404,18 +3404,16 @@ final class ModelGroupedQuery { return _next(groupBy); } - ModelGroupedQuery having(JsonMap having, {bool merge = true}) { - final parsed = OrmGroupByHaving.parse({...having}); - final nextHaving = merge ? _groupBy.having.merge(parsed) : parsed; + ModelGroupedQuery having(OrmGroupByHaving having, {bool merge = true}) { + final nextHaving = merge ? _groupBy.having.merge(having) : having; return _next(_groupBy.copyWith(having: nextHaving)); } ModelGroupedQuery havingWith( - JsonMap Function(JsonMap having) build, { + OrmGroupByHaving Function(OrmGroupByHaving having) build, { bool merge = true, }) { - final current = Map.from(_groupBy.having.toJson()); - final next = build(Map.unmodifiable(current)); + final next = build(_groupBy.having); return having(next, merge: merge); } diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 11c023bb..b0f43a17 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -741,11 +741,7 @@ void main() { await expectLater( users .groupedBy(const ['email']) - .having({ - '_sum': { - 'email': {'gte': 1}, - }, - }, merge: false) + .havingExpr((having) => having.sum('email').gte(1), merge: false) .aggregate((aggregate) => aggregate.sum('id')), throwsA( isA().having( diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 80eb7758..1a36687a 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -133,6 +133,22 @@ void main() { reason: 'Expected ModelGroupedQuery to expose a builder-style havingExpr(...) surface.', ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+having\(OrmGroupByHaving\s+having,\s*\{\s*bool\s+merge\s*=\s*true\s*\}\)', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelGroupedQuery.having(...) to accept structured grouped having clauses instead of raw maps.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+having\(JsonMap\s+having,', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to keep raw JsonMap having out of the public grouped surface.', + ); expect( RegExp(r'\bclass\s+OrmGroupByHavingBuilder\b').hasMatch(source), isTrue, From 04808640c75b102750a494afd125fdf8a2b2cac0 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:09:27 +0800 Subject: [PATCH 143/154] feat(orm)!: implement count-based updateMany orchestration --- docs/orm-v6-api-surface.md | 6 +- pub/orm/lib/src/client/client.dart | 41 ++++------ .../lib/src/client/mutation_repository.dart | 66 ++++++++++++++++ pub/orm/lib/src/generator/writer.dart | 22 +++--- pub/orm/test/client/api_surface_test.dart | 53 +++++++------ pub/orm/test/client/client_test.dart | 79 ++++++++++++++++++- pub/orm/test/client/source_surface_test.dart | 16 ++++ pub/orm/test/generator/generate_test.dart | 6 +- .../operation_telemetry_aggregation_test.dart | 41 ++++++++++ 9 files changed, 266 insertions(+), 64 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index fc749b07..274e88c5 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -131,7 +131,7 @@ Direct mutations: | `delete(...)` | implemented | | `deleteMany(...)` | implemented | | `upsert(...)` | implemented | -| `updateMany(...)` | placeholder | +| `updateMany(...)` | implemented | Chained mutations: @@ -142,6 +142,10 @@ users.where({...}).upsert(create: {...}, update: {...}); users.where({...}).updateMany(data: {...}); ``` +Rules: +1. `updateMany(...)` and `deleteMany(...)` are count terminals. +2. They do not accept row-shaping state such as `select(...)` or `include(...)`. + ## SQL Surface Read: diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index a58aed46..18dbdae9 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -271,13 +271,6 @@ JsonMap _mergePlanAnnotations(JsonMap current, JsonMap next) { int _repositoryOperationSeed = 0; -Never _throwApiNotImplemented( - String surface, { - Map details = const {}, -}) { - throw ApiNotImplementedException(surface: surface, details: details); -} - final class _RepositoryOperation { final String id; final String kind; @@ -1040,6 +1033,7 @@ class ModelDelegate { OrmCollectionContext get client => _client; _OrmDelegateRuntime get _runtime => _client as _OrmDelegateRuntime; + ModelContract get _modelContract => _client.contract.models[modelName]!; late final _RepositoryRelationWhereRewriter _relationWhereRewriter = _RepositoryRelationWhereRewriter(this); late final _OrmReadPlanCompiler _readPlanCompiler = _OrmReadPlanCompiler( @@ -1531,10 +1525,8 @@ class ModelDelegate { Future updateMany({ JsonMap where = const {}, required JsonMap data, - List select = const [], - Map include = const {}, }) => _queryFromSpec( - OrmReadQuerySpec(where: where, select: select, include: include), + OrmReadQuerySpec(where: where), ).updateMany(data: data); Future deleteMany({JsonMap where = const {}}) => @@ -1612,18 +1604,7 @@ class ModelDelegate { Future _updateMany({ required JsonMap data, required OrmReadQuerySpec spec, - }) async { - _throwApiNotImplemented( - 'orm.updateMany', - details: { - 'model': modelName, - 'where': spec.where, - 'data': data, - 'select': spec.select, - 'include': spec.include.keys.toList(growable: false), - }, - ); - } + }) => _RepositoryMutationExecutor(this).updateMany(where: spec.where, data: data); Future _deleteMany({required OrmReadQuerySpec spec}) => _RepositoryMutationExecutor(this).deleteMany(where: spec.where); @@ -3301,6 +3282,8 @@ final class ModelQuery { void _assertMutationQueryState({ required String action, bool allowWhere = true, + bool allowSelect = true, + bool allowInclude = true, }) { final invalidKeys = [ if (!allowWhere && _state.where.isNotEmpty) 'where', @@ -3308,6 +3291,8 @@ final class ModelQuery { if (_state.take != null) 'take', if (_state.orderBy.isNotEmpty) 'orderBy', if (_state.distinct.isNotEmpty) 'distinct', + if (!allowSelect && _state.select.isNotEmpty) 'select', + if (!allowInclude && _state.include.isNotEmpty) 'include', if (_state.cursor != null) 'cursor', if (_state.page != null) 'page', ]; @@ -3341,12 +3326,20 @@ final class ModelQuery { } Future updateMany({required JsonMap data}) { - _assertMutationQueryState(action: 'updateMany'); + _assertMutationQueryState( + action: 'updateMany', + allowSelect: false, + allowInclude: false, + ); return _delegate._updateMany(data: data, spec: _state); } Future deleteMany() { - _assertMutationQueryState(action: 'deleteMany'); + _assertMutationQueryState( + action: 'deleteMany', + allowSelect: false, + allowInclude: false, + ); return _delegate._deleteMany(spec: _state); } diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index 9d1d15b2..b465b47f 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -231,6 +231,53 @@ final class _RepositoryMutationExecutor { }); } + Future updateMany({ + required JsonMap where, + required JsonMap data, + }) { + final trace = _startOperation('updateMany'); + return _delegate._client.transaction((txDb) async { + final scoped = txDb.orm.model(_delegate.modelName); + final executor = _RepositoryMutationExecutor(scoped); + final normalizedWhere = (await scoped._normalizeWhereForExecution( + model: scoped.modelName, + where: where, + operation: trace, + )).where; + final identityRows = await scoped._readAllInternal( + action: OrmAction.read, + where: normalizedWhere, + select: scoped._modelContract.idFields, + repositoryTrace: trace.nextTrace( + phase: 'batch.lookup', + strategy: 'transaction', + ), + include: const {}, + includeDepth: 0, + ); + + var updated = 0; + for (var index = 0; index < identityRows.length; index++) { + final itemWhere = _identityWhereFromRow(identityRows[index]); + final row = await executor.update( + where: itemWhere, + data: data, + select: const [], + include: const {}, + operation: trace, + phase: 'item.update', + strategy: 'transaction', + itemIndex: index, + ); + if (row != null) { + updated += 1; + } + } + + return updated; + }); + } + Future upsert({ required JsonMap where, required JsonMap create, @@ -469,6 +516,25 @@ final class _RepositoryMutationExecutor { return row; } + JsonMap _identityWhereFromRow(JsonMap row) { + final where = {}; + for (final field in _delegate._modelContract.idFields) { + if (!row.containsKey(field) || row[field] == null) { + throw runtimeError( + 'RUNTIME.UPDATE_MANY_IDENTITY_MISSING', + 'updateMany() identity lookup did not return all required id fields.', + details: { + 'model': _delegate.modelName, + 'idField': field, + 'idFields': _delegate._modelContract.idFields, + }, + ); + } + where[field] = row[field]; + } + return where; + } + Future _createNestedInScope({ required JsonMap data, required Map> nestedCreate, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 7d2465ba..0f726df7 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2136,14 +2136,8 @@ final class TypedClientWriter { ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', ); buffer.writeln(' required ${model.updateInputClassName} data,'); - buffer.writeln(' ${model.selectClassName}? select,'); - buffer.writeln(' ${model.includeClassName}? include,'); buffer.writeln(' }) {'); - buffer.writeln(' return query('); - buffer.writeln(' where: where,'); - buffer.writeln(' select: select,'); - buffer.writeln(' include: include,'); - buffer.writeln(' ).updateMany(data: data);'); + buffer.writeln(' return query(where: where).updateMany(data: data);'); buffer.writeln(' }'); buffer.writeln(); @@ -2918,6 +2912,8 @@ final class TypedClientWriter { buffer.writeln(' void _assertMutationQueryState({'); buffer.writeln(' required String action,'); buffer.writeln(' bool allowWhere = true,'); + buffer.writeln(' bool allowSelect = true,'); + buffer.writeln(' bool allowInclude = true,'); buffer.writeln(' }) {'); buffer.writeln(' final invalidKeys = ['); buffer.writeln(" if (!allowWhere && !_where.isEmpty) 'where',"); @@ -2925,6 +2921,8 @@ final class TypedClientWriter { buffer.writeln(" if (_take != null) 'take',"); buffer.writeln(" if (_orderBy.isNotEmpty) 'orderBy',"); buffer.writeln(" if (_distinct.isNotEmpty) 'distinct',"); + buffer.writeln(" if (!allowSelect && _select != null) 'select',"); + buffer.writeln(" if (!allowInclude && _include != null) 'include',"); buffer.writeln(" if (_cursor != null) 'cursor',"); buffer.writeln(" if (_pageSize != null) 'page',"); buffer.writeln(' ];'); @@ -3144,18 +3142,20 @@ final class TypedClientWriter { buffer.writeln( ' Future updateMany({required ${model.updateInputClassName} data}) {', ); - buffer.writeln(" _assertMutationQueryState(action: 'updateMany');"); + buffer.writeln( + " _assertMutationQueryState(action: 'updateMany', allowSelect: false, allowInclude: false);", + ); buffer.writeln(' return _delegate._delegate.updateMany('); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' data: data.toJson(),'); - buffer.writeln(' select: _runtimeSelect,'); - buffer.writeln(' include: _runtimeInclude,'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Future deleteMany() {'); - buffer.writeln(" _assertMutationQueryState(action: 'deleteMany');"); + buffer.writeln( + " _assertMutationQueryState(action: 'deleteMany', allowSelect: false, allowInclude: false);", + ); buffer.writeln(' return _delegate._delegate.deleteMany('); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' );'); diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 4953e92c..304f94c0 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -875,30 +875,35 @@ void main() { ); }); - test( - 'updateMany placeholder throws stable not implemented error', - () async { - final client = OrmClient(contract: contract, engine: MemoryEngine()); - await client.connect(); - try { - final users = client.db.orm.model('User'); - await expectLater( - users - .where({'id': 'u1'}) - .updateMany(data: {'email': 'b@x.com'}), - throwsA( - isA().having( - (error) => error.details['surface'], - 'surface', - 'orm.updateMany', - ), - ), - ); - } finally { - await client.disconnect(); - } - }, - ); + test('updateMany updates matching rows and returns affected count', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'a@x.com'}, + {'id': 'u3', 'email': 'b@x.com'}, + ], + ); + + final affected = await users + .where({'email': 'a@x.com'}) + .updateMany(data: {'email': 'updated@x.com'}); + + expect(affected, 2); + final rows = await users + .orderBy(const [OrmOrderBy('id')]) + .all(); + expect( + rows.map((row) => row['email']).toList(growable: false), + ['updated@x.com', 'updated@x.com', 'b@x.com'], + ); + } finally { + await client.disconnect(); + } + }); }); } diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index b0f43a17..bf1d815b 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -1262,6 +1262,44 @@ void main() { ), ); + expect( + () => users + .select(const ['id']) + .updateMany(data: {'email': 'b@x.com'}), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID', + ) + .having( + (error) => error.details['invalidKeys'], + 'invalidKeys', + ['select'], + ), + ), + ); + + expect( + () => users + .include({'posts': const IncludeSpec()}) + .deleteMany(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID', + ) + .having( + (error) => error.details['invalidKeys'], + 'invalidKeys', + ['include'], + ), + ), + ); + await client.disconnect(); }); @@ -1334,8 +1372,47 @@ void main() { ); engine.reset(); - final deleted = await users.deleteMany( + final updated = await users.updateMany( where: {'email': 'a@x.com'}, + data: {'email': 'updated@x.com'}, + ); + expect(updated, 2); + expect( + engine.executedPlans.map((plan) => plan.action).toList(growable: false), + [OrmAction.read, OrmAction.update, OrmAction.update], + ); + final updateTraces = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final updateOperationId = updateTraces.first.operationId; + expect(updateOperationId, isNotNull); + expect(updateTraces.map((trace) => trace.operationId).toSet(), { + updateOperationId, + }); + expect( + updateTraces.map((trace) => trace.kind).toList(growable: false), + ['User.updateMany', 'User.updateMany', 'User.updateMany'], + ); + expect( + updateTraces.map((trace) => trace.phase).toList(growable: false), + ['batch.lookup', 'item.update', 'item.update'], + ); + expect( + updateTraces.map((trace) => trace.strategy).toList(growable: false), + ['transaction', 'transaction', 'transaction'], + ); + expect( + updateTraces.map((trace) => trace.step).toList(growable: false), + [1, 2, 3], + ); + expect( + updateTraces.map((trace) => trace.itemIndex).toList(growable: false), + [null, 0, 1], + ); + + engine.reset(); + final deleted = await users.deleteMany( + where: {'email': 'updated@x.com'}, ); expect(deleted, 2); expect( diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 1a36687a..b2caa967 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -195,6 +195,14 @@ void main() { reason: 'Expected ModelQuery.createMany(...) to terminate through the private mutation helper.', ); + expect( + RegExp( + r"class\s+ModelQuery\s*\{[\s\S]*?Future\s+updateMany\(\{required\s+JsonMap\s+data\}\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'updateMany',\s*allowSelect:\s*false,\s*allowInclude:\s*false,\s*\);[\s\S]*?return\s+_delegate\._updateMany\(data:\s*data,\s*spec:\s*_state\);", + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.updateMany(...) to reject row-shaping state and terminate through the private mutation helper.', + ); expect( RegExp( r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+deleteMany\(\)\s*\{[\s\S]*?return\s+_delegate\._deleteMany\(spec:\s*_state\);', @@ -203,6 +211,14 @@ void main() { reason: 'Expected ModelQuery.deleteMany(...) to terminate through the private mutation helper.', ); + expect( + RegExp( + r"class\s+ModelQuery\s*\{[\s\S]*?Future\s+deleteMany\(\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'deleteMany',\s*allowSelect:\s*false,\s*allowInclude:\s*false,\s*\);", + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.deleteMany(...) to reject row-shaping state.', + ); }, ); }); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index e56184ed..af353011 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1033,7 +1033,7 @@ typedef Post = ({ ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.updateMany(...) placeholder to expose typed where and data input.', + 'Expected UserDelegate.updateMany(...) to expose typed where and data input.', ); expect( RegExp( @@ -1041,7 +1041,7 @@ typedef Post = ({ ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.updateMany(...) placeholder to expose typed update data input.', + 'Expected UserQuery.updateMany(...) to expose typed update data input.', ); expect( RegExp(r'\bclass UserNestedCreateInput\b').hasMatch(generatedSource), @@ -1221,7 +1221,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateMany\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.updateMany\(data:\s*data\);', + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateMany\(\{[\s\S]*?return\s+query\(where:\s*where\)\.updateMany\(data:\s*data\);', ).hasMatch(generatedSource), isTrue, reason: diff --git a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart index 98324203..26e9cbec 100644 --- a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart +++ b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart @@ -182,6 +182,47 @@ void main() { await client.disconnect(); }); + test('aggregates updateMany into one operation record', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'a@x.com'}, + {'id': 'u3', 'email': 'b@x.com'}, + ], + ); + + final updated = await users.updateMany( + where: {'email': 'a@x.com'}, + data: {'email': 'updated@x.com'}, + ); + + expect(updated, 2); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.updateMany'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isTrue); + expect(telemetry?.statementCount, 3); + expect(telemetry?.affectedRows, 2); + expect( + telemetry?.steps.map((step) => step.trace.phase).toList(), + ['batch.lookup', 'item.update', 'item.update'], + ); + expect( + telemetry?.steps.map((step) => step.trace.step).toList(), + [1, 2, 3], + ); + expect( + telemetry?.steps.map((step) => step.trace.itemIndex).toList(), + [null, 0, 1], + ); + await client.disconnect(); + }); + test('aggregates pageResult probes into one operation record', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); From b19da124232689a2cca9c0dc691b461c72d0fb36 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:43:30 +0800 Subject: [PATCH 144/154] feat(orm)!: align batch count terminals and distinct windows --- docs/orm-v6-api-surface.md | 11 +- pub/orm/lib/src/client/client.dart | 164 +++++++++++++----- .../lib/src/client/mutation_repository.dart | 10 +- .../lib/src/client/read_plan_compiler.dart | 21 +-- pub/orm/lib/src/client/read_repository.dart | 28 ++- pub/orm/lib/src/generator/writer.dart | 66 ++++--- pub/orm/test/client/api_surface_test.dart | 101 ++++++++++- pub/orm/test/client/client_test.dart | 42 +++-- pub/orm/test/client/source_surface_test.dart | 12 +- pub/orm/test/generator/generate_test.dart | 40 +++-- .../operation_telemetry_aggregation_test.dart | 6 +- 11 files changed, 364 insertions(+), 137 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 274e88c5..289d3a7e 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -129,9 +129,9 @@ Direct mutations: | `update(...)` | implemented | | `updateNested(...)` | implemented | | `delete(...)` | implemented | -| `deleteMany(...)` | implemented | +| `deleteCount(...)` | implemented | | `upsert(...)` | implemented | -| `updateMany(...)` | implemented | +| `updateCount(...)` | implemented | Chained mutations: @@ -139,12 +139,13 @@ Chained mutations: users.where({...}).update(data: {...}); users.where({...}).delete(); users.where({...}).upsert(create: {...}, update: {...}); -users.where({...}).updateMany(data: {...}); +users.where({...}).updateCount(data: {...}); ``` Rules: -1. `updateMany(...)` and `deleteMany(...)` are count terminals. -2. They do not accept row-shaping state such as `select(...)` or `include(...)`. +1. `updateCount(...)` and `deleteCount(...)` are count terminals. +2. They require `where(...)` first. +3. They do not accept row-shaping state such as `select(...)` or `include(...)`. ## SQL Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 18dbdae9..a7bc8b06 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -177,6 +177,7 @@ JsonMap _terminalExecutionSummary({ required Map include, JsonMap? cursor, OrmReadPagePlan? page, + bool applyWindowAtClient = false, }) { final hasWindow = cursor != null || page != null; final includeStrategy = include.isEmpty @@ -204,7 +205,9 @@ JsonMap _terminalExecutionSummary({ 'delivery': delivery, 'degraded': degraded, 'reasons': List.unmodifiable(reasons), - 'windowAppliedAt': hasWindow ? 'engine' : 'none', + 'windowAppliedAt': hasWindow + ? (applyWindowAtClient ? 'client' : 'engine') + : 'none', 'distinctAppliedAt': distinct.isEmpty ? 'none' : 'client', 'includeAppliedAt': include.isEmpty ? 'none' : 'repository', if (includeStrategy != null) 'includeStrategy': includeStrategy, @@ -220,7 +223,8 @@ JsonMap _terminalExecutionSummary({ ), 'pageResult': terminal( delivery: page == null ? 'unavailable' : 'pageEnvelope', - degraded: false, + degraded: applyWindowAtClient, + reasons: applyWindowAtClient ? const ['distinct'] : const [], available: page != null, ), }); @@ -1522,15 +1526,15 @@ class ModelDelegate { OrmReadQuerySpec(select: select, include: include), ).createMany(data: data); - Future updateMany({ - JsonMap where = const {}, + Future updateCount({ + required JsonMap where, required JsonMap data, }) => _queryFromSpec( OrmReadQuerySpec(where: where), - ).updateMany(data: data); + ).updateCount(data: data); - Future deleteMany({JsonMap where = const {}}) => - _queryFromSpec(OrmReadQuerySpec(where: where)).deleteMany(); + Future deleteCount({required JsonMap where}) => + _queryFromSpec(OrmReadQuerySpec(where: where)).deleteCount(); Future upsert({ required JsonMap where, @@ -1601,13 +1605,15 @@ class ModelDelegate { this, ).createMany(data: data, select: spec.select, include: spec.include); - Future _updateMany({ + Future _updateCount({ required JsonMap data, required OrmReadQuerySpec spec, - }) => _RepositoryMutationExecutor(this).updateMany(where: spec.where, data: data); + }) => _RepositoryMutationExecutor( + this, + ).updateCount(where: spec.where, data: data); - Future _deleteMany({required OrmReadQuerySpec spec}) => - _RepositoryMutationExecutor(this).deleteMany(where: spec.where); + Future _deleteCount({required OrmReadQuerySpec spec}) => + _RepositoryMutationExecutor(this).deleteCount(where: spec.where); Future _upsert({ required JsonMap create, @@ -1691,15 +1697,38 @@ class ModelDelegate { Future> _collectCollectionRows( EngineResponse response, { required String action, + required List orderBy, required List distinct, int? skip, int? take, + JsonMap? cursor, + OrmReadPagePlan? page, }) async { var rows = await _collectRows(response, action: action); - if (distinct.isEmpty) { + final applyClientDistinct = distinct.isNotEmpty; + final applyClientWindow = cursor != null || page != null; + if (!applyClientDistinct && !applyClientWindow) { return rows; } - rows = _applyDistinctRows(rows: rows, distinct: distinct); + if (applyClientDistinct) { + rows = _applyDistinctRows(rows: rows, distinct: distinct); + } + if (page != null) { + return _applyPageWindowRows(rows: rows, orderBy: orderBy, page: page); + } + if (cursor != null) { + rows = rows + .where( + (row) => + _compareRowToBoundary( + row: row, + boundary: cursor, + orderBy: orderBy, + ) >= + 0, + ) + .toList(growable: false); + } return _sliceRows(rows: rows, skip: skip, take: take); } @@ -1932,9 +1961,53 @@ class ModelDelegate { return rows.sublist(0, page.size); } + List _applyPageWindowRows({ + required List rows, + required List orderBy, + required OrmReadPagePlan page, + }) { + if (page.after case final after?) { + final filtered = rows + .where( + (row) => + _compareRowToBoundary( + row: row, + boundary: after, + orderBy: orderBy, + ) > + 0, + ) + .toList(growable: false); + return page.size >= filtered.length + ? filtered + : filtered.sublist(0, page.size); + } + + if (page.before case final before?) { + final filtered = rows + .where( + (row) => + _compareRowToBoundary( + row: row, + boundary: before, + orderBy: orderBy, + ) < + 0, + ) + .toList(growable: false); + if (page.size >= filtered.length) { + return filtered; + } + return filtered.sublist(filtered.length - page.size); + } + + return page.size >= rows.length ? rows : rows.sublist(0, page.size); + } + Future _buildPageInfo({ required JsonMap where, required List orderBy, + required List distinct, required OrmReadPagePlan page, required List rows, required bool overflowed, @@ -1954,6 +2027,7 @@ class ModelDelegate { : await _hasPageRowAfterCursorBeforeBoundary( where: where, orderBy: orderBy, + distinct: distinct, cursor: endCursor, boundary: page.before!, operation: operation, @@ -1971,6 +2045,7 @@ class ModelDelegate { (final JsonMap after?, _) => await _hasPageRowBeforeBoundary( where: where, orderBy: orderBy, + distinct: distinct, boundary: rows.isEmpty ? after : startCursor!, operation: operation, ), @@ -2012,6 +2087,7 @@ class ModelDelegate { Future _hasPageRowBeforeBoundary({ required JsonMap where, required List orderBy, + required List distinct, required JsonMap boundary, required _RepositoryOperation operation, }) async { @@ -2019,7 +2095,11 @@ class ModelDelegate { action: OrmAction.read, where: where, orderBy: orderBy, - select: orderBy.map((entry) => entry.field).toList(growable: false), + distinct: distinct, + select: { + ...orderBy.map((entry) => entry.field), + ...distinct, + }.toList(growable: false), page: OrmReadPagePlan(size: 1, before: boundary), repositoryTrace: operation.nextTrace( phase: 'page.probe', @@ -2033,6 +2113,7 @@ class ModelDelegate { Future _hasPageRowAfterCursorBeforeBoundary({ required JsonMap where, required List orderBy, + required List distinct, required JsonMap cursor, required JsonMap boundary, required _RepositoryOperation operation, @@ -2043,7 +2124,11 @@ class ModelDelegate { skip: 1, take: 1, orderBy: orderBy, - select: orderBy.map((entry) => entry.field).toList(growable: false), + distinct: distinct, + select: { + ...orderBy.map((entry) => entry.field), + ...distinct, + }.toList(growable: false), cursor: cursor, repositoryTrace: operation.nextTrace( phase: 'page.probe', @@ -3217,20 +3302,7 @@ final class ModelQuery { ); } - void _assertReadExecutionSupported(String terminal) { - if ((_state.cursor != null || _state.page != null) && - _state.distinct.isNotEmpty) { - throw runtimeError( - 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', - 'Cursor and page windows do not support distinct yet.', - details: { - 'model': _delegate.modelName, - 'terminal': terminal, - 'distinct': _state.distinct, - }, - ); - } - } + void _assertReadExecutionSupported(String terminal) {} void _assertGroupedQueryBaseState() { final invalidKeys = [ @@ -3282,6 +3354,7 @@ final class ModelQuery { void _assertMutationQueryState({ required String action, bool allowWhere = true, + bool requireWhere = false, bool allowSelect = true, bool allowInclude = true, }) { @@ -3296,14 +3369,23 @@ final class ModelQuery { if (_state.cursor != null) 'cursor', if (_state.page != null) 'page', ]; - if (invalidKeys.isEmpty) { + if (invalidKeys.isNotEmpty) { + throw runtimeError( + 'PLAN.MUTATION_QUERY_STATE_INVALID', + '$action does not allow query state keys: ${invalidKeys.join(', ')}.', + details: {'action': action, 'invalidKeys': invalidKeys}, + ); + } + if (!requireWhere || _state.where.isNotEmpty) { return; } - throw runtimeError( - 'PLAN.MUTATION_QUERY_STATE_INVALID', - '$action does not allow query state keys: ${invalidKeys.join(', ')}.', - details: {'action': action, 'invalidKeys': invalidKeys}, + 'PLAN.MUTATION_WHERE_REQUIRED', + '$action requires where() first.', + details: { + 'model': _delegate.modelName, + 'action': action, + }, ); } @@ -3325,22 +3407,24 @@ final class ModelQuery { return _delegate._createMany(data: data, spec: _state); } - Future updateMany({required JsonMap data}) { + Future updateCount({required JsonMap data}) { _assertMutationQueryState( - action: 'updateMany', + action: 'updateCount', + requireWhere: true, allowSelect: false, allowInclude: false, ); - return _delegate._updateMany(data: data, spec: _state); + return _delegate._updateCount(data: data, spec: _state); } - Future deleteMany() { + Future deleteCount() { _assertMutationQueryState( - action: 'deleteMany', + action: 'deleteCount', + requireWhere: true, allowSelect: false, allowInclude: false, ); - return _delegate._deleteMany(spec: _state); + return _delegate._deleteCount(spec: _state); } Future upsert({required JsonMap create, required JsonMap update}) { diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index b465b47f..a6f65893 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -204,8 +204,8 @@ final class _RepositoryMutationExecutor { }); } - Future deleteMany({required JsonMap where}) { - final trace = _startOperation('deleteMany'); + Future deleteCount({required JsonMap where}) { + final trace = _startOperation('deleteCount'); return _delegate._client.transaction((txDb) async { final scoped = txDb.orm.model(_delegate.modelName); final executor = _RepositoryMutationExecutor(scoped); @@ -231,11 +231,11 @@ final class _RepositoryMutationExecutor { }); } - Future updateMany({ + Future updateCount({ required JsonMap where, required JsonMap data, }) { - final trace = _startOperation('updateMany'); + final trace = _startOperation('updateCount'); return _delegate._client.transaction((txDb) async { final scoped = txDb.orm.model(_delegate.modelName); final executor = _RepositoryMutationExecutor(scoped); @@ -522,7 +522,7 @@ final class _RepositoryMutationExecutor { if (!row.containsKey(field) || row[field] == null) { throw runtimeError( 'RUNTIME.UPDATE_MANY_IDENTITY_MISSING', - 'updateMany() identity lookup did not return all required id fields.', + 'updateCount() identity lookup did not return all required id fields.', details: { 'model': _delegate.modelName, 'idField': field, diff --git a/pub/orm/lib/src/client/read_plan_compiler.dart b/pub/orm/lib/src/client/read_plan_compiler.dart index a27f17aa..bd060350 100644 --- a/pub/orm/lib/src/client/read_plan_compiler.dart +++ b/pub/orm/lib/src/client/read_plan_compiler.dart @@ -29,19 +29,6 @@ final class _OrmReadPlanCompiler { if (state._cursor != null || state._page != null) { validateStableCursorOrderBy(orderBy: state._orderBy); } - if ((state._cursor != null || state._page != null) && - state._distinct.isNotEmpty) { - throw runtimeError( - 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', - 'Cursor and page windows do not support distinct yet.', - details: { - 'model': _delegate.modelName, - 'distinct': state._distinct, - if (state._cursor != null) 'cursor': state._cursor, - if (state._page != null) 'page': state._page!.toJson(), - }, - ); - } if (state._page != null && state.resultMode != OrmReadResultMode.all) { throw runtimeError( 'PLAN.PAGE_RESULT_MODE_INVALID', @@ -55,6 +42,9 @@ final class _OrmReadPlanCompiler { } final isCollectionRead = state.resultMode != OrmReadResultMode.oneOrNull; + final applyWindowAtClient = + state._distinct.isNotEmpty && + (state._cursor != null || state._page != null); final resolvedTake = state._page != null ? null : state.resultMode == OrmReadResultMode.firstOrNull @@ -79,6 +69,7 @@ final class _OrmReadPlanCompiler { delegate: _delegate, state: state, normalizedInclude: normalizedInclude, + applyWindowAtClient: applyWindowAtClient, plan: OrmPlan.read( contractHash: _delegate._client.contract.hash, target: _delegate._client.contract.target, @@ -105,10 +96,10 @@ final class _OrmReadPlanCompiler { distinct: isCollectionRead ? state._distinct : const [], select: readSelect, include: _buildOrmIncludePlanMap(normalizedInclude), - cursor: state._cursor == null + cursor: applyWindowAtClient || state._cursor == null ? null : OrmReadCursorPlan(values: state._cursor!), - page: state._page, + page: applyWindowAtClient ? null : state._page, resultMode: state.resultMode, ), ); diff --git a/pub/orm/lib/src/client/read_repository.dart b/pub/orm/lib/src/client/read_repository.dart index 619f623e..00407b7f 100644 --- a/pub/orm/lib/src/client/read_repository.dart +++ b/pub/orm/lib/src/client/read_repository.dart @@ -59,14 +59,17 @@ final class OrmPreparedReadQuery { final OrmPlan plan; final _OrmPreparedReadState _state; final Map _normalizedInclude; + final bool _applyWindowAtClient; OrmPreparedReadQuery._({ required ModelDelegate delegate, required this.plan, required _OrmPreparedReadState state, Map normalizedInclude = const {}, + bool applyWindowAtClient = false, }) : _delegate = delegate, _state = state, + _applyWindowAtClient = applyWindowAtClient, _normalizedInclude = Map.unmodifiable( Map.from(normalizedInclude), ); @@ -106,6 +109,7 @@ final class OrmPreparedReadQuery { include: _normalizedInclude, cursor: _cursor, page: _page, + applyWindowAtClient: _applyWindowAtClient, ), }); } @@ -122,6 +126,7 @@ final class OrmPreparedReadQuery { include: _normalizedInclude, cursor: _cursor, page: _page, + applyWindowAtClient: _applyWindowAtClient, ), }); } @@ -234,9 +239,12 @@ final class _RepositoryReadExecutor { final rows = await _delegate._collectCollectionRows( response, action: 'all', + orderBy: prepared._orderBy, distinct: prepared._distinct, skip: prepared._skip, take: prepared._take, + cursor: prepared._applyWindowAtClient ? prepared._cursor : null, + page: prepared._applyWindowAtClient ? prepared._page : null, ); final hydratedRows = await _delegate._resolveIncludeRows( action: action, @@ -281,7 +289,7 @@ final class _RepositoryReadExecutor { skip: null, take: null, orderBy: prepared._orderBy, - distinct: const [], + distinct: prepared._distinct, select: pageSelect, include: prepared._include, cursor: null, @@ -299,7 +307,16 @@ final class _RepositoryReadExecutor { ), ); final response = await _delegate._client.execute(itemsPrepared.plan); - final rawRows = await _collectRows(response, action: 'pageResult'); + final rawRows = await _delegate._collectCollectionRows( + response, + action: 'pageResult', + orderBy: itemsPrepared._orderBy, + distinct: itemsPrepared._distinct, + skip: itemsPrepared._skip, + take: itemsPrepared._take, + cursor: itemsPrepared._applyWindowAtClient ? itemsPrepared._cursor : null, + page: itemsPrepared._applyWindowAtClient ? itemsPrepared._page : null, + ); final overflowed = rawRows.length > page.size; final windowRows = _delegate._trimPageResultRows(rows: rawRows, page: page); final hydratedRows = await _delegate._resolveIncludeRows( @@ -312,6 +329,7 @@ final class _RepositoryReadExecutor { final pageInfo = await _delegate._buildPageInfo( where: prepared._where, orderBy: prepared._orderBy, + distinct: prepared._distinct, page: page, rows: windowRows, overflowed: overflowed, @@ -369,9 +387,12 @@ final class _RepositoryReadExecutor { await _delegate._collectCollectionRows( response, action: 'firstOrNull', + orderBy: prepared._orderBy, distinct: prepared._distinct, skip: prepared._skip, take: 1, + cursor: prepared._applyWindowAtClient ? prepared._cursor : null, + page: prepared._applyWindowAtClient ? prepared._page : null, ), ); if (row == null) { @@ -473,9 +494,12 @@ final class _RepositoryReadExecutor { final rows = await _delegate._collectCollectionRows( response, action: 'stream', + orderBy: prepared._orderBy, distinct: prepared._distinct, skip: prepared._skip, take: prepared._take, + cursor: prepared._applyWindowAtClient ? prepared._cursor : null, + page: prepared._applyWindowAtClient ? prepared._page : null, ); if (rows.isEmpty) { return; diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 0f726df7..fb0a2ee3 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2131,22 +2131,18 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future updateMany({'); - buffer.writeln( - ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', - ); + buffer.writeln(' Future updateCount({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); buffer.writeln(' required ${model.updateInputClassName} data,'); buffer.writeln(' }) {'); - buffer.writeln(' return query(where: where).updateMany(data: data);'); + buffer.writeln(' return query(where: where).updateCount(data: data);'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future deleteMany({'); - buffer.writeln( - ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', - ); + buffer.writeln(' Future deleteCount({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); buffer.writeln(' }) {'); - buffer.writeln(' return query(where: where).deleteMany();'); + buffer.writeln(' return query(where: where).deleteCount();'); buffer.writeln(' }'); buffer.writeln(); @@ -2863,21 +2859,6 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln(' void _assertReadExecutionSupported(String terminal) {'); - buffer.writeln( - ' if ((_cursor != null || _pageSize != null) && _distinct.isNotEmpty) {', - ); - buffer.writeln(' throw runtimeError('); - buffer.writeln(" 'PLAN.CURSOR_DISTINCT_UNSUPPORTED',"); - buffer.writeln( - " 'Cursor and page windows do not support distinct yet.',", - ); - buffer.writeln(' details: {'); - buffer.writeln(" 'model': '$runtimeName',"); - buffer.writeln(" 'terminal': terminal,"); - buffer.writeln(" 'distinct': _runtimeDistinct,"); - buffer.writeln(' },'); - buffer.writeln(' );'); - buffer.writeln(' }'); buffer.writeln(' }'); buffer.writeln(); @@ -2912,6 +2893,7 @@ final class TypedClientWriter { buffer.writeln(' void _assertMutationQueryState({'); buffer.writeln(' required String action,'); buffer.writeln(' bool allowWhere = true,'); + buffer.writeln(' bool requireWhere = false,'); buffer.writeln(' bool allowSelect = true,'); buffer.writeln(' bool allowInclude = true,'); buffer.writeln(' }) {'); @@ -2926,19 +2908,29 @@ final class TypedClientWriter { buffer.writeln(" if (_cursor != null) 'cursor',"); buffer.writeln(" if (_pageSize != null) 'page',"); buffer.writeln(' ];'); - buffer.writeln(' if (invalidKeys.isEmpty) {'); + buffer.writeln(' if (invalidKeys.isNotEmpty) {'); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.MUTATION_QUERY_STATE_INVALID',"); + buffer.writeln( + " '\$action does not allow query state keys: \${invalidKeys.join(', ')}.',", + ); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'action': action,"); + buffer.writeln(" 'invalidKeys': invalidKeys,"); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' if (!requireWhere || !_where.isEmpty) {'); buffer.writeln(' return;'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' throw runtimeError('); - buffer.writeln(" 'PLAN.MUTATION_QUERY_STATE_INVALID',"); - buffer.writeln( - " '\$action does not allow query state keys: \${invalidKeys.join(', ')}.',", - ); + buffer.writeln(" 'PLAN.MUTATION_WHERE_REQUIRED',"); + buffer.writeln(" '\$action requires where() first.',"); buffer.writeln(' details: {'); buffer.writeln(" 'model': '$runtimeName',"); buffer.writeln(" 'action': action,"); - buffer.writeln(" 'invalidKeys': invalidKeys,"); buffer.writeln(' },'); buffer.writeln(' );'); buffer.writeln(' }'); @@ -3140,23 +3132,23 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' Future updateMany({required ${model.updateInputClassName} data}) {', + ' Future updateCount({required ${model.updateInputClassName} data}) {', ); buffer.writeln( - " _assertMutationQueryState(action: 'updateMany', allowSelect: false, allowInclude: false);", + " _assertMutationQueryState(action: 'updateCount', requireWhere: true, allowSelect: false, allowInclude: false);", ); - buffer.writeln(' return _delegate._delegate.updateMany('); + buffer.writeln(' return _delegate._delegate.updateCount('); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' data: data.toJson(),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future deleteMany() {'); + buffer.writeln(' Future deleteCount() {'); buffer.writeln( - " _assertMutationQueryState(action: 'deleteMany', allowSelect: false, allowInclude: false);", + " _assertMutationQueryState(action: 'deleteCount', requireWhere: true, allowSelect: false, allowInclude: false);", ); - buffer.writeln(' return _delegate._delegate.deleteMany('); + buffer.writeln(' return _delegate._delegate.deleteCount('); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' );'); buffer.writeln(' }'); diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 304f94c0..6e3c447f 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -144,6 +144,30 @@ void main() { }, ); + test( + 'inspectPlan marks pageResult as client-windowed when distinct requires buffered paging', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + final inspected = await users + .query() + .orderByField('email') + .orderByField('id') + .distinctField('email') + .page(size: 2) + .inspectPlan(); + + final execution = + inspected['terminalExecution'] as Map; + final pageResult = execution['pageResult'] as Map; + + expect(pageResult['delivery'], 'pageEnvelope'); + expect(pageResult['degraded'], isTrue); + expect(pageResult['windowAppliedAt'], 'client'); + expect(pageResult['reasons'], ['distinct']); + }, + ); + test( 'runtime executes direct aggregate and grouped aggregate plans', () async { @@ -875,7 +899,33 @@ void main() { ); }); - test('updateMany updates matching rows and returns affected count', () async { + test('updateCount and deleteCount require where() first', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => users.query().updateCount(data: {'email': 'x'}), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_WHERE_REQUIRED', + ), + ), + ); + expect( + () => users.query().deleteCount(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_WHERE_REQUIRED', + ), + ), + ); + }); + + test('updateCount updates matching rows and returns affected count', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); try { @@ -890,7 +940,7 @@ void main() { final affected = await users .where({'email': 'a@x.com'}) - .updateMany(data: {'email': 'updated@x.com'}); + .updateCount(data: {'email': 'updated@x.com'}); expect(affected, 2); final rows = await users @@ -904,6 +954,53 @@ void main() { await client.disconnect(); } }); + + test( + 'pageResult executes distinct windows with client-side paging semantics', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'a@x.com'}, + {'id': 'u3', 'email': 'b@x.com'}, + {'id': 'u4', 'email': 'b@x.com'}, + {'id': 'u5', 'email': 'c@x.com'}, + ], + ); + + final query = users + .query() + .orderByField('email') + .orderByField('id') + .distinctField('email'); + final firstPage = await query.page(size: 2).pageResult(); + + expect( + firstPage.items.map((row) => row['email']).toList(growable: false), + ['a@x.com', 'b@x.com'], + ); + expect(firstPage.pageInfo.hasPreviousPage, isFalse); + expect(firstPage.pageInfo.hasNextPage, isTrue); + + final secondPage = await query + .page(size: 2, after: firstPage.pageInfo.endCursor) + .pageResult(); + + expect( + secondPage.items.map((row) => row['email']).toList(growable: false), + ['c@x.com'], + ); + expect(secondPage.pageInfo.hasPreviousPage, isTrue); + expect(secondPage.pageInfo.hasNextPage, isFalse); + } finally { + await client.disconnect(); + } + }, + ); }); } diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index bf1d815b..4759f9cf 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -1246,7 +1246,7 @@ void main() { ); expect( - () => users.take(1).deleteMany(), + () => users.take(1).deleteCount(), throwsA( isA() .having( @@ -1265,7 +1265,7 @@ void main() { expect( () => users .select(const ['id']) - .updateMany(data: {'email': 'b@x.com'}), + .updateCount(data: {'email': 'b@x.com'}), throwsA( isA() .having( @@ -1284,7 +1284,7 @@ void main() { expect( () => users .include({'posts': const IncludeSpec()}) - .deleteMany(), + .deleteCount(), throwsA( isA() .having( @@ -1300,6 +1300,28 @@ void main() { ), ); + expect( + () => users.query().updateCount(data: {'email': 'b@x.com'}), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_WHERE_REQUIRED', + ), + ), + ); + + expect( + () => users.query().deleteCount(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_WHERE_REQUIRED', + ), + ), + ); + await client.disconnect(); }); @@ -1324,7 +1346,7 @@ void main() { await client.disconnect(); }); - test('supports createMany and deleteMany helpers', () async { + test('supports createMany and deleteCount helpers', () async { final engine = _CountingEngine(inner: MemoryEngine()); final client = OrmClient(contract: contract, engine: engine); await client.connect(); @@ -1372,7 +1394,7 @@ void main() { ); engine.reset(); - final updated = await users.updateMany( + final updated = await users.updateCount( where: {'email': 'a@x.com'}, data: {'email': 'updated@x.com'}, ); @@ -1391,7 +1413,7 @@ void main() { }); expect( updateTraces.map((trace) => trace.kind).toList(growable: false), - ['User.updateMany', 'User.updateMany', 'User.updateMany'], + ['User.updateCount', 'User.updateCount', 'User.updateCount'], ); expect( updateTraces.map((trace) => trace.phase).toList(growable: false), @@ -1411,7 +1433,7 @@ void main() { ); engine.reset(); - final deleted = await users.deleteMany( + final deleted = await users.deleteCount( where: {'email': 'updated@x.com'}, ); expect(deleted, 2); @@ -1429,7 +1451,7 @@ void main() { }); expect( deleteTraces.map((trace) => trace.kind).toList(growable: false), - ['User.deleteMany', 'User.deleteMany', 'User.deleteMany'], + ['User.deleteCount', 'User.deleteCount', 'User.deleteCount'], ); expect( deleteTraces.map((trace) => trace.phase).toList(growable: false), @@ -1651,7 +1673,7 @@ void main() { ); test( - 'supports query state helpers for first/count/exists/upsert/deleteMany', + 'supports query state helpers for first/count/exists/upsert/deleteCount', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -1678,7 +1700,7 @@ void main() { ); expect(upserted['id'], 'u3'); - final removed = await query.deleteMany(); + final removed = await query.deleteCount(); expect(removed, 2); expect(await users.count(), 1); await client.disconnect(); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index b2caa967..775b877c 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -197,27 +197,27 @@ void main() { ); expect( RegExp( - r"class\s+ModelQuery\s*\{[\s\S]*?Future\s+updateMany\(\{required\s+JsonMap\s+data\}\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'updateMany',\s*allowSelect:\s*false,\s*allowInclude:\s*false,\s*\);[\s\S]*?return\s+_delegate\._updateMany\(data:\s*data,\s*spec:\s*_state\);", + r"class\s+ModelQuery\s*\{[\s\S]*?Future\s+updateCount\(\{required\s+JsonMap\s+data\}\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'updateCount',\s*requireWhere:\s*true,\s*allowSelect:\s*false,\s*allowInclude:\s*false,\s*\);[\s\S]*?return\s+_delegate\._updateCount\(data:\s*data,\s*spec:\s*_state\);", ).hasMatch(source), isTrue, reason: - 'Expected ModelQuery.updateMany(...) to reject row-shaping state and terminate through the private mutation helper.', + 'Expected ModelQuery.updateCount(...) to reject row-shaping state and terminate through the private mutation helper.', ); expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+deleteMany\(\)\s*\{[\s\S]*?return\s+_delegate\._deleteMany\(spec:\s*_state\);', + r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+deleteCount\(\)\s*\{[\s\S]*?return\s+_delegate\._deleteCount\(spec:\s*_state\);', ).hasMatch(source), isTrue, reason: - 'Expected ModelQuery.deleteMany(...) to terminate through the private mutation helper.', + 'Expected ModelQuery.deleteCount(...) to terminate through the private mutation helper.', ); expect( RegExp( - r"class\s+ModelQuery\s*\{[\s\S]*?Future\s+deleteMany\(\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'deleteMany',\s*allowSelect:\s*false,\s*allowInclude:\s*false,\s*\);", + r"class\s+ModelQuery\s*\{[\s\S]*?Future\s+deleteCount\(\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'deleteCount',\s*requireWhere:\s*true,\s*allowSelect:\s*false,\s*allowInclude:\s*false,\s*\);", ).hasMatch(source), isTrue, reason: - 'Expected ModelQuery.deleteMany(...) to reject row-shaping state.', + 'Expected ModelQuery.deleteCount(...) to reject row-shaping state.', ); }, ); diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index af353011..c04075f5 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1029,19 +1029,27 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateMany\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),\s*required\s+UserUpdateInput\s+data,', + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateCount\(\{\s*required\s+UserWhereInput\s+where,\s*required\s+UserUpdateInput\s+data,', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.updateMany(...) to expose typed where and data input.', + 'Expected UserDelegate.updateCount(...) to expose typed where and data input.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+updateMany\(\{\s*required\s+UserUpdateInput\s+data\}\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+updateCount\(\{\s*required\s+UserUpdateInput\s+data\}\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.updateMany(...) to expose typed update data input.', + 'Expected UserQuery.updateCount(...) to expose typed update data input.', + ); + expect( + generatedSource.contains( + "_assertMutationQueryState(action: 'updateCount', requireWhere: true, allowSelect: false, allowInclude: false);", + ), + isTrue, + reason: + 'Expected UserQuery.updateCount(...) to require where() before execution.', ); expect( RegExp(r'\bclass UserNestedCreateInput\b').hasMatch(generatedSource), @@ -1221,11 +1229,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateMany\(\{[\s\S]*?return\s+query\(where:\s*where\)\.updateMany\(data:\s*data\);', + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateCount\(\{[\s\S]*?return\s+query\(where:\s*where\)\.updateCount\(data:\s*data\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected generated delegate updateMany(...) to route through typed query.', + 'Expected generated delegate updateCount(...) to route through typed query.', ); expect( RegExp( @@ -1237,11 +1245,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+deleteMany\(\{[\s\S]*?return\s+query\(where:\s*where\)\.deleteMany\(\);', + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+deleteCount\(\{[\s\S]*?return\s+query\(where:\s*where\)\.deleteCount\(\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected generated delegate deleteMany(...) to route through typed query.', + 'Expected generated delegate deleteCount(...) to route through typed query.', ); expect( RegExp( @@ -1720,18 +1728,26 @@ typedef Post = ({ ); expect( RegExp( - r'Future\s+deleteMany\(\{\s*UserWhereInput\s+where\s*=\s*const\s+UserWhereInput\(\),', + r'Future\s+deleteCount\(\{\s*required\s+UserWhereInput\s+where,', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserDelegate.deleteMany(...) to accept typed where input.', + 'Expected UserDelegate.deleteCount(...) to accept typed where input.', ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+deleteMany\s*\(\s*\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+deleteCount\s*\(\s*\)', ).hasMatch(generatedSource), isTrue, - reason: 'Expected UserQuery.deleteMany() to exist.', + reason: 'Expected UserQuery.deleteCount() to exist.', + ); + expect( + generatedSource.contains( + "_assertMutationQueryState(action: 'deleteCount', requireWhere: true, allowSelect: false, allowInclude: false);", + ), + isTrue, + reason: + 'Expected UserQuery.deleteCount() to require where() before execution.', ); expect( diff --git a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart index 26e9cbec..3424e910 100644 --- a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart +++ b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart @@ -182,7 +182,7 @@ void main() { await client.disconnect(); }); - test('aggregates updateMany into one operation record', () async { + test('aggregates updateCount into one operation record', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); final users = client.db.orm.model('User'); @@ -195,7 +195,7 @@ void main() { ], ); - final updated = await users.updateMany( + final updated = await users.updateCount( where: {'email': 'a@x.com'}, data: {'email': 'updated@x.com'}, ); @@ -203,7 +203,7 @@ void main() { expect(updated, 2); final telemetry = client.operationTelemetry(); expect(telemetry, isNotNull); - expect(telemetry?.kind, 'User.updateMany'); + expect(telemetry?.kind, 'User.updateCount'); expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); expect(telemetry?.completed, isTrue); expect(telemetry?.statementCount, 3); From ae2a8a7baa3eb5b179fb59c2e4bdbee30ba07dc8 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:11:24 +0800 Subject: [PATCH 145/154] feat(orm)!: execute distinct windows and add batch row terminals --- docs/orm-v6-api-surface.md | 11 +- pub/orm/lib/src/client/client.dart | 158 +++++------------- .../lib/src/client/mutation_repository.dart | 94 +++++++++++ .../lib/src/client/read_plan_compiler.dart | 12 +- pub/orm/lib/src/client/read_repository.dart | 93 +++-------- pub/orm/lib/src/engine/memory_engine.dart | 28 ++++ pub/orm/lib/src/generator/writer.dart | 60 +++++++ pub/orm/lib/src/runtime/core.dart | 10 -- pub/orm/lib/src/sql/adapter.dart | 91 ++++++++++ pub/orm/test/client/api_surface_test.dart | 113 +++++++++++-- pub/orm/test/client/client_test.dart | 90 ++++++++-- pub/orm/test/client/source_surface_test.dart | 16 ++ pub/orm/test/generator/generate_test.dart | 63 +++++++ .../operation_telemetry_aggregation_test.dart | 76 +++++++++ pub/orm/test/sql/sql_adapter_test.dart | 57 +++++++ 15 files changed, 745 insertions(+), 227 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 289d3a7e..d25cf75c 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -127,8 +127,10 @@ Direct mutations: | `createMany(...)` | implemented | | `createNested(...)` | implemented | | `update(...)` | implemented | +| `updateAll(...)` | implemented | | `updateNested(...)` | implemented | | `delete(...)` | implemented | +| `deleteAll(...)` | implemented | | `deleteCount(...)` | implemented | | `upsert(...)` | implemented | | `updateCount(...)` | implemented | @@ -138,14 +140,17 @@ Chained mutations: ```dart users.where({...}).update(data: {...}); users.where({...}).delete(); +users.where({...}).updateAll(data: {...}); +users.where({...}).deleteAll(); users.where({...}).upsert(create: {...}, update: {...}); users.where({...}).updateCount(data: {...}); ``` Rules: -1. `updateCount(...)` and `deleteCount(...)` are count terminals. -2. They require `where(...)` first. -3. They do not accept row-shaping state such as `select(...)` or `include(...)`. +1. `updateAll(...)` and `deleteAll(...)` are row-returning batch terminals. +2. `updateCount(...)` and `deleteCount(...)` are count terminals. +3. All four batch terminals require `where(...)` first. +4. Count terminals do not accept row-shaping state such as `select(...)` or `include(...)`. ## SQL Surface diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index a7bc8b06..4ff7b96e 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -177,7 +177,6 @@ JsonMap _terminalExecutionSummary({ required Map include, JsonMap? cursor, OrmReadPagePlan? page, - bool applyWindowAtClient = false, }) { final hasWindow = cursor != null || page != null; final includeStrategy = include.isEmpty @@ -191,7 +190,6 @@ JsonMap _terminalExecutionSummary({ ).name; final streamReasons = [ if (include.isNotEmpty) 'include', - if (distinct.isNotEmpty) 'distinct', ]; JsonMap terminal({ @@ -205,10 +203,8 @@ JsonMap _terminalExecutionSummary({ 'delivery': delivery, 'degraded': degraded, 'reasons': List.unmodifiable(reasons), - 'windowAppliedAt': hasWindow - ? (applyWindowAtClient ? 'client' : 'engine') - : 'none', - 'distinctAppliedAt': distinct.isEmpty ? 'none' : 'client', + 'windowAppliedAt': hasWindow ? 'engine' : 'none', + 'distinctAppliedAt': distinct.isEmpty ? 'none' : 'engine', 'includeAppliedAt': include.isEmpty ? 'none' : 'repository', if (includeStrategy != null) 'includeStrategy': includeStrategy, }); @@ -223,8 +219,8 @@ JsonMap _terminalExecutionSummary({ ), 'pageResult': terminal( delivery: page == null ? 'unavailable' : 'pageEnvelope', - degraded: applyWindowAtClient, - reasons: applyWindowAtClient ? const ['distinct'] : const [], + degraded: false, + reasons: const [], available: page != null, ), }); @@ -1526,6 +1522,15 @@ class ModelDelegate { OrmReadQuerySpec(select: select, include: include), ).createMany(data: data); + Future> updateAll({ + required JsonMap where, + required JsonMap data, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).updateAll(data: data); + Future updateCount({ required JsonMap where, required JsonMap data, @@ -1536,6 +1541,14 @@ class ModelDelegate { Future deleteCount({required JsonMap where}) => _queryFromSpec(OrmReadQuerySpec(where: where)).deleteCount(); + Future> deleteAll({ + required JsonMap where, + List select = const [], + Map include = const {}, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).deleteAll(); + Future upsert({ required JsonMap where, required JsonMap create, @@ -1605,6 +1618,16 @@ class ModelDelegate { this, ).createMany(data: data, select: spec.select, include: spec.include); + Future> _updateAll({ + required JsonMap data, + required OrmReadQuerySpec spec, + }) => _RepositoryMutationExecutor(this).updateAll( + where: spec.where, + data: data, + select: spec.select, + include: spec.include, + ); + Future _updateCount({ required JsonMap data, required OrmReadQuerySpec spec, @@ -1615,6 +1638,11 @@ class ModelDelegate { Future _deleteCount({required OrmReadQuerySpec spec}) => _RepositoryMutationExecutor(this).deleteCount(where: spec.where); + Future> _deleteAll({required OrmReadQuerySpec spec}) => + _RepositoryMutationExecutor( + this, + ).deleteAll(where: spec.where, select: spec.select, include: spec.include); + Future _upsert({ required JsonMap create, required JsonMap update, @@ -1694,44 +1722,6 @@ class ModelDelegate { ); } - Future> _collectCollectionRows( - EngineResponse response, { - required String action, - required List orderBy, - required List distinct, - int? skip, - int? take, - JsonMap? cursor, - OrmReadPagePlan? page, - }) async { - var rows = await _collectRows(response, action: action); - final applyClientDistinct = distinct.isNotEmpty; - final applyClientWindow = cursor != null || page != null; - if (!applyClientDistinct && !applyClientWindow) { - return rows; - } - if (applyClientDistinct) { - rows = _applyDistinctRows(rows: rows, distinct: distinct); - } - if (page != null) { - return _applyPageWindowRows(rows: rows, orderBy: orderBy, page: page); - } - if (cursor != null) { - rows = rows - .where( - (row) => - _compareRowToBoundary( - row: row, - boundary: cursor, - orderBy: orderBy, - ) >= - 0, - ) - .toList(growable: false); - } - return _sliceRows(rows: rows, skip: skip, take: take); - } - Future _readOneInternal({ required OrmAction action, JsonMap where = const {}, @@ -1961,49 +1951,6 @@ class ModelDelegate { return rows.sublist(0, page.size); } - List _applyPageWindowRows({ - required List rows, - required List orderBy, - required OrmReadPagePlan page, - }) { - if (page.after case final after?) { - final filtered = rows - .where( - (row) => - _compareRowToBoundary( - row: row, - boundary: after, - orderBy: orderBy, - ) > - 0, - ) - .toList(growable: false); - return page.size >= filtered.length - ? filtered - : filtered.sublist(0, page.size); - } - - if (page.before case final before?) { - final filtered = rows - .where( - (row) => - _compareRowToBoundary( - row: row, - boundary: before, - orderBy: orderBy, - ) < - 0, - ) - .toList(growable: false); - if (page.size >= filtered.length) { - return filtered; - } - return filtered.sublist(filtered.length - page.size); - } - - return page.size >= rows.length ? rows : rows.sublist(0, page.size); - } - Future _buildPageInfo({ required JsonMap where, required List orderBy, @@ -2588,29 +2535,6 @@ class ModelDelegate { return next; } - List _applyDistinctRows({ - required List rows, - required List distinct, - }) { - if (rows.isEmpty || distinct.isEmpty) { - return rows; - } - - final seen = <_RelationMergeKey>{}; - final deduplicated = []; - for (final row in rows) { - final key = _RelationMergeKey( - distinct - .map((field) => row.containsKey(field) ? row[field] : null) - .toList(growable: false), - ); - if (seen.add(key)) { - deduplicated.add(row); - } - } - return deduplicated; - } - int _compareOrderByValues(Object? left, Object? right) { if (left == null && right == null) { return 0; @@ -3407,6 +3331,11 @@ final class ModelQuery { return _delegate._createMany(data: data, spec: _state); } + Future> updateAll({required JsonMap data}) { + _assertMutationQueryState(action: 'updateAll', requireWhere: true); + return _delegate._updateAll(data: data, spec: _state); + } + Future updateCount({required JsonMap data}) { _assertMutationQueryState( action: 'updateCount', @@ -3427,6 +3356,11 @@ final class ModelQuery { return _delegate._deleteCount(spec: _state); } + Future> deleteAll() { + _assertMutationQueryState(action: 'deleteAll', requireWhere: true); + return _delegate._deleteAll(spec: _state); + } + Future upsert({required JsonMap create, required JsonMap update}) { _assertMutationQueryState(action: 'upsert'); return _delegate._upsert(create: create, update: update, spec: _state); diff --git a/pub/orm/lib/src/client/mutation_repository.dart b/pub/orm/lib/src/client/mutation_repository.dart index a6f65893..eeaac585 100644 --- a/pub/orm/lib/src/client/mutation_repository.dart +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -204,6 +204,52 @@ final class _RepositoryMutationExecutor { }); } + Future> deleteAll({ + required JsonMap where, + required List select, + required Map include, + }) { + final trace = _startOperation('deleteAll'); + return _delegate._client.transaction((txDb) async { + final scoped = txDb.orm.model(_delegate.modelName); + final executor = _RepositoryMutationExecutor(scoped); + final normalizedWhere = (await scoped._normalizeWhereForExecution( + model: scoped.modelName, + where: where, + operation: trace, + )).where; + final identityRows = await scoped._readAllInternal( + action: OrmAction.read, + where: normalizedWhere, + select: scoped._modelContract.idFields, + repositoryTrace: trace.nextTrace( + phase: 'batch.lookup', + strategy: 'transaction', + ), + include: const {}, + includeDepth: 0, + ); + + final deleted = []; + for (var index = 0; index < identityRows.length; index++) { + final itemWhere = _identityWhereFromRow(identityRows[index]); + final row = await executor.delete( + where: itemWhere, + select: select, + include: include, + operation: trace, + phase: 'item.delete', + strategy: 'transaction', + itemIndex: index, + ); + if (row != null) { + deleted.add(row); + } + } + return deleted; + }); + } + Future deleteCount({required JsonMap where}) { final trace = _startOperation('deleteCount'); return _delegate._client.transaction((txDb) async { @@ -278,6 +324,54 @@ final class _RepositoryMutationExecutor { }); } + Future> updateAll({ + required JsonMap where, + required JsonMap data, + required List select, + required Map include, + }) { + final trace = _startOperation('updateAll'); + return _delegate._client.transaction((txDb) async { + final scoped = txDb.orm.model(_delegate.modelName); + final executor = _RepositoryMutationExecutor(scoped); + final normalizedWhere = (await scoped._normalizeWhereForExecution( + model: scoped.modelName, + where: where, + operation: trace, + )).where; + final identityRows = await scoped._readAllInternal( + action: OrmAction.read, + where: normalizedWhere, + select: scoped._modelContract.idFields, + repositoryTrace: trace.nextTrace( + phase: 'batch.lookup', + strategy: 'transaction', + ), + include: const {}, + includeDepth: 0, + ); + + final updated = []; + for (var index = 0; index < identityRows.length; index++) { + final itemWhere = _identityWhereFromRow(identityRows[index]); + final row = await executor.update( + where: itemWhere, + data: data, + select: select, + include: include, + operation: trace, + phase: 'item.update', + strategy: 'transaction', + itemIndex: index, + ); + if (row != null) { + updated.add(row); + } + } + return updated; + }); + } + Future upsert({ required JsonMap where, required JsonMap create, diff --git a/pub/orm/lib/src/client/read_plan_compiler.dart b/pub/orm/lib/src/client/read_plan_compiler.dart index bd060350..216e6b7d 100644 --- a/pub/orm/lib/src/client/read_plan_compiler.dart +++ b/pub/orm/lib/src/client/read_plan_compiler.dart @@ -42,9 +42,6 @@ final class _OrmReadPlanCompiler { } final isCollectionRead = state.resultMode != OrmReadResultMode.oneOrNull; - final applyWindowAtClient = - state._distinct.isNotEmpty && - (state._cursor != null || state._page != null); final resolvedTake = state._page != null ? null : state.resultMode == OrmReadResultMode.firstOrNull @@ -69,7 +66,6 @@ final class _OrmReadPlanCompiler { delegate: _delegate, state: state, normalizedInclude: normalizedInclude, - applyWindowAtClient: applyWindowAtClient, plan: OrmPlan.read( contractHash: _delegate._client.contract.hash, target: _delegate._client.contract.target, @@ -90,16 +86,16 @@ final class _OrmReadPlanCompiler { repositoryTrace: state._repositoryTrace, model: _delegate.modelName, where: state._where, - skip: isCollectionRead && state._distinct.isEmpty ? state._skip : null, - take: isCollectionRead && state._distinct.isEmpty ? resolvedTake : null, + skip: isCollectionRead ? state._skip : null, + take: isCollectionRead ? resolvedTake : null, orderBy: isCollectionRead ? state._orderBy : const [], distinct: isCollectionRead ? state._distinct : const [], select: readSelect, include: _buildOrmIncludePlanMap(normalizedInclude), - cursor: applyWindowAtClient || state._cursor == null + cursor: state._cursor == null ? null : OrmReadCursorPlan(values: state._cursor!), - page: applyWindowAtClient ? null : state._page, + page: state._page, resultMode: state.resultMode, ), ); diff --git a/pub/orm/lib/src/client/read_repository.dart b/pub/orm/lib/src/client/read_repository.dart index 00407b7f..46afd942 100644 --- a/pub/orm/lib/src/client/read_repository.dart +++ b/pub/orm/lib/src/client/read_repository.dart @@ -59,17 +59,14 @@ final class OrmPreparedReadQuery { final OrmPlan plan; final _OrmPreparedReadState _state; final Map _normalizedInclude; - final bool _applyWindowAtClient; OrmPreparedReadQuery._({ required ModelDelegate delegate, required this.plan, required _OrmPreparedReadState state, Map normalizedInclude = const {}, - bool applyWindowAtClient = false, }) : _delegate = delegate, _state = state, - _applyWindowAtClient = applyWindowAtClient, _normalizedInclude = Map.unmodifiable( Map.from(normalizedInclude), ); @@ -78,8 +75,6 @@ final class OrmPreparedReadQuery { int? get _skip => _state._skip; - int? get _take => _state._take; - List get _orderBy => _state._orderBy; List get _distinct => _state._distinct; @@ -109,7 +104,6 @@ final class OrmPreparedReadQuery { include: _normalizedInclude, cursor: _cursor, page: _page, - applyWindowAtClient: _applyWindowAtClient, ), }); } @@ -126,7 +120,6 @@ final class OrmPreparedReadQuery { include: _normalizedInclude, cursor: _cursor, page: _page, - applyWindowAtClient: _applyWindowAtClient, ), }); } @@ -236,16 +229,7 @@ final class _RepositoryReadExecutor { required int includeDepth, }) async { final response = await _delegate._client.execute(prepared.plan); - final rows = await _delegate._collectCollectionRows( - response, - action: 'all', - orderBy: prepared._orderBy, - distinct: prepared._distinct, - skip: prepared._skip, - take: prepared._take, - cursor: prepared._applyWindowAtClient ? prepared._cursor : null, - page: prepared._applyWindowAtClient ? prepared._page : null, - ); + final rows = await _collectRows(response, action: 'all'); final hydratedRows = await _delegate._resolveIncludeRows( action: action, rows: rows, @@ -307,16 +291,7 @@ final class _RepositoryReadExecutor { ), ); final response = await _delegate._client.execute(itemsPrepared.plan); - final rawRows = await _delegate._collectCollectionRows( - response, - action: 'pageResult', - orderBy: itemsPrepared._orderBy, - distinct: itemsPrepared._distinct, - skip: itemsPrepared._skip, - take: itemsPrepared._take, - cursor: itemsPrepared._applyWindowAtClient ? itemsPrepared._cursor : null, - page: itemsPrepared._applyWindowAtClient ? itemsPrepared._page : null, - ); + final rawRows = await _collectRows(response, action: 'pageResult'); final overflowed = rawRows.length > page.size; final windowRows = _delegate._trimPageResultRows(rows: rawRows, page: page); final hydratedRows = await _delegate._resolveIncludeRows( @@ -360,41 +335,26 @@ final class _RepositoryReadExecutor { return rows.isEmpty ? null : rows.first; } - final effectivePrepared = prepared._distinct.isEmpty - ? await _delegate._prepareReadQuery( - state: prepared._state.copyWith( - resultMode: OrmReadResultMode.firstOrNull, - spec: prepared._state._spec.copyWith( - where: prepared._where, - skip: prepared._skip, - orderBy: prepared._orderBy, - distinct: prepared._distinct, - select: prepared._select, - include: prepared._include, - cursor: null, - page: null, - ), - annotations: prepared._annotations, - repositoryTrace: prepared._repositoryTrace, - ), - ) - : prepared; + final effectivePrepared = await _delegate._prepareReadQuery( + state: prepared._state.copyWith( + resultMode: OrmReadResultMode.firstOrNull, + spec: prepared._state._spec.copyWith( + where: prepared._where, + skip: prepared._skip, + orderBy: prepared._orderBy, + distinct: prepared._distinct, + select: prepared._select, + include: prepared._include, + cursor: null, + page: null, + ), + annotations: prepared._annotations, + repositoryTrace: prepared._repositoryTrace, + ), + ); final response = await _delegate._client.execute(effectivePrepared.plan); - final row = prepared._distinct.isEmpty - ? await _collectSingleRow(response, action: 'firstOrNull') - : _firstOrNull( - await _delegate._collectCollectionRows( - response, - action: 'firstOrNull', - orderBy: prepared._orderBy, - distinct: prepared._distinct, - skip: prepared._skip, - take: 1, - cursor: prepared._applyWindowAtClient ? prepared._cursor : null, - page: prepared._applyWindowAtClient ? prepared._page : null, - ), - ); + final row = await _collectSingleRow(response, action: 'firstOrNull'); if (row == null) { return null; } @@ -480,7 +440,7 @@ final class _RepositoryReadExecutor { }) async* { final response = await _delegate._client.execute(prepared.plan); - if (prepared._normalizedInclude.isEmpty && prepared._distinct.isEmpty) { + if (prepared._normalizedInclude.isEmpty) { await for (final row in _streamRows(response, action: 'stream')) { yield _delegate._shapeRow( row, @@ -491,16 +451,7 @@ final class _RepositoryReadExecutor { return; } - final rows = await _delegate._collectCollectionRows( - response, - action: 'stream', - orderBy: prepared._orderBy, - distinct: prepared._distinct, - skip: prepared._skip, - take: prepared._take, - cursor: prepared._applyWindowAtClient ? prepared._cursor : null, - page: prepared._applyWindowAtClient ? prepared._page : null, - ); + final rows = await _collectRows(response, action: 'stream'); if (rows.isEmpty) { return; } diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index 6766d852..72794a7e 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -106,6 +106,10 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { rows.sort((left, right) => _compareRows(left, right, read.orderBy)); } + if (read.distinct.isNotEmpty) { + rows = _applyDistinctRows(rows, read.distinct); + } + rows = _applyReadWindow(rows, read); final projected = rows @@ -127,6 +131,10 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { rows.sort((left, right) => _compareRows(left, right, read.orderBy)); } + if (read.distinct.isNotEmpty) { + rows = _applyDistinctRows(rows, read.distinct); + } + rows = _applyReadWindow(rows, read); final projected = rows .map((row) => _projectRow(row, read.select)) @@ -145,6 +153,26 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { ); } + List _applyDistinctRows(List rows, List distinct) { + if (rows.isEmpty || distinct.isEmpty) { + return rows; + } + + final seen = <_MemoryGroupKey>{}; + final deduplicated = []; + for (final row in rows) { + final key = _MemoryGroupKey( + distinct + .map((field) => row.containsKey(field) ? row[field] : null) + .toList(growable: false), + ); + if (seen.add(key)) { + deduplicated.add(row); + } + } + return deduplicated; + } + EngineResponse _readGroupedAggregate(List bucket, OrmReadPlan read) { final aggregate = read.aggregate!; final groupBy = read.groupBy!; diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index fb0a2ee3..0f9c6a79 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2139,6 +2139,20 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future> updateAll({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).updateAll(data: data);'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future deleteCount({'); buffer.writeln(' required ${model.whereInputClassName} where,'); buffer.writeln(' }) {'); @@ -2146,6 +2160,19 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future> deleteAll({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' where: where,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).deleteAll();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future count({'); buffer.writeln( ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', @@ -3131,6 +3158,24 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln( + ' Future> updateAll({required ${model.updateInputClassName} data}) async {', + ); + buffer.writeln( + " _assertMutationQueryState(action: 'updateAll', requireWhere: true);", + ); + buffer.writeln(' final rows = await _delegate._delegate.updateAll('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' data: data.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln( + ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( ' Future updateCount({required ${model.updateInputClassName} data}) {', ); @@ -3154,6 +3199,21 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' Future> deleteAll() async {'); + buffer.writeln( + " _assertMutationQueryState(action: 'deleteAll', requireWhere: true);", + ); + buffer.writeln(' final rows = await _delegate._delegate.deleteAll('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' select: _runtimeSelect,'); + buffer.writeln(' include: _runtimeInclude,'); + buffer.writeln(' );'); + buffer.writeln( + ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Future<${model.dataClassName}?> delete() async {'); buffer.writeln(" _assertMutationQueryState(action: 'delete');"); buffer.writeln(' final row = await _delegate._delegate.delete('); diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart index ad1dc74f..b9f76201 100644 --- a/pub/orm/lib/src/runtime/core.dart +++ b/pub/orm/lib/src/runtime/core.dart @@ -975,16 +975,6 @@ final class OrmRuntimeCore implements RuntimeCore { } final page = plan.page; - if ((cursor != null || page != null) && plan.distinct.isNotEmpty) { - throw runtimeError( - 'PLAN.CURSOR_DISTINCT_UNSUPPORTED', - 'Cursor and page windows do not support distinct yet.', - details: { - 'model': model.name, - 'distinct': plan.distinct, - }, - ); - } if ((cursor != null || page != null) && plan.orderBy.isEmpty) { throw runtimeError( 'PLAN.CURSOR_ORDER_BY_REQUIRED', diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index beb28391..92a8e601 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -152,6 +152,14 @@ final class SqlAdapter required String model, }) { final read = plan.read!; + if (read.distinct.isNotEmpty) { + return _buildReadSourceQuery( + table: table, + model: model, + read: read, + selectColumns: _buildSelectColumns(read.select), + ); + } final whereParams = []; final whereClause = _buildWhereClause( model: model, @@ -432,6 +440,11 @@ final class SqlAdapter return select.map(_id).join(', '); } + String _buildAllModelColumns(ModelContract model) { + final fields = model.fields.toList(growable: false)..sort(); + return fields.map(_id).join(', '); + } + List _aggregateBaseFields(OrmReadAggregatePlan aggregate) { final fields = { ...aggregate.count, @@ -514,6 +527,14 @@ final class SqlAdapter required OrmReadPlan read, required String selectColumns, }) { + if (read.distinct.isNotEmpty) { + return _buildDistinctReadSourceQuery( + table: table, + model: model, + read: read, + selectColumns: selectColumns, + ); + } final whereParams = []; final whereClause = _buildWhereClause( model: model, @@ -554,6 +575,76 @@ final class SqlAdapter ); } + SqlStatement _buildDistinctReadSourceQuery({ + required String table, + required String model, + required OrmReadPlan read, + required String selectColumns, + }) { + final modelContract = contract.models[model]; + if (modelContract == null) { + throw ModelNotFoundException(model, contract.models.keys); + } + + final sourceColumns = _buildAllModelColumns(modelContract); + final resolvedSelectColumns = selectColumns == '*' + ? sourceColumns + : selectColumns; + final whereParams = []; + final whereClause = _buildWhereClause( + model: model, + where: read.where, + params: whereParams, + ); + final partitionOrder = _buildOrderByClause( + read.orderBy.isEmpty + ? read.distinct + .map((field) => OrmOrderBy(field)) + .toList(growable: false) + : read.orderBy, + ); + final distinctPartition = read.distinct.map(_id).join(', '); + final baseQuery = + 'SELECT $sourceColumns, ' + 'ROW_NUMBER() OVER (PARTITION BY $distinctPartition$partitionOrder) ' + 'AS ${_id('_distinct_rank')} ' + 'FROM ${_id(table)}$whereClause'; + final rankWhere = ' WHERE ${_id('_distinct_rank')} = 1'; + final windowParams = []; + final windowPredicate = _buildCursorWindowPredicate( + read: read, + params: windowParams, + ); + final mergedWhereClause = _mergeWhereClauses(rankWhere, windowPredicate); + final orderByClause = _buildOrderByClause(read.orderBy); + + if (read.page?.before != null) { + final limitParams = []; + final innerOrderByClause = _buildOrderByClause( + _reverseOrderBy(read.orderBy), + ); + final innerLimitClause = _buildReadLimitOffsetClause(read, limitParams); + return SqlStatement( + action: OrmAction.read, + text: + 'SELECT $resolvedSelectColumns FROM (' + 'SELECT * FROM ($baseQuery) AS ${_id('_distinct')}$mergedWhereClause' + '$innerOrderByClause$innerLimitClause' + ') AS ${_id('_page')}$orderByClause', + parameters: [...whereParams, ...windowParams, ...limitParams], + ); + } + + final params = [...whereParams, ...windowParams]; + return SqlStatement( + action: OrmAction.read, + text: + 'SELECT $resolvedSelectColumns FROM ($baseQuery) AS ${_id('_distinct')}' + '$mergedWhereClause$orderByClause${_buildReadLimitOffsetClause(read, params)}', + parameters: params, + ); + } + String _buildWhereClause({ required String model, required JsonMap where, diff --git a/pub/orm/test/client/api_surface_test.dart b/pub/orm/test/client/api_surface_test.dart index 6e3c447f..7ce12c16 100644 --- a/pub/orm/test/client/api_surface_test.dart +++ b/pub/orm/test/client/api_surface_test.dart @@ -123,7 +123,7 @@ void main() { ); test( - 'inspectPlan marks stream as bufferedYield when distinct requires client-side collection', + 'inspectPlan keeps distinct streams native when execution handles deduplication', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); @@ -137,15 +137,15 @@ void main() { inspected['terminalExecution'] as Map; final stream = execution['stream'] as Map; - expect(stream['delivery'], 'bufferedYield'); - expect(stream['degraded'], isTrue); - expect(stream['reasons'], ['distinct']); - expect(stream['distinctAppliedAt'], 'client'); + expect(stream['delivery'], 'nativeStream'); + expect(stream['degraded'], isFalse); + expect(stream['reasons'], isEmpty); + expect(stream['distinctAppliedAt'], 'engine'); }, ); test( - 'inspectPlan marks pageResult as client-windowed when distinct requires buffered paging', + 'inspectPlan exposes engine-backed distinct page envelopes', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); @@ -162,9 +162,9 @@ void main() { final pageResult = execution['pageResult'] as Map; expect(pageResult['delivery'], 'pageEnvelope'); - expect(pageResult['degraded'], isTrue); - expect(pageResult['windowAppliedAt'], 'client'); - expect(pageResult['reasons'], ['distinct']); + expect(pageResult['degraded'], isFalse); + expect(pageResult['windowAppliedAt'], 'engine'); + expect(pageResult['reasons'], isEmpty); }, ); @@ -899,10 +899,20 @@ void main() { ); }); - test('updateCount and deleteCount require where() first', () { + test('batch mutation terminals require where() first', () { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); + expect( + () => users.query().updateAll(data: {'email': 'x'}), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_WHERE_REQUIRED', + ), + ), + ); expect( () => users.query().updateCount(data: {'email': 'x'}), throwsA( @@ -923,6 +933,53 @@ void main() { ), ), ); + expect( + () => users.query().deleteAll(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_WHERE_REQUIRED', + ), + ), + ); + }); + + test('updateAll updates matching rows and returns shaped rows', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'a@x.com'}, + {'id': 'u3', 'email': 'b@x.com'}, + ], + ); + + final rows = await users + .query() + .where({'email': 'a@x.com'}) + .select(const ['id', 'email']) + .updateAll(data: {'email': 'updated@x.com'}); + + expect(rows, hasLength(2)); + expect( + rows.map((row) => row['email']).toList(growable: false), + ['updated@x.com', 'updated@x.com'], + ); + expect( + rows.every( + (row) => + row.keys.length == 2 && + row.keys.toSet().containsAll(const {'id', 'email'}), + ), + isTrue, + ); + } finally { + await client.disconnect(); + } }); test('updateCount updates matching rows and returns affected count', () async { @@ -955,8 +1012,42 @@ void main() { } }); + test('deleteAll deletes matching rows and returns deleted rows', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + try { + final users = client.db.orm.model('User'); + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'a@x.com'}, + {'id': 'u3', 'email': 'b@x.com'}, + ], + ); + + final rows = await users + .query() + .where({'email': 'a@x.com'}) + .select(const ['id', 'email']) + .deleteAll(); + + expect(rows, hasLength(2)); + expect( + rows.map((row) => row['email']).toList(growable: false), + ['a@x.com', 'a@x.com'], + ); + final remaining = await users.orderByField('id').all(); + expect( + remaining.map((row) => row['id']).toList(growable: false), + ['u3'], + ); + } finally { + await client.disconnect(); + } + }); + test( - 'pageResult executes distinct windows with client-side paging semantics', + 'pageResult executes distinct windows through engine-backed semantics', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 4759f9cf..603ab22b 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -1346,7 +1346,7 @@ void main() { await client.disconnect(); }); - test('supports createMany and deleteCount helpers', () async { + test('supports batch mutation helpers', () async { final engine = _CountingEngine(inner: MemoryEngine()); final client = OrmClient(contract: contract, engine: engine); await client.connect(); @@ -1393,10 +1393,43 @@ void main() { [0, 1, 2], ); + engine.reset(); + final updatedRows = await users + .query() + .where({'email': 'a@x.com'}) + .select(const ['id', 'email']) + .updateAll(data: {'email': 'updated@x.com'}); + expect(updatedRows, hasLength(2)); + expect( + engine.executedPlans.map((plan) => plan.action).toList(growable: false), + [OrmAction.read, OrmAction.update, OrmAction.update], + ); + final updateAllTraces = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final updateAllOperationId = updateAllTraces.first.operationId; + expect(updateAllOperationId, isNotNull); + expect( + updateAllTraces.map((trace) => trace.operationId).toSet(), + {updateAllOperationId}, + ); + expect( + updateAllTraces.map((trace) => trace.kind).toList(growable: false), + ['User.updateAll', 'User.updateAll', 'User.updateAll'], + ); + expect( + updateAllTraces.map((trace) => trace.phase).toList(growable: false), + ['batch.lookup', 'item.update', 'item.update'], + ); + expect( + updateAllTraces.map((trace) => trace.itemIndex).toList(growable: false), + [null, 0, 1], + ); + engine.reset(); final updated = await users.updateCount( - where: {'email': 'a@x.com'}, - data: {'email': 'updated@x.com'}, + where: {'email': 'updated@x.com'}, + data: {'email': 'counted@x.com'}, ); expect(updated, 2); expect( @@ -1432,14 +1465,47 @@ void main() { [null, 0, 1], ); + engine.reset(); + final deletedRows = await users + .query() + .where({'email': 'counted@x.com'}) + .select(const ['id', 'email']) + .deleteAll(); + expect(deletedRows, hasLength(2)); + expect( + engine.executedPlans.map((plan) => plan.action).toList(growable: false), + [OrmAction.read, OrmAction.delete, OrmAction.delete], + ); + final deleteAllTraces = engine.executedPlans + .map(_readRepositoryTrace) + .toList(growable: false); + final deleteAllOperationId = deleteAllTraces.first.operationId; + expect(deleteAllOperationId, isNotNull); + expect( + deleteAllTraces.map((trace) => trace.operationId).toSet(), + {deleteAllOperationId}, + ); + expect( + deleteAllTraces.map((trace) => trace.kind).toList(growable: false), + ['User.deleteAll', 'User.deleteAll', 'User.deleteAll'], + ); + expect( + deleteAllTraces.map((trace) => trace.phase).toList(growable: false), + ['batch.lookup', 'item.delete', 'item.delete'], + ); + expect( + deleteAllTraces.map((trace) => trace.itemIndex).toList(growable: false), + [null, 0, 1], + ); + engine.reset(); final deleted = await users.deleteCount( - where: {'email': 'updated@x.com'}, + where: {'email': 'b@x.com'}, ); - expect(deleted, 2); + expect(deleted, 1); expect( engine.executedPlans.map((plan) => plan.action).toList(growable: false), - [OrmAction.delete, OrmAction.delete, OrmAction.delete], + [OrmAction.delete, OrmAction.delete], ); final deleteTraces = engine.executedPlans .map(_readRepositoryTrace) @@ -1451,27 +1517,27 @@ void main() { }); expect( deleteTraces.map((trace) => trace.kind).toList(growable: false), - ['User.deleteCount', 'User.deleteCount', 'User.deleteCount'], + ['User.deleteCount', 'User.deleteCount'], ); expect( deleteTraces.map((trace) => trace.phase).toList(growable: false), - ['item.delete', 'item.delete', 'item.delete'], + ['item.delete', 'item.delete'], ); expect( deleteTraces.map((trace) => trace.strategy).toList(growable: false), - ['transaction', 'transaction', 'transaction'], + ['transaction', 'transaction'], ); expect( deleteTraces.map((trace) => trace.step).toList(growable: false), - [1, 2, 3], + [1, 2], ); expect( deleteTraces.map((trace) => trace.itemIndex).toList(growable: false), - [0, 1, 2], + [0, 1], ); final remaining = await users.count(); - expect(remaining, 1); + expect(remaining, 0); await client.disconnect(); }); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 775b877c..8594ec6a 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -195,6 +195,14 @@ void main() { reason: 'Expected ModelQuery.createMany(...) to terminate through the private mutation helper.', ); + expect( + RegExp( + r"class\s+ModelQuery\s*\{[\s\S]*?Future>\s+updateAll\(\{required\s+JsonMap\s+data\}\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'updateAll',\s*requireWhere:\s*true\);[\s\S]*?return\s+_delegate\._updateAll\(data:\s*data,\s*spec:\s*_state\);", + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.updateAll(...) to require where() and terminate through the private mutation helper.', + ); expect( RegExp( r"class\s+ModelQuery\s*\{[\s\S]*?Future\s+updateCount\(\{required\s+JsonMap\s+data\}\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'updateCount',\s*requireWhere:\s*true,\s*allowSelect:\s*false,\s*allowInclude:\s*false,\s*\);[\s\S]*?return\s+_delegate\._updateCount\(data:\s*data,\s*spec:\s*_state\);", @@ -203,6 +211,14 @@ void main() { reason: 'Expected ModelQuery.updateCount(...) to reject row-shaping state and terminate through the private mutation helper.', ); + expect( + RegExp( + r"class\s+ModelQuery\s*\{[\s\S]*?Future>\s+deleteAll\(\)\s*\{[\s\S]*?_assertMutationQueryState\(\s*action:\s*'deleteAll',\s*requireWhere:\s*true\);[\s\S]*?return\s+_delegate\._deleteAll\(spec:\s*_state\);", + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.deleteAll(...) to require where() and terminate through the private mutation helper.', + ); expect( RegExp( r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+deleteCount\(\)\s*\{[\s\S]*?return\s+_delegate\._deleteCount\(spec:\s*_state\);', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index c04075f5..1583c970 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1027,6 +1027,30 @@ typedef Post = ({ reason: 'Expected update where parameter to use UserWhereUniqueInput.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+updateAll\(\{\s*required\s+UserWhereInput\s+where,\s*required\s+UserUpdateInput\s+data,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.updateAll(...) to expose typed where and data input.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+updateAll\(\{\s*required\s+UserUpdateInput\s+data\}\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.updateAll(...) to expose typed update data input.', + ); + expect( + generatedSource.contains( + "_assertMutationQueryState(action: 'updateAll', requireWhere: true);", + ), + isTrue, + reason: + 'Expected UserQuery.updateAll(...) to require where() before execution.', + ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateCount\(\{\s*required\s+UserWhereInput\s+where,\s*required\s+UserUpdateInput\s+data,', @@ -1227,6 +1251,14 @@ typedef Post = ({ reason: 'Expected generated delegate updateNested(...) to route through typed query.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+updateAll\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.updateAll\(data:\s*data\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate updateAll(...) to route through typed query.', + ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+updateCount\(\{[\s\S]*?return\s+query\(where:\s*where\)\.updateCount\(data:\s*data\);', @@ -1243,6 +1275,14 @@ typedef Post = ({ reason: 'Expected generated delegate update(...) to route through typed query.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+deleteAll\(\{[\s\S]*?return\s+query\([\s\S]*?where:\s*where,[\s\S]*?select:\s*select,[\s\S]*?include:\s*include,[\s\S]*?\)\.deleteAll\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate deleteAll(...) to route through typed query.', + ); expect( RegExp( r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+deleteCount\(\{[\s\S]*?return\s+query\(where:\s*where\)\.deleteCount\(\);', @@ -1726,6 +1766,29 @@ typedef Post = ({ reason: 'Expected UserQuery.createMany(...) to exist with typed input list.', ); + expect( + RegExp( + r'Future>\s+deleteAll\(\{\s*required\s+UserWhereInput\s+where,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.deleteAll(...) to accept typed where input.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future>\s+deleteAll\s*\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.deleteAll() to exist.', + ); + expect( + generatedSource.contains( + "_assertMutationQueryState(action: 'deleteAll', requireWhere: true);", + ), + isTrue, + reason: + 'Expected UserQuery.deleteAll() to require where() before execution.', + ); expect( RegExp( r'Future\s+deleteCount\(\{\s*required\s+UserWhereInput\s+where,', diff --git a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart index 3424e910..54a5a75d 100644 --- a/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart +++ b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart @@ -223,6 +223,82 @@ void main() { await client.disconnect(); }); + test('aggregates updateAll into one operation record', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'a@x.com'}, + {'id': 'u3', 'email': 'b@x.com'}, + ], + ); + + final updated = await users + .query() + .where({'email': 'a@x.com'}) + .select(const ['id', 'email']) + .updateAll(data: {'email': 'updated@x.com'}); + + expect(updated, hasLength(2)); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.updateAll'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isTrue); + expect(telemetry?.statementCount, 3); + expect(telemetry?.affectedRows, 2); + expect( + telemetry?.steps.map((step) => step.trace.phase).toList(), + ['batch.lookup', 'item.update', 'item.update'], + ); + expect( + telemetry?.steps.map((step) => step.trace.itemIndex).toList(), + [null, 0, 1], + ); + await client.disconnect(); + }); + + test('aggregates deleteAll into one operation record', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.createMany( + data: [ + {'id': 'u1', 'email': 'a@x.com'}, + {'id': 'u2', 'email': 'a@x.com'}, + {'id': 'u3', 'email': 'b@x.com'}, + ], + ); + + final deleted = await users + .query() + .where({'email': 'a@x.com'}) + .select(const ['id', 'email']) + .deleteAll(); + + expect(deleted, hasLength(2)); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.deleteAll'); + expect(telemetry?.outcome, RuntimeTelemetryOutcome.success); + expect(telemetry?.completed, isTrue); + expect(telemetry?.statementCount, 3); + expect(telemetry?.affectedRows, 2); + expect( + telemetry?.steps.map((step) => step.trace.phase).toList(), + ['batch.lookup', 'item.delete', 'item.delete'], + ); + expect( + telemetry?.steps.map((step) => step.trace.itemIndex).toList(), + [null, 0, 1], + ); + await client.disconnect(); + }); + test('aggregates pageResult probes into one operation record', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); diff --git a/pub/orm/test/sql/sql_adapter_test.dart b/pub/orm/test/sql/sql_adapter_test.dart index 7ebf1f16..5850abd0 100644 --- a/pub/orm/test/sql/sql_adapter_test.dart +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -191,6 +191,63 @@ void main() { expect(statement.parameters, [4, 2]); }); + test('lowers distinct cursor reads through ranked deduplication', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + orderBy: const [OrmOrderBy('email'), OrmOrderBy('id')], + distinct: const ['email'], + cursor: OrmReadCursorPlan( + values: const {'email': 'a@example.com', 'id': 2}, + ), + take: 2, + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT "email", "id" FROM (SELECT "email", "id", ' + 'ROW_NUMBER() OVER (PARTITION BY "email" ORDER BY "email" ASC, "id" ASC) ' + 'AS "_distinct_rank" FROM "users") AS "_distinct" ' + 'WHERE "_distinct_rank" = 1 AND ((("email" > ?) OR ("email" = ? AND "id" > ?)) OR ("email" = ? AND "id" = ?)) ' + 'ORDER BY "email" ASC, "id" ASC LIMIT ?', + ); + expect( + statement.parameters, + ['a@example.com', 'a@example.com', 2, 'a@example.com', 2, 2], + ); + }); + + test('lowers distinct page before reads through ranked deduplication', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + orderBy: const [OrmOrderBy('email'), OrmOrderBy('id')], + distinct: const ['email'], + page: OrmReadPagePlan( + size: 2, + before: const {'email': 'c@example.com', 'id': 4}, + ), + ); + + final statement = adapter.lower(plan); + expect( + statement.text, + 'SELECT "email", "id" FROM (SELECT * FROM (SELECT "email", "id", ' + 'ROW_NUMBER() OVER (PARTITION BY "email" ORDER BY "email" ASC, "id" ASC) ' + 'AS "_distinct_rank" FROM "users") AS "_distinct" ' + 'WHERE "_distinct_rank" = 1 AND (("email" < ?) OR ("email" = ? AND "id" < ?)) ' + 'ORDER BY "email" DESC, "id" DESC LIMIT ?) AS "_page" ' + 'ORDER BY "email" ASC, "id" ASC', + ); + expect( + statement.parameters, + ['c@example.com', 'c@example.com', 4, 2], + ); + }); + test('lowers aggregate read shapes through a windowed subquery', () { final adapter = SqlAdapter(contract: contract); final plan = readPlan( From a14ac543782bb7d6fb645cc7a3f26a5c3cac42b5 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:20:40 +0800 Subject: [PATCH 146/154] refactor(orm)!: remove grouped havingWith wrappers --- docs/orm-v6-api-surface.md | 9 +++++---- pub/orm/lib/src/client/client.dart | 8 -------- pub/orm/lib/src/generator/writer.dart | 8 -------- pub/orm/test/client/source_surface_test.dart | 8 ++++++++ pub/orm/test/generator/generate_test.dart | 8 ++++++++ 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index d25cf75c..a89f8007 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -106,12 +106,13 @@ Rules: This makes stream delivery explicit: - `stream()` stays `nativeStream` only when the repository can yield rows directly from the runtime response. - - `include(...)` or `distinct(...)` force `stream()` to - `bufferedYield`, with reasons and include strategy surfaced in - `terminalExecution.stream`. + - `include(...)` forces `stream()` to `bufferedYield`, with reasons and + include strategy surfaced in `terminalExecution.stream`. + - `distinct(...)` remains `nativeStream` when execution can apply + deduplication directly. 8. Grouped aggregation is a dedicated surface: - `groupedBy(...)` only accepts a where-only base query. - - `having(...)` and `havingWith(...)` accept structured grouped predicates. + - `having(...)` accepts structured grouped predicates. - `havingExpr(...)` is the primary builder-style entrypoint before `aggregate(...)`. 9. `include(...)` is unsupported on `aggregate(...)` and diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 4ff7b96e..345bc30a 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3420,14 +3420,6 @@ final class ModelGroupedQuery { return _next(_groupBy.copyWith(having: nextHaving)); } - ModelGroupedQuery havingWith( - OrmGroupByHaving Function(OrmGroupByHaving having) build, { - bool merge = true, - }) { - final next = build(_groupBy.having); - return having(next, merge: merge); - } - ModelGroupedQuery havingExpr( OrmGroupByHaving Function(OrmGroupByHavingBuilder having) build, { bool merge = true, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 0f9c6a79..b0324875 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -3330,14 +3330,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln( - ' ${model.groupedQueryClassName} havingWith(${model.groupByHavingClassName} Function(${model.groupByHavingClassName} having) build, {bool merge = true}) {', - ); - buffer.writeln(' final next = build(_groupBy.having);'); - buffer.writeln(' return having(next, merge: merge);'); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( ' ${model.groupedQueryClassName} havingExpr(${model.groupByHavingClassName} Function(${model.groupByHavingClassName}Builder having) build, {bool merge = true}) {', ); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 8594ec6a..7087c7ec 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -125,6 +125,14 @@ void main() { reason: 'Expected ModelGroupedQuery.toPlan() to expose the grouped aggregate plan surface.', ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+havingWith\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to avoid redundant havingWith(...) wrappers.', + ); expect( RegExp( r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+havingExpr\(\s*OrmGroupByHaving\s+Function\(OrmGroupByHavingBuilder\s+having\)\s+build,\s*\{\s*bool\s+merge\s*=\s*true,\s*\}\s*\)', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 1583c970..28ac612f 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1670,6 +1670,14 @@ typedef Post = ({ reason: 'Expected UserGroupedQuery.aggregateWith(...) to reuse the runtime grouped builder execution path.', ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?UserGroupedQuery\s+havingWith\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupedQuery to avoid redundant havingWith(...) wrappers.', + ); expect( RegExp( r'class\s+UserGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+_runtimeGrouped\(UserGroupBySpec\s+groupBy\)\s*\{[\s\S]*?\.groupedBy\([\s\S]*?where:\s*_where\.toJson\(\),[\s\S]*?\.configure\(groupBy\.toRuntimeSpec\(\)\);', From c97de126b143efeb24a15a7d48d7d655aefd9583 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:36:22 +0800 Subject: [PATCH 147/154] feat(orm)!: tighten grouped having contracts --- docs/orm-v6-api-surface.md | 3 + pub/orm/lib/src/client/client.dart | 37 +--- pub/orm/lib/src/engine/memory_engine.dart | 19 -- pub/orm/lib/src/generator/writer.dart | 199 +++++++++---------- pub/orm/lib/src/runtime/plan.dart | 94 ++++----- pub/orm/lib/src/sql/adapter.dart | 34 ---- pub/orm/test/client/client_test.dart | 52 +++++ pub/orm/test/client/source_surface_test.dart | 16 ++ pub/orm/test/generator/generate_test.dart | 28 ++- 9 files changed, 238 insertions(+), 244 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index a89f8007..8ebab970 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -115,6 +115,9 @@ Rules: - `having(...)` accepts structured grouped predicates. - `havingExpr(...)` is the primary builder-style entrypoint before `aggregate(...)`. + - grouped `having` supports comparison operators only: + `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`. + - repeated `having(...)` / `havingExpr(...)` calls compose with `AND`. 9. `include(...)` is unsupported on `aggregate(...)` and `groupedBy(...).aggregate(...)`, including direct plan execution. diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 345bc30a..1d7d0381 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -35,14 +35,9 @@ typedef IncludeExecutionStrategySelector = const int _defaultMaxIncludeDepth = 4; const Object _stateKeepToken = Object(); const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; -const Set _filterOperators = { +const Set _groupByHavingOperators = { 'equals', 'not', - 'in', - 'notIn', - 'contains', - 'startsWith', - 'endsWith', 'gt', 'gte', 'lt', @@ -2386,7 +2381,7 @@ class ModelDelegate { } final unknownOperators = conditionMap.keys - .where((operator) => !_filterOperators.contains(operator)) + .where((operator) => !_groupByHavingOperators.contains(operator)) .toList(growable: false); if (unknownOperators.isNotEmpty) { throw runtimeError( @@ -2396,7 +2391,7 @@ class ModelDelegate { 'model': modelName, 'source': source, 'unknownOperators': unknownOperators, - 'supportedOperators': _filterOperators.toList(growable: false), + 'supportedOperators': _groupByHavingOperators.toList(growable: false), }, ); } @@ -2404,17 +2399,6 @@ class ModelDelegate { for (final entry in conditionMap.entries) { final operator = entry.key; final operand = entry.value; - if ((operator == 'in' || operator == 'notIn') && operand is! List) { - throw runtimeError( - 'PLAN.GROUP_BY_HAVING_OPERATOR_INVALID', - 'GroupBy having in/notIn expects a list operand.', - details: { - 'model': modelName, - 'source': '$source.$operator', - 'operator': operator, - }, - ); - } if (operator == 'not') { _assertGroupByHavingCondition( condition: operand, @@ -2933,21 +2917,6 @@ final class OrmGroupByHavingPredicateBuilder { OrmGroupByHaving notEquals(Object? value) => _condition(OrmGroupByHavingCondition(not: value)); - OrmGroupByHaving inList(List values) => - _condition(OrmGroupByHavingCondition(inValues: values)); - - OrmGroupByHaving notInList(List values) => - _condition(OrmGroupByHavingCondition(notInValues: values)); - - OrmGroupByHaving contains(String value) => - _condition(OrmGroupByHavingCondition(contains: value)); - - OrmGroupByHaving startsWith(String value) => - _condition(OrmGroupByHavingCondition(startsWith: value)); - - OrmGroupByHaving endsWith(String value) => - _condition(OrmGroupByHavingCondition(endsWith: value)); - OrmGroupByHaving gt(Object? value) => _condition(OrmGroupByHavingCondition(gt: value)); diff --git a/pub/orm/lib/src/engine/memory_engine.dart b/pub/orm/lib/src/engine/memory_engine.dart index 72794a7e..0ecdec1b 100644 --- a/pub/orm/lib/src/engine/memory_engine.dart +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -859,25 +859,6 @@ final class MemoryEngine implements OrmEngine, ConnectionCapableEngine { return false; } } - if (condition.inValues != null && !_matchIn(actual, condition.inValues)) { - return false; - } - if (condition.notInValues != null && - !_matchNotIn(actual, condition.notInValues)) { - return false; - } - if (condition.contains != null && - !_matchStringOperation(actual, condition.contains, 'contains')) { - return false; - } - if (condition.startsWith != null && - !_matchStringOperation(actual, condition.startsWith, 'startsWith')) { - return false; - } - if (condition.endsWith != null && - !_matchStringOperation(actual, condition.endsWith, 'endsWith')) { - return false; - } if (condition.gt != null && !_matchComparison(actual, condition.gt, 'gt')) { return false; } diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index b0324875..5a972839 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -686,17 +686,17 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln('class ${model.groupByHavingConditionClassName} {'); - buffer.writeln(' final Object? value;'); + buffer.writeln(' final OrmGroupByHavingCondition _value;'); buffer.writeln(); buffer.writeln( - ' const ${model.groupByHavingConditionClassName}._(this.value);', + ' const ${model.groupByHavingConditionClassName}._(this._value);', ); buffer.writeln(); buffer.writeln( ' static ${model.groupByHavingConditionClassName} equals(Object? value) {', ); buffer.writeln( - ' return ${model.groupByHavingConditionClassName}._(value);', + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(equals: value));', ); buffer.writeln(' }'); buffer.writeln(); @@ -704,7 +704,7 @@ final class TypedClientWriter { ' static ${model.groupByHavingConditionClassName} notEquals(Object? value) {', ); buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'not': value});", + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(not: value));', ); buffer.writeln(' }'); buffer.writeln(); @@ -712,47 +712,7 @@ final class TypedClientWriter { ' static ${model.groupByHavingConditionClassName} not(${model.groupByHavingConditionClassName} condition) {', ); buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'not': condition.toJsonValue()});", - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( - ' static ${model.groupByHavingConditionClassName} inList(List values) {', - ); - buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'in': values});", - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( - ' static ${model.groupByHavingConditionClassName} notInList(List values) {', - ); - buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'notIn': values});", - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( - ' static ${model.groupByHavingConditionClassName} contains(String value) {', - ); - buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'contains': value});", - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( - ' static ${model.groupByHavingConditionClassName} startsWith(String value) {', - ); - buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'startsWith': value});", - ); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( - ' static ${model.groupByHavingConditionClassName} endsWith(String value) {', - ); - buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'endsWith': value});", + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(not: condition.toJsonValue()));', ); buffer.writeln(' }'); buffer.writeln(); @@ -760,7 +720,7 @@ final class TypedClientWriter { ' static ${model.groupByHavingConditionClassName} gt(Object? value) {', ); buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'gt': value});", + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(gt: value));', ); buffer.writeln(' }'); buffer.writeln(); @@ -768,7 +728,7 @@ final class TypedClientWriter { ' static ${model.groupByHavingConditionClassName} gte(Object? value) {', ); buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'gte': value});", + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(gte: value));', ); buffer.writeln(' }'); buffer.writeln(); @@ -776,7 +736,7 @@ final class TypedClientWriter { ' static ${model.groupByHavingConditionClassName} lt(Object? value) {', ); buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'lt': value});", + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(lt: value));', ); buffer.writeln(' }'); buffer.writeln(); @@ -784,40 +744,41 @@ final class TypedClientWriter { ' static ${model.groupByHavingConditionClassName} lte(Object? value) {', ); buffer.writeln( - " return ${model.groupByHavingConditionClassName}._({'lte': value});", + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(lte: value));', ); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Object? toJsonValue() => value;'); + buffer.writeln(' Object? toJsonValue() => _value.toJsonValue();'); buffer.writeln('}'); buffer.writeln(); buffer.writeln('class ${model.groupByHavingClassName} {'); - buffer.writeln(' final Map value;'); + buffer.writeln(' final OrmGroupByHaving _value;'); buffer.writeln(); buffer.writeln( - ' const ${model.groupByHavingClassName}() : value = const {};', + ' const ${model.groupByHavingClassName}() : _value = const OrmGroupByHaving.empty();', ); buffer.writeln(); - buffer.writeln(' const ${model.groupByHavingClassName}._(this.value);'); + buffer.writeln(' const ${model.groupByHavingClassName}._(this._value);'); buffer.writeln(); buffer.writeln( - ' static ${model.groupByHavingClassName} raw(Map value) {', + ' static ${model.groupByHavingClassName} and(List<${model.groupByHavingClassName}> clauses) {', ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); buffer.writeln( - ' return ${model.groupByHavingClassName}._(Map.from(value));', + ' OrmGroupByHaving([', ); - buffer.writeln(' }'); - buffer.writeln(); buffer.writeln( - ' static ${model.groupByHavingClassName} and(List<${model.groupByHavingClassName}> clauses) {', + ' OrmGroupByHavingLogicalNode(', ); - buffer.writeln(' return ${model.groupByHavingClassName}._('); - buffer.writeln(' {'); buffer.writeln( - " 'AND': clauses.map((clause) => clause.toJson()).toList(growable: false),", + ' operator: OrmGroupByHavingLogicalOperator.and,', ); - buffer.writeln(' },'); + buffer.writeln( + ' clauses: clauses.map((clause) => clause._value).toList(growable: false),', + ); + buffer.writeln(' ),'); + buffer.writeln(' ]),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -825,11 +786,20 @@ final class TypedClientWriter { ' static ${model.groupByHavingClassName} or(List<${model.groupByHavingClassName}> clauses) {', ); buffer.writeln(' return ${model.groupByHavingClassName}._('); - buffer.writeln(' {'); buffer.writeln( - " 'OR': clauses.map((clause) => clause.toJson()).toList(growable: false),", + ' OrmGroupByHaving([', ); - buffer.writeln(' },'); + buffer.writeln( + ' OrmGroupByHavingLogicalNode(', + ); + buffer.writeln( + ' operator: OrmGroupByHavingLogicalOperator.or,', + ); + buffer.writeln( + ' clauses: clauses.map((clause) => clause._value).toList(growable: false),', + ); + buffer.writeln(' ),'); + buffer.writeln(' ]),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -837,11 +807,20 @@ final class TypedClientWriter { ' static ${model.groupByHavingClassName} not(List<${model.groupByHavingClassName}> clauses) {', ); buffer.writeln(' return ${model.groupByHavingClassName}._('); - buffer.writeln(' {'); buffer.writeln( - " 'NOT': clauses.map((clause) => clause.toJson()).toList(growable: false),", + ' OrmGroupByHaving([', ); - buffer.writeln(' },'); + buffer.writeln( + ' OrmGroupByHavingLogicalNode(', + ); + buffer.writeln( + ' operator: OrmGroupByHavingLogicalOperator.not,', + ); + buffer.writeln( + ' clauses: clauses.map((clause) => clause._value).toList(growable: false),', + ); + buffer.writeln(' ),'); + buffer.writeln(' ]),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -853,8 +832,12 @@ final class TypedClientWriter { ); buffer.writeln(' return ${model.groupByHavingClassName}._('); buffer.writeln( - ' {field.value: condition.toJsonValue()},', + ' OrmGroupByHaving([', + ); + buffer.writeln( + ' OrmGroupByHavingPredicateNode(field: field.value, condition: condition._value),', ); + buffer.writeln(' ]),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -865,11 +848,13 @@ final class TypedClientWriter { ') {', ); buffer.writeln(' return ${model.groupByHavingClassName}._('); - buffer.writeln(' {'); buffer.writeln( - " '_count': {field.value: condition.toJsonValue()},", + ' OrmGroupByHaving([', ); - buffer.writeln(' },'); + buffer.writeln( + ' OrmGroupByHavingPredicateNode(field: field.value, condition: condition._value, bucket: OrmGroupByHavingMetricBucket.count),', + ); + buffer.writeln(' ]),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -879,11 +864,13 @@ final class TypedClientWriter { ') {', ); buffer.writeln(' return ${model.groupByHavingClassName}._('); - buffer.writeln(' {'); buffer.writeln( - " '_count': {'all': condition.toJsonValue()},", + ' OrmGroupByHaving([', ); - buffer.writeln(' },'); + buffer.writeln( + " OrmGroupByHavingPredicateNode(field: 'all', condition: condition._value, bucket: OrmGroupByHavingMetricBucket.count),", + ); + buffer.writeln(' ]),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -895,11 +882,13 @@ final class TypedClientWriter { ') {', ); buffer.writeln(' return ${model.groupByHavingClassName}._('); - buffer.writeln(' {'); buffer.writeln( - " '_$bucket': {field.value: condition.toJsonValue()},", + ' OrmGroupByHaving([', + ); + buffer.writeln( + ' OrmGroupByHavingPredicateNode(field: field.value, condition: condition._value, bucket: OrmGroupByHavingMetricBucket.$bucket),', ); - buffer.writeln(' },'); + buffer.writeln(' ]),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); @@ -908,19 +897,19 @@ final class TypedClientWriter { ' ${model.groupByHavingClassName} merge(${model.groupByHavingClassName} other) {', ); buffer.writeln( - ' return ${model.groupByHavingClassName}._({...value, ...other.value});', + ' return ${model.groupByHavingClassName}._(_value.merge(other._value));', ); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' OrmGroupByHaving toRuntimeHaving() {'); - buffer.writeln(' return OrmGroupByHaving.parse(toJson());'); + buffer.writeln(' return _value;'); buffer.writeln(' }'); buffer.writeln(); buffer.writeln(' Map toJson() {'); - buffer.writeln(' return Map.from(value);'); + buffer.writeln(' return _value.toJson();'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' bool get isEmpty => value.isEmpty;'); + buffer.writeln(' bool get isEmpty => _value.isEmpty;'); buffer.writeln('}'); buffer.writeln(); @@ -991,26 +980,6 @@ final class TypedClientWriter { ' ${model.groupByHavingClassName} notEquals(Object? value) => _build(${model.groupByHavingConditionClassName}.notEquals(value));', ); buffer.writeln(); - buffer.writeln( - ' ${model.groupByHavingClassName} inList(List values) => _build(${model.groupByHavingConditionClassName}.inList(values));', - ); - buffer.writeln(); - buffer.writeln( - ' ${model.groupByHavingClassName} notInList(List values) => _build(${model.groupByHavingConditionClassName}.notInList(values));', - ); - buffer.writeln(); - buffer.writeln( - ' ${model.groupByHavingClassName} contains(String value) => _build(${model.groupByHavingConditionClassName}.contains(value));', - ); - buffer.writeln(); - buffer.writeln( - ' ${model.groupByHavingClassName} startsWith(String value) => _build(${model.groupByHavingConditionClassName}.startsWith(value));', - ); - buffer.writeln(); - buffer.writeln( - ' ${model.groupByHavingClassName} endsWith(String value) => _build(${model.groupByHavingConditionClassName}.endsWith(value));', - ); - buffer.writeln(); buffer.writeln( ' ${model.groupByHavingClassName} gt(Object? value) => _build(${model.groupByHavingConditionClassName}.gt(value));', ); @@ -2889,6 +2858,31 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); + buffer.writeln(' void _assertAggregateQueryState() {'); + buffer.writeln(' final invalidKeys = ['); + buffer.writeln(" if (_skip != null) 'skip',"); + buffer.writeln(" if (_take != null) 'take',"); + buffer.writeln(" if (_distinct.isNotEmpty) 'distinct',"); + buffer.writeln(" if (_select != null) 'select',"); + buffer.writeln(" if (_include != null) 'include',"); + buffer.writeln(' ];'); + buffer.writeln(' if (invalidKeys.isEmpty) {'); + buffer.writeln(' return;'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' throw runtimeError('); + buffer.writeln(" 'PLAN.AGGREGATE_QUERY_STATE_INVALID',"); + buffer.writeln( + " 'aggregate() does not allow query state keys: \${invalidKeys.join(', ')}.',", + ); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'invalidKeys': invalidKeys,"); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' void _assertGroupedQueryBaseState() {'); buffer.writeln(' final invalidKeys = ['); buffer.writeln(" if (_skip != null) 'skip',"); @@ -3045,6 +3039,7 @@ final class TypedClientWriter { ' Future<${model.aggregateResultClassName}> aggregateWith(${model.aggregateSpecClassName} aggregate) {', ); buffer.writeln(" _assertReadExecutionSupported('aggregate');"); + buffer.writeln(' _assertAggregateQueryState();'); buffer.writeln(' return _delegate._delegate.aggregateWith('); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' orderBy: _runtimeOrderBy,'); diff --git a/pub/orm/lib/src/runtime/plan.dart b/pub/orm/lib/src/runtime/plan.dart index 78f6213a..0d3c7ebf 100644 --- a/pub/orm/lib/src/runtime/plan.dart +++ b/pub/orm/lib/src/runtime/plan.dart @@ -208,29 +208,21 @@ final class OrmGroupByHavingCondition { final Object? shorthand; final Object? equals; final Object? not; - final List? inValues; - final List? notInValues; - final String? contains; - final String? startsWith; - final String? endsWith; final Object? gt; final Object? gte; final Object? lt; final Object? lte; + final Map extra; const OrmGroupByHavingCondition({ this.shorthand, this.equals, this.not, - this.inValues, - this.notInValues, - this.contains, - this.startsWith, - this.endsWith, this.gt, this.gte, this.lt, this.lte, + this.extra = const {}, }); factory OrmGroupByHavingCondition.parse(Object? value) { @@ -238,18 +230,29 @@ final class OrmGroupByHavingCondition { return OrmGroupByHavingCondition(shorthand: value); } + final raw = Map.from(value); + return OrmGroupByHavingCondition( - equals: value['equals'], - not: value['not'], - inValues: _coerceObjectList(value['in']), - notInValues: _coerceObjectList(value['notIn']), - contains: value['contains'] as String?, - startsWith: value['startsWith'] as String?, - endsWith: value['endsWith'] as String?, - gt: value['gt'], - gte: value['gte'], - lt: value['lt'], - lte: value['lte'], + equals: raw['equals'], + not: raw['not'], + gt: raw['gt'], + gte: raw['gte'], + lt: raw['lt'], + lte: raw['lte'], + extra: Map.unmodifiable( + Map.fromEntries( + raw.entries.where( + (entry) => !const { + 'equals', + 'not', + 'gt', + 'gte', + 'lt', + 'lte', + }.contains(entry.key), + ), + ), + ), ); } @@ -257,15 +260,11 @@ final class OrmGroupByHavingCondition { shorthand == null && equals == null && not == null && - inValues == null && - notInValues == null && - contains == null && - startsWith == null && - endsWith == null && gt == null && gte == null && lt == null && - lte == null; + lte == null && + extra.isEmpty; Object? toJsonValue() { if (shorthand != null) { @@ -279,21 +278,6 @@ final class OrmGroupByHavingCondition { if (not != null) { map['not'] = not; } - if (inValues != null) { - map['in'] = inValues; - } - if (notInValues != null) { - map['notIn'] = notInValues; - } - if (contains != null) { - map['contains'] = contains; - } - if (startsWith != null) { - map['startsWith'] = startsWith; - } - if (endsWith != null) { - map['endsWith'] = endsWith; - } if (gt != null) { map['gt'] = gt; } @@ -306,6 +290,9 @@ final class OrmGroupByHavingCondition { if (lte != null) { map['lte'] = lte; } + if (extra.isNotEmpty) { + map.addAll(extra); + } return map; } } @@ -416,14 +403,20 @@ final class OrmGroupByHaving { bool get isNotEmpty => nodes.isNotEmpty; OrmGroupByHaving merge(OrmGroupByHaving other) => - OrmGroupByHaving.parse({...toJson(), ...other.toJson()}); + OrmGroupByHaving([...nodes, ...other.nodes]); JsonMap toJson() { - final map = {}; - for (final node in nodes) { - map.addAll(node.toJson()); + if (nodes.isEmpty) { + return const {}; } - return map; + if (nodes.length == 1) { + return nodes.single.toJson(); + } + return { + 'AND': nodes + .map((node) => OrmGroupByHaving([node]).toJson()) + .toList(growable: false), + }; } } @@ -453,13 +446,6 @@ final class OrmReadGroupByPlan { }; } -List? _coerceObjectList(Object? value) { - if (value is! List) { - return null; - } - return List.unmodifiable(value.cast()); -} - OrmGroupByHavingLogicalOperator? _parseLogicalOperator(String key) { return switch (key) { 'AND' => OrmGroupByHavingLogicalOperator.and, diff --git a/pub/orm/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart index 92a8e601..77baad5b 100644 --- a/pub/orm/lib/src/sql/adapter.dart +++ b/pub/orm/lib/src/sql/adapter.dart @@ -1411,11 +1411,6 @@ final class SqlAdapter final operand = switch (operator) { 'equals' => condition.equals, 'not' => condition.not, - 'in' => condition.inValues, - 'notIn' => condition.notInValues, - 'contains' => condition.contains, - 'startsWith' => condition.startsWith, - 'endsWith' => condition.endsWith, 'gt' => condition.gt, 'gte' => condition.gte, 'lt' => condition.lt, @@ -1464,35 +1459,6 @@ final class SqlAdapter } params.add(operand); return '$leftOperand <> ?'; - case 'in': - final values = _coerceListOperand(operand); - if (values.isEmpty) { - return '0 = 1'; - } - params.addAll(values); - return '$leftOperand IN (${List.filled(values.length, '?').join(', ')})'; - case 'notIn': - final values = _coerceListOperand(operand); - if (values.isEmpty) { - return '1 = 1'; - } - params.addAll(values); - return '$leftOperand NOT IN (${List.filled(values.length, '?').join(', ')})'; - case 'contains': - case 'startsWith': - case 'endsWith': - if (operand is! String) { - return '0 = 1'; - } - final escaped = _escapeLikePattern(operand); - final pattern = switch (operator) { - 'contains' => '%$escaped%', - 'startsWith' => '$escaped%', - 'endsWith' => '%$escaped', - _ => escaped, - }; - params.add(pattern); - return "$leftOperand LIKE ? ESCAPE '\\'"; case 'gt': params.add(operand); return '$leftOperand > ?'; diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 603ab22b..fd38c2d1 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -658,6 +658,30 @@ void main() { await client.disconnect(); }); + test('merges repeated groupBy having clauses with AND semantics', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + await users.create(data: {'id': 2, 'email': 'a@x.com'}); + await users.create(data: {'id': 10, 'email': 'b@x.com'}); + await users.create(data: {'id': 20, 'email': 'b@x.com'}); + await users.create(data: {'id': 30, 'email': 'b@x.com'}); + + final grouped = await users + .query() + .groupedBy(const ['email']) + .havingExpr((having) => having.countAll().gte(2), merge: false) + .havingExpr((having) => having.countAll().lte(2)) + .aggregate((aggregate) => aggregate.countAll().sum('id')); + + expect(grouped, hasLength(1)); + expect(grouped.single['email'], 'a@x.com'); + expect(grouped.single['count'], {'all': 2}); + await client.disconnect(); + }); + test('rejects groupedBy when row-query state is already present', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); await client.connect(); @@ -754,6 +778,34 @@ void main() { await client.disconnect(); }); + test('rejects unsupported grouped having operators', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.model('User'); + + await expectLater( + users + .groupedBy(const ['email']) + .having( + OrmGroupByHaving.parse(const { + '_count': { + 'all': {'in': [1, 2]}, + }, + }), + merge: false, + ) + .aggregate((aggregate) => aggregate.countAll()), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.GROUP_BY_HAVING_OPERATOR_INVALID', + ), + ), + ); + await client.disconnect(); + }); + test('aggregate rejects empty aggregate builder', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 7087c7ec..4296e69b 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -163,6 +163,22 @@ void main() { reason: 'Expected dynamic client source to include OrmGroupByHavingBuilder.', ); + expect( + RegExp( + r'class\s+OrmGroupByHavingPredicateBuilder\s*\{[\s\S]*?inList\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected grouped having predicate builders to avoid list-based operators.', + ); + expect( + RegExp( + r'class\s+OrmGroupByHavingPredicateBuilder\s*\{[\s\S]*?(contains|startsWith|endsWith)\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected grouped having predicate builders to avoid string pattern operators.', + ); expect( RegExp( r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+orderBy\(', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 28ac612f..1411feef 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1610,7 +1610,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregateWith\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?aggregate:\s*aggregate\.toRuntimeSpec\(\),[\s\S]*?then\(UserAggregateResult\.fromJson\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregateWith\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_assertAggregateQueryState\(\);[\s\S]*?aggregate:\s*aggregate\.toRuntimeSpec\(\),[\s\S]*?then\(UserAggregateResult\.fromJson\)', ).hasMatch(generatedSource), isTrue, reason: @@ -1708,6 +1708,14 @@ typedef Post = ({ reason: 'Expected generated source to include typed groupBy having helper.', ); + expect( + RegExp( + r'class\s+UserGroupByHaving\s*\{[\s\S]*?raw\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected typed grouped having to avoid raw map escape hatches.', + ); expect( RegExp(r'\bclass UserAggregateBuilder\b').hasMatch(generatedSource), isTrue, @@ -1736,6 +1744,24 @@ typedef Post = ({ reason: 'Expected generated source to keep grouped orderBy out of the typed public surface.', ); + expect( + generatedSource.contains( + 'UserGroupByHaving inList(List values)', + ), + isFalse, + reason: + 'Expected typed grouped having predicate builders to avoid list-based operators.', + ); + expect( + generatedSource.contains('UserGroupByHaving contains(String value)') || + generatedSource.contains( + 'UserGroupByHaving startsWith(String value)', + ) || + generatedSource.contains('UserGroupByHaving endsWith(String value)'), + isFalse, + reason: + 'Expected typed grouped having predicate builders to avoid string pattern operators.', + ); expect( RegExp( r'class\s+UserGroupedQuery\s*\{[\s\S]*?UserGroupedQuery\s+orderBy\(', From 7943c3849e0325fb739761497b9f32c91c383a73 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:43:30 +0800 Subject: [PATCH 148/154] feat(orm)!: flatten aggregate result surfaces --- pub/orm/lib/src/client/client.dart | 2 - pub/orm/lib/src/generator/writer.dart | 155 ++++++------------- pub/orm/test/client/source_surface_test.dart | 8 + pub/orm/test/generator/generate_test.dart | 92 +++++------ 4 files changed, 97 insertions(+), 160 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 1d7d0381..7faa381f 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3367,8 +3367,6 @@ final class ModelGroupedQuery { List get byFields => _groupBy.by; - JsonMap get havingClause => _groupBy.having.toJson(); - ModelGroupedQuery configure(OrmGroupBySpec groupBy) { if (!_sameStringList(left: _groupBy.by, right: groupBy.by)) { throw runtimeError( diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 5a972839..e840288f 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -616,24 +616,6 @@ final class TypedClientWriter { final relationFields = model.model.fields .where((field) => field.isRelation) .toList(growable: false); - final aggregateCountBucketClassName = - '${model.classBaseName}AggregateCountBucket'; - final aggregateMinBucketClassName = - '${model.classBaseName}AggregateMinBucket'; - final aggregateMaxBucketClassName = - '${model.classBaseName}AggregateMaxBucket'; - final aggregateSumBucketClassName = - '${model.classBaseName}AggregateSumBucket'; - final aggregateAvgBucketClassName = - '${model.classBaseName}AggregateAvgBucket'; - final aggregateBucketClassNames = { - 'count': aggregateCountBucketClassName, - 'min': aggregateMinBucketClassName, - 'max': aggregateMaxBucketClassName, - 'sum': aggregateSumBucketClassName, - 'avg': aggregateAvgBucketClassName, - }; - buffer.writeln('class ${model.distinctClassName} {'); buffer.writeln(' final String value;'); buffer.writeln(); @@ -998,15 +980,6 @@ final class TypedClientWriter { buffer.writeln('}'); buffer.writeln(); - for (final entry in aggregateBucketClassNames.entries) { - _writeAggregateBucketClass( - buffer: buffer, - scalarFields: scalarFields, - className: entry.value, - bucket: entry.key, - ); - } - buffer.writeln('class ${model.aggregateSpecClassName} {'); buffer.writeln(' final bool countAll;'); buffer.writeln(' final List<${model.distinctClassName}> count;'); @@ -1223,18 +1196,11 @@ final class TypedClientWriter { buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' int? get countAll => count.all;'); - buffer.writeln(); - for (final entry in aggregateBucketClassNames.entries) { - buffer.writeln(' ${entry.value} get ${entry.key} {'); - buffer.writeln(' return ${entry.value}._('); - buffer.writeln( - " _readJsonMap(_value['${entry.key}']) ?? const {},", - ); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(); - } + _writeAggregateResultGetters( + buffer: buffer, + scalarFields: scalarFields, + sourceAccessor: '_value', + ); buffer.writeln(' Map toJson() {'); buffer.writeln(' return Map.from(_value);'); buffer.writeln(' }'); @@ -1264,28 +1230,11 @@ final class TypedClientWriter { buffer.writeln(' $fieldType get $memberName => $decode;'); } buffer.writeln(); - buffer.writeln(' int? get countAll => count.all;'); - buffer.writeln(); - for (final entry in aggregateBucketClassNames.entries) { - buffer.writeln(' ${entry.value} get ${entry.key} {'); - buffer.writeln(' return ${entry.value}._('); - buffer.writeln( - " _readJsonMap(_value['${entry.key}']) ?? const {},", - ); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(); - } - buffer.writeln(' ${model.aggregateResultClassName} get aggregate {'); - buffer.writeln(' return ${model.aggregateResultClassName}.fromJson('); - buffer.writeln(' {'); - for (final entry in aggregateBucketClassNames.entries) { - buffer.writeln(" '${entry.key}': ${entry.key}.toJson(),"); - } - buffer.writeln(' },'); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(); + _writeAggregateResultGetters( + buffer: buffer, + scalarFields: scalarFields, + sourceAccessor: '_value', + ); buffer.writeln(' Map toJson() {'); buffer.writeln(' return Map.from(_value);'); buffer.writeln(' }'); @@ -1694,55 +1643,51 @@ final class TypedClientWriter { buffer.writeln(); } - void _writeAggregateBucketClass({ + void _writeAggregateResultGetters({ required StringBuffer buffer, required List scalarFields, - required String className, - required String bucket, + required String sourceAccessor, }) { - final fields = scalarFields - .where( - (field) => - _supportsAggregateBucketField(field: field, bucket: bucket), - ) - .toList(growable: false); - - buffer.writeln('class $className {'); - buffer.writeln(' final Map _value;'); - buffer.writeln(); - buffer.writeln(' const $className._(this._value);'); - buffer.writeln(); - - if (bucket == 'count') { - buffer.writeln(" int? get all => _readInt(_value['all']);"); - buffer.writeln(); - } - - for (final scalarField in fields) { - final memberName = _toLowerCamelIdentifier( - scalarField.name, - fallback: 'field', - ); - final fieldName = _escapeString(scalarField.name); - final accessor = "_value['$fieldName']"; - final decode = _aggregateBucketDecodeExpression( - field: scalarField, - bucket: bucket, - accessor: accessor, - ); - final fieldType = _aggregateBucketFieldType( - field: scalarField, - bucket: bucket, - ); - buffer.writeln(' $fieldType get $memberName => $decode;'); - buffer.writeln(); + for (final bucket in const ['count', 'min', 'max', 'sum', 'avg']) { + if (bucket == 'count') { + buffer.writeln( + " int? get countAll => _readInt(_readJsonMap($sourceAccessor['count'])?['all']);", + ); + buffer.writeln(); + } + for (final scalarField in scalarFields) { + if (!_supportsAggregateBucketField(field: scalarField, bucket: bucket)) { + continue; + } + final getterName = _aggregateBucketGetterName( + bucket: bucket, + field: scalarField.name, + ); + final fieldName = _escapeString(scalarField.name); + final accessor = + "_readJsonMap($sourceAccessor['$bucket'])?['$fieldName']"; + final decode = _aggregateBucketDecodeExpression( + field: scalarField, + bucket: bucket, + accessor: accessor, + ); + final fieldType = _aggregateBucketFieldType( + field: scalarField, + bucket: bucket, + ); + buffer.writeln(' $fieldType get $getterName => $decode;'); + buffer.writeln(); + } } + } - buffer.writeln(' Map toJson() {'); - buffer.writeln(' return Map.from(_value);'); - buffer.writeln(' }'); - buffer.writeln('}'); - buffer.writeln(); + String _aggregateBucketGetterName({ + required String bucket, + required String field, + }) { + final bucketPart = _toLowerCamelIdentifier(bucket, fallback: 'bucket'); + final fieldPart = _toUpperCamelIdentifier(field, fallback: 'Field'); + return '$bucketPart$fieldPart'; } bool _supportsAggregateBucketField({ diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 4296e69b..9a57e54d 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -163,6 +163,14 @@ void main() { reason: 'Expected dynamic client source to include OrmGroupByHavingBuilder.', ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?havingClause', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to keep havingClause out of the public grouped surface.', + ); expect( RegExp( r'class\s+OrmGroupByHavingPredicateBuilder\s*\{[\s\S]*?inList\(', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 1411feef..5e4bc37f 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1443,76 +1443,46 @@ typedef Post = ({ 'Expected generated source to include aggregate result wrapper.', ); expect( - RegExp( - r'\bclass UserAggregateCountBucket\b', - ).hasMatch(generatedSource), - isTrue, - reason: - 'Expected generated source to include typed aggregate count bucket.', - ); - expect( - RegExp(r'\bclass UserAggregateMinBucket\b').hasMatch(generatedSource), - isTrue, - reason: - 'Expected generated source to include typed aggregate min bucket.', - ); - expect( - RegExp(r'\bclass UserAggregateMaxBucket\b').hasMatch(generatedSource), - isTrue, - reason: - 'Expected generated source to include typed aggregate max bucket.', - ); - expect( - RegExp(r'\bclass UserAggregateSumBucket\b').hasMatch(generatedSource), - isTrue, - reason: - 'Expected generated source to include typed aggregate sum bucket.', - ); - expect( - RegExp(r'\bclass UserAggregateAvgBucket\b').hasMatch(generatedSource), - isTrue, - reason: - 'Expected generated source to include typed aggregate avg bucket.', - ); - expect( - RegExp( - r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateCountBucket\s+get\s+count', - ).hasMatch(generatedSource), - isTrue, + generatedSource.contains('UserAggregateCountBucket') || + generatedSource.contains('UserAggregateMinBucket') || + generatedSource.contains('UserAggregateMaxBucket') || + generatedSource.contains('UserAggregateSumBucket') || + generatedSource.contains('UserAggregateAvgBucket'), + isFalse, reason: - 'Expected UserAggregateResult to expose typed count bucket getter.', + 'Expected typed aggregate results to avoid per-bucket wrapper classes.', ); expect( RegExp( - r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateMinBucket\s+get\s+min', + r'class\s+UserAggregateResult\s*\{[\s\S]*?int\?\s+get\s+countAll\s*=>', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserAggregateResult to expose typed min bucket getter.', + 'Expected UserAggregateResult to expose a direct countAll getter.', ); expect( RegExp( - r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateMaxBucket\s+get\s+max', + r'class\s+UserAggregateResult\s*\{[\s\S]*?int\?\s+get\s+countEmail\s*=>', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserAggregateResult to expose typed max bucket getter.', + 'Expected UserAggregateResult to expose direct typed count field getters.', ); expect( RegExp( - r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateSumBucket\s+get\s+sum', + r'class\s+UserAggregateResult\s*\{[\s\S]*?(int|double)\?\s+get\s+sumId\s*=>', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserAggregateResult to expose typed sum bucket getter.', + 'Expected UserAggregateResult to expose direct typed sum field getters.', ); expect( RegExp( - r'class\s+UserAggregateResult\s*\{[\s\S]*?UserAggregateAvgBucket\s+get\s+avg', + r'class\s+UserAggregateResult\s*\{[\s\S]*?double\?\s+get\s+avgId\s*=>', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserAggregateResult to expose typed avg bucket getter.', + 'Expected UserAggregateResult to expose direct typed avg field getters.', ); expect( RegExp( @@ -1522,14 +1492,6 @@ typedef Post = ({ reason: 'Expected UserAggregateResult to avoid exposing public map payload.', ); - expect( - RegExp( - r'class\s+UserAggregateCountBucket\s*\{[\s\S]*?field\(UserDistinct\s+field\)', - ).hasMatch(generatedSource), - isFalse, - reason: - 'Expected typed aggregate buckets to avoid dynamic field(...) map-style accessor.', - ); expect( RegExp( r'Future>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', @@ -1560,6 +1522,22 @@ typedef Post = ({ reason: 'Expected UserGroupByResult to expose typed getter for scalar email.', ); + expect( + RegExp( + r'class\s+UserGroupByResult\s*\{[\s\S]*?int\?\s+get\s+countAll\s*=>', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupByResult to expose direct aggregate getters.', + ); + expect( + RegExp( + r'class\s+UserGroupByResult\s*\{[\s\S]*?(int|double)\?\s+get\s+sumId\s*=>', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupByResult to expose direct aggregate field getters.', + ); expect( RegExp( r'class\s+UserGroupByResult\s*\{[\s\S]*?Object\?\s+field\(UserDistinct\s+field\)', @@ -1576,6 +1554,14 @@ typedef Post = ({ reason: 'Expected UserGroupByResult to avoid exposing public map payload.', ); + expect( + RegExp( + r'class\s+UserGroupByResult\s*\{[\s\S]*?UserAggregateResult\s+get\s+aggregate', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupByResult to avoid nested aggregate wrapper accessors.', + ); expect( generatedSource.contains( 'UserWhereInput having = const UserWhereInput()', From b8318183ff49da4d48ed025837af4d6f86f87638 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:51:19 +0800 Subject: [PATCH 149/154] refactor(orm)!: remove grouped configure bridge --- pub/orm/lib/src/client/client.dart | 33 ------------------- pub/orm/lib/src/generator/writer.dart | 34 ++------------------ pub/orm/test/client/client_test.dart | 20 ++++-------- pub/orm/test/client/source_surface_test.dart | 8 +++++ pub/orm/test/generator/generate_test.dart | 18 ++++++++++- 5 files changed, 34 insertions(+), 79 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 7faa381f..47a53322 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3367,21 +3367,6 @@ final class ModelGroupedQuery { List get byFields => _groupBy.by; - ModelGroupedQuery configure(OrmGroupBySpec groupBy) { - if (!_sameStringList(left: _groupBy.by, right: groupBy.by)) { - throw runtimeError( - 'PLAN.GROUP_BY_FIELDS_MISMATCH', - 'configure() cannot replace the grouped fields after groupedBy().', - details: { - 'model': _delegate.modelName, - 'currentBy': _groupBy.by, - 'nextBy': groupBy.by, - }, - ); - } - return _next(groupBy); - } - ModelGroupedQuery having(OrmGroupByHaving having, {bool merge = true}) { final nextHaving = merge ? _groupBy.having.merge(having) : having; return _next(_groupBy.copyWith(having: nextHaving)); @@ -3475,24 +3460,6 @@ final class _RelationMergeKey { int get hashCode => Object.hashAll(parts); } -bool _sameStringList({ - required List left, - required List right, -}) { - if (identical(left, right)) { - return true; - } - if (left.length != right.length) { - return false; - } - for (var index = 0; index < left.length; index++) { - if (left[index] != right[index]) { - return false; - } - } - return true; -} - Map _createCollectionRegistry( OrmContract contract, Map collections, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index e840288f..c4eb5434 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -3212,7 +3212,6 @@ final class TypedClientWriter { required StringBuffer buffer, required _ResolvedModel model, }) { - final runtimeName = _escapeString(model.model.runtimeName); final aggregateBuilderClassName = '${model.classBaseName}AggregateBuilder'; buffer.writeln('class ${model.groupedQueryClassName} {'); buffer.writeln(' final ${model.delegateClassName} _delegate;'); @@ -3228,35 +3227,6 @@ final class TypedClientWriter { buffer.writeln(' _groupBy = groupBy;'); buffer.writeln(); - buffer.writeln( - ' ${model.groupedQueryClassName} configure(${model.groupBySpecClassName} groupBy) {', - ); - buffer.writeln( - ' final currentBy = _groupBy.by.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln( - ' final nextBy = groupBy.by.map((entry) => entry.value).toList(growable: false);', - ); - buffer.writeln( - ' final sameBy = currentBy.length == nextBy.length && Iterable.generate(currentBy.length).every((index) => currentBy[index] == nextBy[index]);', - ); - buffer.writeln(' if (!sameBy) {'); - buffer.writeln(' throw runtimeError('); - buffer.writeln(" 'PLAN.GROUP_BY_FIELDS_MISMATCH',"); - buffer.writeln( - " 'configure() cannot replace the grouped fields after groupedBy().',", - ); - buffer.writeln(' details: {'); - buffer.writeln(" 'model': '$runtimeName',"); - buffer.writeln(" 'currentBy': currentBy,"); - buffer.writeln(" 'nextBy': nextBy,"); - buffer.writeln(' },'); - buffer.writeln(' );'); - buffer.writeln(' }'); - buffer.writeln(' return _next(groupBy);'); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( ' ${model.groupedQueryClassName} having(${model.groupByHavingClassName} having, {bool merge = true}) {', ); @@ -3360,7 +3330,9 @@ final class TypedClientWriter { ); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' )'); - buffer.writeln(' .configure(groupBy.toRuntimeSpec());'); + buffer.writeln( + ' .having(groupBy.having.toRuntimeHaving(), merge: false);', + ); buffer.writeln(' }'); buffer.writeln(); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index fd38c2d1..3b7ce1ba 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -711,26 +711,18 @@ void main() { final plan = await users .query() .groupedBy(const ['email']) - .configure( - OrmGroupBySpec( - by: const ['email'], - countAll: true, - having: OrmGroupByHaving.parse(const { - '_count': { - 'all': {'gte': 2}, - }, - }), - sum: const ['id'], - ), + .having( + OrmGroupByHaving.parse(const { + 'email': {'equals': 'a@example.com'}, + }), + merge: false, ) .toPlan(); expect(plan.read?.shape, OrmReadShape.groupedAggregate); expect(plan.read?.groupBy?.by, ['email']); expect(plan.read?.groupBy?.having.toJson(), { - '_count': { - 'all': {'gte': 2}, - }, + 'email': {'equals': 'a@example.com'}, }); expect(plan.read?.aggregate, isNotNull); }, diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 9a57e54d..4966b794 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -157,6 +157,14 @@ void main() { reason: 'Expected ModelGroupedQuery to keep raw JsonMap having out of the public grouped surface.', ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+configure\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to keep configure(...) out of the public grouped surface.', + ); expect( RegExp(r'\bclass\s+OrmGroupByHavingBuilder\b').hasMatch(source), isTrue, diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 5e4bc37f..3a385ee2 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1668,9 +1668,25 @@ typedef Post = ({ RegExp( r'class\s+UserGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+_runtimeGrouped\(UserGroupBySpec\s+groupBy\)\s*\{[\s\S]*?\.groupedBy\([\s\S]*?where:\s*_where\.toJson\(\),[\s\S]*?\.configure\(groupBy\.toRuntimeSpec\(\)\);', ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupedQuery to avoid the configure(...) grouped bridge.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+_runtimeGrouped\(UserGroupBySpec\s+groupBy\)\s*\{[\s\S]*?\.groupedBy\([\s\S]*?where:\s*_where\.toJson\(\),[\s\S]*?\.having\(groupBy\.having\.toRuntimeHaving\(\),\s*merge:\s*false\);', + ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserGroupedQuery to keep a single runtime grouped builder bridge.', + 'Expected UserGroupedQuery to keep a single runtime grouped builder bridge through groupedBy(...).having(...).', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?UserGroupedQuery\s+configure\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupedQuery to keep configure(...) out of the typed grouped surface.', ); expect( RegExp( From f0f2ef903c8c0b8ae464f86cb022a7dc3258d2f3 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:54:29 +0800 Subject: [PATCH 150/154] refactor(orm)!: drop grouped aggregateWith terminals --- pub/orm/lib/src/client/client.dart | 4 ++-- pub/orm/lib/src/generator/writer.dart | 4 ++-- pub/orm/test/client/source_surface_test.dart | 12 ++++++++++-- pub/orm/test/generator/generate_test.dart | 12 ++++++++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 47a53322..c34a7126 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3384,9 +3384,9 @@ final class ModelGroupedQuery { Future> aggregate( OrmAggregateBuilder Function(OrmAggregateBuilder aggregate) build, - ) => aggregateWith(build(OrmAggregateBuilder()).toSpec()); + ) => _executeAggregate(build(OrmAggregateBuilder()).toSpec()); - Future> aggregateWith(OrmAggregateSpec aggregate) { + Future> _executeAggregate(OrmAggregateSpec aggregate) { _assertExecutionSupported('aggregate'); _delegate._assertAggregateSpecRequested(aggregate, terminal: 'aggregate'); return _prepareGrouped( diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index c4eb5434..9f671f53 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -3264,13 +3264,13 @@ final class TypedClientWriter { ' Future> aggregate($aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build) {', ); buffer.writeln( - ' return aggregateWith(build($aggregateBuilderClassName()).toSpec());', + ' return _executeAggregate(build($aggregateBuilderClassName()).toSpec());', ); buffer.writeln(' }'); buffer.writeln(); buffer.writeln( - ' Future> aggregateWith(${model.aggregateSpecClassName} aggregate) {', + ' Future> _executeAggregate(${model.aggregateSpecClassName} aggregate) {', ); buffer.writeln(' return _executeSpec('); buffer.writeln(' _groupBy.copyWith('); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 4966b794..a5701009 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -111,11 +111,19 @@ void main() { ); expect( RegExp( - r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(OrmAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_prepareGrouped\([\s\S]*?groupBy:\s*_groupBy\.copyWith\([\s\S]*?prepared\.execute\(\)', + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+aggregate\([\s\S]*?\)\s*=>\s*_executeAggregate\(build\(OrmAggregateBuilder\(\)\)\.toSpec\(\)\);', ).hasMatch(source), isTrue, reason: - 'Expected ModelGroupedQuery.aggregateWith(...) to prepare a grouped aggregate plan before execution.', + 'Expected ModelGroupedQuery.aggregate(...) to be the only public grouped aggregate terminal.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to keep aggregateWith(...) out of the public grouped surface.', ); expect( RegExp( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 3a385ee2..008efc18 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1650,11 +1650,19 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_runtimeGrouped\(groupBy\)[\s\S]*?OrmAggregateSpec\([\s\S]*?countAll:\s*groupBy\.countAll,[\s\S]*?UserGroupByResult\.fromJson', + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future>\s+aggregate\([\s\S]*?_executeAggregate\(build\(UserAggregateBuilder\(\)\)\.toSpec\(\)\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserGroupedQuery.aggregateWith(...) to reuse the runtime grouped builder execution path.', + 'Expected UserGroupedQuery.aggregate(...) to be the only public typed grouped aggregate terminal.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupedQuery to keep aggregateWith(...) out of the typed grouped surface.', ); expect( RegExp( From a53c2aa268647702ada162904daf0a5f632f0d42 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:59:03 +0800 Subject: [PATCH 151/154] refactor(orm)!: remove grouped plan inspection terminals --- pub/orm/lib/src/client/client.dart | 8 ------ pub/orm/lib/src/generator/writer.dart | 10 -------- pub/orm/test/client/client_test.dart | 26 -------------------- pub/orm/test/client/source_surface_test.dart | 14 ++++++++--- pub/orm/test/generator/generate_test.dart | 12 ++++----- 5 files changed, 17 insertions(+), 53 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index c34a7126..bc28d555 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3416,14 +3416,6 @@ final class ModelGroupedQuery { } } - Future toPlan() async { - return (await _prepareGrouped(groupBy: _groupBy)).plan; - } - - Future inspectPlan() async { - return (await _prepareGrouped(groupBy: _groupBy)).inspectPlan(); - } - Future _prepareGrouped({ required OrmGroupBySpec groupBy, }) { diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 9f671f53..967cb961 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -3250,16 +3250,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln(' Future toPlan() {'); - buffer.writeln(' return _runtimeGrouped(_groupBy).toPlan();'); - buffer.writeln(' }'); - buffer.writeln(); - - buffer.writeln(' Future inspectPlan() {'); - buffer.writeln(' return _runtimeGrouped(_groupBy).inspectPlan();'); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( ' Future> aggregate($aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build) {', ); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 3b7ce1ba..1ee56f6b 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -702,32 +702,6 @@ void main() { await client.disconnect(); }); - test( - 'grouped builder compiles to structured grouped aggregate plan', - () async { - final client = OrmClient(contract: contract, engine: MemoryEngine()); - final users = client.db.orm.model('User'); - - final plan = await users - .query() - .groupedBy(const ['email']) - .having( - OrmGroupByHaving.parse(const { - 'email': {'equals': 'a@example.com'}, - }), - merge: false, - ) - .toPlan(); - - expect(plan.read?.shape, OrmReadShape.groupedAggregate); - expect(plan.read?.groupBy?.by, ['email']); - expect(plan.read?.groupBy?.having.toJson(), { - 'email': {'equals': 'a@example.com'}, - }); - expect(plan.read?.aggregate, isNotNull); - }, - ); - test('aggregate rejects unsupported row-query state keys', () async { final client = OrmClient(contract: contract, engine: MemoryEngine()); final users = client.db.orm.model('User'); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index a5701009..8b39c11e 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -127,11 +127,19 @@ void main() { ); expect( RegExp( - r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future\s+toPlan\(\)\s+async\s*\{[\s\S]*?_prepareGrouped\(groupBy:\s*_groupBy\)\)\.plan;', + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future\s+toPlan\(', ).hasMatch(source), - isTrue, + isFalse, + reason: + 'Expected ModelGroupedQuery to keep toPlan() out of the public grouped surface.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future\s+inspectPlan\(', + ).hasMatch(source), + isFalse, reason: - 'Expected ModelGroupedQuery.toPlan() to expose the grouped aggregate plan surface.', + 'Expected ModelGroupedQuery to keep inspectPlan() out of the public grouped surface.', ); expect( RegExp( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 008efc18..771fd59d 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1634,19 +1634,19 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future\s+toPlan\(\)\s*\{[\s\S]*?_runtimeGrouped\(_groupBy\)\.toPlan\(\);', + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future\s+toPlan\(', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected UserGroupedQuery.toPlan() to reuse the runtime grouped builder plan path.', + 'Expected UserGroupedQuery to keep toPlan() out of the typed grouped surface.', ); expect( RegExp( - r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future\s+inspectPlan\(\)\s*\{[\s\S]*?_runtimeGrouped\(_groupBy\)\.inspectPlan\(\);', + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future\s+inspectPlan\(', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected UserGroupedQuery.inspectPlan() to reuse the runtime grouped builder inspection path.', + 'Expected UserGroupedQuery to keep inspectPlan() out of the typed grouped surface.', ); expect( RegExp( From b469e28e1d04d6f6c22326a3e661f41319d9d352 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:04:05 +0800 Subject: [PATCH 152/154] refactor(orm)!: hide typed aggregate spec terminals --- pub/orm/lib/src/client/client.dart | 6 ++-- pub/orm/lib/src/generator/writer.dart | 16 ++-------- pub/orm/test/client/source_surface_test.dart | 32 ++++++++++++-------- pub/orm/test/generator/generate_test.dart | 20 ++++++++---- 4 files changed, 39 insertions(+), 35 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index bc28d555..0b9fe1da 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1475,7 +1475,7 @@ class ModelDelegate { cursor: cursor, page: page, ), - ).aggregateWith(aggregate); + )._executeAggregate(aggregate); ModelGroupedQuery groupedBy( List by, { @@ -3175,10 +3175,10 @@ final class ModelQuery { OrmAggregateBuilder Function(OrmAggregateBuilder aggregate) build, ) { _assertReadExecutionSupported('aggregate'); - return aggregateWith(build(OrmAggregateBuilder()).toSpec()); + return _executeAggregate(build(OrmAggregateBuilder()).toSpec()); } - Future aggregateWith(OrmAggregateSpec aggregate) { + Future _executeAggregate(OrmAggregateSpec aggregate) { _assertAggregateQueryState(); _delegate._assertAggregateSpecRequested(aggregate, terminal: 'aggregate'); return _delegate diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 967cb961..022447d6 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2118,18 +2118,6 @@ final class TypedClientWriter { buffer.writeln(' }'); buffer.writeln(); - buffer.writeln( - ' Future<${model.aggregateResultClassName}> aggregateWith({', - ); - buffer.writeln( - ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', - ); - buffer.writeln(' required ${model.aggregateSpecClassName} aggregate,'); - buffer.writeln(' }) {'); - buffer.writeln(' return query(where: where).aggregateWith(aggregate);'); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln(' ${model.groupedQueryClassName} groupedBy('); buffer.writeln(' List<${model.distinctClassName}> by, {'); buffer.writeln( @@ -2975,13 +2963,13 @@ final class TypedClientWriter { ); buffer.writeln(" _assertReadExecutionSupported('aggregate');"); buffer.writeln( - ' return aggregateWith(build($aggregateBuilderClassName()).toSpec());', + ' return _executeAggregate(build($aggregateBuilderClassName()).toSpec());', ); buffer.writeln(' }'); buffer.writeln(); buffer.writeln( - ' Future<${model.aggregateResultClassName}> aggregateWith(${model.aggregateSpecClassName} aggregate) {', + ' Future<${model.aggregateResultClassName}> _executeAggregate(${model.aggregateSpecClassName} aggregate) {', ); buffer.writeln(" _assertReadExecutionSupported('aggregate');"); buffer.writeln(' _assertAggregateQueryState();'); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 8b39c11e..229041a3 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -40,14 +40,14 @@ void main() { reason: 'Expected ModelDelegate.create(...) to route through _queryFromSpec(...).create(...).', ); - expect( - RegExp( - r'class\s+ModelDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(\{[\s\S]*?required\s+OrmAggregateSpec\s+aggregate,[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\.aggregateWith\(aggregate\);', - ).hasMatch(source), - isTrue, - reason: - 'Expected ModelDelegate.aggregateWith(...) to route structured aggregate execution through query terminals.', - ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(\{[\s\S]*?required\s+OrmAggregateSpec\s+aggregate,[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\._executeAggregate\(aggregate\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelDelegate.aggregateWith(...) to route structured aggregate execution through the private query aggregate bridge.', + ); expect( RegExp( r'class\s+ModelDelegate\s*\{[\s\S]*?ModelGroupedQuery\s+groupedBy\(\s*List\s+by,\s*\{[\s\S]*?JsonMap\s+where\s*=\s*const\s+\{\},[\s\S]*?\)\s*=>\s*_queryFromSpec\(OrmReadQuerySpec\(where:\s*where\)\)\.groupedBy\(by\);', @@ -73,17 +73,25 @@ void main() { RegExp( r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregate\(\s*OrmAggregateBuilder\s+Function\(OrmAggregateBuilder\s+aggregate\)\s+build,\s*\)\s*\{[\s\S]*?return\s+aggregateWith\(build\(OrmAggregateBuilder\(\)\)\.toSpec\(\)\);', ).hasMatch(source), - isTrue, + isFalse, reason: - 'Expected ModelQuery.aggregate(...) to route through the aggregate builder callback.', + 'Expected ModelQuery.aggregate(...) to avoid aggregateWith(...) as a public bridge.', ); expect( RegExp( - r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregateWith\(OrmAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_assertAggregateQueryState\(\);[\s\S]*?_prepareAggregateQuery\(spec:\s*_state,\s*aggregate:\s*aggregate\)[\s\S]*?prepared\.execute\(\)', + r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregate\(\s*OrmAggregateBuilder\s+Function\(OrmAggregateBuilder\s+aggregate\)\s+build,\s*\)\s*\{[\s\S]*?return\s+_executeAggregate\(build\(OrmAggregateBuilder\(\)\)\.toSpec\(\)\);', ).hasMatch(source), isTrue, reason: - 'Expected ModelQuery.aggregateWith(...) to prepare an aggregate plan before execution.', + 'Expected ModelQuery.aggregate(...) to route through a private aggregate executor.', + ); + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future\s+aggregateWith\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelQuery to keep aggregateWith(...) out of the public query surface.', ); expect( RegExp( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 771fd59d..e2f33a26 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1333,11 +1333,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(\{[\s\S]*?required\s+UserAggregateSpec\s+aggregate,[\s\S]*?return\s+query\(where:\s*where\)\.aggregateWith\(aggregate\);', + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(', ).hasMatch(generatedSource), - isTrue, + isFalse, reason: - 'Expected generated delegate aggregateWith(...) to route structured aggregate specs through typed query.', + 'Expected generated delegate to keep aggregateWith(...) out of the public typed surface.', ); expect( RegExp( @@ -1588,7 +1588,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregate\(UserAggregateBuilder\s+Function\(UserAggregateBuilder\s+aggregate\)\s+build\)\s*\{[\s\S]*?return\s+aggregateWith\(build\(UserAggregateBuilder\(\)\)\.toSpec\(\)\);', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregate\(UserAggregateBuilder\s+Function\(UserAggregateBuilder\s+aggregate\)\s+build\)\s*\{[\s\S]*?return\s+_executeAggregate\(build\(UserAggregateBuilder\(\)\)\.toSpec\(\)\);', ).hasMatch(generatedSource), isTrue, reason: @@ -1596,11 +1596,19 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregateWith\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_assertAggregateQueryState\(\);[\s\S]*?aggregate:\s*aggregate\.toRuntimeSpec\(\),[\s\S]*?then\(UserAggregateResult\.fromJson\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+_executeAggregate\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_assertAggregateQueryState\(\);[\s\S]*?aggregate:\s*aggregate\.toRuntimeSpec\(\),[\s\S]*?then\(UserAggregateResult\.fromJson\)', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserQuery.aggregateWith(...) to route through runtime structured aggregate specs.', + 'Expected UserQuery aggregate execution to route through a private typed aggregate bridge.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+aggregateWith\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserQuery to keep aggregateWith(...) out of the public typed query surface.', ); expect( RegExp( From c81d6a7d6b89654f5828b2779c1157ef168d9e15 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:07:18 +0800 Subject: [PATCH 153/154] refactor(orm)!: remove aggregate spec bridge terminals --- pub/orm/lib/src/client/client.dart | 15 --------------- pub/orm/lib/src/generator/writer.dart | 10 ++++++---- pub/orm/test/client/source_surface_test.dart | 16 ++++++++-------- pub/orm/test/generator/generate_test.dart | 14 +++++++++++--- 4 files changed, 25 insertions(+), 30 deletions(-) diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index 0b9fe1da..d680c363 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -1462,21 +1462,6 @@ class ModelDelegate { ), ).aggregate(build); - Future aggregateWith({ - JsonMap where = const {}, - List orderBy = const [], - JsonMap? cursor, - OrmReadPagePlan? page, - required OrmAggregateSpec aggregate, - }) => _queryFromSpec( - OrmReadQuerySpec( - where: where, - orderBy: orderBy, - cursor: cursor, - page: page, - ), - )._executeAggregate(aggregate); - ModelGroupedQuery groupedBy( List by, { JsonMap where = const {}, diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index 022447d6..e935f080 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -2973,12 +2973,14 @@ final class TypedClientWriter { ); buffer.writeln(" _assertReadExecutionSupported('aggregate');"); buffer.writeln(' _assertAggregateQueryState();'); - buffer.writeln(' return _delegate._delegate.aggregateWith('); + buffer.writeln(' return _delegate._delegate.aggregate('); buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' orderBy: _runtimeOrderBy,'); buffer.writeln(' cursor: _runtimeCursor,'); buffer.writeln(' page: _runtimePage,'); - buffer.writeln(' aggregate: aggregate.toRuntimeSpec(),'); + buffer.writeln( + ' build: (current) => current.merge(aggregate.toRuntimeSpec()),', + ); buffer.writeln(' ).then(${model.aggregateResultClassName}.fromJson);'); buffer.writeln(' }'); buffer.writeln(); @@ -3272,7 +3274,7 @@ final class TypedClientWriter { ' Future> _executeSpec(${model.groupBySpecClassName} groupBy) {', ); buffer.writeln(' return _runtimeGrouped(groupBy)'); - buffer.writeln(' .aggregateWith('); + buffer.writeln(' .aggregate((aggregate) => aggregate.merge('); buffer.writeln(' OrmAggregateSpec('); buffer.writeln(' countAll: groupBy.countAll,'); buffer.writeln( @@ -3291,7 +3293,7 @@ final class TypedClientWriter { ' avg: groupBy.avg.map((entry) => entry.value).toList(growable: false),', ); buffer.writeln(' ),'); - buffer.writeln(' )'); + buffer.writeln(' ))'); buffer.writeln( ' .then((rows) => rows.map(${model.groupByResultClassName}.fromJson).toList(growable: false));', ); diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index 229041a3..ce6e99af 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -40,14 +40,14 @@ void main() { reason: 'Expected ModelDelegate.create(...) to route through _queryFromSpec(...).create(...).', ); - expect( - RegExp( - r'class\s+ModelDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(\{[\s\S]*?required\s+OrmAggregateSpec\s+aggregate,[\s\S]*?\)\s*=>\s*_queryFromSpec\([\s\S]*?\)\._executeAggregate\(aggregate\);', - ).hasMatch(source), - isTrue, - reason: - 'Expected ModelDelegate.aggregateWith(...) to route structured aggregate execution through the private query aggregate bridge.', - ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelDelegate to keep aggregateWith(...) out of the public delegate surface.', + ); expect( RegExp( r'class\s+ModelDelegate\s*\{[\s\S]*?ModelGroupedQuery\s+groupedBy\(\s*List\s+by,\s*\{[\s\S]*?JsonMap\s+where\s*=\s*const\s+\{\},[\s\S]*?\)\s*=>\s*_queryFromSpec\(OrmReadQuerySpec\(where:\s*where\)\)\.groupedBy\(by\);', diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index e2f33a26..7670b929 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1596,7 +1596,7 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserQuery\s*\{[\s\S]*?Future\s+_executeAggregate\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_assertAggregateQueryState\(\);[\s\S]*?aggregate:\s*aggregate\.toRuntimeSpec\(\),[\s\S]*?then\(UserAggregateResult\.fromJson\)', + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+_executeAggregate\(UserAggregateSpec\s+aggregate\)\s*\{[\s\S]*?_assertAggregateQueryState\(\);[\s\S]*?build:\s*\(current\)\s*=>\s*current\.merge\(aggregate\.toRuntimeSpec\(\)\),[\s\S]*?then\(UserAggregateResult\.fromJson\)', ).hasMatch(generatedSource), isTrue, reason: @@ -1656,6 +1656,14 @@ typedef Post = ({ reason: 'Expected UserGroupedQuery to keep inspectPlan() out of the typed grouped surface.', ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected generated delegate to avoid aggregateWith(...) after aggregate bridge removal.', + ); expect( RegExp( r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future>\s+aggregate\([\s\S]*?_executeAggregate\(build\(UserAggregateBuilder\(\)\)\.toSpec\(\)\);', @@ -1666,11 +1674,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future>\s+aggregateWith\(', + r'class\s+UserGroupedQuery\s*\{[\s\S]*?\.aggregateWith\(', ).hasMatch(generatedSource), isFalse, reason: - 'Expected UserGroupedQuery to keep aggregateWith(...) out of the typed grouped surface.', + 'Expected typed grouped execution to avoid aggregateWith(...) bridges.', ); expect( RegExp( From d57daa8b383762b5f99dbec623251f3cf0e690d1 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:13:10 +0800 Subject: [PATCH 154/154] refactor(orm)!: remove grouped structured having terminals --- docs/orm-v6-api-surface.md | 5 ++--- pub/orm/lib/src/client/client.dart | 9 +-------- pub/orm/lib/src/generator/writer.dart | 19 ++++++------------- pub/orm/test/client/client_test.dart | 4 ++-- pub/orm/test/client/source_surface_test.dart | 6 +++--- pub/orm/test/generator/generate_test.dart | 4 ++-- 6 files changed, 16 insertions(+), 31 deletions(-) diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md index 8ebab970..aa5501c3 100644 --- a/docs/orm-v6-api-surface.md +++ b/docs/orm-v6-api-surface.md @@ -112,12 +112,11 @@ Rules: deduplication directly. 8. Grouped aggregation is a dedicated surface: - `groupedBy(...)` only accepts a where-only base query. - - `having(...)` accepts structured grouped predicates. - - `havingExpr(...)` is the primary builder-style entrypoint before + - `havingExpr(...)` is the grouped predicate entrypoint before `aggregate(...)`. - grouped `having` supports comparison operators only: `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`. - - repeated `having(...)` / `havingExpr(...)` calls compose with `AND`. + - repeated `havingExpr(...)` calls compose with `AND`. 9. `include(...)` is unsupported on `aggregate(...)` and `groupedBy(...).aggregate(...)`, including direct plan execution. diff --git a/pub/orm/lib/src/client/client.dart b/pub/orm/lib/src/client/client.dart index d680c363..7ec0d44d 100644 --- a/pub/orm/lib/src/client/client.dart +++ b/pub/orm/lib/src/client/client.dart @@ -3352,19 +3352,12 @@ final class ModelGroupedQuery { List get byFields => _groupBy.by; - ModelGroupedQuery having(OrmGroupByHaving having, {bool merge = true}) { - final nextHaving = merge ? _groupBy.having.merge(having) : having; - return _next(_groupBy.copyWith(having: nextHaving)); - } - ModelGroupedQuery havingExpr( OrmGroupByHaving Function(OrmGroupByHavingBuilder having) build, { bool merge = true, }) { final next = build(const OrmGroupByHavingBuilder()); - return _next( - _groupBy.copyWith(having: merge ? _groupBy.having.merge(next) : next), - ); + return _next(_groupBy.copyWith(having: merge ? _groupBy.having.merge(next) : next)); } Future> aggregate( diff --git a/pub/orm/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart index e935f080..1f0111f5 100644 --- a/pub/orm/lib/src/generator/writer.dart +++ b/pub/orm/lib/src/generator/writer.dart @@ -3218,28 +3218,21 @@ final class TypedClientWriter { buffer.writeln(); buffer.writeln( - ' ${model.groupedQueryClassName} having(${model.groupByHavingClassName} having, {bool merge = true}) {', + ' ${model.groupedQueryClassName} havingExpr(${model.groupByHavingClassName} Function(${model.groupByHavingClassName}Builder having) build, {bool merge = true}) {', + ); + buffer.writeln( + ' final next = build(const ${model.groupByHavingClassName}Builder());', ); buffer.writeln(' return _next('); buffer.writeln(' _groupBy.copyWith('); buffer.writeln( - ' having: merge ? _groupBy.having.merge(having) : having,', + ' having: merge ? _groupBy.having.merge(next) : next,', ); buffer.writeln(' ),'); buffer.writeln(' );'); buffer.writeln(' }'); buffer.writeln(); - buffer.writeln( - ' ${model.groupedQueryClassName} havingExpr(${model.groupByHavingClassName} Function(${model.groupByHavingClassName}Builder having) build, {bool merge = true}) {', - ); - buffer.writeln( - ' final next = build(const ${model.groupByHavingClassName}Builder());', - ); - buffer.writeln(' return having(next, merge: merge);'); - buffer.writeln(' }'); - buffer.writeln(); - buffer.writeln( ' Future> aggregate($aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build) {', ); @@ -3311,7 +3304,7 @@ final class TypedClientWriter { buffer.writeln(' where: _where.toJson(),'); buffer.writeln(' )'); buffer.writeln( - ' .having(groupBy.having.toRuntimeHaving(), merge: false);', + ' .havingExpr((_) => groupBy.having.toRuntimeHaving(), merge: false);', ); buffer.writeln(' }'); buffer.writeln(); diff --git a/pub/orm/test/client/client_test.dart b/pub/orm/test/client/client_test.dart index 1ee56f6b..ad064049 100644 --- a/pub/orm/test/client/client_test.dart +++ b/pub/orm/test/client/client_test.dart @@ -752,8 +752,8 @@ void main() { await expectLater( users .groupedBy(const ['email']) - .having( - OrmGroupByHaving.parse(const { + .havingExpr( + (_) => OrmGroupByHaving.parse(const { '_count': { 'all': {'in': [1, 2]}, }, diff --git a/pub/orm/test/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart index ce6e99af..0cd7e36e 100644 --- a/pub/orm/test/client/source_surface_test.dart +++ b/pub/orm/test/client/source_surface_test.dart @@ -167,11 +167,11 @@ void main() { ); expect( RegExp( - r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+having\(OrmGroupByHaving\s+having,\s*\{\s*bool\s+merge\s*=\s*true\s*\}\)', + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+having\(OrmGroupByHaving\s+having,', ).hasMatch(source), - isTrue, + isFalse, reason: - 'Expected ModelGroupedQuery.having(...) to accept structured grouped having clauses instead of raw maps.', + 'Expected ModelGroupedQuery to keep structured having(...) out of the public grouped surface.', ); expect( RegExp( diff --git a/pub/orm/test/generator/generate_test.dart b/pub/orm/test/generator/generate_test.dart index 7670b929..5246be64 100644 --- a/pub/orm/test/generator/generate_test.dart +++ b/pub/orm/test/generator/generate_test.dart @@ -1698,11 +1698,11 @@ typedef Post = ({ ); expect( RegExp( - r'class\s+UserGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+_runtimeGrouped\(UserGroupBySpec\s+groupBy\)\s*\{[\s\S]*?\.groupedBy\([\s\S]*?where:\s*_where\.toJson\(\),[\s\S]*?\.having\(groupBy\.having\.toRuntimeHaving\(\),\s*merge:\s*false\);', + r'class\s+UserGroupedQuery\s*\{[\s\S]*?ModelGroupedQuery\s+_runtimeGrouped\(UserGroupBySpec\s+groupBy\)\s*\{[\s\S]*?\.groupedBy\([\s\S]*?where:\s*_where\.toJson\(\),[\s\S]*?\.havingExpr\(\(_\)\s*=>\s*groupBy\.having\.toRuntimeHaving\(\),\s*merge:\s*false\);', ).hasMatch(generatedSource), isTrue, reason: - 'Expected UserGroupedQuery to keep a single runtime grouped builder bridge through groupedBy(...).having(...).', + 'Expected UserGroupedQuery to keep a single runtime grouped builder bridge through groupedBy(...).havingExpr(...).', ); expect( RegExp(