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/ai-team-workflow.md b/docs/ai-team-workflow.md new file mode 100644 index 00000000..92e619b5 --- /dev/null +++ b/docs/ai-team-workflow.md @@ -0,0 +1,128 @@ +# AI 团队协作规范(ORM) + +## 当前执行文档 +- 蓝图:`docs/orm-v6-blueprint.md` +- 路线图与门禁:`docs/orm-v6-execution-plan.md` + +## 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。 +- 测试:是否覆盖正反例、是否有新增回归用例。 diff --git a/docs/orm-v6-api-surface.md b/docs/orm-v6-api-surface.md new file mode 100644 index 00000000..aa5501c3 --- /dev/null +++ b/docs/orm-v6-api-surface.md @@ -0,0 +1,185 @@ +# 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(...)` | implemented | +| `page(...)` | implemented | + +Read terminals: + +| Method | Status | +| --- | --- | +| `toPlan()` | implemented | +| `inspectPlan()` | implemented | +| `all()` | implemented | +| `pageResult()` | implemented | +| `stream()` | implemented | +| `firstOrNull()` | implemented | +| `oneOrNull()` | implemented | +| `count()` | implemented | +| `exists()` | implemented | +| `aggregate(...)` | implemented | +| `groupedBy(...).aggregate(...)` | implemented | +| `explain()` | implemented | + +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. 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 + `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(...)` 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. + - `havingExpr(...)` is the grouped predicate entrypoint before + `aggregate(...)`. + - grouped `having` supports comparison operators only: + `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`. + - repeated `havingExpr(...)` calls compose with `AND`. +9. `include(...)` is unsupported on `aggregate(...)` and + `groupedBy(...).aggregate(...)`, including direct plan execution. + +## ORM Mutation Surface + +Direct mutations: + +| Method | Status | +| --- | --- | +| `create(...)` | implemented | +| `createMany(...)` | implemented | +| `createNested(...)` | implemented | +| `update(...)` | implemented | +| `updateAll(...)` | implemented | +| `updateNested(...)` | implemented | +| `delete(...)` | implemented | +| `deleteAll(...)` | implemented | +| `deleteCount(...)` | implemented | +| `upsert(...)` | implemented | +| `updateCount(...)` | implemented | + +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. `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 + +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/docs/orm-v6-blueprint.md b/docs/orm-v6-blueprint.md new file mode 100644 index 00000000..70e5159b --- /dev/null +++ b/docs/orm-v6-blueprint.md @@ -0,0 +1,83 @@ +# ORM v6 实施蓝图(Dart 化) + +## 1. 目标 +- 在 v6 未发布阶段完成破坏式收敛,建立统一 fluent API 与 contract-first 运行时边界。 +- 所有查询入口统一编译为不可变 Plan。 +- 执行层保持单 Plan 单语句;多语句编排仅允许在 repository 层显式表达。 + +## 2. 范围与非范围 +范围: +- 契约工件完整化:relations、capabilities、target、hash 校验链。 +- fluent 查询主路径:`where / select / include / orderBy / take / skip / all / 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.orm.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])) + .all(); +``` + +```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 验收矩阵骨架。 + 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..6ffb183a --- /dev/null +++ b/playground/orm.config.dart @@ -0,0 +1,6 @@ +import 'package:orm/config.dart'; + +const config = Config( + provider: .sqlite, + output: '', // TODO: update output path +); diff --git a/playground/orm.schema.dart b/playground/orm.schema.dart new file mode 100644 index 00000000..32ea5736 --- /dev/null +++ b/playground/orm.schema.dart @@ -0,0 +1,24 @@ +import 'package:orm/schema.dart'; + +@model +typedef User = ({ + @id String id, + String email, + DateTime createdAt, + DateTime updatedAt, + + @Relation() List posts, +}); + +@model +typedef Post = ({ + @id String id, + String title, + String content, + String authorId, + + DateTime createdAt, + DateTime updatedAt, + + @Relation(fields: {'authorId'}) User author, +}); 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/bin/orm.dart b/pub/orm/bin/orm.dart new file mode 100644 index 00000000..1a712bb6 --- /dev/null +++ b/pub/orm/bin/orm.dart @@ -0,0 +1,273 @@ +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': + final parseResult = _parseGenerateArgs(commandArgs); + if (parseResult.helpRequested) { + _printGenerateHelp(); + exitCode = 0; + return; + } + + 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; + 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); + exitCode = 64; + } +} + +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; + + 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 $commandName: ${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 $commandName: ${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'); + 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( + ' 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}) { + final sink = stream ?? stdout; + sink.writeln('Generate typed client.'); + sink.writeln('Usage: dart run orm generate [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 path from config.output'); + 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( + ' - config.output, or lib/orm_client.g.dart when output is empty', + ); +} + +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; + 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/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/main.dart b/pub/orm/lib/main.dart index 4079f807..c63aed11 100644 --- a/pub/orm/lib/main.dart +++ b/pub/orm/lib/main.dart @@ -1,22 +1,18 @@ -// import 'dart:async'; +import 'package:analysis_server_plugin/plugin.dart'; +import 'package:analysis_server_plugin/registry.dart'; -// import 'package:analysis_server_plugin/registry.dart'; +import 'src/analyzer/fixes/config_required_fix.dart'; +import 'src/analyzer/rules/config_required_rule.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 + void register(PluginRegistry registry) { + registry.registerWarningRule(ConfigRequiredRule()); + registry.registerFixForRule(ConfigRequiredRule.code, ConfigRequiredFix.new); + } +} -// // 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(); diff --git a/pub/orm/lib/orm.dart b/pub/orm/lib/orm.dart new file mode 100644 index 00000000..81dc4feb --- /dev/null +++ b/pub/orm/lib/orm.dart @@ -0,0 +1,23 @@ +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'; +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'; +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/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/analyzer/README.md b/pub/orm/lib/src/analyzer/README.md new file mode 100644 index 00000000..d05f9bfe --- /dev/null +++ b/pub/orm/lib/src/analyzer/README.md @@ -0,0 +1,26 @@ +# 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. +- `utils/`: shared helpers. + +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/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..42b24598 --- /dev/null +++ b/pub/orm/lib/src/analyzer/fixes/config_required_fix.dart @@ -0,0 +1,61 @@ +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 '../utils/config_utils.dart'; + +class ConfigRequiredFix extends ResolvedCorrectionProducer { + static const FixKind _kind = FixKind( + 'orm.fix.config_required', + DartFixKindPriority.standard, + 'Define ORM config: const config = Config(...)', + ); + + ConfigRequiredFix({required super.context}); + + @override + CorrectionApplicability get applicability => + CorrectionApplicability.singleLocation; + + @override + FixKind get fixKind => _kind; + + @override + Future compute(ChangeBuilder builder) async { + if (findConfigVariable(unit) != null) { + return; + } + + 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/rules/config_required_rule.dart b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart new file mode 100644 index 00000000..753eaabf --- /dev/null +++ b/pub/orm/lib/src/analyzer/rules/config_required_rule.dart @@ -0,0 +1,143 @@ +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'; + +import '../utils/config_utils.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.", + severity: DiagnosticSeverity.ERROR, + ); + + 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(_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); + 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 (!isConfigVariable(variable)) { + continue; + } + + final initializer = variable.initializer; + if (initializer is InstanceCreationExpression && + _isOrmConfigInitializer( + initializer, + configImport, + hasLocalConfig, + )) { + 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.toString() == 'package: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; + } +} 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..b3b3bcaf --- /dev/null +++ b/pub/orm/lib/src/analyzer/utils/config_utils.dart @@ -0,0 +1,107 @@ +import 'package:analyzer/dart/ast/ast.dart'; + +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 isConfigVariable(VariableDeclaration variable) { + return variable.name.lexeme == 'config'; +} + +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 _nextLineOffset(unit, 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 _nextLineOffset(unit, anchor.end); +} + +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); +} + +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 { + 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/client/client.dart b/pub/orm/lib/src/client/client.dart new file mode 100644 index 00000000..7ec0d44d --- /dev/null +++ b/pub/orm/lib/src/client/client.dart @@ -0,0 +1,3553 @@ +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'; + +part 'include_planner.dart'; +part 'mutation_repository.dart'; +part 'relation_where_rewriter.dart'; +part 'read_plan_compiler.dart'; +part 'read_repository.dart'; + +typedef CollectionFactory = + ModelDelegate Function({ + required OrmCollectionContext client, + 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; +const Object _stateKeepToken = Object(); +const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; +const Set _groupByHavingOperators = { + 'equals', + 'not', + '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', + 'none', +}; +const Set _toOneRelationWhereOperators = {'is', 'isNot'}; + +IncludeExecutionStrategy defaultIncludeExecutionStrategySelector({ + required OrmContract contract, + required String modelName, + required OrmAction action, + required Map include, + required int depth, +}) { + if (contract.capabilities.includeSingleQuery) { + return IncludeExecutionStrategy.singleQuery; + } + 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({ + this.where = const {}, + this.skip, + this.take, + this.orderBy = const [], + this.select = const [], + this.include = const {}, + }); + + 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}, + ); + } +} + +@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, + ); + } +} + +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', + ]; + + 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' : 'engine', + '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, + reasons: const [], + available: page != null, + ), + }); +} + +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; +} + +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, + ); + } + + 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, + String? relation, + int? itemIndex, + }) { + _step += 1; + return OrmRepositoryTrace( + operationId: id, + kind: kind, + step: _step, + phase: phase, + strategy: strategy, + relation: relation, + itemIndex: itemIndex, + ); + } +} + +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), + ); +} + +Map _buildOrmIncludePlanMap( + Map include, +) { + if (include.isEmpty) { + return const {}; + } + return { + for (final entry in include.entries) + entry.key: _buildOrmIncludePlan(entry.value), + }; +} + +abstract interface class OrmDbContext { + OrmDbNamespace get db; +} + +abstract interface class OrmExecutionContext { + OrmContract get contract; + + Future execute(OrmPlan plan); +} + +abstract interface class OrmCollectionContext implements OrmExecutionContext { + IncludeExecutionStrategySelector get includeStrategySelector; + + int get maxIncludeDepth; + + Future transaction(Future Function(OrmDbNamespace txDb) run); +} + +abstract interface class _OrmDelegateRuntime implements OrmCollectionContext { + ModelDelegate _resolveDelegate(String modelKey); + + Future explainPlan(OrmPlan plan); +} + +final class OrmClient implements OrmDbContext, _OrmDelegateRuntime { + @override + final OrmContract contract; + final OrmEngine engine; + final OrmRuntimeCore _runtime; + final Map _delegates = {}; + final Map _collectionRegistry; + late final OrmDbNamespace _db = OrmDbNamespace( + sqlContext: this, + resolveDelegate: _resolveDelegate, + ); + @override + final IncludeExecutionStrategySelector includeStrategySelector; + @override + final int maxIncludeDepth; + + 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 {}, + this.includeStrategySelector = defaultIncludeExecutionStrategySelector, + this.maxIncludeDepth = _defaultMaxIncludeDepth, + }) : assert(maxIncludeDepth > 0, 'maxIncludeDepth must be greater than 0.'), + _runtime = OrmRuntimeCore( + contract: contract, + engine: engine, + plugins: plugins, + verify: verify, + mode: mode, + log: log, + ), + _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, + explainPlan: connection.explain, + collectionRegistry: _collectionRegistry, + includeStrategySelector: includeStrategySelector, + maxIncludeDepth: maxIncludeDepth, + ); + + try { + return await run(scoped); + } finally { + await connection.release(); + } + } + + Future withTransaction( + Future Function(OrmScopedClient transaction) run, + ) async { + final connection = await _runtime.connection(); + OrmRuntimeTransaction? transaction; + + try { + final openedTransaction = await connection.transaction(); + transaction = openedTransaction; + final scoped = OrmScopedClient._( + contract: contract, + executePlan: openedTransaction.execute, + explainPlan: openedTransaction.explain, + collectionRegistry: _collectionRegistry, + includeStrategySelector: includeStrategySelector, + maxIncludeDepth: maxIncludeDepth, + ); + final value = await run(scoped); + await openedTransaction.commit(); + return value; + } catch (_) { + if (transaction != null) { + 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(); + + RuntimeOperationTelemetryEvent? operationTelemetry([String? operationId]) { + return _runtime.operationTelemetry(operationId); + } + + List recentOperationTelemetry({ + int limit = 50, + }) { + return _runtime.recentOperationTelemetry(limit: limit); + } + + @override + OrmDbNamespace get db => _db; + + @override + ModelDelegate _resolveDelegate(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 + 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)); + } + + String _resolveModelOrThrow({required String modelKey}) { + final resolved = _resolveModel(modelKey); + if (resolved != null) { + return resolved; + } + throw ModelNotFoundException(modelKey, contract.models.keys); + } + + String? _resolveModel(String modelKey) { + if (contract.models.containsKey(modelKey)) { + return modelKey; + } + return null; + } +} + +final class OrmScopedClient implements OrmDbContext, _OrmDelegateRuntime { + @override + final OrmContract contract; + final Future Function(OrmPlan plan) _executePlan; + final Future Function(OrmPlan plan) _explainPlan; + final Map _collectionRegistry; + final Map _delegates = {}; + late final OrmDbNamespace _db = OrmDbNamespace( + sqlContext: this, + resolveDelegate: _resolveDelegate, + ); + @override + final IncludeExecutionStrategySelector includeStrategySelector; + @override + final int maxIncludeDepth; + + OrmScopedClient._({ + required this.contract, + required Future Function(OrmPlan plan) executePlan, + required Future Function(OrmPlan plan) explainPlan, + required Map collectionRegistry, + required this.includeStrategySelector, + required this.maxIncludeDepth, + }) : _executePlan = executePlan, + _explainPlan = explainPlan, + _collectionRegistry = collectionRegistry; + + @override + ModelDelegate _resolveDelegate(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 + OrmDbNamespace get db => _db; + + @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); + } + + String _resolveModelOrThrow({required String modelKey}) { + final resolved = _resolveModel(modelKey); + if (resolved != null) { + return resolved; + } + throw ModelNotFoundException(modelKey, contract.models.keys); + } + + String? _resolveModel(String modelKey) { + if (contract.models.containsKey(modelKey)) { + return modelKey; + } + return null; + } +} + +@immutable +final class OrmSqlMutationResult { + final JsonMap? row; + final int affectedRows; + + const OrmSqlMutationResult({this.row, this.affectedRows = 0}); +} + +final class OrmDbNamespace { + final OrmExecutionContext _sqlContext; + final ModelDelegate Function(String modelKey) _resolveDelegate; + + late final OrmModelNamespace orm = OrmModelNamespace(_resolveDelegate); + late final OrmSqlApi sql = OrmSqlApi( + _sqlContext, + resolveDelegate: _resolveDelegate, + ); + + OrmDbNamespace({ + required OrmExecutionContext sqlContext, + required ModelDelegate Function(String modelKey) resolveDelegate, + }) : _sqlContext = sqlContext, + _resolveDelegate = resolveDelegate; +} + +final class OrmModelNamespace { + final ModelDelegate Function(String modelKey) _resolveModel; + + OrmModelNamespace(this._resolveModel); + + ModelDelegate model(String modelKey) => _resolveModel(modelKey); +} + +final class OrmSqlApi { + final OrmExecutionContext _client; + final ModelDelegate Function(String modelKey) _resolveDelegate; + + const OrmSqlApi( + this._client, { + required ModelDelegate Function(String modelKey) resolveDelegate, + }) : _resolveDelegate = resolveDelegate; + + OrmSqlSelectBuilder from(String modelKey) { + return OrmSqlSelectBuilder._( + client: _client, + modelName: _resolveSqlModelName( + resolveDelegate: _resolveDelegate, + modelKey: modelKey, + ), + ); + } + + OrmSqlInsertBuilder insertInto(String modelKey) { + return OrmSqlInsertBuilder._( + client: _client, + modelName: _resolveSqlModelName( + resolveDelegate: _resolveDelegate, + modelKey: modelKey, + ), + ); + } + + OrmSqlUpdateBuilder update(String modelKey) { + return OrmSqlUpdateBuilder._( + client: _client, + modelName: _resolveSqlModelName( + resolveDelegate: _resolveDelegate, + modelKey: modelKey, + ), + ); + } + + OrmSqlDeleteBuilder deleteFrom(String modelKey) { + return OrmSqlDeleteBuilder._( + client: _client, + modelName: _resolveSqlModelName( + resolveDelegate: _resolveDelegate, + modelKey: modelKey, + ), + ); + } +} + +@immutable +final class OrmSqlSelectBuilder { + final OrmExecutionContext _client; + final String _modelName; + final JsonMap _where; + final int? _skip; + final int? _take; + final List _orderBy; + final List _distinct; + final List _select; + + OrmSqlSelectBuilder._({ + required OrmExecutionContext 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 toPlan() { + return _buildSqlPlan( + client: _client, + modelName: _modelName, + action: OrmAction.read, + where: _where, + skip: _skip, + take: _take, + orderBy: _orderBy, + distinct: _distinct, + select: _select, + ); + } + + Future> all() async { + final response = await _client.execute(toPlan()); + return _collectRows(response, action: 'sql.all'); + } + + Future firstOrNull() async { + final response = await _client.execute(take(1).toPlan()); + return _collectSingleRow(response, action: 'sql.firstOrNull'); + } + + Stream stream() async* { + final response = await _client.execute(toPlan()); + yield* _streamRows(response, action: 'sql.stream'); + } + + 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 OrmExecutionContext _client; + final String _modelName; + final JsonMap _data; + final List _select; + + OrmSqlInsertBuilder._({ + required OrmExecutionContext 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 toPlan() { + return _buildSqlPlan( + client: _client, + modelName: _modelName, + action: OrmAction.create, + mutationResultMode: OrmMutationResultMode.rowOrNull, + data: _data, + select: _select, + ); + } + + Future execute() async { + final response = await _client.execute(toPlan()); + return OrmSqlMutationResult( + row: await _collectSingleRow(response, 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 OrmExecutionContext _client; + final String _modelName; + final JsonMap _where; + final JsonMap _data; + final List _select; + + OrmSqlUpdateBuilder._({ + required OrmExecutionContext 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 toPlan() { + return _buildSqlPlan( + client: _client, + modelName: _modelName, + action: OrmAction.update, + mutationResultMode: OrmMutationResultMode.rowOrNull, + where: _where, + data: _data, + select: _select, + ); + } + + Future execute() async { + final response = await _client.execute(toPlan()); + return OrmSqlMutationResult( + row: await _collectSingleRow(response, 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 OrmExecutionContext _client; + final String _modelName; + final JsonMap _where; + final List _select; + + OrmSqlDeleteBuilder._({ + required OrmExecutionContext 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 toPlan() { + return _buildSqlPlan( + client: _client, + modelName: _modelName, + action: OrmAction.delete, + mutationResultMode: OrmMutationResultMode.rowOrNull, + where: _where, + select: _select, + ); + } + + Future execute() async { + final response = await _client.execute(toPlan()); + return OrmSqlMutationResult( + row: await _collectSingleRow(response, 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 ModelDelegate Function(String modelKey) resolveDelegate, + required String modelKey, +}) { + final delegate = resolveDelegate(modelKey); + return delegate.modelName; +} + +OrmPlan _buildSqlPlan({ + required OrmExecutionContext client, + required String modelName, + required OrmAction action, + OrmMutationResultMode? mutationResultMode, + JsonMap where = const {}, + JsonMap data = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], +}) { + final contract = client.contract; + 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, + ); +} + +class ModelDelegate { + final OrmCollectionContext _client; + final String modelName; + + ModelDelegate({required OrmCollectionContext client, required this.modelName}) + : _client = client; + + @protected + 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( + this, + ); + late final _RepositoryReadExecutor _readRepository = _RepositoryReadExecutor( + this, + ); + + ModelQuery query() => _queryFromSpec(OrmReadQuerySpec()); + + ModelQuery _queryFromSpec(OrmReadQuerySpec spec) => ModelQuery._(this, spec); + + 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}) => + query().orderByField(field, order: order); + + ModelQuery skip(int value) => query().skip(value); + + 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( + 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}) => + query().distinct(fields, append: append); + + ModelQuery distinctField(String field) => query().distinctField(field); + + 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(), + }) => query().includeRelation(relation, spec: spec); + + Future prepareRead({required OrmReadQuerySpec spec}) { + return _prepareReadQuery( + state: _OrmPreparedReadState( + resultMode: OrmReadResultMode.all, + spec: spec, + ), + ); + } + + 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), + ), + baseSpec: prepared._state._spec, + groupBy: groupBy, + ); + } + + Future _prepareReadQuery({ + 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, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + }) => _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 {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + }) => _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 {}, + List orderBy = const [], + List select = const [], + Map include = const {}, + required OrmReadPagePlan page, + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + orderBy: orderBy, + select: select, + include: include, + page: page, + ), + ).pageResult(); + + Stream stream({ + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + }) => _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 {}, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where, select: select, include: include), + ).oneOrNull(); + + Future firstOrNull({ + JsonMap where = const {}, + int? skip, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + }) => _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, + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + orderBy: orderBy, + cursor: cursor, + page: page, + ), + ).count(); + + Future exists({ + JsonMap where = const {}, + List orderBy = const [], + JsonMap? cursor, + OrmReadPagePlan? page, + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + orderBy: orderBy, + cursor: cursor, + page: page, + ), + ).exists(); + + 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, + }) => _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 {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + JsonMap? cursor, + OrmReadPagePlan? page, + }) => _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 {}, + List orderBy = const [], + JsonMap? cursor, + OrmReadPagePlan? page, + required OrmAggregateBuilder Function(OrmAggregateBuilder aggregate) build, + }) => _queryFromSpec( + OrmReadQuerySpec( + where: where, + orderBy: orderBy, + cursor: cursor, + page: page, + ), + ).aggregate(build); + + ModelGroupedQuery groupedBy( + List by, { + JsonMap where = const {}, + }) => _queryFromSpec(OrmReadQuerySpec(where: where)).groupedBy(by); + + 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> 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, + }) => _queryFromSpec( + OrmReadQuerySpec(where: where), + ).updateCount(data: data); + + 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, + 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 _create({ + required JsonMap data, + required OrmReadQuerySpec spec, + }) => _RepositoryMutationExecutor( + this, + ).create(data: data, select: spec.select, include: spec.include); + + Future _createNested({ + required JsonMap data, + required Map> create, + required OrmReadQuerySpec spec, + }) => _RepositoryMutationExecutor(this).createNested( + data: data, + nestedCreate: create, + select: spec.select, + include: spec.include, + ); + + Future> _createMany({ + required List data, + required OrmReadQuerySpec spec, + }) => _RepositoryMutationExecutor( + 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, + }) => _RepositoryMutationExecutor( + this, + ).updateCount(where: spec.where, data: data); + + 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, + required OrmReadQuerySpec spec, + }) => _RepositoryMutationExecutor(this).upsert( + where: spec.where, + create: create, + update: update, + select: spec.select, + include: spec.include, + ); + + Future _update({ + required JsonMap data, + required OrmReadQuerySpec spec, + }) => _RepositoryMutationExecutor(this).update( + where: spec.where, + data: data, + select: spec.select, + include: spec.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, + 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, + required int includeDepth, + }) async { + final prepared = await _prepareReadQuery( + 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, + action: action, + includeDepth: includeDepth, + ); + } + + Future _readOneInternal({ + required OrmAction action, + JsonMap where = const {}, + List select = const [], + Map include = const {}, + JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, + required int includeDepth, + }) async { + final prepared = await _prepareReadQuery( + state: _OrmPreparedReadState( + resultMode: OrmReadResultMode.all, + spec: OrmReadQuerySpec(where: where, select: select, include: include), + annotations: annotations, + repositoryTrace: repositoryTrace, + ), + ); + return _readRepository.oneOrNull( + prepared: prepared, + action: action, + includeDepth: includeDepth, + ); + } + + Future> _resolveIncludeRows({ + required OrmAction action, + required List rows, + required Map include, + required int depth, + _RepositoryOperation? operation, + }) { + return _RepositoryIncludePlanner(this).resolve( + action: action, + rows: rows, + include: include, + depth: depth, + operation: operation, + ); + } + + 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; + } + + 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 _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 List distinct, + 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, + distinct: distinct, + 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, + distinct: distinct, + 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 List distinct, + required JsonMap boundary, + required _RepositoryOperation operation, + }) async { + final rows = await _readAllInternal( + action: OrmAction.read, + where: where, + orderBy: orderBy, + distinct: distinct, + select: { + ...orderBy.map((entry) => entry.field), + ...distinct, + }.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 List distinct, + 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, + distinct: distinct, + select: { + ...orderBy.map((entry) => entry.field), + ...distinct, + }.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; + } + + void _validateStableCursorOrderBy({required List orderBy}) { + _readPlanCompiler.validateStableCursorOrderBy(orderBy: orderBy); + } + + List _expandSelectForInclude({ + required String model, + required List select, + required Map include, + }) { + return _readPlanCompiler.expandSelectForInclude( + model: model, + select: select, + include: include, + ); + } + + 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, + ); + } + } + + 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', + ); + _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 (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'); + _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 _assertGroupByHavingFields({ + required OrmGroupByHaving 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.toJson(), + 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) => !_groupByHavingOperators.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': _groupByHavingOperators.toList(growable: false), + }, + ); + } + + for (final entry in conditionMap.entries) { + final operator = entry.key; + final operand = entry.value; + if (operator == 'not') { + _assertGroupByHavingCondition( + condition: operand, + source: '$source.$operator', + ); + } + } + } + + 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]; + } + + 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); + } + + 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, + 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; + } + + 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; + } + return Map.from(data); + } + + Future<_RelationWhereRewriteResult> _normalizeWhereForExecution({ + required String model, + required JsonMap where, + _RepositoryOperation? operation, + }) { + return _relationWhereRewriter.rewrite( + model: model, + where: where, + operation: operation, + ); + } + + Map _normalizeInclude(Map include) { + return _readPlanCompiler.normalizeInclude(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 +final class OrmReadQuerySpec { + 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; + + OrmReadQuerySpec({ + JsonMap where = const {}, + this.skip, + this.take, + 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 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); + + 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 +final class OrmGroupBySpec { + final List by; + final OrmGroupByHaving having; + final bool countAll; + final List count; + final List min; + final List max; + final List sum; + final List avg; + + OrmGroupBySpec({ + required List by, + this.having = const OrmGroupByHaving.empty(), + this.countAll = false, + List count = const [], + List min = const [], + List max = const [], + List sum = const [], + List avg = const [], + }) : by = List.unmodifiable(by), + count = List.unmodifiable(count), + min = List.unmodifiable(min), + max = List.unmodifiable(max), + sum = List.unmodifiable(sum), + avg = List.unmodifiable(avg); + + OrmGroupBySpec copyWith({ + List? by, + OrmGroupByHaving? having, + bool? countAll, + List? count, + List? min, + List? max, + List? sum, + List? avg, + }) { + return OrmGroupBySpec( + by: by ?? this.by, + having: having ?? this.having, + 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 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 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; + final OrmReadQuerySpec _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 distinctValues => _state.distinct; + + List get selectedFields => _state.select; + + 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} + : {...where}; + return _next(_state.copyWith(where: nextWhere)); + } + + 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] + : [...orderBy]; + return _next(_state.copyWith(orderBy: nextOrderBy)); + } + + ModelQuery orderByField(String field, {SortOrder order = SortOrder.asc}) { + return orderBy([OrmOrderBy(field, order: order)]); + } + + ModelQuery distinct(List fields, {bool append = false}) { + final nextDistinct = append + ? [..._state.distinct, ...fields] + : [...fields]; + return _next(_state.copyWith(distinct: nextDistinct)); + } + + ModelQuery distinctField(String field) { + return distinct([field], append: true); + } + + ModelQuery select(List fields, {bool append = false}) { + final nextSelect = append + ? [..._state.select, ...fields] + : [...fields]; + return _next(_state.copyWith(select: nextSelect)); + } + + 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); + } + + ModelQuery include(Map include, {bool merge = true}) { + final nextInclude = merge + ? _mergeIncludeSpecMap(_state.include, include) + : {...include}; + + return _next(_state.copyWith(include: nextInclude)); + } + + 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(), + }) { + return include({relation: spec}); + } + + ModelQuery skip(int value) { + return _next(_state.copyWith(skip: value, page: null)); + } + + ModelQuery take(int value) { + return _next(_state.copyWith(take: value, page: null)); + } + + ModelQuery cursor(JsonMap cursor) { + if (_state.orderBy.isEmpty) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + 'cursor() requires orderBy() first.', + details: {'model': _delegate.modelName}, + ); + } + _delegate._validateStableCursorOrderBy(orderBy: _state.orderBy); + if (cursor.isEmpty) { + throw PlanCursorWindowInvalidException( + reason: 'cursorEmpty', + details: {'model': _delegate.modelName}, + ); + } + return _next(_state.copyWith(cursor: cursor, page: null)); + } + + ModelQuery page({required int size, JsonMap? after, JsonMap? before}) { + if (_state.orderBy.isEmpty) { + throw runtimeError( + 'PLAN.CURSOR_ORDER_BY_REQUIRED', + 'page() requires orderBy() first.', + details: {'model': _delegate.modelName}, + ); + } + _delegate._validateStableCursorOrderBy(orderBy: _state.orderBy); + 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( + _state.copyWith( + skip: null, + take: null, + cursor: null, + page: OrmReadPagePlan(size: size, after: after, before: before), + ), + ); + } + + ModelQuery unbounded() { + return _next(_state.copyWith(take: null, page: null)); + } + + Future _prepareRead() { + return _delegate.prepareRead(spec: _state); + } + + Future toPlan() async { + return (await _prepareRead()).plan; + } + + Future inspectPlan() async { + return (await _prepareRead()).inspectPlan(); + } + + Future> all() async { + _assertReadExecutionSupported('all'); + return (await _prepareRead()).all(); + } + + Future> pageResult() async { + _assertReadExecutionSupported('pageResult'); + return (await _prepareRead()).pageResult(); + } + + Stream stream() async* { + _assertReadExecutionSupported('stream'); + final prepared = await _prepareRead(); + yield* prepared.stream(); + } + + Future oneOrNull() async { + _assertReadExecutionSupported('oneOrNull'); + return (await _prepareRead()).oneOrNull(); + } + + Future firstOrNull() async { + _assertReadExecutionSupported('firstOrNull'); + return (await _prepareRead()).firstOrNull(); + } + + Future count() { + _assertReadExecutionSupported('count'); + return _delegate._count(spec: _state); + } + + Future exists() { + _assertReadExecutionSupported('exists'); + return _delegate._exists(spec: _state); + } + + Future explain() async { + return (await _prepareRead()).explain(); + } + + Future aggregate( + OrmAggregateBuilder Function(OrmAggregateBuilder aggregate) build, + ) { + _assertReadExecutionSupported('aggregate'); + return _executeAggregate(build(OrmAggregateBuilder()).toSpec()); + } + + Future _executeAggregate(OrmAggregateSpec aggregate) { + _assertAggregateQueryState(); + _delegate._assertAggregateSpecRequested(aggregate, terminal: 'aggregate'); + return _delegate + ._prepareAggregateQuery(spec: _state, aggregate: aggregate) + .then((prepared) => prepared.execute()); + } + + ModelGroupedQuery groupedBy(List by) { + _assertGroupedQueryBaseState(); + return ModelGroupedQuery._( + _delegate, + _state.copyWith(), + OrmGroupBySpec(by: by), + ); + } + + void _assertReadExecutionSupported(String terminal) {} + + 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 _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, + bool requireWhere = false, + bool allowSelect = true, + bool allowInclude = 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 (!allowSelect && _state.select.isNotEmpty) 'select', + if (!allowInclude && _state.include.isNotEmpty) 'include', + if (_state.cursor != null) 'cursor', + if (_state.page != null) 'page', + ]; + 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_WHERE_REQUIRED', + '$action requires where() first.', + details: { + 'model': _delegate.modelName, + 'action': action, + }, + ); + } + + Future create({required JsonMap data}) { + _assertMutationQueryState(action: 'create', allowWhere: false); + 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, 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', + requireWhere: true, + allowSelect: false, + allowInclude: false, + ); + return _delegate._updateCount(data: data, spec: _state); + } + + Future deleteCount() { + _assertMutationQueryState( + action: 'deleteCount', + requireWhere: true, + allowSelect: false, + allowInclude: false, + ); + 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); + } + + Future update({required JsonMap data}) { + _assertMutationQueryState(action: 'update'); + return _delegate._update(data: data, spec: _state); + } + + Future updateNested({ + required JsonMap data, + Map> create = const >{}, + }) { + _assertMutationQueryState(action: 'updateNested'); + return _delegate._updateNested(data: data, create: create, spec: _state); + } + + Future delete() { + _assertMutationQueryState(action: 'delete'); + return _delegate._delete(spec: _state); + } + + ModelQuery _next(OrmReadQuerySpec nextState) => + 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; + + 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( + OrmAggregateBuilder Function(OrmAggregateBuilder aggregate) build, + ) => _executeAggregate(build(OrmAggregateBuilder()).toSpec()); + + Future> _executeAggregate(OrmAggregateSpec aggregate) { + _assertExecutionSupported('aggregate'); + _delegate._assertAggregateSpecRequested(aggregate, terminal: 'aggregate'); + return _prepareGrouped( + groupBy: _groupBy.copyWith( + countAll: aggregate.countAll, + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + sum: aggregate.sum, + avg: aggregate.avg, + ), + ).then((prepared) => prepared.execute()); + } + + 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 _prepareGrouped({ + required OrmGroupBySpec groupBy, + }) { + _assertExecutionSupported('aggregate'); + return _delegate._prepareGroupedQuery( + baseSpec: _baseState, + groupBy: groupBy, + ); + } + + ModelGroupedQuery _next(OrmGroupBySpec nextGroupBy) => + ModelGroupedQuery._(_delegate, _baseState, nextGroupBy); +} + +@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 _createCollectionRegistry( + OrmContract contract, + Map collections, +) { + if (collections.isEmpty) { + return const {}; + } + + final registry = {}; + + for (final entry in collections.entries) { + if (!contract.models.containsKey(entry.key)) { + throw ModelNotFoundException(entry.key, contract.models.keys); + } + registry[entry.key] = entry.value; + } + + return registry; +} + +Stream _streamRows( + EngineResponse response, { + required String action, +}) async* { + await for (final value in response.rows) { + yield _coerceRow(value, action: action); + } +} + +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 row; +} + +JsonMap _coerceRow(Object? value, {required String action}) { + if (value is Map) { + return Map.from(value); + } + if (value is Map) { + return value.map((key, item) => MapEntry(key.toString(), item)); + } + throw RuntimeResponseShapeException( + action: action, + expected: 'Map', + actual: value, + ); +} + +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; + } + 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; +} 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..b1d4ac9f --- /dev/null +++ b/pub/orm/lib/src/client/include_planner.dart @@ -0,0 +1,237 @@ +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, + _RepositoryOperation? operation, + }) { + 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, + ); + 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, + ), + }; + } + + Future> _resolveSingleQuery({ + required List rows, + required Map include, + required int depth, + required _RepositoryOperation operation, + }) 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._runtime._resolveDelegate( + relation.relatedModel, + ); + _delegate._validateIncludePagination(include: relationInclude); + + final relatedRows = await _loadRelationRowsSingleQuery( + relatedDelegate: relatedDelegate, + relation: relation, + relationInclude: relationInclude, + depth: depth, + operation: operation, + ); + 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, + required _RepositoryOperation operation, + }) 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._runtime._resolveDelegate( + 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._readAllInternal( + action: OrmAction.read, + where: {...relationInclude.where, ...relationWhere}, + skip: relationInclude.skip, + take: relationInclude.take, + orderBy: relationInclude.orderBy, + select: relationInclude.select, + include: relationInclude.include, + repositoryTrace: operation.nextTrace( + phase: 'include.load', + strategy: 'multiQuery', + relation: relationName, + ), + 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, + required _RepositoryOperation operation, + }) { + final baseWhere = _delegate._buildSingleQueryRelationBaseWhere( + includeWhere: relationInclude.where, + relation: relation, + ); + + return relatedDelegate._readAllInternal( + action: OrmAction.read, + where: baseWhere, + orderBy: relationInclude.orderBy, + select: _delegate._buildSingleQueryRelationSelect( + include: relationInclude, + relation: relation, + ), + include: relationInclude.include, + repositoryTrace: operation.nextTrace( + 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 new file mode 100644 index 00000000..eeaac585 --- /dev/null +++ b/pub/orm/lib/src/client/mutation_repository.dart @@ -0,0 +1,808 @@ +part of 'client.dart'; + +@immutable +final class _PreparedMutationPlan { + final OrmPlan plan; + final Map include; + + 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 trace = operation ?? _startOperation('create'); + final normalized = await _normalizeMutationInput( + where: const {}, + select: select, + include: include, + operation: trace, + ); + final prepared = _composeMutationPlan( + action: OrmAction.create, + mutationResultMode: OrmMutationResultMode.row, + data: data, + normalized: normalized, + repositoryTrace: trace.nextTrace( + phase: phase, + strategy: strategy, + relation: relation, + itemIndex: itemIndex, + ), + ); + final normalizedInclude = prepared.include; + final response = await _delegate._client.execute(prepared.plan); + + var row = await _collectSingleRow(response, 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, + _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, + where: where, + data: data, + select: select, + include: include, + responseAction: 'update', + operation: trace, + phase: phase, + strategy: strategy, + relation: relation, + itemIndex: itemIndex, + ); + } + + Future delete({ + 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, + where: where, + data: const {}, + select: select, + include: include, + responseAction: 'delete', + operation: trace, + phase: phase, + strategy: strategy, + itemIndex: itemIndex, + ); + } + + Future createNested({ + required JsonMap data, + required Map> nestedCreate, + 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( + data: data, + nestedCreate: nestedCreate, + select: select, + include: include, + operation: trace, + ); + }); + } + + Future updateNested({ + required JsonMap where, + required JsonMap data, + required Map> nestedCreate, + 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( + where: where, + data: data, + nestedCreate: nestedCreate, + select: select, + include: include, + operation: trace, + ); + }); + } + + Future> createMany({ + required List data, + 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 (var index = 0; index < data.length; index++) { + final item = data[index]; + rows.add( + await executor.create( + data: item, + select: select, + include: include, + operation: trace, + phase: 'item.create', + strategy: 'transaction', + itemIndex: index, + ), + ); + } + return rows; + }); + } + + 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 { + 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; + } + deleted += 1; + } + return deleted; + }); + } + + Future updateCount({ + required JsonMap where, + required JsonMap data, + }) { + final trace = _startOperation('updateCount'); + 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> 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, + required JsonMap update, + 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._readOneInternal( + action: OrmAction.read, + where: where, + repositoryTrace: trace.nextTrace( + phase: 'branch.lookup', + strategy: 'branch', + ), + includeDepth: 0, + ); + if (existing == null) { + return executor.create( + data: create, + select: select, + include: include, + operation: trace, + phase: 'branch.create', + strategy: 'branch', + ); + } + + final updatedRow = await executor.update( + where: where, + data: update, + select: select, + include: include, + operation: trace, + phase: 'branch.update', + strategy: 'branch', + ); + 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, + required _RepositoryOperation operation, + required String phase, + required String strategy, + String? relation, + int? itemIndex, + }) async { + final normalized = await _normalizeMutationInput( + where: where, + select: select, + include: include, + operation: operation, + ); + 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, + repositoryTrace: operation.nextTrace( + phase: phase, + strategy: strategy, + relation: relation, + itemIndex: itemIndex, + ), + ); + + 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, + operation: operation, + ); + if (row == null) { + return null; + } + + return _shapeMutationRow( + action: action, + row: row, + select: select, + include: normalizedInclude, + ); + } + + Future<_NormalizedMutationInput> _normalizeMutationInput({ + 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( + model: _delegate.modelName, + where: where, + operation: operation, + )).where; + + 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, + OrmRepositoryTrace? repositoryTrace, + }) { + 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', + repositoryTrace: repositoryTrace, + model: _delegate.modelName, + action: action, + where: normalized.where, + data: data, + select: normalized.select, + resultMode: mutationResultMode, + ), + ); + } + + Future _preloadDeleteRow({ + required OrmAction action, + required JsonMap where, + required List select, + required Map include, + required _RepositoryOperation operation, + }) { + 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, + ), + repositoryTrace: operation.nextTrace( + phase: 'fallback.preload', + strategy: 'returningDisabledFallback', + ), + 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, + required _RepositoryOperation operation, + }) async { + var row = await _collectSingleRow(response, 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, + ), + repositoryTrace: operation.nextTrace( + phase: 'fallback.reload', + strategy: 'returningDisabledFallback', + ), + include: const {}, + includeDepth: 0, + ), + OrmAction.delete => preDeleteRow, + _ => row, + }; + } + 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', + 'updateCount() 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, + required List select, + required Map include, + required _RepositoryOperation operation, + }) 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 {}, + operation: operation, + phase: 'root.create', + strategy: 'transaction', + ); + + for (final entry in normalizedCreate.entries) { + final relation = _delegate._resolveRelation( + model: _delegate.modelName, + relationName: entry.key, + ); + final related = _delegate._runtime._resolveDelegate( + relation.relatedModel, + ); + final relatedExecutor = _RepositoryMutationExecutor(related); + for (var index = 0; index < entry.value.length; index++) { + final child = entry.value[index]; + final linkedData = _delegate._linkNestedData( + parent: created, + relationName: entry.key, + relation: relation, + data: child, + ); + await relatedExecutor.create( + data: linkedData, + select: const [], + include: const {}, + operation: operation, + phase: 'child.create', + strategy: 'transaction', + relation: entry.key, + itemIndex: index, + ); + } + } + + 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, + required _RepositoryOperation operation, + }) 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 {}, + operation: operation, + phase: 'root.update', + strategy: 'transaction', + ); + if (updated == null) { + return null; + } + + for (final entry in normalizedCreate.entries) { + final relation = _delegate._resolveRelation( + model: _delegate.modelName, + relationName: entry.key, + ); + final related = _delegate._runtime._resolveDelegate( + relation.relatedModel, + ); + final relatedExecutor = _RepositoryMutationExecutor(related); + for (var index = 0; index < entry.value.length; index++) { + final child = entry.value[index]; + final linkedData = _delegate._linkNestedData( + parent: updated, + relationName: entry.key, + relation: relation, + data: child, + ); + await relatedExecutor.create( + data: linkedData, + select: const [], + include: const {}, + operation: operation, + phase: 'child.create', + strategy: 'transaction', + relation: entry.key, + itemIndex: index, + ); + } + } + + 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/client/read_plan_compiler.dart b/pub/orm/lib/src/client/read_plan_compiler.dart new file mode 100644 index 00000000..216e6b7d --- /dev/null +++ b/pub/orm/lib/src/client/read_plan_compiler.dart @@ -0,0 +1,196 @@ +part of 'client.dart'; + +final class _OrmReadPlanCompiler { + final ModelDelegate _delegate; + + _OrmReadPlanCompiler(this._delegate); + + OrmPreparedReadQuery compile({required _OrmPreparedReadState state}) { + if (state._skip case final offset? when offset < 0) { + throw PlanInvalidPaginationException(key: 'skip', value: offset); + } + if (state._take case final limit? when limit < 0) { + throw PlanInvalidPaginationException(key: 'take', value: limit); + } + + final normalizedInclude = normalizeInclude(state._include); + 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 (state._cursor != null) 'cursor': state._cursor, + if (state._page != null) 'page': state._page!.toJson(), + }, + ); + } + if (state._cursor != null || state._page != null) { + validateStableCursorOrderBy(orderBy: state._orderBy); + } + 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': state.resultMode.name, + 'page': state._page!.toJson(), + }, + ); + } + + final isCollectionRead = state.resultMode != OrmReadResultMode.oneOrNull; + final resolvedTake = state._page != null + ? null + : state.resultMode == OrmReadResultMode.firstOrNull + ? 1 + : state._take; + final readSelect = switch (state.resultMode) { + OrmReadResultMode.oneOrNull => expandSelectForInclude( + model: _delegate.modelName, + select: state._select, + include: normalizedInclude, + ), + OrmReadResultMode.all || + OrmReadResultMode.firstOrNull => expandSelectForExecution( + model: _delegate.modelName, + select: state._select, + include: normalizedInclude, + distinct: state._distinct, + ), + }; + + return OrmPreparedReadQuery._( + delegate: _delegate, + state: state, + normalizedInclude: 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( + state._annotations, + state._distinct.isEmpty + ? const {} + : { + 'distinct': List.from( + state._distinct, + growable: false, + ), + }, + ), + repositoryTrace: state._repositoryTrace, + model: _delegate.modelName, + where: state._where, + 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: state._cursor == null + ? null + : OrmReadCursorPlan(values: state._cursor!), + page: state._page, + resultMode: state.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; + } + + 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), + }, + ); + } +} 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..46afd942 --- /dev/null +++ b/pub/orm/lib/src/client/read_repository.dart @@ -0,0 +1,474 @@ +part of 'client.dart'; + +@immutable +final class _OrmPreparedReadState { + final OrmReadResultMode resultMode; + final OrmReadQuerySpec _spec; + final JsonMap _annotations; + final OrmRepositoryTrace? _repositoryTrace; + + _OrmPreparedReadState({ + required this.resultMode, + required OrmReadQuerySpec spec, + JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, + }) : _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 +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; + + 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({ + ...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, + ); + } +} + +@immutable +final class OrmPreparedAggregateQuery { + final ModelDelegate _delegate; + final OrmPlan plan; + + const OrmPreparedAggregateQuery._({ + required ModelDelegate delegate, + required this.plan, + required OrmReadQuerySpec spec, + required OrmAggregateSpec aggregate, + }) : _delegate = delegate; + + Future inspectPlan() async => + Map.unmodifiable(plan.toJson()); + + Future execute() => + _delegate._readRepository.aggregate(prepared: this); +} + +@immutable +final class OrmPreparedGroupedQuery { + final ModelDelegate _delegate; + final OrmPlan plan; + + const OrmPreparedGroupedQuery._({ + required ModelDelegate delegate, + required this.plan, + required OrmReadQuerySpec baseSpec, + required OrmGroupBySpec groupBy, + }) : _delegate = delegate; + + Future inspectPlan() async => + Map.unmodifiable(plan.toJson()); + + Future> execute() => + _delegate._readRepository.grouped(prepared: this); +} + +final class _RepositoryReadExecutor { + final ModelDelegate _delegate; + + _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, + required int includeDepth, + }) async { + final response = await _delegate._client.execute(prepared.plan); + final rows = await _collectRows(response, action: 'all'); + 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( + state: prepared._state.copyWith( + resultMode: OrmReadResultMode.all, + spec: prepared._state._spec.copyWith( + where: prepared._where, + skip: null, + take: null, + orderBy: prepared._orderBy, + distinct: prepared._distinct, + 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); + 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, + distinct: prepared._distinct, + 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 = 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 = await _collectSingleRow(response, action: 'firstOrNull'); + 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( + 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); + + 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) { + await for (final row in _streamRows(response, action: 'stream')) { + yield _delegate._shapeRow( + row, + select: prepared._select, + include: prepared._normalizedInclude, + ); + } + return; + } + + final rows = await _collectRows(response, action: 'stream'); + 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/client/relation_where_rewriter.dart b/pub/orm/lib/src/client/relation_where_rewriter.dart new file mode 100644 index 00000000..f1656058 --- /dev/null +++ b/pub/orm/lib/src/client/relation_where_rewriter.dart @@ -0,0 +1,476 @@ +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<_RelationWhereRewriteResult> rewrite({ + required String model, + required JsonMap where, + _RepositoryOperation? operation, + String lookupPhase = 'where.relationLookup', + String lookupStrategy = 'relationWhereLookup', + }) async { + if (where.isEmpty) { + return const _RelationWhereRewriteResult( + where: {}, + usedLookups: false, + ); + } + if (_delegate._client.contract.target == 'sql-family') { + return _RelationWhereRewriteResult(where: where, usedLookups: false); + } + 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 { + 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( + session: session, + 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( + session: session, + 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 _RelationWhereRewriteSession session, + required String model, + required Object? operand, + }) async { + final nestedWhere = _coerceWhereMap(operand); + if (nestedWhere != null) { + return _rewriteRelationWhere( + session: session, + model: model, + where: nestedWhere, + ); + } + + final nestedWhereList = _coerceWhereList(operand); + if (nestedWhereList == null) { + return operand; + } + + final normalized = []; + for (final entry in nestedWhereList) { + 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, + }) 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( + session: session, + relationName: relationName, + relation: relation, + operator: 'some', + operand: where['some'], + ); + clauses.add( + await _buildRelationMembershipClause( + session: session, + relationName: relationName, + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } + + if (where.containsKey('none')) { + final relationWhere = await _normalizeRelationOperatorWhere( + session: session, + relationName: relationName, + relation: relation, + operator: 'none', + operand: where['none'], + ); + clauses.add( + await _buildRelationMembershipClause( + session: session, + relationName: relationName, + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } + + if (where.containsKey('every')) { + final relationWhere = await _normalizeRelationOperatorWhere( + session: session, + relationName: relationName, + relation: relation, + operator: 'every', + operand: where['every'], + ); + clauses.add( + await _buildRelationMembershipClause( + session: session, + relationName: relationName, + relation: relation, + relatedWhere: {'NOT': relationWhere}, + include: false, + ), + ); + } + } else { + if (where.containsKey('is')) { + final isOperand = where['is']; + if (isOperand == null) { + clauses.add( + await _buildRelationMembershipClause( + session: session, + relationName: relationName, + relation: relation, + relatedWhere: const {}, + include: false, + ), + ); + } else { + final relationWhere = await _normalizeRelationOperatorWhere( + session: session, + relationName: relationName, + relation: relation, + operator: 'is', + operand: isOperand, + ); + clauses.add( + await _buildRelationMembershipClause( + session: session, + relationName: relationName, + relation: relation, + relatedWhere: relationWhere, + include: true, + ), + ); + } + } + + if (where.containsKey('isNot')) { + final isNotOperand = where['isNot']; + if (isNotOperand == null) { + clauses.add( + await _buildRelationMembershipClause( + session: session, + relationName: relationName, + relation: relation, + relatedWhere: const {}, + include: true, + ), + ); + } else { + final relationWhere = await _normalizeRelationOperatorWhere( + session: session, + relationName: relationName, + relation: relation, + operator: 'isNot', + operand: isNotOperand, + ); + clauses.add( + await _buildRelationMembershipClause( + session: session, + relationName: relationName, + relation: relation, + relatedWhere: relationWhere, + include: false, + ), + ); + } + } + } + + if (clauses.isEmpty) { + return null; + } + if (clauses.length == 1) { + return clauses.single; + } + return {'AND': clauses}; + } + + Future _normalizeRelationOperatorWhere({ + required _RelationWhereRewriteSession session, + 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( + 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, + ); + + 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/contract/contract.dart b/pub/orm/lib/src/contract/contract.dart new file mode 100644 index 00000000..a3cc0a6b --- /dev/null +++ b/pub/orm/lib/src/contract/contract.dart @@ -0,0 +1,271 @@ +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 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); +} + +@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 String target; + final String markerStorageHash; + final String? profileHash; + final Map models; + final Map aliases; + final ContractCapabilities capabilities; + + OrmContract({ + required this.version, + required this.hash, + this.target = 'generic', + String? markerStorageHash, + this.profileHash, + required Map models, + Map aliases = const {}, + this.capabilities = const ContractCapabilities(), + }) : markerStorageHash = markerStorageHash ?? hash, + models = Map.unmodifiable(models), + aliases = Map.unmodifiable(aliases) { + _validateModelIdFields(this.models); + _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, + }, + ); + } + } + } +} + +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; + } + 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..c304ca94 --- /dev/null +++ b/pub/orm/lib/src/engine/engine.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +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, + this.executionMode = EngineExecutionMode.buffered, + this.executionSource = EngineExecutionSource.buffered, + }); + + 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, + executionMode: EngineExecutionMode.buffered, + executionSource: EngineExecutionSource.buffered, + ); + } + return EngineResponse( + rows: Stream.value(data), + affectedRows: affectedRows, + executionMode: EngineExecutionMode.buffered, + executionSource: EngineExecutionSource.buffered, + ); + } + + factory EngineResponse.empty({int affectedRows = 0}) { + return EngineResponse( + rows: const Stream.empty(), + affectedRows: affectedRows, + executionMode: EngineExecutionMode.buffered, + executionSource: EngineExecutionSource.buffered, + ); + } +} + +abstract interface class RuntimeQueryable { + Future execute(OrmPlan plan); +} + +abstract interface class EngineConnection implements RuntimeQueryable { + Future transaction(); + + 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(); +} + +abstract interface class OrmEngine implements RuntimeQueryable { + Future open(); + + Future close(); +} + +abstract interface class ExplainCapableEngine { + Future describePlan(OrmPlan plan); +} 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..0ecdec1b --- /dev/null +++ b/pub/orm/lib/src/engine/memory_engine.dart @@ -0,0 +1,1064 @@ +import '../core/sort_order.dart'; +import '../runtime/plan.dart'; +import '../runtime/types.dart'; +import 'engine.dart'; + +const List _whereOperatorOrder = [ + 'equals', + 'not', + 'in', + 'notIn', + 'contains', + 'startsWith', + 'endsWith', + 'gt', + 'gte', + 'lt', + 'lte', +]; + +const Set _whereOperators = { + 'equals', + 'not', + 'in', + 'notIn', + 'contains', + 'startsWith', + 'endsWith', + 'gt', + 'gte', + 'lt', + 'lte', +}; + +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.read => _read(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 _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) { + 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)) + .toList(growable: false); + + return switch (read.resultMode) { + OrmReadResultMode.firstOrNull || OrmReadResultMode.oneOrNull => + EngineResponse.buffered(_firstOrNull(projected)), + _ => EngineResponse.buffered(projected), + }; + } + + 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)); + } + + if (read.distinct.isNotEmpty) { + rows = _applyDistinctRows(rows, read.distinct); + } + + 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, + ), + ); + } + + 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!; + 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; + + 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); + bucket.add(row); + return EngineResponse.buffered( + _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, mutation.where)) { + continue; + } + + final updated = {...row, ...mutation.data}; + bucket[index] = updated; + return EngineResponse.buffered( + _projectRow(updated, mutation.select), + affectedRows: 1, + ); + } + return EngineResponse.empty(affectedRows: 0); + } + + 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, mutation.where)) { + continue; + } + + bucket.removeAt(index); + return EngineResponse.buffered( + _projectRow(row, mutation.select), + affectedRows: 1, + ); + } + return EngineResponse.empty(affectedRows: 0); + } + + bool _matches(JsonMap row, JsonMap where) { + for (final entry in where.entries) { + 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, + 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; + } + + final operand = operatorMap[operator]; + final matched = switch (operator) { + 'equals' => actualValue == operand, + '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), + '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; + } + + 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) { + 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); + } + + 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; + } + 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]; + final rightValue = right[order.field]; + final comparison = _compareValues(leftValue, rightValue); + if (comparison == 0) { + continue; + } + return order.order == SortOrder.asc ? comparison : -comparison; + } + 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; + } + 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 _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 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: actual, + condition: node.condition, + )) { + return false; + } + } + } + return true; + } + + bool _matchesGroupByHavingLogical({ + required JsonMap row, + required OrmGroupByHavingLogicalNode node, + }) { + final clauses = node.clauses; + return switch (node.operator) { + OrmGroupByHavingLogicalOperator.and => clauses.every( + (clause) => _matchesGroupByHaving(row: row, having: clause), + ), + OrmGroupByHavingLogicalOperator.or => clauses.any( + (clause) => _matchesGroupByHaving(row: row, having: clause), + ), + OrmGroupByHavingLogicalOperator.not => clauses.every( + (clause) => !_matchesGroupByHaving(row: row, having: clause), + ), + }; + } + + bool _matchesGroupByHavingCondition({ + required Object? actual, + required OrmGroupByHavingCondition condition, + }) { + if (condition.shorthand != null) { + return actual == condition.shorthand; + } + 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.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', + '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); + +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)), + }; +} + +T? _firstOrNull(List values) { + if (values.isEmpty) { + return null; + } + return values.first; +} 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..23ac9077 --- /dev/null +++ b/pub/orm/lib/src/generator/client_emitter.dart @@ -0,0 +1,133 @@ +import 'model.dart'; +import 'snapshot.dart'; +import 'writer.dart'; + +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); +} + +TypedModel _toTypedModel(SchemaModelDefinition model) { + final typedFields = model.fields.map(_toTypedField).toList(growable: false); + return TypedModel(name: model.name, fields: typedFields); +} + +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: true, + includeInCreate: false, + includeInUpdate: false, + ); + } + + 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, + includeInWhere: !parsed.isList, + includeInWhereUnique: + (field.isId || _isConventionalIdFieldName(field.name)) && + !parsed.isList, + ); +} + +_ParsedType _parseType(String source) { + final normalizedSource = source.trim(); + var typeSource = normalizedSource; + var isNullable = false; + var isList = false; + + if (typeSource.endsWith('?')) { + isNullable = true; + typeSource = typeSource.substring(0, typeSource.length - 1).trim(); + } + + 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(); + } + } + + final scalarType = _scalarTypeFor(typeSource); + if (scalarType != null) { + return _ParsedType.scalar( + scalarType: scalarType, + isNullable: isNullable, + isList: isList, + ); + } + + return _ParsedType.relation( + relationModel: _relationModelName(typeSource), + isNullable: isNullable, + isList: isList, + ); +} + +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 _relationModelName(String source) { + final compact = source.replaceAll(RegExp(r'\s+'), ''); + final tokens = compact.split('.'); + final last = tokens.last; + if (last.isEmpty) { + return source; + } + return last; +} + +bool _isConventionalIdFieldName(String name) { + return name.trim().toLowerCase() == 'id'; +} + +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 new file mode 100644 index 00000000..d63212c4 --- /dev/null +++ b/pub/orm/lib/src/generator/command.dart @@ -0,0 +1,67 @@ +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, + 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, + outputOverridePath: outputPath, + ); + final schema = loadSchema(cwd: workingDirectory, config: config); + final outputFile = _resolveOutputFile( + cwd: workingDirectory, + configuredOutputPath: config.outputPath, + ); + + final generated = emitTypedClient(schema: schema); + + 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 _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..14c8b20d --- /dev/null +++ b/pub/orm/lib/src/generator/config_loader.dart @@ -0,0 +1,311 @@ +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, + String? configPath, + String? schemaOverridePath, + String? outputOverridePath, +}) { + final configFile = _resolveConfigFile(cwd: cwd, configPath: configPath); + if (!configFile.existsSync()) { + final configOverride = _readOverridePath( + configPath, + optionName: '--config', + ); + throw GeneratorException( + configOverride == null + ? 'Cannot find orm.config.dart in current working directory.' + : 'Cannot find config file.', + path: configFile.path, + 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.', + ); + } + + 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( + 'Config file 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 = + _readOverridePath(outputOverridePath, optionName: '--output') ?? + _readStringArg( + namedArguments, + key: 'output', + file: configFile, + defaultValue: _defaultOutputPath, + ); + 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, + ); +} + +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, +}) { + 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? _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; + } + 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/contract_command.dart b/pub/orm/lib/src/generator/contract_command.dart new file mode 100644 index 00000000..79e041bc --- /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, config: config); + 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..f7434e33 --- /dev/null +++ b/pub/orm/lib/src/generator/contract_emitter.dart @@ -0,0 +1,527 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'snapshot.dart'; + +String emitContractArtifact({ + required SchemaSnapshot schema, + required GeneratorConfigSnapshot config, +}) { + final runtimeProfile = _runtimeProfileForProvider(config.provider); + final modelInfos = _indexModels(schema.models); + + final models = SplayTreeMap(); + for (final modelInfo in modelInfos.values) { + models[modelInfo.model.name] = { + 'name': modelInfo.model.name, + 'table': _defaultTableName(modelInfo.model.name), + 'fields': modelInfo.scalarFieldNames, + 'idFields': modelInfo.idFields, + 'relations': _buildRelations(owner: modelInfo, modelInfos: modelInfos), + }; + } + + final aliases = _buildAliases(modelInfos.values); + final capabilities = runtimeProfile.capabilities; + + final canonical = { + 'version': '1.0.0', + 'target': runtimeProfile.target, + 'models': models, + 'aliases': aliases, + 'capabilities': capabilities, + }; + final hash = _stableHash(jsonEncode(canonical)); + + final contract = { + 'version': '1.0.0', + 'hash': hash, + 'target': runtimeProfile.target, + 'markerStorageHash': hash, + 'models': models, + '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) { + '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 _pluralize(String value) { + if (value.isEmpty || value.endsWith('s')) { + return value; + } + return '${value}s'; +} + +String _defaultTableName(String modelName) { + if (modelName.isEmpty) { + return modelName; + } + 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 value[0].toUpperCase() + value.substring(1); +} + +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'; +} + +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/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..0f987871 --- /dev/null +++ b/pub/orm/lib/src/generator/generator.dart @@ -0,0 +1,2 @@ +export 'command.dart' show runGenerateCommand; +export 'contract_command.dart' show runContractEmitCommand; diff --git a/pub/orm/lib/src/generator/model.dart b/pub/orm/lib/src/generator/model.dart new file mode 100644 index 00000000..b3deae14 --- /dev/null +++ b/pub/orm/lib/src/generator/model.dart @@ -0,0 +1,84 @@ +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 includeInWhereUnique; + 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.includeInWhereUnique = false, + 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.includeInWhereUnique = false, + 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/schema_loader.dart b/pub/orm/lib/src/generator/schema_loader.dart new file mode 100644 index 00000000..97f0115a --- /dev/null +++ b/pub/orm/lib/src/generator/schema_loader.dart @@ -0,0 +1,270 @@ +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(); + final isId = _hasAnnotationIgnoreCase(field.metadata, 'id'); + final relation = _readRelationAnnotation(field.metadata); + 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, + isId: isId, + relation: relation, + ), + ); + } + + 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; +} + +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; +} + +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'; + } + 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..3a9fe6b8 --- /dev/null +++ b/pub/orm/lib/src/generator/snapshot.dart @@ -0,0 +1,55 @@ +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, + }); +} + +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, + }); +} + +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/lib/src/generator/writer.dart b/pub/orm/lib/src/generator/writer.dart new file mode 100644 index 00000000..1f0111f5 --- /dev/null +++ b/pub/orm/lib/src/generator/writer.dart @@ -0,0 +1,4428 @@ +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); + _writeWhereFilterClasses(buffer); + _writeGeneratedClientClass(buffer: buffer, models: resolvedModels); + + 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) { + _writeRelationWhereFilterClasses( + buffer: buffer, + model: model, + lookup: modelLookup, + ); + } + + 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.whereUnique, + lookup: modelLookup, + ); + _writeDataOrInputClass( + buffer: buffer, + model: model, + classKind: _TemplateClassKind.cursor, + 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, non_constant_identifier_names', + ); + + buffer.writeln(); + buffer.writeln("import '${options.ormImport}';"); + buffer.writeln(); + buffer.writeln('const Object _typedStateKeepToken = Object();'); + buffer.writeln(); + } + + void _writeWhereFilterClasses(StringBuffer buffer) { + buffer.writeln('class StringWhereFilter {'); + buffer.writeln(' final String? 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(' final String? contains;'); + buffer.writeln(' final String? startsWith;'); + buffer.writeln(' final String? endsWith;'); + 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(' this.contains,'); + buffer.writeln(' this.startsWith,'); + buffer.writeln(' this.endsWith,'); + buffer.writeln(' });'); + 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('); + 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(" 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();'); + buffer.writeln(' }'); + buffer.writeln(); + 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 && contains == null && startsWith == null && endsWith == 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(" 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(); + 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(' contains == null &&'); + buffer.writeln(' startsWith == null &&'); + buffer.writeln(' endsWith == null;'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class IntWhereFilter {'); + buffer.writeln(' final int? 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) {'); + 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('); + 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() {'); + 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 =>'); + 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(' 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) {', + ); + 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('); + 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() {'); + 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 =>'); + 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({'); + 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('); + 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() {'); + 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 =>'); + 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(' 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) {', + ); + 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('); + 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() {'); + 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 =>'); + 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 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.length == 1 && 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() {'); + 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('}'); + buffer.writeln(); + } + + void _writeGeneratedClientClass({ + required StringBuffer buffer, + required List<_ResolvedModel> models, + }) { + buffer.writeln('class GeneratedOrmClient {'); + buffer.writeln(' final OrmDbContext _context;'); + buffer.writeln(); + buffer.writeln(' GeneratedOrmClient(this._context);'); + buffer.writeln(); + buffer.writeln( + ' late final GeneratedOrmDb db = GeneratedOrmDb(_context.db);', + ); + + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class GeneratedOrmDb {'); + buffer.writeln(' final OrmDbNamespace _db;'); + buffer.writeln(); + buffer.writeln(' GeneratedOrmDb(this._db);'); + buffer.writeln(); + buffer.writeln( + ' late final GeneratedOrmCollections orm = GeneratedOrmCollections(_db.orm);', + ); + buffer.writeln(); + buffer.writeln( + ' late final GeneratedOrmSql sql = GeneratedOrmSql(_db.sql);', + ); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class GeneratedOrmCollections {'); + buffer.writeln(' final OrmModelNamespace _orm;'); + buffer.writeln(); + buffer.writeln(' GeneratedOrmCollections(this._orm);'); + buffer.writeln(); + + for (final model in models) { + buffer.writeln( + ' late final ${model.delegateClassName} ${model.propertyName} =', + ); + buffer.writeln( + " ${model.delegateClassName}(_orm.model('${_escapeString(model.model.runtimeName)}'));", + ); + buffer.writeln(); + } + + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class GeneratedOrmSql {'); + buffer.writeln(' final OrmSqlApi _api;'); + buffer.writeln(); + buffer.writeln(' GeneratedOrmSql(this._api);'); + buffer.writeln(); + buffer.writeln(' OrmSqlApi get raw => _api;'); + buffer.writeln(); + for (final model in models) { + buffer.writeln( + ' late final ${model.sqlClassName} ${model.propertyName} =', + ); + buffer.writeln(' ${model.sqlClassName}(_api);'); + buffer.writeln(); + } + buffer.writeln('}'); + 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.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(); + 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.groupByHavingConditionClassName} {'); + buffer.writeln(' final OrmGroupByHavingCondition _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}._(OrmGroupByHavingCondition(equals: value));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} notEquals(Object? value) {', + ); + buffer.writeln( + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(not: value));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} not(${model.groupByHavingConditionClassName} condition) {', + ); + buffer.writeln( + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(not: condition.toJsonValue()));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} gt(Object? value) {', + ); + buffer.writeln( + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(gt: value));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} gte(Object? value) {', + ); + buffer.writeln( + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(gte: value));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} lt(Object? value) {', + ); + buffer.writeln( + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(lt: value));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingConditionClassName} lte(Object? value) {', + ); + buffer.writeln( + ' return ${model.groupByHavingConditionClassName}._(OrmGroupByHavingCondition(lte: value));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Object? toJsonValue() => _value.toJsonValue();'); + buffer.writeln('}'); + buffer.writeln(); + + buffer.writeln('class ${model.groupByHavingClassName} {'); + buffer.writeln(' final OrmGroupByHaving _value;'); + buffer.writeln(); + buffer.writeln( + ' const ${model.groupByHavingClassName}() : _value = const OrmGroupByHaving.empty();', + ); + buffer.writeln(); + buffer.writeln(' const ${model.groupByHavingClassName}._(this._value);'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} and(List<${model.groupByHavingClassName}> clauses) {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln( + ' OrmGroupByHaving([', + ); + buffer.writeln( + ' OrmGroupByHavingLogicalNode(', + ); + buffer.writeln( + ' operator: OrmGroupByHavingLogicalOperator.and,', + ); + buffer.writeln( + ' clauses: clauses.map((clause) => clause._value).toList(growable: false),', + ); + buffer.writeln(' ),'); + 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( + ' OrmGroupByHaving([', + ); + 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(); + buffer.writeln( + ' static ${model.groupByHavingClassName} not(List<${model.groupByHavingClassName}> clauses) {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln( + ' OrmGroupByHaving([', + ); + 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(); + buffer.writeln( + ' static ${model.groupByHavingClassName} by(' + '${model.distinctClassName} field, ' + '${model.groupByHavingConditionClassName} condition' + ') {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln( + ' OrmGroupByHaving([', + ); + buffer.writeln( + ' OrmGroupByHavingPredicateNode(field: field.value, condition: condition._value),', + ); + buffer.writeln(' ]),'); + 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( + ' OrmGroupByHaving([', + ); + buffer.writeln( + ' OrmGroupByHavingPredicateNode(field: field.value, condition: condition._value, bucket: OrmGroupByHavingMetricBucket.count),', + ); + buffer.writeln(' ]),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln( + ' static ${model.groupByHavingClassName} countAll(' + '${model.groupByHavingConditionClassName} condition' + ') {', + ); + buffer.writeln(' return ${model.groupByHavingClassName}._('); + buffer.writeln( + ' OrmGroupByHaving([', + ); + buffer.writeln( + " OrmGroupByHavingPredicateNode(field: 'all', condition: condition._value, bucket: OrmGroupByHavingMetricBucket.count),", + ); + 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( + ' OrmGroupByHaving([', + ); + buffer.writeln( + ' OrmGroupByHavingPredicateNode(field: field.value, condition: condition._value, bucket: OrmGroupByHavingMetricBucket.$bucket),', + ); + buffer.writeln(' ]),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + } + buffer.writeln( + ' ${model.groupByHavingClassName} merge(${model.groupByHavingClassName} other) {', + ); + buffer.writeln( + ' return ${model.groupByHavingClassName}._(_value.merge(other._value));', + ); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' OrmGroupByHaving toRuntimeHaving() {'); + buffer.writeln(' return _value;'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' Map toJson() {'); + buffer.writeln(' return _value.toJson();'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' bool get isEmpty => _value.isEmpty;'); + 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} 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(); + + 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(' ${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,'); + 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(); + + 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;'); + 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.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(' ${model.groupBySpecClassName} copyWith({'); + buffer.writeln(' List<${model.distinctClassName}>? by,'); + buffer.writeln(' ${model.groupByHavingClassName}? having,'); + 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(' 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( + ' by: by.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' having: having.toRuntimeHaving(),'); + 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(); + 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(); + _writeAggregateResultGetters( + buffer: buffer, + scalarFields: scalarFields, + sourceAccessor: '_value', + ); + buffer.writeln(' Map toJson() {'); + buffer.writeln(' return Map.from(_value);'); + buffer.writeln(' }'); + 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(' }'); + 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(); + _writeAggregateResultGetters( + buffer: buffer, + scalarFields: scalarFields, + sourceAccessor: '_value', + ); + 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'); + 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( + ' ${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 [];'); + } 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( + ' ${relationModel.whereInputClassName} where = const ${relationModel.whereInputClassName}(),', + ); + buffer.writeln(' int? skip,'); + buffer.writeln(' int? take,'); + buffer.writeln( + ' List<${relationModel.orderByClassName}> orderBy = const <${relationModel.orderByClassName}>[],', + ); + 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( + ' 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( + ' $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( + ' $includeClassName orderBy(List<${relationModel.orderByClassName}> orderBy, {bool append = true}) {', + ); + buffer.writeln(' return $includeClassName('); + buffer.writeln(' where: _where,'); + buffer.writeln(' skip: _skip,'); + buffer.writeln(' take: _take,'); + buffer.writeln(' orderBy: append'); + buffer.writeln( + ' ? <${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( + ' $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(),'); + 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( + ' ${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( + ' ${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, + 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 {};'); + } 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(); + + 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 _writeAggregateResultGetters({ + required StringBuffer buffer, + required List scalarFields, + required String sourceAccessor, + }) { + 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(); + } + } + } + + 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({ + 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, + }) { + buffer.writeln('class ${model.delegateClassName} {'); + buffer.writeln(' final ModelDelegate _delegate;'); + buffer.writeln(); + 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( + ' List<${model.distinctClassName}> distinct = const <${model.distinctClassName}>[],', + ); + 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(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' );'); + 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} cursor(${model.cursorInputClassName} cursor) => query().cursor(cursor);', + ); + buffer.writeln(); + + buffer.writeln(' ${model.queryClassName} page({'); + 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(); + + 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}(),', + ); + 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(' ).all();'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> oneOrNull({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + 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(); + + buffer.writeln(' Future<${model.dataClassName}?> firstOrNull({'); + 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(' ${model.includeClassName}? include,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + 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(' ).firstOrNull();'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}> create({'); + buffer.writeln(' required ${model.createInputClassName} data,'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).create(data: data);'); + 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(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).createNested(data: data, create: create);'); + 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(' }) {'); + buffer.writeln(' return query('); + buffer.writeln(' select: select,'); + buffer.writeln(' include: include,'); + buffer.writeln(' ).createMany(data: data);'); + 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}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + 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(); + + 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(' }) {'); + 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(); + + buffer.writeln(' Future<${model.dataClassName}?> delete({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + 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(); + + buffer.writeln(' Future<${model.dataClassName}> upsert({'); + buffer.writeln(' required ${model.whereUniqueInputClassName} where,'); + buffer.writeln(' required ${model.createInputClassName} create,'); + buffer.writeln(' required ${model.updateInputClassName} update,'); + buffer.writeln(' ${model.selectClassName}? select,'); + buffer.writeln(' ${model.includeClassName}? include,'); + 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(); + + buffer.writeln(' Future updateCount({'); + buffer.writeln(' required ${model.whereInputClassName} where,'); + buffer.writeln(' required ${model.updateInputClassName} data,'); + buffer.writeln(' }) {'); + buffer.writeln(' return query(where: where).updateCount(data: data);'); + 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(' }) {'); + buffer.writeln(' return query(where: where).deleteCount();'); + 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}(),', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return query(where: where).count();'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future exists({'); + buffer.writeln( + ' ${model.whereInputClassName} where = const ${model.whereInputClassName}(),', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return query(where: where).exists();'); + 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( + ' required $aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build,', + ); + buffer.writeln(' }) {'); + buffer.writeln(' return query(where: where).aggregate(build);'); + 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(' 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(' ${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(' ).stream();'); + buffer.writeln(' }'); + + buffer.writeln('}'); + buffer.writeln(); + _writeTypedQueryClass(buffer: buffer, model: model); + _writeTypedGroupedQueryClass(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( + ' 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}(),', + ); + 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 = _fields(select);'); + 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(' 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(' ).toPlan();'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future> all({'); + 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 _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(' ).all();'); + buffer.writeln(' return _decodeRows(rows);'); + 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 _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(' ).stream()) {'); + buffer.writeln(' yield _decodeRow(row);'); + buffer.writeln(' }'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> firstOrNull({'); + 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 _selectBuilder('); + buffer.writeln(' where: where,'); + buffer.writeln(' skip: skip,'); + buffer.writeln(' orderBy: orderBy,'); + buffer.writeln(' distinct: distinct,'); + buffer.writeln(' select: select,'); + buffer.writeln(' ).firstOrNull();'); + buffer.writeln(' return _decodeOptionalRow(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 = _fields(returning);'); + 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( + ' return _decodeOptionalRow(await insertPlan(data: data, returning: returning).one());', + ); + 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 = _fields(returning);'); + 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( + ' return _decodeOptionalRow(await updatePlan(where: where, data: data, returning: returning).one());', + ); + 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 = _fields(returning);'); + 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( + ' return _decodeOptionalRow(await deletePlan(where: where, returning: returning).one());', + ); + buffer.writeln(' }'); + + buffer.writeln('}'); + buffer.writeln(); + } + + void _writeTypedQueryClass({ + required StringBuffer buffer, + 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); + 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 List<${model.distinctClassName}> _distinct;'); + buffer.writeln(' final ${model.selectClassName}? _select;'); + buffer.writeln(' final ${model.includeClassName}? _include;'); + buffer.writeln(' final ${model.cursorInputClassName}? _cursor;'); + buffer.writeln(' final int? _pageSize;'); + buffer.writeln(' final ${model.cursorInputClassName}? _pageAfter;'); + buffer.writeln(' final ${model.cursorInputClassName}? _pageBefore;'); + 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 List<${model.distinctClassName}> distinct,'); + buffer.writeln(' required ${model.selectClassName}? select,'); + buffer.writeln(' required ${model.includeClassName}? include,'); + buffer.writeln(' ${model.cursorInputClassName}? cursor,'); + buffer.writeln(' int? pageSize,'); + buffer.writeln(' ${model.cursorInputClassName}? pageAfter,'); + buffer.writeln(' ${model.cursorInputClassName}? pageBefore,'); + 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( + ' _distinct = List<${model.distinctClassName}>.unmodifiable(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( + ' ${model.queryClassName} where(${model.whereInputClassName} where, {bool merge = true}) {', + ); + buffer.writeln(' return ${model.queryClassName}._('); + buffer.writeln(' delegate: _delegate,'); + buffer.writeln(' where: merge ? _where.andWith(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: _pageSize,'); + buffer.writeln(' pageAfter: _pageAfter,'); + buffer.writeln(' pageBefore: _pageBefore,'); + buffer.writeln(' );'); + 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,'); + 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: _pageSize,'); + buffer.writeln(' pageAfter: _pageAfter,'); + buffer.writeln(' pageBefore: _pageBefore,'); + 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(' 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(); + + 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(' cursor: _cursor,'); + buffer.writeln(' pageSize: null,'); + buffer.writeln(' pageAfter: null,'); + buffer.writeln(' pageBefore: null,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' ${model.queryClassName} cursor(${model.cursorInputClassName} cursor) {', + ); + 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(); + + buffer.writeln(' ${model.queryClassName} page({'); + buffer.writeln(' required int size,'); + buffer.writeln(' ${model.cursorInputClassName}? after,'); + buffer.writeln(' ${model.cursorInputClassName}? before,'); + 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(); + + 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(' 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(); + + buffer.writeln( + ' ${model.queryClassName} distinct(List<${model.distinctClassName}> distinct, {bool append = false}) {', + ); + 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: append'); + buffer.writeln( + ' ? <${model.distinctClassName}>[..._distinct, ...distinct]', + ); + 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(); + + buffer.writeln( + ' ${model.queryClassName} select(${model.selectClassName}? select, {bool merge = false}) {', + ); + 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: merge'); + buffer.writeln( + ' ? (select == null ? _select : (_select?.merge(select) ?? select))', + ); + 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(); + + 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}) {', + ); + 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: merge'); + buffer.writeln( + ' ? (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(); + 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, + 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( + ' List get _runtimeOrderBy => _orderBy.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln(); + buffer.writeln( + ' List get _runtimeDistinct => _distinct.map((entry) => entry.value).toList(growable: false);', + ); + buffer.writeln(); + buffer.writeln( + ' List get _runtimeSelect => _select?.toFields() ?? const [];', + ); + buffer.writeln(); + buffer.writeln( + ' Map get _runtimeInclude => _include?.toIncludeMap() ?? const {};', + ); + 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(' return OrmReadPagePlan('); + buffer.writeln(' size: size,'); + buffer.writeln(' after: _pageAfter?.toJson(),'); + buffer.writeln(' before: _pageBefore?.toJson(),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + buffer.writeln(' OrmReadQuerySpec _readSpec() {'); + buffer.writeln(' return OrmReadQuerySpec('); + 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 _prepareRead() {'); + buffer.writeln( + ' return _delegate._delegate.prepareRead(spec: _readSpec());', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' void _assertReadExecutionSupported(String terminal) {'); + 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',"); + 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,'); + buffer.writeln(' bool requireWhere = false,'); + buffer.writeln(' bool allowSelect = true,'); + buffer.writeln(' bool allowInclude = 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 (!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(' ];'); + 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_WHERE_REQUIRED',"); + buffer.writeln(" '\$action requires where() first.',"); + buffer.writeln(' details: {'); + buffer.writeln(" 'model': '$runtimeName',"); + buffer.writeln(" 'action': action,"); + buffer.writeln(' },'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future toPlan() async {'); + buffer.writeln(' return (await _prepareRead()).plan;'); + buffer.writeln(' }'); + 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 (await _prepareRead()).all();'); + buffer.writeln( + ' return rows.map(${model.dataClassName}.fromJson).toList(growable: false);', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future> pageResult() async {', + ); + buffer.writeln(" _assertReadExecutionSupported('pageResult');"); + buffer.writeln( + ' final result = await (await _prepareRead()).pageResult();', + ); + buffer.writeln( + ' return result.mapItems(${model.dataClassName}.fromJson);', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln(' Future<${model.dataClassName}?> oneOrNull() async {'); + buffer.writeln(" _assertReadExecutionSupported('oneOrNull');"); + buffer.writeln(' final row = await (await _prepareRead()).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(' Future<${model.dataClassName}?> firstOrNull() async {'); + buffer.writeln(" _assertReadExecutionSupported('firstOrNull');"); + buffer.writeln( + ' final row = await (await _prepareRead()).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(" _assertReadExecutionSupported('stream');"); + 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() async {'); + buffer.writeln(' return (await _prepareRead()).explain();'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future<${model.aggregateResultClassName}> aggregate($aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build) {', + ); + buffer.writeln(" _assertReadExecutionSupported('aggregate');"); + buffer.writeln( + ' return _executeAggregate(build($aggregateBuilderClassName()).toSpec());', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future<${model.aggregateResultClassName}> _executeAggregate(${model.aggregateSpecClassName} aggregate) {', + ); + buffer.writeln(" _assertReadExecutionSupported('aggregate');"); + buffer.writeln(' _assertAggregateQueryState();'); + 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( + ' build: (current) => current.merge(aggregate.toRuntimeSpec()),', + ); + buffer.writeln(' ).then(${model.aggregateResultClassName}.fromJson);'); + 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<${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 {', + ); + 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);', + ); + 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(' }) async {'); + 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;'); + buffer.writeln(' }'); + buffer.writeln(' return ${model.dataClassName}.fromJson(row);'); + 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> 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}) {', + ); + buffer.writeln( + " _assertMutationQueryState(action: 'updateCount', requireWhere: true, allowSelect: false, allowInclude: false);", + ); + 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 deleteCount() {'); + buffer.writeln( + " _assertMutationQueryState(action: 'deleteCount', requireWhere: true, allowSelect: false, allowInclude: false);", + ); + buffer.writeln(' return _delegate._delegate.deleteCount('); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' );'); + 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('); + 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('); + 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(" _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(); + } + + void _writeTypedGroupedQueryClass({ + required StringBuffer buffer, + required _ResolvedModel model, + }) { + final aggregateBuilderClassName = '${model.classBaseName}AggregateBuilder'; + 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} 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(next) : next,', + ); + buffer.writeln(' ),'); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future> aggregate($aggregateBuilderClassName Function($aggregateBuilderClassName aggregate) build) {', + ); + buffer.writeln( + ' return _executeAggregate(build($aggregateBuilderClassName()).toSpec());', + ); + buffer.writeln(' }'); + buffer.writeln(); + + buffer.writeln( + ' Future> _executeAggregate(${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 _runtimeGrouped(groupBy)'); + buffer.writeln(' .aggregate((aggregate) => aggregate.merge('); + 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( + ' by: groupBy.by.map((entry) => entry.value).toList(growable: false),', + ); + buffer.writeln(' where: _where.toJson(),'); + buffer.writeln(' )'); + buffer.writeln( + ' .havingExpr((_) => groupBy.having.toRuntimeHaving(), merge: 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, + required Map lookup, + }) { + final relationFields = model.model.fields + .where((field) => field.isRelation) + .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 {'); + 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}? is_;'); + buffer.writeln(' final ${relationModel.whereInputClassName}? isNot;'); + buffer.writeln(' final bool isNull;'); + buffer.writeln(' final bool isNotNull;'); + buffer.writeln(); + 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( + " 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( + " isNotNull: hasIsNot && value['isNot'] == null,", + ); + 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 (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 =>'); + buffer.writeln(' is_ == null &&'); + buffer.writeln(' isNot == null &&'); + buffer.writeln(' !isNull &&'); + buffer.writeln(' !isNotNull;'); + } + 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)); + final includeLogicalWhere = classKind == _TemplateClassKind.where; + + buffer.writeln('class $className {'); + + for (final field in fields) { + final type = _fieldType( + owner: model, + field: field.field, + classKind: classKind, + lookup: lookup, + ); + 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 || includeLogicalWhere) { + 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},'); + } + if (includeLogicalWhere) { + buffer.writeln(' this.and,'); + buffer.writeln(' this.or,'); + buffer.writeln(' this.not,'); + } + buffer.writeln(' });'); + } else { + buffer.writeln(); + 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) {', + ); + 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)}']", + lookup: lookup, + ); + if (_isOptionalField(field.field, classKind: classKind)) { + buffer.writeln(' ${field.memberName}: $decodeExpression,'); + } else { + final type = _fieldType( + owner: model, + field: field.field, + classKind: classKind, + lookup: lookup, + ); + final nonNullableType = _stripNullable(type); + buffer.writeln( + " ${field.memberName}: _requiredValue<$nonNullableType>($decodeExpression, '${_escapeString(field.field.name)}'),", + ); + } + } + 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(' }'); + + 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( + owner: model, + field: field.field, + classKind: classKind, + memberName: memberName, + lookup: lookup, + ); + final isWhereFilter = + (_isWhereFilterClassKind(classKind) && field.field.isScalar) || + _isRelationWhereFilterField(field: field.field, classKind: classKind); + + if (isOptional) { + if (isWhereFilter) { + 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,", + ); + } + } + 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(' }'); + if (classKind == _TemplateClassKind.whereUnique) { + buffer.writeln(); + buffer.writeln(' ${model.whereInputClassName} toWhereInput() {'); + 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) { + 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(); + } + + 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('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;'); + 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 propertyName = _makeUnique( + base: _toUpperCamelIdentifier(model.name, fallback: 'Model'), + used: usedGetterNames, + ); + resolved.add( + _ResolvedModel( + model: model, + classBaseName: classBaseName, + propertyName: propertyName, + ), + ); + } + + return resolved; + } + + Iterable _fieldsForClass( + TypedModel model, + _TemplateClassKind classKind, + ) { + return switch (classKind) { + _TemplateClassKind.data => model.fields, + _TemplateClassKind.where => model.fields.where( + (field) => field.includeInWhere, + ), + _TemplateClassKind.whereUnique => model.fields.where( + _includeInWhereUnique, + ), + _TemplateClassKind.cursor => model.fields.where(_includeInCursor), + _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.whereUnique => model.whereUniqueInputClassName, + _TemplateClassKind.cursor => model.cursorInputClassName, + _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.whereUnique => true, + _TemplateClassKind.cursor => true, + _TemplateClassKind.create => field.isNullable, + _TemplateClassKind.update => true, + }; + } + + 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, + ); + + if (optional) { + return '$baseType?'; + } + return baseType; + } + + String _baseType({ + required _ResolvedModel owner, + required TypedField field, + required _TemplateClassKind classKind, + required Map lookup, + }) { + 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; + 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 _ResolvedModel owner, + required TypedField field, + required _TemplateClassKind classKind, + required String accessor, + required Map lookup, + }) { + if (_isWhereFilterClassKind(classKind) && field.isScalar) { + 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, + relationFieldName: field.name, + ); + return '$filterClass.fromJsonValue($accessor)'; + } + + 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 _ResolvedModel owner, + required TypedField field, + required _TemplateClassKind classKind, + required String memberName, + required Map lookup, + }) { + if (_isWhereFilterClassKind(classKind) && field.isScalar) { + return '$memberName.toJsonValue()'; + } + if (_isRelationWhereFilterField(field: field, classKind: classKind)) { + return '$memberName.toJsonValue()'; + } + + 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 _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', + _TemplateClassKind.where => 'WhereInput', + _TemplateClassKind.whereUnique => 'WhereUniqueInput', + _TemplateClassKind.cursor => 'CursorInput', + _TemplateClassKind.create => 'CreateInput', + _TemplateClassKind.update => 'UpdateInput', + }; + } + + bool _isWhereFilterClassKind(_TemplateClassKind classKind) { + return classKind == _TemplateClassKind.where; + } + + bool _isRelationWhereFilterField({ + required TypedField field, + required _TemplateClassKind classKind, + }) { + return classKind == _TemplateClassKind.where && field.isRelation; + } + + bool _includeInWhereUnique(TypedField field) { + if (!field.isScalar || field.isList) { + return false; + } + if (field.includeInWhereUnique) { + return true; + } + return _isConventionalIdFieldName(field.name) && field.includeInWhere; + } + + bool _includeInCursor(TypedField field) { + return field.isScalar && !field.isList; + } + + bool _isConventionalIdFieldName(String name) { + return name.trim().toLowerCase() == 'id'; + } + + String _relationIncludeClassName({ + required _ResolvedModel owner, + required String relationFieldName, + }) { + final relationPart = _toUpperCamelIdentifier( + relationFieldName, + fallback: 'Relation', + ); + 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); + 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) { + 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; + } + 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, whereUnique, cursor, create, update } + +final class _ResolvedModel { + final TypedModel model; + final String classBaseName; + final String propertyName; + + const _ResolvedModel({ + required this.model, + required this.classBaseName, + required this.propertyName, + }); + + String get delegateClassName => '${classBaseName}Delegate'; + + String get sqlClassName => '${classBaseName}Sql'; + + String get queryClassName => '${classBaseName}Query'; + + String get distinctClassName => '${classBaseName}Distinct'; + + String get dataClassName => '${classBaseName}Data'; + + String get whereInputClassName => '${classBaseName}WhereInput'; + + String get whereUniqueInputClassName => '${classBaseName}WhereUniqueInput'; + + String get cursorInputClassName => '${classBaseName}CursorInput'; + + String get createInputClassName => '${classBaseName}CreateInput'; + + String get nestedCreateInputClassName => '${classBaseName}NestedCreateInput'; + + String get updateInputClassName => '${classBaseName}UpdateInput'; + + String get orderByClassName => '${classBaseName}OrderBy'; + + String get groupByOrderByClassName => '${classBaseName}GroupByOrderBy'; + + String get groupByHavingClassName => '${classBaseName}GroupByHaving'; + + 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 groupedQueryClassName => '${classBaseName}GroupedQuery'; + + String get selectClassName => '${classBaseName}Select'; + + String get includeClassName => '${classBaseName}Include'; +} + +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', +}; diff --git a/pub/orm/lib/src/runtime/core.dart b/pub/orm/lib/src/runtime/core.dart new file mode 100644 index 00000000..b9f76201 --- /dev/null +++ b/pub/orm/lib/src/runtime/core.dart @@ -0,0 +1,1554 @@ +import 'dart:collection'; + +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(); +const Set _whereLogicalKeys = {'AND', 'OR', 'NOT'}; +const Set _toManyRelationWhereOperators = { + 'some', + 'every', + 'none', +}; +const Set _toOneRelationWhereOperators = {'is', 'isNot'}; + +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 } + +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.shape == OrmReadShape.aggregate) { + return 1; + } + 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': '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, + 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(), + }); +} + +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; + final bool requireMarker; + final ContractMarkerReader? markerReader; + + const RuntimeVerifyOptions({ + this.mode = RuntimeVerifyMode.onFirstUse, + this.requireMarker = false, + this.markerReader, + }); +} + +enum RuntimeTelemetryOutcome { success, runtimeError } + +@immutable +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; + final DateTime recordedAt; + final OrmRepositoryTrace trace; + + const 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, + required this.recordedAt, + required this.trace, + }); +} + +@immutable +final class RuntimeOperationTelemetryEvent { + final String operationId; + final String kind; + final RuntimeTelemetryOutcome outcome; + final bool completed; + 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.completed, + 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; + final OrmAction action; + final RuntimeTelemetryOutcome outcome; + final bool completed; + final EngineExecutionMode? executionMode; + final EngineExecutionSource? executionSource; + final int durationMs; + final DateTime recordedAt; + final OrmRepositoryTrace? repositoryTrace; + + const 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, + }); + + 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 { + Future execute(OrmPlan plan); +} + +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(); +} + +abstract interface class RuntimeCore implements OrmRuntimeQueryable { + Future connect(); + + Future disconnect(); + + bool get isConnected; + + Future connection(); + + RuntimeTelemetryEvent? telemetry(); + + Future explain(OrmPlan plan); + + 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; + final RuntimeMode mode; + final RuntimeLog log; + final List _plugins; + late final PluginContext _pluginContext; + + bool _connected = false; + bool _startupVerified = false; + bool _firstUseVerified = false; + RuntimeTelemetryEvent? _telemetry; + RuntimeOperationTelemetryEvent? _operationTelemetry; + final LinkedHashMap + _operationTelemetryById = + LinkedHashMap(); + + 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; + _operationTelemetry = null; + _operationTelemetryById.clear(); + } + + @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; + + @override + Future explain(OrmPlan plan) async { + return _explainOnSource(plan, engine); + } + + @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, + ) async { + _ensureConnected(); + _assertPlan(plan); + await _verifyForRequest(); + + final startedAt = DateTime.now(); + + try { + for (final plugin in _plugins) { + await plugin.beforeExecute(plan, _pluginContext); + } + + final response = await queryable.execute(plan); + return EngineResponse( + rows: _observeExecutionRows( + 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; + await _recordExecutionFailure( + plan: plan, + error: error, + stackTrace: stackTrace, + rowCount: 0, + latencyMs: latencyMs, + startedAt: startedAt, + ); + rethrow; + } + } + + Stream _observeExecutionRows({ + required OrmPlan plan, + required Stream rows, + required int affectedRows, + required EngineExecutionMode executionMode, + required EngineExecutionSource executionSource, + 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; + } + + completed = true; + await _recordExecutionSuccess( + plan: plan, + rowCount: rowCount, + affectedRows: affectedRows, + executionMode: executionMode, + executionSource: executionSource, + startedAt: startedAt, + ); + } catch (error, stackTrace) { + failed = true; + final latencyMs = DateTime.now().difference(startedAt).inMilliseconds; + await _recordExecutionFailure( + plan: plan, + error: error, + stackTrace: stackTrace, + rowCount: rowCount, + latencyMs: latencyMs, + executionMode: executionMode, + executionSource: executionSource, + startedAt: startedAt, + ); + rethrow; + } finally { + if (!completed && !failed) { + await _recordExecutionInterrupted( + plan: plan, + rowCount: rowCount, + affectedRows: affectedRows, + executionMode: executionMode, + executionSource: executionSource, + startedAt: startedAt, + ); + } + } + } + + 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, + required int affectedRows, + required EngineExecutionMode executionMode, + required EngineExecutionSource executionSource, + 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); + } + + _telemetry = RuntimeTelemetryEvent( + model: plan.model, + action: plan.action, + outcome: RuntimeTelemetryOutcome.success, + completed: true, + executionMode: executionMode, + executionSource: executionSource, + durationMs: latencyMs, + recordedAt: DateTime.now(), + repositoryTrace: plan.repositoryTrace, + ); + _recordOperationTelemetry( + plan: plan, + outcome: RuntimeTelemetryOutcome.success, + completed: true, + executionMode: executionMode, + executionSource: executionSource, + 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, + 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, + ); + if (error is! PlanRepositoryTraceInvalidException) { + _recordOperationTelemetry( + plan: plan, + outcome: RuntimeTelemetryOutcome.runtimeError, + completed: false, + executionMode: executionMode, + executionSource: executionSource, + rowCount: rowCount, + affectedRows: 0, + durationMs: latencyMs, + 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. + } + } + + 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 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, + latencyMs: latencyMs, + completed: false, + ); + + for (final plugin in _plugins) { + await plugin.afterExecute(plan, result, _pluginContext); + } + } + + 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, + ); + } + + 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); + } + + final model = contract.models[plan.model]!; + _assertPlanModes(plan); + _assertRepositoryTrace(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 _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 _recordOperationTelemetry({ + required OrmPlan plan, + required RuntimeTelemetryOutcome outcome, + required bool completed, + EngineExecutionMode? executionMode, + EngineExecutionSource? executionSource, + 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, + completed: completed, + executionMode: executionMode, + executionSource: executionSource, + 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, + completed: (current?.completed ?? true) && completed, + 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, + }) { + _assertReadShape(model: model, plan: plan); + _assertWhereFields(model: model, where: plan.where, source: 'where'); + _assertKnownFields( + model: model, + 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) { + throw PlanInvalidPaginationException(key: 'skip', value: skip); + } + + 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 ((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( + 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 (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), + }, + ); + } + } + } + + 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, + }, + ); + } + 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', + '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: [ + ...aggregate.count, + ...aggregate.min, + ...aggregate.max, + ...aggregate.sum, + ...aggregate.avg, + ], + source: 'aggregate', + ); + 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, + }, + ); + } + 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', + '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, + 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}, + ); + } + } + } + + 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({ + 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({ + 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 _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; + } + final relation = model.relations[key]; + if (relation != null) { + _assertRelationWhereFields( + relation: relation, + 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 _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) { + 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; +} + +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 explain(OrmPlan plan) { + _ensureNotReleased(); + return _core._explainOnSource(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(); + await _inner.commit(); + _completed = true; + } + + @override + Future rollback() async { + _ensureActive(); + try { + await _inner.rollback(); + } finally { + _completed = true; + } + } + + @override + Future execute(OrmPlan plan) { + _ensureActive(); + return _core._executeOnQueryable(plan, _inner); + } + + @override + Future explain(OrmPlan plan) { + _ensureActive(); + return _core._explainOnSource(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..6e1eac15 --- /dev/null +++ b/pub/orm/lib/src/runtime/errors.dart @@ -0,0 +1,363 @@ +import 'package:meta/meta.dart'; + +import 'plan.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 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; + + 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 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; + final OrmMutationResultMode? mutationResultMode; + final bool hasRead; + final bool hasMutation; + + PlanResultModeActionInvalidException({ + required this.action, + required this.readResultMode, + required this.mutationResultMode, + required this.hasRead, + required this.hasMutation, + }) : super( + code: 'PLAN.RESULT_MODE_ACTION_INVALID', + category: RuntimeErrorCategory.plan, + message: 'Plan branch shape does not match the requested action.', + details: { + 'action': action.name, + 'hasRead': hasRead, + 'readResultMode': readResultMode?.name, + 'hasMutation': hasMutation, + 'mutationResultMode': mutationResultMode?.name, + }, + ); +} + +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 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( + 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..0d3c7ebf --- /dev/null +++ b/pub/orm/lib/src/runtime/plan.dart @@ -0,0 +1,654 @@ +import 'package:meta/meta.dart'; + +import '../core/sort_order.dart'; +import 'types.dart'; + +enum OrmAction { read, create, update, delete } + +enum OrmReadResultMode { all, firstOrNull, oneOrNull } + +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; + + 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; + 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, + }); + + JsonMap toJson() => { + 'operationId': operationId, + 'kind': kind, + 'step': step, + 'phase': phase, + 'strategy': strategy, + if (relation != null) 'relation': relation, + if (itemIndex != null) 'itemIndex': itemIndex, + }; +} + +@immutable +final class OrmOrderBy { + final String field; + final SortOrder order; + + const OrmOrderBy(this.field, {this.order = SortOrder.asc}); + + JsonMap toJson() => {'field': field, 'order': order.name}; +} + +@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); + + 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 +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 OrmReadCursorPlan? cursor; + final OrmReadPagePlan? page; + final OrmReadResultMode resultMode; + final OrmReadShape shape; + final OrmReadAggregatePlan? aggregate; + final OrmReadGroupByPlan? groupBy; + + OrmReadPlan({ + JsonMap where = const {}, + this.skip, + this.take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + 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), + 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, + '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 OrmGroupByHavingCondition { + final Object? shorthand; + final Object? equals; + final Object? not; + final Object? gt; + final Object? gte; + final Object? lt; + final Object? lte; + final Map extra; + + const OrmGroupByHavingCondition({ + this.shorthand, + this.equals, + this.not, + this.gt, + this.gte, + this.lt, + this.lte, + this.extra = const {}, + }); + + factory OrmGroupByHavingCondition.parse(Object? value) { + if (value is! Map) { + return OrmGroupByHavingCondition(shorthand: value); + } + + final raw = Map.from(value); + + return OrmGroupByHavingCondition( + 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), + ), + ), + ), + ); + } + + bool get isEmpty => + shorthand == null && + equals == null && + not == null && + gt == null && + gte == null && + lt == null && + lte == null && + extra.isEmpty; + + Object? toJsonValue() { + if (shorthand != null) { + return shorthand; + } + + final map = {}; + if (equals != null) { + map['equals'] = equals; + } + if (not != null) { + map['not'] = not; + } + if (gt != null) { + map['gt'] = gt; + } + if (gte != null) { + map['gte'] = gte; + } + if (lt != null) { + map['lt'] = lt; + } + if (lte != null) { + map['lte'] = lte; + } + if (extra.isNotEmpty) { + map.addAll(extra); + } + 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([...nodes, ...other.nodes]); + + JsonMap toJson() { + if (nodes.isEmpty) { + return const {}; + } + if (nodes.length == 1) { + return nodes.single.toJson(); + } + return { + 'AND': nodes + .map((node) => OrmGroupByHaving([node]).toJson()) + .toList(growable: false), + }; + } +} + +@immutable +final class OrmReadGroupByPlan { + final List by; + final OrmGroupByHaving having; + final List orderBy; + final int? skip; + final int? take; + + OrmReadGroupByPlan({ + required List by, + this.having = const OrmGroupByHaving.empty(), + List orderBy = const [], + this.skip, + this.take, + }) : by = List.unmodifiable(by), + orderBy = List.unmodifiable(orderBy); + + JsonMap toJson() => { + 'by': by, + 'having': having.toJson(), + 'orderBy': orderBy.map((entry) => entry.toJson()).toList(growable: false), + if (skip != null) 'skip': skip, + if (take != null) 'take': take, + }; +} + +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; + 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); + + JsonMap toJson() => { + 'where': where, + 'data': data, + 'select': select, + 'resultMode': resultMode.name, + }; +} + +@immutable +final class OrmPlan { + final String contractHash; + final String? target; + final String? storageHash; + final String? profileHash; + final String? lane; + final JsonMap annotations; + final OrmRepositoryTrace? repositoryTrace; + final String model; + final OrmAction action; + final OrmReadPlan? read; + final OrmMutationPlan? mutation; + + OrmPlan({ + required this.contractHash, + this.target, + this.storageHash, + this.profileHash, + this.lane, + JsonMap annotations = const {}, + this.repositoryTrace, + 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 {}, + OrmRepositoryTrace? repositoryTrace, + required String model, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + Map include = const {}, + OrmReadCursorPlan? cursor, + OrmReadPagePlan? page, + required OrmReadResultMode resultMode, + OrmReadShape shape = OrmReadShape.rows, + OrmReadAggregatePlan? aggregate, + OrmReadGroupByPlan? groupBy, + }) { + return OrmPlan( + contractHash: contractHash, + target: target, + storageHash: storageHash, + profileHash: profileHash, + lane: lane, + annotations: annotations, + repositoryTrace: repositoryTrace, + model: model, + action: OrmAction.read, + read: OrmReadPlan( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + include: include, + cursor: cursor, + page: page, + resultMode: resultMode, + shape: shape, + aggregate: aggregate, + groupBy: groupBy, + ), + ); + } + + factory OrmPlan.mutation({ + required String contractHash, + String? target, + String? storageHash, + String? profileHash, + String? lane, + JsonMap annotations = const {}, + OrmRepositoryTrace? repositoryTrace, + 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, + repositoryTrace: repositoryTrace, + model: model, + action: action, + mutation: OrmMutationPlan( + where: where, + data: data, + select: select, + resultMode: resultMode, + ), + ); + } + + 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/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..16aa159d --- /dev/null +++ b/pub/orm/lib/src/runtime/plugins/budgets.dart @@ -0,0 +1,111 @@ +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) { + final read = plan.read; + if (plan.action == OrmAction.read && + read != null && + read.take != null && + read.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': read.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..247e662b --- /dev/null +++ b/pub/orm/lib/src/runtime/plugins/lints.dart @@ -0,0 +1,106 @@ +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) { + final read = plan.read; + final mutation = plan.mutation; + + if (_isMutation(plan) && (mutation == null || mutation.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.read && + read != null && + read.resultMode == OrmReadResultMode.oneOrNull && + read.where.isEmpty) { + _handle( + ctx: ctx, + severity: options.uniqueWithoutWhere, + code: 'LINT.UNIQUE_WITHOUT_WHERE', + message: 'oneOrNull requires a non-empty where clause.', + details: {'model': plan.model}, + ); + } + + if (plan.action == OrmAction.read && + read != null && + read.resultMode == OrmReadResultMode.all && + read.take == null) { + _handle( + ctx: ctx, + severity: options.unboundedRead, + code: 'LINT.UNBOUNDED_READ', + message: 'Unbounded read 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.read => 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/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); } diff --git a/pub/orm/lib/src/schema/relation.dart b/pub/orm/lib/src/schema/relation.dart index 360a33d2..6dae59c2 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. @@ -45,10 +45,10 @@ final class Relation { @literal /// Creates a relation annotation describing a model relationship. const Relation({ - required this.references, + this.fields, + this.references, this.name, this.map, - this.fields, this.onDelete, this.onUpdate, }); 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/lib/src/sql/adapter.dart b/pub/orm/lib/src/sql/adapter.dart new file mode 100644 index 00000000..77baad5b --- /dev/null +++ b/pub/orm/lib/src/sql/adapter.dart @@ -0,0 +1,1809 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../core/sort_order.dart'; +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 'codec.dart'; +import 'types.dart'; + +const List _whereOperatorOrder = [ + 'equals', + 'not', + 'in', + 'notIn', + 'contains', + 'startsWith', + 'endsWith', + 'gt', + 'gte', + 'lt', + 'lte', +]; + +const Set _whereOperators = { + 'equals', + 'not', + 'in', + 'notIn', + 'contains', + 'startsWith', + 'endsWith', + 'gt', + 'gte', + 'lt', + 'lte', +}; + +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'; +const String _aggregateEmptyAlias = '__empty'; +const String _aggregateRowAlias = '__row'; + +final class SqlAdapter + implements + TargetAdapter, + ExplainCapableTargetAdapter, + ReadStreamCapableTargetAdapter { + final OrmContract contract; + final String identifierQuote; + final SqlFieldCodecResolver? codecResolver; + + SqlAdapter({ + this.identifierQuote = '"', + required this.contract, + this.codecResolver, + }); + + @override + SqlStatement lower(OrmPlan plan) { + final model = contract.models[plan.model]; + if (model == null) { + throw ModelNotFoundException(plan.model, contract.models.keys); + } + + return switch (plan.action) { + OrmAction.read => _lowerRead( + plan: plan, + table: model.table, + model: plan.model, + ), + 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 + JsonMap describe( + OrmPlan plan, + SqlStatement request, { + JsonMap? driverExplain, + }) { + return Map.unmodifiable({ + 'source': driverExplain == null ? 'adapter' : 'driver', + 'target': contract.target, + 'request': Map.unmodifiable({ + 'kind': 'sql', + 'action': request.action.name, + 'text': request.text, + 'parameterCount': request.parameters.length, + }), + if (driverExplain != null) 'driver': driverExplain, + }); + } + + SqlStatement _lowerRead({ + 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!; + if (read.distinct.isNotEmpty) { + return _buildReadSourceQuery( + table: table, + model: model, + read: read, + selectColumns: _buildSelectColumns(read.select), + ); + } + 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 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)}' + '$mergedWhereClause$orderByClause${_buildReadLimitOffsetClause(read, params)}', + parameters: params, + ); + } + + 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; + if (resolver == null) { + return switch (plan.action) { + OrmAction.read => _decodeReadResult( + rows: response.rows, + affectedRows: response.affectedRows, + plan: plan, + ), + OrmAction.create || + OrmAction.update || + OrmAction.delete => EngineResponse.buffered( + _firstOrNull(response.rows), + affectedRows: response.affectedRows, + ), + }; + } + + final decodedRows = _decodeRows(model: plan.model, rows: response.rows); + return switch (plan.action) { + OrmAction.read => _decodeReadResult( + rows: decodedRows, + affectedRows: response.affectedRows, + plan: plan, + ), + OrmAction.create || + OrmAction.update || + OrmAction.delete => EngineResponse.buffered( + _firstOrNull(decodedRows), + affectedRows: response.affectedRows, + ), + }; + } + + @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, + required String model, + }) { + final mutation = plan.mutation!; + final columns = mutation.data.keys.toList(growable: false); + final values = columns + .map( + (column) => _encodeValue( + model: model, + field: column, + value: mutation.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)${_buildMutationReturningClause(mutation.select)}', + parameters: values, + ); + } + + SqlStatement _lowerUpdate({ + required OrmPlan plan, + required String table, + required String model, + }) { + final mutation = plan.mutation!; + final setColumns = mutation.data.keys.toList(growable: false); + final setValues = setColumns + .map( + (column) => _encodeValue( + model: model, + field: column, + value: mutation.data[column], + ), + ) + .toList(growable: false); + + final params = [...setValues]; + final wherePart = _buildWhereClause( + model: model, + where: mutation.where, + params: params, + ); + + return SqlStatement( + action: plan.action, + text: + 'UPDATE ${_id(table)} SET ' + '${setColumns.map((column) => '${_id(column)} = ?').join(', ')}' + '$wherePart${_buildMutationReturningClause(mutation.select)}', + parameters: params, + ); + } + + EngineResponse _decodeReadResult({ + required List rows, + required int affectedRows, + required OrmPlan plan, + }) { + 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, + ), + }; + } + + SqlStatement _lowerDelete({ + required OrmPlan plan, + required String table, + required String model, + }) { + final mutation = plan.mutation!; + final params = []; + final wherePart = _buildWhereClause( + model: model, + where: mutation.where, + params: params, + ); + + return SqlStatement( + action: plan.action, + text: + 'DELETE FROM ${_id(table)}' + '$wherePart${_buildMutationReturningClause(mutation.select)}', + parameters: params, + ); + } + + String _buildSelectColumns(List select) { + if (select.isEmpty) { + return '*'; + } + 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, + ...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 ''; + } + + return ' RETURNING ${_buildSelectColumns(select)}'; + } + + SqlStatement _buildReadSourceQuery({ + required String table, + required String model, + 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, + 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, + ); + } + + 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, + required List params, + }) { + if (where.isEmpty) { + 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'; + } + + String _buildWhereExpression({ + 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) { + final key = entry.key; + if (_whereLogicalKeys.contains(key)) { + predicates.add( + _buildWhereLogicalPredicate( + model: model, + 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; + } + + predicates.addAll( + _buildWhereFieldPredicates( + model: model, + field: key, + condition: entry.value, + params: params, + fieldRefPrefix: fieldRefPrefix, + ), + ); + } + + if (predicates.isEmpty) { + return '1 = 1'; + } + + return predicates.join(' AND '); + } + + String _buildWhereLogicalPredicate({ + required String model, + 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', + }; + } + + String _buildWhereAndPredicate({ + required String model, + required Object? operand, + required List params, + required String rowRef, + required String? fieldRefPrefix, + }) { + final where = _coerceWhereMap(operand); + if (where != null) { + final nested = _buildWhereExpression( + model: model, + where: where, + params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ); + 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, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ), + ) + .toList(growable: false); + return '(${predicates.join(' AND ')})'; + } + + String _buildWhereOrPredicate({ + required String model, + required Object? operand, + required List params, + required String rowRef, + required String? fieldRefPrefix, + }) { + final where = _coerceWhereMap(operand); + if (where != null) { + final nested = _buildWhereExpression( + model: model, + where: where, + params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ); + 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, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ), + ) + .toList(growable: false); + return '(${predicates.join(' OR ')})'; + } + + String _buildWhereNotPredicate({ + required String model, + required Object? operand, + required List params, + required String rowRef, + required String? fieldRefPrefix, + }) { + final where = _coerceWhereMap(operand); + if (where != null) { + final nested = _buildWhereExpression( + model: model, + where: where, + params: params, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ); + 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, + rowRef: rowRef, + fieldRefPrefix: fieldRefPrefix, + ), + ) + .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, + required String? fieldRefPrefix, + }) { + final predicates = []; + final operatorMap = _coerceOperatorMap(condition); + if (operatorMap == null) { + predicates.add( + '${_fieldReference(field: field, fieldRefPrefix: fieldRefPrefix)} = ?', + ); + 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], + 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; + } + 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; + } + + 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, + required String model, + required String field, + required String operator, + required Object? operand, + required String? fieldRefPrefix, + }) { + final idField = _fieldReference( + field: field, + fieldRefPrefix: fieldRefPrefix, + ); + + 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 '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( + _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'); + } + } + + 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; + } + 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? _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 ''; + } + + final clauses = orderBy.map((entry) { + final direction = entry.order.name.toUpperCase(); + return '${_id(entry.field)} $direction'; + }); + + 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 OrmGroupByHaving having, + required List params, + }) { + if (having.isEmpty) { + return ''; + } + return ' HAVING ${_buildGroupedHavingExpression(having: having, params: params)}'; + } + + String _buildGroupedHavingExpression({ + required OrmGroupByHaving having, + required List params, + }) { + final predicates = []; + for (final node in having.nodes) { + switch (node) { + case OrmGroupByHavingLogicalNode(): + predicates.add( + _buildGroupedHavingLogicalPredicate(node: node, params: params), + ); + case OrmGroupByHavingPredicateNode(): + predicates.add( + _buildGroupedHavingConditionPredicate( + 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, + ), + ); + } + } + if (predicates.isEmpty) { + return '1 = 1'; + } + return predicates.join(' AND '); + } + + String _buildGroupedHavingLogicalPredicate({ + required OrmGroupByHavingLogicalNode node, + required List params, + }) { + 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 node.operator == OrmGroupByHavingLogicalOperator.not + ? 'NOT ($clauses)' + : clauses; + } + + String _buildGroupedHavingConditionPredicate({ + required String leftOperand, + required String field, + required OrmGroupByHavingCondition condition, + required List params, + }) { + if (condition.shorthand != null) { + params.add(condition.shorthand); + return '$leftOperand = ?'; + } + if (condition.isEmpty) { + return '1 = 1'; + } + + final predicates = []; + for (final operator in _whereOperatorOrder) { + final operand = switch (operator) { + 'equals' => condition.equals, + 'not' => condition.not, + 'gt' => condition.gt, + 'gte' => condition.gte, + 'lt' => condition.lt, + 'lte' => condition.lte, + _ => null, + }; + if (operand == null) { + continue; + } + 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': + if (operand is Map) { + final predicate = _buildGroupedHavingConditionPredicate( + leftOperand: leftOperand, + field: field, + condition: OrmGroupByHavingCondition.parse(operand), + params: params, + ); + return 'NOT ($predicate)'; + } + params.add(operand); + return '$leftOperand <> ?'; + 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 _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, + 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; + } + 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.page?.size ?? plan.take, + }; + + if (effectiveTake case final take?) { + clauses.add(' LIMIT ?'); + params.add(take); + } + + if (plan.skip case final skip?) { + if (effectiveTake == null) { + clauses.add(' LIMIT -1'); + } + clauses.add(' OFFSET ?'); + params.add(skip); + } + + return clauses.join(); + } + + 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; + } + + 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, + 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); + } +} + +@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; + } + return values.first; +} 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; 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/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/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/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 @@ - diff --git a/pub/orm/lib/src/target/adapter.dart b/pub/orm/lib/src/target/adapter.dart new file mode 100644 index 00000000..c13d5fdf --- /dev/null +++ b/pub/orm/lib/src/target/adapter.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +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, {JsonMap? driverExplain}); +} + +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 new file mode 100644 index 00000000..3e03c0db --- /dev/null +++ b/pub/orm/lib/src/target/driver.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +abstract interface class TargetDriver { + Future open(); + + Future close(); + + Future execute(TRequest request); +} + +abstract interface class ExplainCapableTargetDriver { + Future> explain(TRequest request); +} + +abstract interface class TargetDriverConnection { + Future execute(TRequest request); + + Future> transaction(); + + Future release(); +} + +abstract interface class ExplainCapableTargetDriverConnection { + Future> explain(TRequest request); +} + +abstract interface class TargetDriverTransaction { + Future execute(TRequest request); + + Future commit(); + + Future rollback(); +} + +abstract interface class ExplainCapableTargetDriverTransaction { + Future> explain(TRequest request); +} + +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 new file mode 100644 index 00000000..a3d344c0 --- /dev/null +++ b/pub/orm/lib/src/target/engine.dart @@ -0,0 +1,312 @@ +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, ExplainCapableEngine { + 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 { + _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); + } + + @override + Future describePlan(OrmPlan plan) async { + _ensureOpen(); + return _describeTargetPlan( + plan: plan, + adapter: adapter, + request: adapter.lower(plan), + describeRequest: _driverExplain(driver), + ); + } + + @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, ExplainCapableEngineConnection { + 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 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); + } + + @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(); + } + + @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.'); + } + } +} + +final class _AdapterDriverTransaction + implements EngineTransaction, ExplainCapableEngineTransaction { + 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 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); + } + + @override + Future rollback() async { + _ensureActive(); + _completed = true; + 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.'); + } + } +} + +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), + executionMode: EngineExecutionMode.stream, + executionSource: EngineExecutionSource.directStream, + ); + } + 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 +>(TargetDriver driver) { + if (driver + case final ReadStreamCapableTargetDriver + streamDriver) { + return streamDriver.stream; + } + 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 +>(TargetDriverConnection connection) { + if (connection + case final ReadStreamCapableTargetDriverConnection + streamConnection) { + return streamConnection.stream; + } + 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 +>(TargetDriverTransaction transaction) { + if (transaction + case final ReadStreamCapableTargetDriverTransaction + streamTransaction) { + return streamTransaction.stream; + } + 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/pubspec.yaml b/pub/orm/pubspec.yaml index ab09b508..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 @@ -11,7 +14,10 @@ dependencies: analyzer: ^9.0.0 analysis_server_plugin: ^0.3.4 meta: ^1.17.0 + 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..9e658b68 --- /dev/null +++ b/pub/orm/test/analyzer/config_required_fix_test.dart @@ -0,0 +1,12 @@ +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:orm/src/analyzer/fixes/config_required_fix.dart'; +import 'package:test/test.dart'; + +void main() { + test('fix kind ids', () { + final context = StubCorrectionProducerContext.instance; + final fix = ConfigRequiredFix(context: context); + + expect(fix.fixKind.id, 'orm.fix.config_required'); + }); +} 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..8cd8479e --- /dev/null +++ b/pub/orm/test/analyzer/config_required_rule_test.dart @@ -0,0 +1,117 @@ +// 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 +class ConfigRequiredRuleTest extends AnalysisRuleTest { + @override + void setUp() { + rule = ConfigRequiredRule(); + super.setUp(); + newPubspecYamlFile(testPackageRootPath, 'name: orm\n'); + newSinglePackageConfigJsonFile( + packagePath: testPackageRootPath, + name: 'orm', + ); + 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); + final resolved = await resolveFile(path); + final diagnosticNode = _diagnosticNode(resolved.unit); + await assertDiagnosticsInFile(path, [ + lint(diagnosticNode.offset, diagnosticNode.length), + ]); + } + + Future _assertValidConfig(String content) async { + final path = join(testPackageRootPath, 'orm.config.dart'); + newFile(path, content); + await assertNoDiagnosticsInFile(path); + } + + Future test_missingConfig() async { + await _assertMissingConfig(r''' +// ignore_for_file: unused_import +import 'package:orm/config.dart'; +'''); + } + + Future test_validConfig() async { + await _assertValidConfig(r''' +import 'package:orm/config.dart'; + +const config = Config( + provider: DatabaseProvider.sqlite, + output: '', +); +'''); + } + + Future test_prefixedImport() async { + await _assertValidConfig(r''' +import 'package:orm/config.dart' as orm; + +const config = orm.Config( + provider: orm.DatabaseProvider.sqlite, + output: '', +); +'''); + } + + Future test_localConfigClass() async { + await _assertMissingConfig(r''' +class Config { + const Config(); +} + +const config = Config(); +'''); + } + + Future test_notConst() async { + await _assertMissingConfig(r''' +import 'package:orm/config.dart'; + +final config = Config( + provider: DatabaseProvider.sqlite, + output: '', +); +'''); + } +} + +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); + }); +} 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..7ce12c16 --- /dev/null +++ b/pub/orm/test/client/api_surface_test.dart @@ -0,0 +1,1208 @@ +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 compiles into structured query plan state', () async { + 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(); + + 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 { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + final plan = await users + .query() + .orderByField('id') + .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'}); + 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 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 keeps distinct streams native when execution handles deduplication', + () 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'], 'nativeStream'); + expect(stream['degraded'], isFalse); + expect(stream['reasons'], isEmpty); + expect(stream['distinctAppliedAt'], 'engine'); + }, + ); + + test( + 'inspectPlan exposes engine-backed distinct page envelopes', + () 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'], isFalse); + expect(pageResult['windowAppliedAt'], 'engine'); + expect(pageResult['reasons'], isEmpty); + }, + ); + + test( + 'runtime executes direct aggregate and grouped aggregate plans', + () 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': 4, 'email': 'a@x.com'}, + ); + await users.create( + data: {'id': 3, 'email': 'b@x.com'}, + ); + + 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(); + } + }, + ); + + 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( + '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'); + + 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'); + expect(summary['executionMode'], 'deferred'); + 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(); + } + }); + + 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 { + 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'); + expect(client.telemetry(), isNull); + expect(client.operationTelemetry(), isNull); + } finally { + await client.disconnect(); + } + }, + ); + + 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( + '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()?.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(); + } + }, + ); + + 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(); + 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( + cursorRows.map((row) => row['id']).toList(growable: false), + [3, 4], + ); + expect( + 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('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(); + 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 pagePlan = await users + .query() + .orderByField('id') + .page(size: 2, after: {'id': 2}) + .toPlan(); + + 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, + ), + ); + final pageResponse = await client.execute(pagePlan); + final cursorRows = await _readEngineRows(cursorResponse); + final pageRows = await _readEngineRows(pageResponse); + + expect( + cursorRows.map((row) => row['id']).toList(growable: false), + [2, 3, 4], + ); + expect( + pageRows.map((row) => row['id']).toList(growable: false), + [3, 4], + ); + } finally { + await client.disconnect(); + } + }); + + test('cursor and page validation are deterministic', () { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + expect( + () => + users.query().orderByField('id').cursor(const {}), + throwsA(isA()), + ); + expect( + () => users.query().orderByField('id').page(size: 0), + throwsA(isA()), + ); + expect( + () => users + .query() + .orderByField('id') + .page( + size: 10, + after: {'id': 'u1'}, + before: {'id': 'u2'}, + ), + throwsA(isA()), + ); + }); + + 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('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'); + + expect( + () => users.query().orderByField('id').pageResult(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.PAGE_RESULT_REQUIRES_PAGE_WINDOW', + ), + ), + ); + }); + + 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( + 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', + ), + ), + ); + 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 { + 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'}) + .updateCount(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(); + } + }); + + 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 through engine-backed 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(); + } + }, + ); + }); +} + +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.'); + } +} + +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; + 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); + } +} + +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) { + 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 new file mode 100644 index 00000000..ad064049 --- /dev/null +++ b/pub/orm/test/client/client_test.dart @@ -0,0 +1,5266 @@ +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'}, + ); + + 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'}, + ); + + 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( + contract: contract, + modelName: 'User', + action: OrmAction.read, + 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.read, + 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(); + + final users = client.db.orm.model('User'); + final created = await users.create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + expect(created['id'], 'u1'); + + final allRows = await users.all(); + expect(allRows, hasLength(1)); + expect(allRows.first['email'], 'a@example.com'); + + final unique = await users.oneOrNull( + 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.all(); + expect(remaining, isEmpty); + await client.disconnect(); + }); + + test('requires explicit connect', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + final users = client.db.orm.model('User'); + + await expectLater( + users.all(), + throwsA(isA()), + ); + }); + + test('supports db.sql select and mutation builders', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + + final insertResult = await client.db.sql + .insertInto('User') + .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.db.sql + .from('User') + .where({'id': 'u1'}) + .select(const ['email']) + .all(); + expect(selectedRows, hasLength(1)); + expect(selectedRows.single['email'], 'a@example.com'); + + final updated = await client.db.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.db.sql + .deleteFrom('User') + .where({'id': 'u1'}) + .returning(const ['id']) + .execute(); + expect(deleted.affectedRows, 1); + expect(deleted.row?['id'], 'u1'); + + final remaining = await client.db.sql.from('User').all(); + expect(remaining, isEmpty); + await client.disconnect(); + }); + + test('db.sql requires explicit connect', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await expectLater( + client.db.sql.from('User').all(), + throwsA(isA()), + ); + }); + + 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.model('User'); + await users.create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + + final sqlRow = await client.db.sql.from('User').where({ + 'id': 'u1', + }).firstOrNull(); + expect(sqlRow?['email'], 'a@example.com'); + + final ormRow = await client.db.orm + .model('User') + .oneOrNull(where: {'id': 'u1'}); + expect(ormRow?['id'], 'u1'); + 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(); + + await expectLater( + client.execute( + OrmPlan( + contractHash: 'mismatch', + model: 'User', + action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), + ), + ), + throwsA(isA()), + ); + }); + + 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.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), + ), + ), + throwsA(isA()), + ); + + await expectLater( + client.execute( + OrmPlan( + contractHash: profileContract.hash, + target: profileContract.target, + storageHash: 'other-storage', + profileHash: profileContract.profileHash, + model: 'User', + action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), + ), + ), + throwsA(isA()), + ); + + await expectLater( + client.execute( + OrmPlan( + contractHash: profileContract.hash, + target: profileContract.target, + storageHash: profileContract.markerStorageHash, + profileHash: 'other-profile', + model: 'User', + action: OrmAction.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), + ), + ), + throwsA(isA()), + ); + await client.disconnect(); + }, + ); + + 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, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), + mutation: OrmMutationPlan( + resultMode: 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, + read: OrmReadPlan(resultMode: OrmReadResultMode.oneOrNull), + mutation: OrmMutationPlan( + resultMode: OrmMutationResultMode.rowOrNull, + ), + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.RESULT_MODE_ACTION_INVALID', + ), + ), + ); + } + + 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(); + }); + + 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(); + final users = client.db.orm.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.all( + orderBy: const [OrmOrderBy('email')], + skip: 1, + take: 1, + ); + + expect(rows.single['email'], 'b@x.com'); + 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.db.orm.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.all( + orderBy: const [OrmOrderBy('id')], + distinct: const ['email'], + ); + expect( + distinctRows.map((row) => row['id']).toList(growable: false), + ['u1', 'u3'], + ); + + final pagedDistinctRows = await users.all( + 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') + .all(); + expect( + 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(); + }, + ); + + test('supports aggregate helpers 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': null}); + await users.create(data: {'id': 3, 'email': 'b@x.com'}); + + final aggregate = await users.aggregate( + build: (aggregate) => aggregate + .countAll() + .count('email') + .min('id') + .max('id') + .sum('id') + .avg('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.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': 4, 'email': 'b@x.com'}); + + final grouped = await users + .query() + .groupedBy(const ['email']) + .aggregate((aggregate) => aggregate.countAll().sum('id').avg('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['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 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']) + .havingExpr((having) => having.countAll().gte(2), merge: false) + .aggregate((aggregate) => aggregate.countAll().sum('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(); + }); + + 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((aggregate) => aggregate.countAll().sum('id')); + + expect(grouped, hasLength(2)); + 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(); + 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('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((aggregate) => aggregate.countAll()), + 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(); + final users = client.db.orm.model('User'); + + await users.create(data: {'id': 1, 'email': 'a@x.com'}); + + await expectLater( + users + .groupedBy(const ['email']) + .havingExpr((having) => having.sum('email').gte(1), merge: false) + .aggregate((aggregate) => aggregate.sum('id')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'PLAN.GROUP_BY_HAVING_FIELD_INVALID', + ), + ), + ); + 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']) + .havingExpr( + (_) => 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'); + + 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(); + 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 gtRows = await users.all( + where: { + 'id': {'gt': 2}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect(gtRows.map((row) => row['id']).toList(growable: false), [ + 3, + 4, + ]); + + final inRows = await users.all( + 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.all( + 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 string where operators contains/startsWith/endsWith 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': '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.all( + 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.all( + where: { + 'email': {'startsWith': 'alph'}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + startsWithRows.map((row) => row['id']).toList(growable: false), + ['u1', 'u3'], + ); + + final endsWithRows = await users.all( + where: { + 'email': {'endsWith': 'sample.com'}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + endsWithRows.map((row) => row['id']).toList(growable: false), + ['u4'], + ); + await client.disconnect(); + }, + ); + + 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.db.orm.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.all( + 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.all( + 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.all( + 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 { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.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.oneOrNull( + 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.db.orm.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.all(); + final page = await narrowed.all(); + + expect(all, hasLength(3)); + expect(page, hasLength(1)); + expect(page.single['email'], 'b@x.com'); + await client.disconnect(); + }); + + test( + 'query toPlan emits orm lane metadata and structured include plan', + () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + final users = client.db.orm.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.read); + 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']); + expect(posts?.include['author']?.select, ['email']); + }, + ); + + 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.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'}, + ); + final updatePlan = engine.executedPlans.single; + expect(updatePlan.lane, 'orm'); + expect(updatePlan.action, OrmAction.update); + expect(updatePlan.mutation?.resultMode, OrmMutationResultMode.rowOrNull); + + final sqlPlan = client.db.sql + .update('User') + .where({'id': 'u1'}) + .set({'email': 'c@x.com'}) + .toPlan(); + expect(sqlPlan.lane, 'sql'); + expect(sqlPlan.action, OrmAction.update); + expect(sqlPlan.mutation?.resultMode, OrmMutationResultMode.rowOrNull); + + 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.db.orm.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.oneOrNull(); + 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.db.orm.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', + }).oneOrNull(); + 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', + }).oneOrNull(); + expect(remaining, isNull); + await client.disconnect(); + }); + + test('supports firstOrNull, count and exists helpers', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.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.firstOrNull( + 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 stream-first reads on delegate and query', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.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 + .stream(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('rejects unsupported query state on mutation terminals', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.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).deleteCount(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID', + ) + .having( + (error) => error.details['invalidKeys'], + 'invalidKeys', + ['take'], + ), + ), + ); + + expect( + () => users + .select(const ['id']) + .updateCount(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()}) + .deleteCount(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'PLAN.MUTATION_QUERY_STATE_INVALID', + ) + .having( + (error) => error.details['invalidKeys'], + 'invalidKeys', + ['include'], + ), + ), + ); + + 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(); + }); + + test('supports upsert create and update branches', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + 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'); + + 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 batch mutation helpers', () async { + final engine = _CountingEngine(inner: MemoryEngine()); + final client = OrmClient(contract: contract, engine: engine); + await client.connect(); + final users = client.db.orm.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)); + 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 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': 'updated@x.com'}, + data: {'email': 'counted@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.updateCount', 'User.updateCount', 'User.updateCount'], + ); + 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 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': 'b@x.com'}, + ); + expect(deleted, 1); + expect( + engine.executedPlans.map((plan) => plan.action).toList(growable: false), + [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.deleteCount', 'User.deleteCount'], + ); + expect( + deleteTraces.map((trace) => trace.phase).toList(growable: false), + ['item.delete', 'item.delete'], + ); + expect( + deleteTraces.map((trace) => trace.strategy).toList(growable: false), + ['transaction', 'transaction'], + ); + expect( + deleteTraces.map((trace) => trace.step).toList(growable: false), + [1, 2], + ); + expect( + deleteTraces.map((trace) => trace.itemIndex).toList(growable: false), + [0, 1], + ); + + final remaining = await users.count(); + expect(remaining, 0); + await client.disconnect(); + }); + + test('createMany_rolls_back_on_partial_failure', () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + await client.connect(); + final users = client.db.orm.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.all( + 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 { + 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'); + + 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.oneOrNull( + where: {'id': 'u1'}, + ); + expect(remaining, isNull); + await client.disconnect(); + }, + ); + + 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.db.orm + .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.db.orm.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], + ); + 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(); + }, + ); + + 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.db.orm.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 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'}, + ); + expect(remaining, isNull); + await client.disconnect(); + }, + ); + + test( + 'supports query state helpers for first/count/exists/upsert/deleteCount', + () async { + final client = OrmClient(contract: contract, engine: MemoryEngine()); + 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': 'a@x.com'}, + ); + + final query = users.where({'email': 'a@x.com'}); + final first = await query.orderByField('id').firstOrNull(); + 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.deleteCount(); + expect(removed, 2); + expect(await users.count(), 1); + await client.disconnect(); + }, + ); + + 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.db.orm.model('User'); + await users.create( + data: {'id': 'u3', 'email': 'u3@example.com'}, + ); + + final someRows = await users.all( + where: { + 'posts': { + 'some': { + 'title': {'contains': 'A'}, + }, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + someRows.map((row) => row['id']).toList(growable: false), + ['u1'], + ); + + final noneRows = await users.all( + 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.all( + where: { + 'posts': { + 'every': { + 'title': {'contains': 'A'}, + }, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + everyRows.map((row) => row['id']).toList(growable: false), + ['u3'], + ); + await client.disconnect(); + }, + ); + + 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, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final posts = client.db.orm.model('Post'); + + await posts.create( + data: {'id': 'p4', 'userId': 'ux', 'title': 'Post D'}, + ); + + final isRows = await posts.all( + 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.all( + where: { + 'author': { + 'isNot': {'id': 'u1'}, + }, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + isNotRows.map((row) => row['id']).toList(growable: false), + ['p3', 'p4'], + ); + + final relationMissingRows = await posts.all( + where: { + 'author': {'isNot': const {}}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + relationMissingRows.map((row) => row['id']).toList(growable: false), + ['p4'], + ); + + final isNullRows = await posts.all( + where: { + 'author': {'is': null}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + isNullRows.map((row) => row['id']).toList(growable: false), + ['p4'], + ); + + final isNotNullRows = await posts.all( + where: { + 'author': {'isNot': null}, + }, + orderBy: const [OrmOrderBy('id')], + ); + expect( + isNotNullRows.map((row) => row['id']).toList(growable: false), + ['p1', 'p2', 'p3'], + ); + 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, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final users = client.db.orm.model('User'); + await users.create( + data: {'id': 'u3', 'email': 'u3@example.com'}, + ); + + final rows = await users.all( + 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.db.orm.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.oneOrNull( + where: {'id': 'u2'}, + ); + expect(persisted?['email'], 'u2+updated@example.com'); + 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, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + final posts = client.db.orm.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.oneOrNull( + where: {'id': 'p3'}, + ); + expect(persisted?['title'], 'Post C updated'); + 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.db.orm + .model('User') + .all( + where: { + 'posts': { + 'some': {'title': 'Post A'}, + }, + }, + ); + + expect(countingEngine.executeCount, 1); + expect(countingEngine.executedPlans.single.model, 'User'); + expect(countingEngine.executedPlans.single.read?.where, { + 'posts': { + 'some': {'title': 'Post A'}, + }, + }); + 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.db.orm + .model('User') + .all( + 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 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 { + 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.db.orm + .model('User') + .all( + 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('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( + '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 { + 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( + 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 findManyPlans = engine.executedPlans + .where((plan) => plan.action == OrmAction.read) + .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 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.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(); + + 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(); + } + }, + ); + + 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.db.orm + .model('User') + .all( + include: {'posts': const IncludeSpec()}, + ), + throwsA(isA()), + ); + } finally { + 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.db.orm.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 explicit nested create orchestration with transaction', + () async { + final client = OrmClient( + contract: relationalContract, + engine: MemoryEngine(), + ); + await client.connect(); + + final created = await client.db.orm + .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.db.orm + .model('Post') + .all(where: {'userId': 'u3'}); + expect(persistedPosts, hasLength(2)); + await client.disconnect(); + }, + ); + + 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, + engine: MemoryEngine(), + ); + await client.connect(); + + await expectLater( + client.db.orm + .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.db.orm + .model('User') + .oneOrNull(where: {'id': 'u4'}); + expect(rolledBackUser, isNull); + 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.db.orm + .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.db.orm + .model('User') + .oneOrNull(where: {'id': 'u1'}); + expect(persistedUser?['email'], 'u1+updated@example.com'); + + final persistedChild = await client.db.orm + .model('Post') + .oneOrNull(where: {'id': 'p4'}); + expect(persistedChild?['userId'], 'u1'); + await client.disconnect(); + }, + ); + + 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, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + final updated = await client.db.orm + .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.db.orm + .model('Post') + .oneOrNull(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.db.orm + .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.db.orm + .model('User') + .oneOrNull(where: {'id': 'u1'}); + expect(rolledBackUser?['email'], 'u1@example.com'); + + final rolledBackChild = await client.db.orm + .model('Post') + .oneOrNull(where: {'id': 'p10'}); + expect(rolledBackChild, 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.db.orm.model('User'); + + final delegatedRows = await users.include({ + 'posts': IncludeSpec( + orderBy: const [OrmOrderBy('id')], + take: 1, + ), + }).all(); + 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, + ), + }); + 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.oneOrNull(); + final includePosts = _readRowsValue(includeRow?['posts']); + expect(includePosts, hasLength(1)); + expect(includePosts.single['id'], 'p1'); + + final deepMergedRow = await deepMergedInclude.oneOrNull(); + 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( + 'posts', + spec: IncludeSpec( + orderBy: const [OrmOrderBy('id')], + include: { + 'author': IncludeSpec(select: const ['email']), + }, + ), + ) + .oneOrNull(); + + 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 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, + engine: MemoryEngine(), + ); + await client.connect(); + await _seedRelationalData(client); + + final row = await client.db.orm + .model('Post') + .oneOrNull( + 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.db.orm + .model('User') + .all( + 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.db.orm + .model('User') + .all( + 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.db.orm + .model('User') + .oneOrNull( + 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.db.orm + .model('User') + .all(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, + engine: MemoryEngine(), + collections: { + 'User': + ({ + required OrmCollectionContext client, + required String modelName, + }) { + return _UsersCollection(client: client, modelName: modelName); + }, + }, + ); + await client.connect(); + + final first = client.db.orm.model('User'); + final second = client.db.orm.model('User'); + + expect(first, same(second)); + expect(first, isA<_UsersCollection>()); + 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(); + + final connection = await client.connection(); + await connection.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.create, + mutation: OrmMutationPlan( + data: {'id': 'u1', 'email': 'a@example.com'}, + resultMode: OrmMutationResultMode.row, + ), + ), + ); + + final transaction = await connection.transaction(); + await transaction.execute( + OrmPlan( + contractHash: contract.hash, + model: 'User', + action: OrmAction.update, + mutation: OrmMutationPlan( + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + resultMode: OrmMutationResultMode.rowOrNull, + ), + ), + ); + await transaction.commit(); + await connection.release(); + + final row = await client.db.orm + .model('User') + .oneOrNull(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.db.orm + .model('User') + .create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + }); + + final row = await client.db.orm + .model('User') + .oneOrNull(where: {'id': 'u1'}); + expect(row?['email'], 'a@example.com'); + 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.db.sql.from('User').take(1).all(); + expect(rows, isEmpty); + }); + + expect(engine.connectionCount, 1); + expect(engine.connectionExecutePlans, hasLength(1)); + expect(engine.connectionExecutePlans.single.action, OrmAction.read); + expect(engine.connectionExecutePlans.single.read?.take, 1); + 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.db.orm.model('User').all(); + expect(rows, isEmpty); + }); + + expect(engine.connectionCount, 1); + expect(engine.connectionExecutePlans, hasLength(1)); + expect(engine.connectionExecutePlans.single.action, OrmAction.read); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); + + 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( + '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(); + + await client.withTransaction((transaction) async { + await transaction.db.orm + .model('User') + .create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + }); + + final row = await client.db.orm + .model('User') + .oneOrNull(where: {'id': 'u1'}); + expect(row?['email'], 'a@example.com'); + 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.db.sql.insertInto('User').values({ + 'id': 'u1', + 'email': 'a@example.com', + }).execute(); + }); + + final row = await client.db.orm + .model('User') + .oneOrNull(where: {'id': 'u1'}); + expect(row?['email'], 'a@example.com'); + 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.db.orm.model('User').all(); + expect(rows, isEmpty); + }); + + 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, 0); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); + + 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 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 { + 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(); + + await expectLater( + () => client.withTransaction((transaction) async { + await transaction.db.orm + .model('User') + .create( + data: {'id': 'u1', 'email': 'a@example.com'}, + ); + throw StateError('stop'); + }), + throwsA(isA()), + ); + + final row = await client.db.orm + .model('User') + .oneOrNull(where: {'id': 'u1'}); + expect(row, isNull); + 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.db.orm.model('User').all(); + throw StateError('stop'); + }), + 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, 0); + expect(engine.rollbackCount, 1); + expect(engine.releaseCount, 1); + await client.disconnect(); + }, + ); + + 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.db.orm.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.db.orm.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 { + 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(); + final users = client.db.orm.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, + mutation: OrmMutationPlan( + where: {'id': 'u1'}, + data: {'email': 'b@example.com'}, + resultMode: OrmMutationResultMode.rowOrNull, + ), + ), + ); + await transaction.rollback(); + await connection.release(); + + final row = await users.oneOrNull(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.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), + ), + ), + throwsA(isA()), + ); + expect( + () => connection.explain(_scopedReadPlan(contract)), + 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.read, + read: OrmReadPlan(resultMode: OrmReadResultMode.all), + ), + ), + 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(); + await client.db.orm.model('User').all(); + + final telemetry = client.telemetry(); + expect(telemetry, isNotNull); + 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(); + }); + + 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?.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( + 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.db.orm.model('User').all(); + await client.db.orm.model('User').all(); + 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.db.orm.model('User').all(); + expect(readCount, 1); + await client.db.orm.model('User').all(); + 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.db.orm.model('User').all(); + await client.db.orm.model('User').all(); + + expect(readCount, 2); + 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, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.onFirstUse, + requireMarker: true, + markerReader: CallbackMarkerReader(() async => null), + ), + ); + await client.connect(); + + await expectLater( + client.db.orm.model('User').all(), + throwsA(isA()), + ); + 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, + engine: MemoryEngine(), + verify: RuntimeVerifyOptions( + mode: RuntimeVerifyMode.onFirstUse, + requireMarker: true, + markerReader: CallbackMarkerReader(() async => 'other-hash'), + ), + ); + await client.connect(); + + await expectLater( + client.db.orm.model('User').all(), + throwsA(isA()), + ); + 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(); + + await expectLater( + client.db.orm + .model('User') + .all( + where: { + 'OR': [ + {'id': 'u1'}, + {'email': 'a@x.com'}, + ], + }, + ), + completes, + ); + await expectLater( + client.db.orm.model('User').all(where: {'age': 1}), + throwsA(isA()), + ); + await expectLater( + client.db.orm + .model('User') + .all( + where: { + 'AND': [ + {'id': 'u1'}, + {'age': 1}, + ], + }, + ), + throwsA(isA()), + ); + await expectLater( + client.db.orm.model('User').create(data: {'age': 1}), + throwsA(isA()), + ); + await expectLater( + client.db.orm + .model('User') + .all(orderBy: const [OrmOrderBy('age')]), + throwsA(isA()), + ); + await expectLater( + client.db.orm.model('User').all(select: const ['age']), + throwsA(isA()), + ); + await expectLater( + client.db.orm.model('User').all(distinct: 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.db.orm.model('User').all(skip: -1), + throwsA(isA()), + ); + await expectLater( + client.db.orm.model('User').all(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.db.orm.model('User').all(); + + expect(plugin.events, ['before:read', 'after:read']); + 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.db.sql.from('User').all(); + + expect(plugin.events, ['before:read', 'after:read']); + 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.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(); + }); + + 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.db.orm.model('User').all(), + 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.db.orm.model('User').all(); + 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.db.orm.model('User').all(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.db.orm.model('User').all(), + throwsA(isA()), + ); + 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.db.sql.from('User').all(), + throwsA(isA()), + ); + await client.disconnect(); + }); +} + +Future _seedRelationalData(OrmClient client) async { + final users = client.db.orm.model('User'); + final posts = client.db.orm.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'}, + ); +} + +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; + } + 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}.'); +} + +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) { + fail('Expected repository trace on plan ${plan.action.name}.'); + } + return trace; +} + +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 = []; + + @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 _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 {} + + @override + Future execute(OrmPlan plan) { + throw StateError('boom'); + } + + @override + 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 {} + + @override + Future execute(OrmPlan plan) async { + return EngineResponse.buffered('bad-shape'); + } + + @override + 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.empty(affectedRows: response.affectedRows); + } + return response; + } + + @override + Future open() => inner.open(); +} + +final class _CountingEngine implements OrmEngine, ConnectionCapableEngine { + final OrmEngine inner; + var executeCount = 0; + final List executedPlans = []; + + _CountingEngine({required this.inner}); + + @override + Future close() => inner.close(); + + @override + Future execute(OrmPlan plan) async { + _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 { + final OrmEngine inner; + + _BadRelatedFindManyShapeEngine({required this.inner}); + + @override + Future close() => inner.close(); + + @override + Future execute(OrmPlan plan) async { + if (plan.model == 'Post' && plan.action == OrmAction.read) { + return EngineResponse.buffered('bad-shape'); + } + return inner.execute(plan); + } + + @override + Future open() => inner.open(); +} + +final class _TrackingConnectionEngine + implements OrmEngine, ConnectionCapableEngine { + final bool failOnTransactionStart; + final bool failOnCommit; + final bool failOnRollback; + final bool failOnConnectionExplain; + final bool failOnTransactionExplain; + var connectionCount = 0; + var transactionCount = 0; + var releaseCount = 0; + var commitCount = 0; + var rollbackCount = 0; + final List connectionExecutePlans = []; + final List connectionExplainPlans = []; + final List transactionExecutePlans = []; + final List transactionExplainPlans = []; + + _TrackingConnectionEngine({ + this.failOnTransactionStart = false, + this.failOnCommit = false, + this.failOnRollback = false, + this.failOnConnectionExplain = false, + this.failOnTransactionExplain = false, + }); + + @override + Future close() async {} + + @override + Future connection() async { + connectionCount += 1; + return _TrackingEngineConnection(this); + } + + @override + Future execute(OrmPlan plan) async { + return EngineResponse.buffered(const []); + } + + @override + Future open() async {} +} + +final class _TrackingEngineConnection + implements EngineConnection, ExplainCapableEngineConnection { + final _TrackingConnectionEngine _engine; + + _TrackingEngineConnection(this._engine); + + @override + Future execute(OrmPlan plan) async { + _engine.connectionExecutePlans.add(plan); + return EngineResponse.buffered(const []); + } + + @override + Future release() async { + _engine.releaseCount += 1; + } + + @override + Future transaction() async { + _engine.transactionCount += 1; + if (_engine.failOnTransactionStart) { + throw StateError('transaction start failed'); + } + return _TrackingEngineTransaction(_engine); + } + + @override + Future describePlan(OrmPlan plan) async { + _engine.connectionExplainPlans.add(plan); + if (_engine.failOnConnectionExplain) { + throw StateError('connection explain failed'); + } + return {'source': 'connection'}; + } +} + +final class _TrackingEngineTransaction + implements EngineTransaction, ExplainCapableEngineTransaction { + final _TrackingConnectionEngine _engine; + + _TrackingEngineTransaction(this._engine); + + @override + Future commit() async { + _engine.commitCount += 1; + if (_engine.failOnCommit) { + throw StateError('commit failed'); + } + } + + @override + Future execute(OrmPlan plan) async { + _engine.transactionExecutePlans.add(plan); + return EngineResponse.buffered(const []); + } + + @override + Future rollback() async { + _engine.rollbackCount += 1; + if (_engine.failOnRollback) { + throw StateError('rollback failed'); + } + } + + @override + Future describePlan(OrmPlan plan) async { + _engine.transactionExplainPlans.add(plan); + if (_engine.failOnTransactionExplain) { + throw StateError('transaction explain failed'); + } + return {'source': 'transaction'}; + } +} + +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/client/source_surface_test.dart b/pub/orm/test/client/source_surface_test.dart new file mode 100644 index 00000000..0cd7e36e --- /dev/null +++ b/pub/orm/test/client/source_surface_test.dart @@ -0,0 +1,305 @@ +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\(', + ).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\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelDelegate.groupedBy(...) to create grouped queries from where-only query specs.', + ); + expect( + RegExp( + r'class\s+ModelDelegate\s*\{[\s\S]*?Future>\s+groupByWith\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelDelegate to avoid redundant groupByWith(...) wrappers.', + ); + }); + + test( + 'query terminals route aggregate groupBy and mutations to private helpers', + () { + expect( + 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), + isFalse, + reason: + 'Expected ModelQuery.aggregate(...) to avoid aggregateWith(...) as a public bridge.', + ); + expect( + 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+_executeAggregate\(build\(OrmAggregateBuilder\(\)\)\.toSpec\(\)\);', + ).hasMatch(source), + isTrue, + reason: + '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( + 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\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelQuery to avoid redundant groupBy(...) convenience terminals.', + ); + expect( + RegExp( + r'class\s+ModelQuery\s*\{[\s\S]*?Future>\s+groupByWith\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelQuery to avoid redundant groupByWith(...) terminals.', + ); + expect( + RegExp( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future>\s+aggregate\([\s\S]*?\)\s*=>\s*_executeAggregate\(build\(OrmAggregateBuilder\(\)\)\.toSpec\(\)\);', + ).hasMatch(source), + isTrue, + reason: + '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( + r'class\s+ModelGroupedQuery\s*\{[\s\S]*?Future\s+toPlan\(', + ).hasMatch(source), + 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 to keep inspectPlan() out of the public grouped 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*\)', + ).hasMatch(source), + isTrue, + 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,', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to keep structured having(...) out of the public grouped surface.', + ); + 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'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, + 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\(', + ).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\(', + ).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\(', + ).hasMatch(source), + isFalse, + reason: + 'Expected ModelGroupedQuery to avoid row-query all() terminals.', + ); + 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+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\);", + ).hasMatch(source), + isTrue, + 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\);', + ).hasMatch(source), + isTrue, + reason: + 'Expected ModelQuery.deleteCount(...) to terminate through the private mutation helper.', + ); + expect( + RegExp( + 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.deleteCount(...) to reject row-shaping state.', + ); + }, + ); + }); +} 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/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/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 new file mode 100644 index 00000000..5246be64 --- /dev/null +++ b/pub/orm/test/generator/generate_test.dart @@ -0,0 +1,2474 @@ +import 'dart:convert'; +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('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("_orm.model('CliOnlyUser')"), + isTrue, + reason: 'Expected CLI schema model in generated output.', + ); + expect( + generatedSource.contains("_orm.model('ConfigOnlyUser')"), + isFalse, + reason: 'Did not expect config schema model after --schema override.', + ); + }); + + 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); + 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 userMap = user as Map; + expect(userMap['idFields'], ['id']); + final relations = userMap['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 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)); + + 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)); + + 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'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.', + ); + expect( + generatedSource.contains('db.orm.user;'), + isFalse, + 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\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected GeneratedOrmDb to expose db.orm and db.sql namespaces.', + ); + expect( + RegExp( + 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 exact model-name sql delegates.', + ); + expect( + RegExp( + r'class\s+GeneratedOrmCollections\s*\{[\s\S]*?_orm\.model\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected GeneratedOrmCollections to materialize typed delegates.', + ); + + 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 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'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+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*\)', + ).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'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\(\)', + ).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]*?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 read specs and prepared read objects.', + ); + 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( + 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+UserDelegate\s*\{[\s\S]*?UserQuery\s+cursor\(\s*UserCursorInput\s+cursor\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: + '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*UserCursorInput\?\s+after,\s*UserCursorInput\?\s+before,', + ).hasMatch(generatedSource), + isTrue, + 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.', + ); + 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.', + ); + 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*\)\s+async[\s\S]*?\(await\s+_prepareRead\(\)\)\.plan', + ).hasMatch(generatedSource), + isTrue, + reason: + '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+async[\s\S]*?\(await\s+_prepareRead\(\)\)\.inspectPlan\(\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.inspectPlan() to compile typed state through prepared read inspection.', + ); + 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[\s\S]*?\(await\s+_prepareRead\(\)\)\.explain\(\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.explain() to compile typed state through prepared read explain.', + ); + expect( + RegExp( + r'\bFuture>\s+all\s*\(\s*\)', + ).hasMatch(generatedSource), + 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]*?\(await\s+_prepareRead\(\)\)\.pageResult\(\)', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.pageResult() to expose structured page envelope mapping through prepared reads.', + ); + expect( + RegExp( + r'\bFuture\s+firstOrNull\s*\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserQuery.firstOrNull() 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]*?OrmPlan\s+toPlan\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose typed toPlan helper.', + ); + expect( + RegExp( + r'class\s+UserSql\s*\{[\s\S]*?Future>\s+all\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose typed all 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+firstOrNull\(', + ).hasMatch(generatedSource), + isTrue, + reason: 'Expected UserSql to expose typed firstOrNull 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'class\s+UserSql\s*\{[\s\S]*?Future\s+insertResult\(', + ).hasMatch(generatedSource), + 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), + 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), + isFalse, + reason: + 'Expected UserSql to stop exposing deleteResult helper and use deletePlan().execute() for raw mutation results.', + ); + 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 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'class\s+UserGroupBySpec\s*\{[\s\S]*?final\s+UserGroupByHaving\s+having;[\s\S]*?UserGroupBySpec\s+copyWith\(', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupBySpec to keep grouped fields, having state, and copyWith helpers.', + ); + expect( + RegExp(r'\bclass UserWhereUniqueInput\b').hasMatch(generatedSource), + 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;', + ).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.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( + 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,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected oneOrNull 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'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,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.updateCount(...) to expose typed where and data input.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+updateCount\(\{\s*required\s+UserUpdateInput\s+data\}\)', + ).hasMatch(generatedSource), + isTrue, + reason: + '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), + 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+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\(\),', + ).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'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,', + ).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'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\(\),', + ).hasMatch(generatedSource), + 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+\[\],', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected all to expose typed distinct parameter in generated delegate.', + ); + expect( + RegExp( + r'Future\s+firstOrNull\(\{[\s\S]*?List\s+distinct\s*=\s*const\s+\[\],', + ).hasMatch(generatedSource), + isTrue, + 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\([\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 direct typed toWhereInput() conversion without JSON round-tripping.', + ); + 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( + 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+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\);', + ).hasMatch(generatedSource), + isTrue, + 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\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate updateCount(...) 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+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\(\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated delegate deleteCount(...) 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\(\);', + ).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*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: + 'Expected generated delegate aggregate(...) to route through typed query.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future\s+aggregateWith\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected generated delegate to keep aggregateWith(...) out of the public typed surface.', + ); + expect( + RegExp( + 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), + isFalse, + reason: + '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), + isFalse, + reason: + 'Expected generated delegate to avoid redundant groupByWith(...) wrappers.', + ); + 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( + 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( + 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+\[\],', + ).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\(\s*List\s+distinct,\s*\{\s*bool\s+append\s*=\s*false,?\s*\}\s*\)', + ).hasMatch(generatedSource), + isTrue, + 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'\bclass UserAggregateResult\b').hasMatch(generatedSource), + isTrue, + reason: + 'Expected generated source to include aggregate result wrapper.', + ); + expect( + generatedSource.contains('UserAggregateCountBucket') || + generatedSource.contains('UserAggregateMinBucket') || + generatedSource.contains('UserAggregateMaxBucket') || + generatedSource.contains('UserAggregateSumBucket') || + generatedSource.contains('UserAggregateAvgBucket'), + isFalse, + reason: + 'Expected typed aggregate results to avoid per-bucket wrapper classes.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?int\?\s+get\s+countAll\s*=>', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose a direct countAll getter.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?int\?\s+get\s+countEmail\s*=>', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose direct typed count field getters.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?(int|double)\?\s+get\s+sumId\s*=>', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose direct typed sum field getters.', + ); + expect( + RegExp( + r'class\s+UserAggregateResult\s*\{[\s\S]*?double\?\s+get\s+avgId\s*=>', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserAggregateResult to expose direct typed avg field getters.', + ); + 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'Future>\s+groupBy\(\{\s*required\s+List\s+by,[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected generated source to avoid redundant typed groupBy convenience helpers.', + ); + expect( + RegExp(r'\bclass UserGroupByResult\b').hasMatch(generatedSource), + isTrue, + 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]*?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\)', + ).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( + 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()', + ), + isFalse, + reason: + 'Expected generated groupBy surfaces to remove row-level having parameter.', + ); + expect( + RegExp( + r'class\s+UserDelegate\s*\{[\s\S]*?Future>\s+groupBy\(\{[\s\S]*?UserGroupByHaving\s+typedHaving\s*=\s*const\s+UserGroupByHaving\(\),', + ).hasMatch(generatedSource), + isFalse, + reason: + '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), + isFalse, + reason: + 'Expected UserDelegate to route grouped aggregation only through groupedBy(...).aggregate(...).', + ); + 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+_executeAggregate\(build\(UserAggregateBuilder\(\)\)\.toSpec\(\)\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserQuery.aggregate(...) to route through the typed aggregate builder callback.', + ); + expect( + RegExp( + 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: + '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( + 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.groupedBy(...) to construct a distinct typed grouped builder.', + ); + expect( + 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), + isFalse, + reason: + '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), + isFalse, + reason: + 'Expected UserQuery to avoid redundant groupByWith(...) terminals.', + ); + 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+toPlan\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected UserGroupedQuery to keep toPlan() out of the typed grouped surface.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?Future\s+inspectPlan\(', + ).hasMatch(generatedSource), + isFalse, + 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\(\)\);', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserGroupedQuery.aggregate(...) to be the only public typed grouped aggregate terminal.', + ); + expect( + RegExp( + r'class\s+UserGroupedQuery\s*\{[\s\S]*?\.aggregateWith\(', + ).hasMatch(generatedSource), + isFalse, + reason: + 'Expected typed grouped execution to avoid aggregateWith(...) bridges.', + ); + 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\(\)\);', + ).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]*?\.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(...).havingExpr(...).', + ); + 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( + 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'\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'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, + reason: + 'Expected generated source to include typed aggregate builder 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, + 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\(', + ).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( + 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+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,', + ).hasMatch(generatedSource), + isTrue, + reason: + 'Expected UserDelegate.deleteCount(...) to accept typed where input.', + ); + expect( + RegExp( + r'class\s+UserQuery\s*\{[\s\S]*?Future\s+deleteCount\s*\(\s*\)', + ).hasMatch(generatedSource), + isTrue, + 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( + 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'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, + 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"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, + 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( + 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( + 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, + reason: 'Expected typed delegate signature to use UserOrderBy.', + ); + }); + + test('generates relation where some/every/none and is/isNot 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'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'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+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\(', + ).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', + ).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'\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+is_;[\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\(\)", + ).hasMatch(generatedSource), + isTrue, + 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.', + ); + 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('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)); + + 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, + List generateArgs = const [], +}) async { + final args = [entryPath, 'generate', ...generateArgs]; + final result = await Process.run( + 'dart', + args, + workingDirectory: workingDirectory, + ); + return _GenerateRun( + args: args, + exitCode: result.exitCode, + stdout: '${result.stdout}', + stderr: '${result.stderr}', + ); +} + +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)) { + 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(); + } +} 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..54a5a75d --- /dev/null +++ b/pub/orm/test/runtime/operation_telemetry_aggregation_test.dart @@ -0,0 +1,568 @@ +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?.completed, isTrue); + 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?.completed, isTrue); + 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?.completed, isTrue); + 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(); + }); + + 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'); + + 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.updateCount( + where: {'email': 'a@x.com'}, + data: {'email': 'updated@x.com'}, + ); + + expect(updated, 2); + final telemetry = client.operationTelemetry(); + expect(telemetry, isNotNull); + expect(telemetry?.kind, 'User.updateCount'); + 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 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(); + 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?.completed, isTrue); + 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(); + }); + + 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(); + }, + ); + }); +} + +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.empty(affectedRows: response.affectedRows); + } + return response; + } + + @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 {} +} 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..f78875d5 --- /dev/null +++ b/pub/orm/test/runtime/plan_surface_test.dart @@ -0,0 +1,90 @@ +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'}, + }); + }); + + 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: OrmGroupByHaving.parse(const { + '_count': { + 'all': {'gte': 2}, + }, + }), + orderBy: const [ + OrmOrderBy('_sum.id', order: SortOrder.desc), + ], + take: 5, + ), + ); + + 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, + }); + }); +} 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..5850abd0 --- /dev/null +++ b/pub/orm/test/sql/sql_adapter_test.dart @@ -0,0 +1,1040 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + 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), + ); + } + + 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(); + + OrmPlan readPlan({ + required OrmContract contract, + required String model, + JsonMap where = const {}, + int? skip, + int? take, + List orderBy = const [], + List distinct = const [], + List select = const [], + OrmReadCursorPlan? cursor, + OrmReadPagePlan? page, + OrmReadResultMode resultMode = OrmReadResultMode.all, + OrmReadShape shape = OrmReadShape.rows, + OrmReadAggregatePlan? aggregate, + OrmReadGroupByPlan? groupBy, + }) { + return OrmPlan( + contractHash: contract.hash, + model: model, + action: OrmAction.read, + read: OrmReadPlan( + where: where, + skip: skip, + take: take, + orderBy: orderBy, + distinct: distinct, + select: select, + cursor: cursor, + page: page, + resultMode: resultMode, + shape: shape, + aggregate: aggregate, + groupBy: groupBy, + ), + ); + } + + 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 = readPlan( + contract: contract, + model: 'User', + 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 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 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( + 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: OrmGroupByHaving.parse({ + '_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( + contract: contract, + model: 'User', + 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('lowers string operators with LIKE and escaped patterns', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + 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('lowers logical where AND/OR/NOT with field filters', () { + final adapter = SqlAdapter(contract: contract); + final plan = readPlan( + contract: contract, + model: 'User', + 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('sql_logical_operand_edge_semantics_are_deterministic', () { + final adapter = SqlAdapter(contract: contract); + + final emptyOperandStatement = adapter.lower( + readPlan( + contract: contract, + model: 'User', + 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( + readPlan( + contract: contract, + model: 'User', + 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); + final plan = readPlan( + contract: contract, + model: 'User', + 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 = readPlan( + contract: contract, + model: 'Post', + 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', + () { + final adapter = SqlAdapter(contract: contract); + final jsonPayload = { + 'contains': 'literal', + 'profile': 'standard', + }; + final plan = readPlan( + contract: contract, + model: 'User', + 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 = readPlan( + contract: contract, + model: 'User', + 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); + + final createStatement = adapter.lower( + mutationPlan( + contract: contract, + 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( + mutationPlan( + contract: contract, + 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( + mutationPlan( + contract: contract, + model: 'User', + action: OrmAction.delete, + where: {'id': 'u1'}, + ), + ); + expect(deleteStatement.text, 'DELETE FROM "users" WHERE "id" = ?'); + expect(deleteStatement.parameters, ['u1']); + }); + + test('lowers mutation statements with returning clause when enabled', () { + final adapter = SqlAdapter(contract: buildContract()); + + final createDefaultSelect = adapter.lower( + mutationPlan( + contract: contract, + 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( + mutationPlan( + contract: contract, + 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( + mutationPlan( + contract: contract, + 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', () async { + final adapter = SqlAdapter(contract: contract); + + final findMany = adapter.decode( + const SqlResult( + rows: [ + {'id': 'u1'}, + {'id': 'u2'}, + ], + ), + readPlan(contract: contract, model: 'User'), + ); + expect(_collectResponseRows(findMany), completion(isA>())); + + final findUnique = adapter.decode( + const SqlResult( + rows: [ + {'id': 'u1'}, + ], + ), + readPlan( + contract: contract, + model: 'User', + resultMode: OrmReadResultMode.oneOrNull, + ), + ); + expect(await _collectSingleResponseRow(findUnique), { + 'id': 'u1', + }); + + final mutation = adapter.decode( + const SqlResult( + rows: [ + {'id': 'u1'}, + ], + affectedRows: 1, + ), + mutationPlan(contract: contract, model: 'User', action: OrmAction.update), + ); + expect(mutation.affectedRows, 1); + expect(await _collectSingleResponseRow(mutation), { + 'id': 'u1', + }); + }); + + test('applies codec encode for where/data and decode for rows', () async { + 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( + mutationPlan( + contract: contract, + 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'}, + ], + ), + readPlan( + contract: contract, + model: 'User', + resultMode: OrmReadResultMode.oneOrNull, + ), + ); + expect(await _collectSingleResponseRow(decoded), { + 'email': 'app:wire:db@example.com', + 'id': 'u1', + }); + }); + + 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( + readPlan( + contract: contract, + model: 'User', + 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('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( + readPlan( + contract: contract, + model: 'User', + 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', () async { + final adapterWithoutCodec = SqlAdapter(contract: contract); + final adapterWithEmptyCodec = SqlAdapter( + contract: contract, + codecResolver: SqlCodecRegistry(), + ); + + final plan = mutationPlan( + contract: contract, + 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 = readPlan( + contract: contract, + model: 'User', + resultMode: OrmReadResultMode.oneOrNull, + ); + + final decodedWithoutCodec = adapterWithoutCodec.decode( + response, + decodePlan, + ); + final decodedWithEmptyCodec = adapterWithEmptyCodec.decode( + response, + decodePlan, + ); + 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', + () 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 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', + ]); + + 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', + }); + }, + ); + + test('throws when lowering unknown model', () { + final adapter = SqlAdapter(contract: contract); + + expect( + () => 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/sql/sql_marker_reader_test.dart b/pub/orm/test/sql/sql_marker_reader_test.dart new file mode 100644 index 00000000..7846cb7f --- /dev/null +++ b/pub/orm/test/sql/sql_marker_reader_test.dart @@ -0,0 +1,291 @@ +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('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: sqlRuntimeVerifyOptions( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult( + rows: [ + {'storage_hash': 'hash-v1'}, + ], + ), + ), + ), + ); + + await client.connect(); + await expectLater(client.db.orm.model('User').all(), 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: sqlRuntimeVerifyOptions( + executor: CallbackSqlMarkerQueryExecutor( + (_) async => const SqlMarkerQueryResult(), + ), + ), + ); + + await client.connect(); + await expectLater( + client.db.orm.model('User').all(), + 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'}, + ), + }, + ); +} 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); + } +} 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..8030598f --- /dev/null +++ b/pub/orm/test/target/adapter_driver_engine_test.dart @@ -0,0 +1,692 @@ +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('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(), + driver: _TrackingDriver(), + ); + + 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(); + 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:read']); + expect(driver.requests, ['User:read']); + expect(response.affectedRows, 1); + + final rows = await response.rows.toList(); + final row = rows.single; + expect(row, isA>()); + if (row case final Map map) { + expect(map['request'], 'User:read'); + expect(map['action'], 'read'); + expect(map['whereId'], 'u1'); + } else { + fail('Expected map response data.'); + } + + await engine.close(); + }); + + 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'}), + ); + + 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(); + }, + ); + + 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(); + 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:read']); + expect(adapter.decodedRaw, ['connection:User:read']); + expect(response.affectedRows, 1); + + 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(); + }); + + 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( + '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(); + 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:read']); + expect(adapter.decodedRaw, ['transaction:User:read']); + expect(inner.commitCount, 1); + 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(); + 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(); + 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 { + 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 as ExplainCapableEngineTransaction).describePlan(_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 {}}) { + return OrmPlan( + contractHash: 'hash', + model: 'User', + action: OrmAction.read, + read: OrmReadPlan(where: where, resultMode: OrmReadResultMode.all), + ); +} + +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 = []; + + @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.buffered({ + 'request': '${plan.model}:${plan.action.name}', + 'action': plan.action.name, + 'whereId': plan.read?.where['id'], + }, affectedRows: 1); + } +} + +final class _ExplainTrackingAdapter extends _TrackingAdapter + implements ExplainCapableTargetAdapter { + @override + JsonMap describe(OrmPlan plan, String request, {JsonMap? driverExplain}) { + return { + 'source': driverExplain == null ? 'adapter' : 'driver', + 'request': {'kind': 'tracking', 'value': request}, + if (driverExplain != null) 'driver': driverExplain, + }; + } +} + +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; + 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'; + } +} + +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; + 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, + 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 _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, + ExplainCapableTargetDriverConnection { + int releaseCount = 0; + int transactionCount = 0; + final List requests = []; + final List explainRequests = []; + 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; + } + + @override + Future explain(String request) async { + explainRequests.add(request); + return {'scope': 'connection', 'value': request}; + } +} + +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, + ExplainCapableTargetDriverTransaction { + int commitCount = 0; + int rollbackCount = 0; + final List requests = []; + final List explainRequests = []; + + @override + Future commit() async { + commitCount += 1; + } + + @override + Future rollback() async { + rollbackCount += 1; + } + + @override + Future execute(String request) async { + requests.add(request); + return 'transaction:$request'; + } + + @override + Future explain(String request) async { + explainRequests.add(request); + 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; + } + } +} 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: