diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index c7020a7d..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -github: medz -open_collective: openodroe diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 82664118..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: 🐛 Bug Report -description: Report an issue that should be fixed -labels: - - bug - - needs triage -body: - - type: markdown - attributes: - value: | - Thank you for submitting a bug report. It helps make Prisma Dart better. -  - If you need help or support using Prisma Dart, and are not reporting a bug, please - join our [Discord](https://discord.gg/ms2X9TQMR8) server, where you can ask questions in the [`Q&A`](https://github.com/medz/prisma-dart/discussions/categories/q-a) forum. -  - Make sure you are running the [latest](https://github.com/medz/prisma-dart/releases/latest) version of Prisma Dart. - The bug you are experiencing may already have been fixed. -  - Please try to include as much information as possible. - - type: input - attributes: - label: What version of Prisma Dart is running? - description: View the version number of the `orm` package in `pubspec.yaml` - validations: - required: true - - type: input - attributes: - label: What version of Prisma CLI is running? - description: Copy the output of `prisma version`. - validations: - required: true - - type: dropdown - attributes: - label: What type of app are you using? - multiple: false - options: - - Dart Native (Server/CLI) - - Flutter - validations: - required: true - - type: dropdown - attributes: - label: What database are you using? - multiple: true - options: - - SQLite - - MySQL - - MariaDB - - PostgreSQL - - MongoDB - - Microsoft SQL Server - - CockroachDB - - type: textarea - attributes: - label: What steps can reproduce the bug? - description: Explain the bug and provide a code snippet that can reproduce it. - validations: - required: true - - type: textarea - attributes: - label: What is the expected behavior? - description: If possible, please provide text instead of a screenshot. - - type: textarea - attributes: - label: What do you see instead? - description: If possible, please provide text instead of a screenshot. - - type: textarea - attributes: - label: Additional information - description: Is there anything else you think we should know? diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7d..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..608235a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Dart and databases + runs-on: ubuntu-24.04 + timeout-minutes: 45 + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: orm + POSTGRES_PASSWORD: orm + POSTGRES_DB: orm_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready --username=orm --dbname=orm_test" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: orm_root + MYSQL_USER: orm + MYSQL_PASSWORD: orm + MYSQL_DATABASE: orm_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysql --protocol=TCP --host=127.0.0.1 --user=orm --password=orm --database=orm_test --execute='SELECT 1'" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + mariadb: + image: mariadb:11.8 + env: + MARIADB_ROOT_PASSWORD: orm_root + MARIADB_USER: orm + MARIADB_PASSWORD: orm + MARIADB_DATABASE: orm_test + ports: + - 3307:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + env: + ORM_TEST_POSTGRES: postgres://orm:orm@127.0.0.1:5432/orm_test + # Migration recovery tests create an isolated database per test process. + ORM_TEST_MYSQL: mysql://root:orm_root@127.0.0.1:3306/orm_test + ORM_TEST_MARIADB: mariadb://root:orm_root@127.0.0.1:3307/orm_test + # Both containers generate self-signed certificates. Still require TLS. + ORM_TEST_MYSQL_TLS: require + ORM_TEST_MARIADB_TLS: require + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dart-lang/setup-dart@v1 + with: + sdk: 3.13.3 + - name: Install dependencies + run: dart pub get --enforce-lockfile + - name: Check formatting + run: dart format --output=none --set-exit-if-changed bin lib test tool example + - name: Analyze Dart package and examples + # The Flutter example is a separate package with its own SDK dependency. + run: | + for target in bin lib test tool example/*.dart example/migrations example/teams; do + dart analyze "$target" + done + - name: Check committed SQLite web assets + run: dart run tool/build_sqlite_web.dart --check + - name: Test SQLite, PostgreSQL, MySQL and MariaDB + run: dart test --concurrency=1 + + browser: + name: Chrome JS and WASM + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dart-lang/setup-dart@v1 + with: + sdk: 3.13.3 + - name: Install dependencies + run: dart pub get --enforce-lockfile + - name: Locate Chrome + run: echo "CHROME_EXECUTABLE=$(command -v google-chrome)" >> "$GITHUB_ENV" + - name: Test Chrome with JavaScript + run: dart run tool/test_browser.dart + - name: Test Chrome with WebAssembly + run: dart run tool/test_browser.dart --wasm diff --git a/.gitignore b/.gitignore index 1f6f6a90..27919f82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ +.dart_tool/ +build/ +*.sqlite +*.sqlite-shm +*.sqlite-wal .DS_Store .env -.idea/ -.vscode/ -.dart_tool/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d8625108 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Dart ORM + +This branch is a new implementation. The design reference is +`research/new-dart-orm-design.md`; old code and upstream architecture are retired. + +- Keep one product package. Add abstractions only to support real use cases. +- Use Dart 3.13 stable syntax, static generation, explicit sessions and typed selections. +- Table identity is independent of Dart record identity. +- Parameterize values, quote identifiers, and validate SQL scope before execution. +- Make relationship query counts and transaction boundaries observable. +- SQLite and PostgreSQL have separate driver configuration and capability checks. +- Verify behavior against real databases. Mark unverified platforms explicitly. +- Preserve immutable migration history and never infer destructive renames. +- Each migration history fixes one database engine. Save only that engine's SQL, + steps and frozen schema; reject mixed histories and mismatched connections. +- Prefer small Conventional Commits. Do not push without explicit authorization. +- Keep `docs/progress.md` accurate, including unfinished work and validation limits. +- Schema snapshots and saved migrations are Dart source. Retire the old JSON file + workflow directly; do not add compatibility readers or parallel output modes. +- Keep historical migration definitions independent of current application models. + Persist reviewed fingerprints and use static imports for migration registration. + +Commands: `dart run bin/orm.dart`, `dart analyze`, `dart test`. diff --git a/README.md b/README.md index 8bccc2f5..db8435ea 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,181 @@ # Dart ORM -> [!IMPORTANT] -> December 2, 2025: I've decided to restart this project! Please check out 👉 "[Dartorm New Beginning!](https://github.com/medz/dart-orm/issues/479)" +A Dart 3.13 ORM with ordinary immutable Dart models, typed relationships, +composable selections, and explicit database sessions. Declare a class once; +generated queries return that class directly. -> ⚠️ **IMPORTANT NOTICE**: Development of Prisma Dart Client has been suspended until after June 2025 due to Prisma's architectural changes from Rust to TypeScript. Current versions will continue to work with existing Prisma versions but will not be updated to support Prisma v7 and beyond until after the suspension period. For more details, please see the [official announcement](https://github.com/medz/prisma-dart/issues/471). +The implementation has separate real SQLite, PostgreSQL, MySQL, MariaDB, Chrome, Flutter Web and Android +verification records. See [current progress](docs/progress.md), +[capability limits](docs/capabilities.md) and [design acceptance](docs/acceptance.md) +for which revision and scenarios each record covers. -[![Pub Version](https://img.shields.io/pub/v/orm?include_prereleases)](https://pub.dev/packages/orm) -[![GitHub License](https://img.shields.io/github/license/medz/prisma-dart)](https://github.com/medz/prisma-dart/blob/main/LICENSE) -[![Docs website](https://img.shields.io/badge/docs-prisma.pub-brightgreen)](https://prisma.pub/) -[![GitHub Sponsors](https://img.shields.io/github/sponsors/medz?label=github%20sponsors)](https://github.com/sponsors/medz) -[![Open Collective sponsors](https://img.shields.io/opencollective/sponsors/openodroe?label=open%20collective)](https://opencollective.com/openodroe) -[![Discord](https://img.shields.io/discord/1035043284457881620?label=discord)](https://discord.gg/ms2X9TQMR8) -[![X (formerly Twitter) Follow](https://img.shields.io/twitter/follow/shiweidu) -](https://twitter.com/shiweidu) +One package, independent modules and database adapters, no runtime reflection. +This branch is unrelated to earlier ORM implementations. -Prisma Client Dart is an auto-generated type-safe ORM. It uses Prisma Engine as the data access layer and is as consistent as possible with the Prisma Client JS/TS APIs. +For a new application with the `orm` dependency: -👉 [Learn how to use Prisma ORM for Dart in your project](https://prisma.pub/). - -```dart -import 'package:orm/orm.dart'; - -final client = PrismaClient(); - -main() { - final users = await client.user.findMany(); -} -``` - -## Installation - -This will add a like this to you packages `pubspec.yaml` (and run an implicit `dart pub get`): - -```yaml -dependencies: - orm: latest +```sh +dart run orm init --database sqlite +dart run orm migrate create 0001_initial +# Review the generated Dart migration. +dart run orm migrate apply ``` -Or you can run the following command: +Choose `postgres`, `mysql` or `mariadb` to initialize that engine's own history. +The [project CLI](docs/cli.md) creates typed Dart configuration, a nominal model, +the generated client and a static migration registry. Initialization and +generation never connect or apply DDL. + +| Module | Independent use | +| --- | --- | +| `values.dart` | Codecs and precise domain values | +| `driver.dart`, `drivers/*.dart` | Parameterized SQL contracts and database adapters | +| `runtime.dart` | Raw SQL sessions, transactions and cursor lifetimes | +| `schema_model.dart` | Physical table metadata | +| `sql.dart` | Typed query construction and offline SQL compilation | +| `orm.dart` | Typed execution and query subscriptions over `SqlDatabase` | +| `migrate.dart` | Schema inspection, plans and immutable migration execution | +| `schema.dart`, `generate.dart`, `cli.dart` | Declaration, static generation and project tools | + +These are separate Dart libraries with directed dependencies. Use a raw driver +without the ORM, compile a typed query without a connection, or run migrations +without current application models. See [API boundaries](docs/api.md). + +The [SQLite entry point](docs/sqlite-web.md) works on native platforms and the web, +with background execution and the same generated query API. Flutter Web bundles +its worker/WASM resources automatically; plain Dart uses `dart run orm web-assets`. +The [native Flutter example](docs/flutter.md) verifies Android APK upgrades, +background SQLite, persistence and commit-driven query subscriptions. ```sh -dart pub add orm +dart pub get +dart run orm generate example/schema.dart +dart run example/main.dart +dart run example/queries.dart +dart test ``` -## Sponsors - -Prisma Client Dart is an [BSD-3 Clause licensed](https://github.com/medz/prisma-dart/blob/main/LICENSE) open source project with its ongoing development made possible entirely by the support of these awesome backers. If you'd like to join them, please consider [sponsoring Seven(@medz)](https://github.com/sponsors/medz) on GitHub. - -

- - sponsors - -

- -## Documentation - -You can find the Prisma Client Dart [on the website](https://prisma.pub). +For incremental generation, enable `orm:orm` for explicit schema roots in +`build.yaml` and run `dart run build_runner watch`. See +[generation and builds](docs/generation.md) for setup, dependency tracking and +reproducible generation measurements. -The documentation is divided into the following sections: +Declare data once in [schema.dart](example/schema.dart): -- [Getting Started](https://prisma.pub/getting-started/) - - [Setup & Configuration](https://prisma.pub/getting-started/setup.html) - - [Prisma Schema](https://prisma.pub/getting-started/schema.html) -- Queries - - [CRUD](https://prisma.pub/queries/crud.html) - - [Select Fields](https://prisma.pub/queries/select-fields.html) - - [Relation queries](https://prisma.pub/queries/relation-queries.html) - - [Filtering and Sorting](https://prisma.pub/queries/filtering-and-sorting.html) - - [Pagination](https://prisma.pub/queries/pagination.html) - - [Aggregation, grouping, and summarizing](https://prisma.pub/queries/aggregation-grouping-summarizing.html) - - [Transactions](https://prisma.pub/queries/transactions.html) - - [Raw database access](https://prisma.pub/queries/raw-database-access.html) - -> You can improve it by sending pull requests to [`docs` folder in the `main` branch](https://github.com/odroe/prisma-dart/tree/main/docs). - -## Examples - -You can also find them in the [`example` folder in the `main` branch](https://github.com/odroe/prisma-dart/tree/main/examples). - -## Query engine support matrix - -| Engine | Dart Native | Dart Web | Flutter Native | Flutter Web | -| ---------- | :---------: | :------: | :------------: | :---------: | -| Binary | ✅ | ❌ | ❌ | ❌ | -| Library | ✅ | ❌ | ✅ | ❌ | -| Data Proxy | ✅ | ✅ | ✅ | ✅ | - -## Contributing - -We welcome contributions! Please read our [contributing guide](CONTRIBUTING.md) to learn about our development process, how to propose bugfixes and improvements, and how to build and test your changes to Prisma. - -Thank you to all the people who already contributed to Prisma Dart! +```dart +final class User({ + @Id.generated() required final int id, + @Unique() required final String email, + required final String? nickname, +}); +final users = entity(); +``` -[![Contributors](https://contrib.rocks/image?repo=medz/prisma-dart)](https://github.com/odroe/prisma-dart/graphs/contributors) +Import the generated client and a driver. The client exports `User` and the driver +exports the portable query API: -## Code of Conduct +```dart +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; +import 'schema.orm.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: .sqlite), + ]); + final User user = await db.users.create(email: 'seven@example.com'); + await db.users.byId(user.id).patch(nickname: .set('Seven')); + final List emails = await db.users.select((u) => u.email).get(); + print(emails); + } finally { + await db.close(); + } +} +``` -This project has adopted the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). For more information see the [Code of Conduct FAQ](https://www.contributor-covenant.org/faq) or contact [hello@odroe.com](mailto:hello@odroe.com) with any additional questions or comments. +For PostgreSQL, import `postgres.dart` and use +`postgres(PostgresOptions(url: url))`; TLS certificate verification is the default. +[MySQL and MariaDB](docs/mysql.md) use `mysql.dart` / `mariadb.dart` with +`await mysql(MysqlOptions(url: url))` / `await mariadb(MariadbOptions(url: url))`. +Each backend has its own transaction options and migration history. Choose the +engine when initializing that history, and keep its reviewed Dart migrations and +static registry in version control. A connection change does not translate history. + +The example above creates a temporary in-memory schema. The persistent +[migration entrypoint](example/migrate.dart) fixes SQLite: run +`dart run example/migrate.dart check`, then set `ORM_SQLITE_PATH` before `apply` or +`verify`. A PostgreSQL project creates its own PostgreSQL history and connection +entrypoint. See [migrations](docs/migrations.md) and [resumable backfills](docs/backfills.md). + +Read [API and mental model](docs/api.md) for imports, current declarations versus +historical schemas, prepared versus executed operations, selection nullability, +and transaction ownership. Inside a transaction, build every query from `tx`; +subqueries, CTEs and UNION operands must share that same view. + +For an existing database, [import a model declaration](docs/importing.md), review +its report, generate the client, and baseline the current schema without copying +existing rows. + +Declare [row CHECK constraints](docs/checks.md) with explicit SQL and optional +backend overrides; generated snapshots support catalog verification, import and +reviewed constraint migrations. + +Use [client defaults](docs/defaults.md) for typed Dart value factories and SQL +defaults for database-generated values, with explicit omission/value/default inputs. +Declare [computed columns](docs/computed.md) for database expressions with typed +read-only results, explicit stored/virtual modes and reviewed migrations. + +Model [many-to-many memberships](docs/relations.md#many-to-many-with-business-fields) +with an explicit association table, typed business fields and per-parent pagination. +Run `dart run example/teams/main.dart` to see its selected records and SQL counts. + +Use `query.inspect()` for SQL templates, selected columns, joins and conditional +relation batches without connecting. Optional `onAcquire`, `onQuery` and `onDecode` +callbacks measure execution phases. See [plans and observations](docs/observability.md). + +For complex SQL files, [generate named queries](docs/named-sql.md) with typed +Record parameters/results, native database structure checks and the same query +composition, transaction and streaming APIs. + +Use `query.stream(batchSize: 128)` with `await for` to read through a database +cursor. Reads and mutations accept `ExecutionOptions` for connection acquisition +limits, statement deadlines and cancellation. See [streaming and execution](docs/execution.md) for connection +lifetime, batch sizing, transaction-wide deadlines and failure outcomes. + +The [runtime cost report](docs/performance.md) compares the same driver, SQL and +result shapes across SQLite, local PostgreSQL and a controlled TCP delay. It +separates normal timing from acquisition probes, live heap and allocation traces. + +Single relationships use JOINs when declared keys prove uniqueness; collections +load in parameter-aware batches. Both support typed nested selections. See +[relationship strategies](docs/relations.md) for composite keys, per-parent +pagination and explicit `.join`/`.batch` choices. + +Domain IDs, custom classes, record values and enums retain their types in generated +APIs. Declare public const codecs with `@UseCodec`; use `@EnumValue` for stable +stored labels. See [types and JSON](docs/types.md) for codec validation, nullable +values and the distinction between SQL NULL and JSON null. + +Use `query.watch()` for typed snapshots after relevant committed writes. +Transactions merge notifications; rollbacks do not notify. See +[query subscriptions](docs/watch.md) for relation dependencies, pause/cancellation, +and explicit notifications for raw SQL or external writers. + +The [query cookbook](example/queries.dart) runs filters, joined ordering, relation +counts, grouped CTEs, windows, subqueries and cursor pagination. See +[query usage](docs/queries.md) for the API and PostgreSQL example configuration. + +Combine scalar or `.row` projections with `union`/`unionAll`, then map the +result to a Record or DTO. Sets support typed exported columns, CTEs, streaming +and subscriptions. See [queries](docs/queries.md) for scope, +nullability and codec requirements. + +Set `ORM_TEST_POSTGRES` to a **disposable** local PostgreSQL database to include +PostgreSQL integration tests. The tests create and drop their own test tables. +Set `ORM_TEST_MYSQL` and `ORM_TEST_MARIADB` for their live suites; migration +recovery tests additionally create and drop isolated databases. Use dedicated test +servers and credentials with the necessary privileges. Their TLS setting defaults +to `verifyFull`; self-signed local fixtures can explicitly set +`ORM_TEST_MYSQL_TLS=require` / `ORM_TEST_MARIADB_TLS=require`. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 18551f46..00000000 --- a/SECURITY.md +++ /dev/null @@ -1,18 +0,0 @@ -# Security Policy - -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 3.x | :white_check_mark: | -| < 3.0 | :x: | - -## Reporting a Vulnerability - -A security hole is different from a common error, For bugs that affect data security, we recommend reporting through the following channels: - -1. [Twitter message](https://twitter.com/odroeinc) -2. [Email](mailto:hello@odroe.com) diff --git a/analysis_options.yaml b/analysis_options.yaml index 572dd239..dc50580e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,8 @@ include: package:lints/recommended.yaml +analyzer: + exclude: + - research/** + language: + strict-casts: true + strict-inference: true + strict-raw-types: true diff --git a/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm b/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm new file mode 100644 index 00000000..03e9d20e Binary files /dev/null and b/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm differ diff --git a/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js b/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js new file mode 100644 index 00000000..0ecd6231 --- /dev/null +++ b/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js @@ -0,0 +1,6520 @@ +(function dartProgram(){function copyProperties(a,b){var s=Object.keys(a) +for(var r=0;r=0)return true +if(typeof version=="function"&&version.length==0){var q=version() +if(/^\d+\.\d+\.\d+\.\d+$/.test(q))return true}}catch(p){}return false}() +function inherit(a,b){a.prototype.constructor=a +a.prototype["$i"+a.name]=a +if(b!=null){if(z){Object.setPrototypeOf(a.prototype,b.prototype) +return}var s=Object.create(b.prototype) +copyProperties(a.prototype,s) +a.prototype=s}}function inheritMany(a,b){for(var s=0;s4294967295)throw A.d(A.P(a,0,4294967295,"length",null)) +return J.kN(new Array(a),b)}, +kN(a,b){var s=A.x(a,b.h("r<0>")) +s.$flags=1 +return s}, +bo(a){if(typeof a=="number"){if(Math.floor(a)==a)return J.c0.prototype +return J.d6.prototype}if(typeof a=="string")return J.aS.prototype +if(a==null)return J.c1.prototype +if(typeof a=="boolean")return J.d5.prototype +if(Array.isArray(a))return J.r.prototype +if(typeof a!="object"){if(typeof a=="function")return J.aB.prototype +if(typeof a=="symbol")return J.bw.prototype +if(typeof a=="bigint")return J.U.prototype +return a}if(a instanceof A.e)return a +return J.hd(a)}, +cL(a){if(typeof a=="string")return J.aS.prototype +if(a==null)return a +if(Array.isArray(a))return J.r.prototype +if(typeof a!="object"){if(typeof a=="function")return J.aB.prototype +if(typeof a=="symbol")return J.bw.prototype +if(typeof a=="bigint")return J.U.prototype +return a}if(a instanceof A.e)return a +return J.hd(a)}, +e2(a){if(a==null)return a +if(Array.isArray(a))return J.r.prototype +if(typeof a!="object"){if(typeof a=="function")return J.aB.prototype +if(typeof a=="symbol")return J.bw.prototype +if(typeof a=="bigint")return J.U.prototype +return a}if(a instanceof A.e)return a +return J.hd(a)}, +mJ(a){if(typeof a=="number")return J.bv.prototype +if(typeof a=="string")return J.aS.prototype +if(a==null)return a +if(!(a instanceof A.e))return J.bg.prototype +return a}, +mK(a){if(typeof a=="string")return J.aS.prototype +if(a==null)return a +if(!(a instanceof A.e))return J.bg.prototype +return a}, +jG(a){if(a==null)return a +if(typeof a!="object"){if(typeof a=="function")return J.aB.prototype +if(typeof a=="symbol")return J.bw.prototype +if(typeof a=="bigint")return J.U.prototype +return a}if(a instanceof A.e)return a +return J.hd(a)}, +ab(a,b){if(a==null)return b==null +if(typeof a!="object")return b!=null&&a===b +return J.bo(a).I(a,b)}, +kk(a,b){return J.e2(a).t(a,b)}, +kl(a){return J.jG(a).bt(a)}, +ii(a,b,c){return J.jG(a).bu(a,b,c)}, +km(a,b){return J.mJ(a).m(a,b)}, +ij(a,b){return J.e2(a).H(a,b)}, +ag(a){return J.bo(a).gu(a)}, +cN(a){return J.e2(a).gG(a)}, +ac(a){return J.cL(a).gk(a)}, +kn(a){return J.bo(a).gF(a)}, +ko(a,b,c){return J.e2(a).ak(a,b,c)}, +kp(a,b){return J.e2(a).aq(a,b)}, +hv(a,b,c){return J.mK(a).a1(a,b,c)}, +b4(a){return J.bo(a).i(a)}, +d3:function d3(){}, +d5:function d5(){}, +c1:function c1(){}, +c2:function c2(){}, +aT:function aT(){}, +dl:function dl(){}, +bg:function bg(){}, +aB:function aB(){}, +U:function U(){}, +bw:function bw(){}, +r:function r(a){this.$ti=a}, +d4:function d4(){}, +ey:function ey(a){this.$ti=a}, +bR:function bR(a,b,c){var _=this +_.a=a +_.b=b +_.c=0 +_.d=null +_.$ti=c}, +bv:function bv(){}, +c0:function c0(){}, +d6:function d6(){}, +aS:function aS(){}},A={hC:function hC(){}, +iI(a){return new A.bx("Field '"+a+"' has not been initialized.")}, +kQ(a){return new A.bx("Field '"+a+"' has already been initialized.")}, +aX(a,b){a=a+b&536870911 +a=a+((a&524287)<<10)&536870911 +return a^a>>>6}, +hL(a){a=a+((a&67108863)<<3)&536870911 +a^=a>>>11 +return a+((a&16383)<<15)&536870911}, +h9(a,b,c){return a}, +i9(a){var s,r +for(s=$.a9.length,r=0;rc)A.u(A.P(b,0,c,"start",null))}return new A.cn(a,b,c,d.h("cn<0>"))}, +kU(a,b,c,d){if(t.O.b(a))return new A.bV(a,b,c.h("@<0>").E(d).h("bV<1,2>")) +return new A.bb(a,b,c.h("@<0>").E(d).h("bb<1,2>"))}, +l6(a,b,c){var s="count" +if(t.O.b(a)){A.hw(b,s,t.S) +A.bA(b,s) +return new A.bW(a,b,c.h("bW<0>"))}A.hw(b,s,t.S) +A.bA(b,s) +return new A.bd(a,b,c.h("bd<0>"))}, +ex(){return new A.be("No element")}, +iE(){return new A.be("Too many elements")}, +kL(){return new A.be("Too few elements")}, +bx:function bx(a){this.a=a}, +eP:function eP(){}, +i:function i(){}, +V:function V(){}, +cn:function cn(a,b,c,d){var _=this +_.a=a +_.b=b +_.c=c +_.$ti=d}, +b9:function b9(a,b,c){var _=this +_.a=a +_.b=b +_.c=0 +_.d=null +_.$ti=c}, +bb:function bb(a,b,c){this.a=a +this.b=b +this.$ti=c}, +bV:function bV(a,b,c){this.a=a +this.b=b +this.$ti=c}, +c6:function c6(a,b,c){var _=this +_.a=null +_.b=a +_.c=b +_.$ti=c}, +a5:function a5(a,b,c){this.a=a +this.b=b +this.$ti=c}, +cp:function cp(a,b,c){this.a=a +this.b=b +this.$ti=c}, +bh:function bh(a,b,c){this.a=a +this.b=b +this.$ti=c}, +bd:function bd(a,b,c){this.a=a +this.b=b +this.$ti=c}, +bW:function bW(a,b,c){this.a=a +this.b=b +this.$ti=c}, +ch:function ch(a,b,c){this.a=a +this.b=b +this.$ti=c}, +T:function T(){}, +ce:function ce(a,b){this.a=a +this.$ti=b}, +jU(a){var s=A.jT(a) +if(s!=null)return s +return"minified:"+a}, +nP(a,b){var s +if(b!=null){s=b.x +if(s!=null)return s}return t.aU.b(a)}, +n(a){var s +if(typeof a=="string")return a +if(typeof a=="number"){if(a!==0)return""+a}else if(!0===a)return"true" +else if(!1===a)return"false" +else if(a==null)return"null" +s=J.b4(a) +return s}, +dm(a){var s,r=$.iR +if(r==null)r=$.iR=Symbol("identityHashCode") +s=a[r] +if(s==null){s=Math.random()*0x3fffffff|0 +a[r]=s}return s}, +iS(a,b){var s,r=/^\s*[+-]?((0x[a-f0-9]+)|(\d+)|([a-z0-9]+))\s*$/i.exec(a) +if(r==null)return null +if(3>=r.length)return A.b(r,3) +s=r[3] +if(s!=null)return parseInt(a,10) +if(r[2]!=null)return parseInt(a,16) +return null}, +dn(a){var s,r,q,p +if(a instanceof A.e)return A.Y(A.aw(a),null) +s=J.bo(a) +if(s===B.a8||s===B.aa||t.ak.b(a)){r=B.p(a) +if(r!=="Object"&&r!=="")return r +q=a.constructor +if(typeof q=="function"){p=q.name +if(typeof p=="string"&&p!=="Object"&&p!=="")return p}}return A.Y(A.aw(a),null)}, +iT(a){var s,r,q +if(a==null||typeof a=="number"||A.cI(a))return J.b4(a) +if(typeof a=="string")return JSON.stringify(a) +if(a instanceof A.aP)return a.i(0) +if(a instanceof A.aH)return a.bq(!0) +s=$.kj() +for(r=0;r<1;++r){q=s[r].du(a) +if(q!=null)return q}return"Instance of '"+A.dn(a)+"'"}, +kY(a,b,c){var s,r,q,p +if(c<=500&&b===0&&c===a.length)return String.fromCharCode.apply(null,a) +for(s=b,r="";s>>0,s&1023|56320)}throw A.d(A.P(a,0,1114111,null,null))}, +kZ(a,b,c,d,e,f,g,h,i){var s,r,q,p=b-1 +if(0<=a&&a<100){a+=400 +p-=4800}s=B.a.n(h,1000) +r=Date.UTC(a,p,c,d,e,f,g+B.a.l(h-s,1000)) +q=!0 +if(!isNaN(r))if(!(r<-864e13))if(!(r>864e13))q=r===864e13&&s!==0 +if(q)return null +return r}, +W(a){if(a.date===void 0)a.date=new Date(a.a) +return a.date}, +eO(a){return a.c?A.W(a).getUTCFullYear()+0:A.W(a).getFullYear()+0}, +eM(a){return a.c?A.W(a).getUTCMonth()+1:A.W(a).getMonth()+1}, +eJ(a){return a.c?A.W(a).getUTCDate()+0:A.W(a).getDate()+0}, +eK(a){return a.c?A.W(a).getUTCHours()+0:A.W(a).getHours()+0}, +eL(a){return a.c?A.W(a).getUTCMinutes()+0:A.W(a).getMinutes()+0}, +eN(a){return a.c?A.W(a).getUTCSeconds()+0:A.W(a).getSeconds()+0}, +hJ(a){return a.c?A.W(a).getUTCMilliseconds()+0:A.W(a).getMilliseconds()+0}, +kX(a){return B.a.n((a.c?A.W(a).getUTCDay()+0:A.W(a).getDay()+0)+6,7)+1}, +kW(a){var s=a.$thrownJsError +if(s==null)return null +return A.bp(s)}, +iU(a,b){var s +if(a.$thrownJsError==null){s=new Error() +A.C(a,s) +a.$thrownJsError=s +s.stack=b.i(0)}}, +he(a){throw A.d(A.i4(a))}, +b(a,b){if(a==null)J.ac(a) +throw A.d(A.ha(a,b))}, +ha(a,b){var s,r="index",q=null +if(!A.bJ(b))return new A.ah(!0,b,r,q) +s=J.ac(a) +if(b<0||b>=s)return A.ew(b,s,a,q,r) +return new A.aC(q,q,!0,b,r,"Value not in range")}, +mG(a,b,c){if(a>c)return A.P(a,0,c,"start",null) +if(b!=null)if(bc)return A.P(b,a,c,"end",null) +return new A.ah(!0,b,"end",null)}, +i4(a){return new A.ah(!0,a,null,null)}, +d(a){return A.C(a,new Error())}, +C(a,b){var s +if(a==null)a=new A.aE() +b.dartException=a +s=A.n1 +if("defineProperty" in Object){Object.defineProperty(b,"message",{get:s}) +b.name=""}else b.toString=s +return b}, +n1(){return J.b4(this.dartException)}, +u(a,b){throw A.C(a,b==null?new Error():b)}, +l(a,b,c){var s +if(b==null)b=0 +if(c==null)c=0 +s=Error() +A.u(A.lY(a,b,c),s)}, +lY(a,b,c){var s,r,q,p,o,n,m,l,k +if(typeof b=="string")s=b +else{r="[]=;add;removeWhere;retainWhere;removeRange;setRange;setInt8;setInt16;setInt32;setUint8;setUint16;setUint32;setFloat32;setFloat64".split(";") +q=r.length +p=b +if(p>q){c=p/q|0 +p%=q}s=r[p]}o=typeof c=="string"?c:"modify;remove from;add to".split(";")[c] +n=t.b.b(a)?"list":"ByteData" +m=a.$flags|0 +l="a " +if((m&4)!==0)k="constant " +else if((m&2)!==0){k="unmodifiable " +l="an "}else k=(m&1)!==0?"fixed-length ":"" +return new A.co("'"+s+"': Cannot "+o+" "+l+k+n)}, +hr(a){throw A.d(A.aQ(a))}, +aF(a){var s,r,q,p,o,n +a=A.jM(a.replace(String({}),"$receiver$")) +s=a.match(/\\\$[a-zA-Z]+\\\$/g) +if(s==null)s=A.x([],t.s) +r=s.indexOf("\\$arguments\\$") +q=s.indexOf("\\$argumentsExpr\\$") +p=s.indexOf("\\$expr\\$") +o=s.indexOf("\\$method\\$") +n=s.indexOf("\\$receiver\\$") +return new A.f_(a.replace(new RegExp("\\\\\\$arguments\\\\\\$","g"),"((?:x|[^x])*)").replace(new RegExp("\\\\\\$argumentsExpr\\\\\\$","g"),"((?:x|[^x])*)").replace(new RegExp("\\\\\\$expr\\\\\\$","g"),"((?:x|[^x])*)").replace(new RegExp("\\\\\\$method\\\\\\$","g"),"((?:x|[^x])*)").replace(new RegExp("\\\\\\$receiver\\\\\\$","g"),"((?:x|[^x])*)"),r,q,p,o,n)}, +f0(a){return function($expr$){var $argumentsExpr$="$arguments$" +try{$expr$.$method$($argumentsExpr$)}catch(s){return s.message}}(a)}, +iY(a){return function($expr$){try{$expr$.$method$}catch(s){return s.message}}(a)}, +hD(a,b){var s=b==null,r=s?null:b.method +return new A.d8(a,r,s?null:b.receiver)}, +I(a){var s +if(a==null)return new A.eF(a) +if(a instanceof A.bY){s=a.a +return A.b3(a,s==null?A.bl(s):s)}if(typeof a!=="object")return a +if("dartException" in a)return A.b3(a,a.dartException) +return A.my(a)}, +b3(a,b){if(t.Q.b(b))if(b.$thrownJsError==null)b.$thrownJsError=a +return b}, +my(a){var s,r,q,p,o,n,m,l,k,j,i,h,g +if(!("message" in a))return a +s=a.message +if("number" in a&&typeof a.number=="number"){r=a.number +q=r&65535 +if((B.a.B(r,16)&8191)===10)switch(q){case 438:return A.b3(a,A.hD(A.n(s)+" (Error "+q+")",null)) +case 445:case 5007:A.n(s) +return A.b3(a,new A.cb())}}if(a instanceof TypeError){p=$.k2() +o=$.k3() +n=$.k4() +m=$.k5() +l=$.k8() +k=$.k9() +j=$.k7() +$.k6() +i=$.kb() +h=$.ka() +g=p.N(s) +if(g!=null)return A.b3(a,A.hD(A.A(s),g)) +else{g=o.N(s) +if(g!=null){g.method="call" +return A.b3(a,A.hD(A.A(s),g))}else if(n.N(s)!=null||m.N(s)!=null||l.N(s)!=null||k.N(s)!=null||j.N(s)!=null||m.N(s)!=null||i.N(s)!=null||h.N(s)!=null){A.A(s) +return A.b3(a,new A.cb())}}return A.b3(a,new A.dw(typeof s=="string"?s:""))}if(a instanceof RangeError){if(typeof s=="string"&&s.indexOf("call stack")!==-1)return new A.cl() +s=function(b){try{return String(b)}catch(f){}return null}(a) +return A.b3(a,new A.ah(!1,null,null,typeof s=="string"?s.replace(/^RangeError:\s*/,""):s))}if(typeof InternalError=="function"&&a instanceof InternalError)if(typeof s=="string"&&s==="too much recursion")return new A.cl() +return a}, +bp(a){var s +if(a instanceof A.bY)return a.b +if(a==null)return new A.cA(a) +s=a.$cachedTrace +if(s!=null)return s +s=new A.cA(a) +if(typeof a==="object")a.$cachedTrace=s +return s}, +jJ(a){if(a==null)return J.ag(a) +if(typeof a=="object")return A.dm(a) +return J.ag(a)}, +m8(a,b,c,d,e,f){t.Z.a(a) +switch(A.c(b)){case 0:return a.$0() +case 1:return a.$1(c) +case 2:return a.$2(c,d) +case 3:return a.$3(c,d,e) +case 4:return a.$4(c,d,e,f)}throw A.d(A.iz("Unsupported number of arguments for wrapped closure"))}, +bN(a,b){var s +if(a==null)return null +s=a.$identity +if(!!s)return s +s=A.mE(a,b) +a.$identity=s +return s}, +mE(a,b){var s +switch(b){case 0:s=a.$0 +break +case 1:s=a.$1 +break +case 2:s=a.$2 +break +case 3:s=a.$3 +break +case 4:s=a.$4 +break +default:s=null}if(s!=null)return s.bind(a) +return function(c,d,e){return function(f,g,h,i){return e(c,d,f,g,h,i)}}(a,b,A.m8)}, +kw(a2){var s,r,q,p,o,n,m,l,k,j,i=a2.co,h=a2.iS,g=a2.iI,f=a2.nDA,e=a2.aI,d=a2.fs,c=a2.cs,b=d[0],a=c[0],a0=i[b],a1=a2.fT +a1.toString +s=h?Object.create(new A.dt().constructor.prototype):Object.create(new A.br(null,null).constructor.prototype) +s.$initialize=s.constructor +r=h?function static_tear_off(){this.$initialize()}:function tear_off(a3,a4){this.$initialize(a3,a4)} +s.constructor=r +r.prototype=s +s.$_name=b +s.$_target=a0 +q=!h +if(q)p=A.is(b,a0,g,f) +else{s.$static_name=b +p=a0}s.$S=A.ks(a1,h,g) +s[a]=p +for(o=p,n=1;n=0)return a.replace(/\$/g,"$$$$") +return a}, +jM(a){if(/[[\]{}()*+?.\\^$|]/.test(a))return a.replace(/[[\]{}()*+?.\\^$|]/g,"\\$&") +return a}, +mZ(a,b,c){var s=A.n_(a,b,c) +return s}, +n_(a,b,c){var s,r,q +if(b===""){if(a==="")return c +s=a.length +for(r=c,q=0;q=0)return a.split(b).join(c) +return a.replace(new RegExp(A.jM(b),"g"),A.mH(c))}, +cy:function cy(a,b){this.a=a +this.b=b}, +bF:function bF(a,b){this.a=a +this.b=b}, +cz:function cz(a,b){this.a=a +this.b=b}, +bT:function bT(){}, +bU:function bU(a,b,c){this.a=a +this.b=b +this.$ti=c}, +cf:function cf(){}, +f_:function f_(a,b,c,d,e,f){var _=this +_.a=a +_.b=b +_.c=c +_.d=d +_.e=e +_.f=f}, +cb:function cb(){}, +d8:function d8(a,b,c){this.a=a +this.b=b +this.c=c}, +dw:function dw(a){this.a=a}, +eF:function eF(a){this.a=a}, +bY:function bY(a,b){this.a=a +this.b=b}, +cA:function cA(a){this.a=a +this.b=null}, +aP:function aP(){}, +cS:function cS(){}, +cT:function cT(){}, +du:function du(){}, +dt:function dt(){}, +br:function br(a,b){this.a=a +this.b=b}, +dr:function dr(a){this.a=a}, +c3:function c3(a){var _=this +_.a=0 +_.f=_.e=_.d=_.c=_.b=null +_.r=0 +_.$ti=a}, +ez:function ez(a,b){var _=this +_.a=a +_.b=b +_.d=_.c=null}, +c5:function c5(a,b){this.a=a +this.$ti=b}, +c4:function c4(a,b,c,d){var _=this +_.a=a +_.b=b +_.c=c +_.d=null +_.$ti=d}, +eA:function eA(a,b){this.a=a +this.$ti=b}, +b8:function b8(a,b,c,d){var _=this +_.a=a +_.b=b +_.c=c +_.d=null +_.$ti=d}, +hf:function hf(a){this.a=a}, +hg:function hg(a){this.a=a}, +hh:function hh(a){this.a=a}, +aH:function aH(){}, +b0:function b0(){}, +d7:function d7(a,b){var _=this +_.a=a +_.b=b +_.e=_.d=_.c=null}, +fA:function fA(a){this.b=a}, +n0(a){throw A.C(new A.bx("Field '"+a+"' has been assigned during initialization."),new Error())}, +D(){throw A.C(A.iI(""),new Error())}, +jS(){throw A.C(A.kQ(""),new Error())}, +fl(a){var s=new A.fk(a) +return s.b=s}, +fk:function fk(a){this.a=a +this.b=null}, +lW(a){return a}, +cH(a,b,c){}, +lZ(a){return a}, +iO(a,b,c){var s +A.cH(a,b,c) +s=new DataView(a,b) +return s}, +ak(a,b,c){A.cH(a,b,c) +c=B.a.l(a.byteLength-b,4) +return new Int32Array(a,b,c)}, +kV(a,b,c){A.cH(a,b,c) +return new Uint32Array(a,b,c)}, +iP(a){return new Uint8Array(a)}, +ar(a,b,c){A.cH(a,b,c) +return c==null?new Uint8Array(a,b):new Uint8Array(a,b,c)}, +aL(a,b,c){if(a>>>0!==a||a>=c)throw A.d(A.ha(b,a))}, +lX(a,b,c){var s +if(!(a>>>0!==a))s=b>>>0!==b||a>b||b>c +else s=!0 +if(s)throw A.d(A.mG(a,b,c)) +return b}, +aU:function aU(){}, +by:function by(){}, +c9:function c9(){}, +fJ:function fJ(a){this.a=a}, +c7:function c7(){}, +L:function L(){}, +c8:function c8(){}, +a6:function a6(){}, +dc:function dc(){}, +dd:function dd(){}, +de:function de(){}, +df:function df(){}, +dg:function dg(){}, +dh:function dh(){}, +di:function di(){}, +ca:function ca(){}, +bc:function bc(){}, +cu:function cu(){}, +cv:function cv(){}, +cw:function cw(){}, +cx:function cx(){}, +hK(a,b){var s=b.c +return s==null?b.c=A.cC(a,"ai",[b.x]):s}, +iV(a){var s=a.w +if(s===6||s===7)return A.iV(a.x) +return s===11||s===12}, +l5(a){return a.as}, +b2(a){return A.fI(v.typeUniverse,a,!1)}, +bm(a1,a2,a3,a4){var s,r,q,p,o,n,m,l,k,j,i,h,g,f,e,d,c,b,a,a0=a2.w +switch(a0){case 5:case 1:case 2:case 3:case 4:return a2 +case 6:s=a2.x +r=A.bm(a1,s,a3,a4) +if(r===s)return a2 +return A.jf(a1,r,!0) +case 7:s=a2.x +r=A.bm(a1,s,a3,a4) +if(r===s)return a2 +return A.je(a1,r,!0) +case 8:q=a2.y +p=A.bL(a1,q,a3,a4) +if(p===q)return a2 +return A.cC(a1,a2.x,p) +case 9:o=a2.x +n=A.bm(a1,o,a3,a4) +m=a2.y +l=A.bL(a1,m,a3,a4) +if(n===o&&l===m)return a2 +return A.hW(a1,n,l) +case 10:k=a2.x +j=a2.y +i=A.bL(a1,j,a3,a4) +if(i===j)return a2 +return A.jg(a1,k,i) +case 11:h=a2.x +g=A.bm(a1,h,a3,a4) +f=a2.y +e=A.mv(a1,f,a3,a4) +if(g===h&&e===f)return a2 +return A.jd(a1,g,e) +case 12:d=a2.y +a4+=d.length +c=A.bL(a1,d,a3,a4) +o=a2.x +n=A.bm(a1,o,a3,a4) +if(c===d&&n===o)return a2 +return A.hX(a1,n,c,!0) +case 13:b=a2.x +if(b=p)return A.b(q,0) +s=A.cE(v.typeUniverse,A.i3(q[0]),"@<0>") +for(r=1;r=0)p+=" "+r[q];++q}return p+"})"}, +jt(a3,a4,a5){var s,r,q,p,o,n,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1=", ",a2=null +if(a5!=null){s=a5.length +if(a4==null)a4=A.x([],t.s) +else a2=a4.length +r=a4.length +for(q=s;q>0;--q)B.b.t(a4,"T"+(r+q)) +for(p=t.X,o="<",n="",q=0;q=0))return A.b(a4,l) +o=o+n+a4[l] +k=a5[q] +j=k.w +if(!(j===2||j===3||j===4||j===5||k===p))o+=" extends "+A.Y(k,a4)}o+=">"}else o="" +p=a3.x +i=a3.y +h=i.a +g=h.length +f=i.b +e=f.length +d=i.c +c=d.length +b=A.Y(p,a4) +for(a="",a0="",q=0;q0){a+=a0+"[" +for(a0="",q=0;q0){a+=a0+"{" +for(a0="",q=0;q "+b}, +Y(a,b){var s,r,q,p,o,n,m,l=a.w +if(l===5)return"erased" +if(l===2)return"dynamic" +if(l===3)return"void" +if(l===1)return"Never" +if(l===4)return"any" +if(l===6){s=a.x +r=A.Y(s,b) +q=s.w +return(q===11||q===12?"("+r+")":r)+"?"}if(l===7)return"FutureOr<"+A.Y(a.x,b)+">" +if(l===8){p=A.mx(a.x) +o=a.y +return o.length>0?p+("<"+A.jA(o,b)+">"):p}if(l===10)return A.mm(a,b) +if(l===11)return A.jt(a,b,null) +if(l===12)return A.jt(a.x,b,a.y) +if(l===13){n=a.x +m=b.length +n=m-1-n +if(!(n>=0&&n0)p+="<"+A.cB(c)+">" +s=a.eC.get(p) +if(s!=null)return s +r=new A.al(null,null) +r.w=8 +r.x=b +r.y=c +if(c.length>0)r.c=c[0] +r.as=p +q=A.b1(a,r) +a.eC.set(p,q) +return q}, +hW(a,b,c){var s,r,q,p,o,n +if(b.w===9){s=b.x +r=b.y.concat(c)}else{r=c +s=b}q=s.as+(";<"+A.cB(r)+">") +p=a.eC.get(q) +if(p!=null)return p +o=new A.al(null,null) +o.w=9 +o.x=s +o.y=r +o.as=q +n=A.b1(a,o) +a.eC.set(q,n) +return n}, +jg(a,b,c){var s,r,q="+"+(b+"("+A.cB(c)+")"),p=a.eC.get(q) +if(p!=null)return p +s=new A.al(null,null) +s.w=10 +s.x=b +s.y=c +s.as=q +r=A.b1(a,s) +a.eC.set(q,r) +return r}, +jd(a,b,c){var s,r,q,p,o,n=b.as,m=c.a,l=m.length,k=c.b,j=k.length,i=c.c,h=i.length,g="("+A.cB(m) +if(j>0){s=l>0?",":"" +g+=s+"["+A.cB(k)+"]"}if(h>0){s=l>0?",":"" +g+=s+"{"+A.lA(i)+"}"}r=n+(g+")") +q=a.eC.get(r) +if(q!=null)return q +p=new A.al(null,null) +p.w=11 +p.x=b +p.y=c +p.as=r +o=A.b1(a,p) +a.eC.set(r,o) +return o}, +hX(a,b,c,d){var s,r=b.as+("<"+A.cB(c)+">"),q=a.eC.get(r) +if(q!=null)return q +s=A.lC(a,b,c,r,d) +a.eC.set(r,s) +return s}, +lC(a,b,c,d,e){var s,r,q,p,o,n,m,l +if(e){s=c.length +r=A.fN(s) +for(q=0,p=0;p0){n=A.bm(a,b,r,0) +m=A.bL(a,c,r,0) +return A.hX(a,n,m,c!==m)}}l=new A.al(null,null) +l.w=12 +l.x=b +l.y=c +l.as=d +return A.b1(a,l)}, +lq(a,b,c,d){return{u:a,e:b,r:c,s:[],p:0,n:d}}, +lw(a){var s,r,q,p,o,n,m,l=a.r,k=a.s +for(s=l.length,r=0;r=48&&q<=57)r=A.ls(r+1,q,l,k) +else if((((q|32)>>>0)-97&65535)<26||q===95||q===36||q===124)r=A.jb(a,r,l,k,!1) +else if(q===46)r=A.jb(a,r,l,k,!0) +else{++r +switch(q){case 44:break +case 58:k.push(!1) +break +case 33:k.push(!0) +break +case 59:k.push(A.bk(a.u,a.e,k.pop())) +break +case 94:k.push(A.lE(a.u,k.pop())) +break +case 35:k.push(A.cD(a.u,5,"#")) +break +case 64:k.push(A.cD(a.u,2,"@")) +break +case 126:k.push(A.cD(a.u,3,"~")) +break +case 60:k.push(a.p) +a.p=k.length +break +case 62:A.lu(a,k) +break +case 38:A.lt(a,k) +break +case 63:p=a.u +k.push(A.jf(p,A.bk(p,a.e,k.pop()),a.n)) +break +case 47:p=a.u +k.push(A.je(p,A.bk(p,a.e,k.pop()),a.n)) +break +case 40:k.push(-3) +k.push(a.p) +a.p=k.length +break +case 41:A.lr(a,k) +break +case 91:k.push(a.p) +a.p=k.length +break +case 93:o=k.splice(a.p) +A.jc(a.u,a.e,o) +a.p=k.pop() +k.push(o) +k.push(-1) +break +case 123:k.push(a.p) +a.p=k.length +break +case 125:o=k.splice(a.p) +A.lx(a.u,a.e,o) +a.p=k.pop() +k.push(o) +k.push(-2) +break +case 43:n=l.indexOf("(",r) +k.push(l.substring(r,n)) +k.push(-4) +k.push(a.p) +a.p=k.length +r=n+1 +break +default:throw"Bad character "+q}}}m=k.pop() +return A.bk(a.u,a.e,m)}, +ls(a,b,c,d){var s,r,q=b-48 +for(s=c.length;a=48&&r<=57))break +q=q*10+(r-48)}d.push(q) +return a}, +jb(a,b,c,d,e){var s,r,q,p,o,n,m=b+1 +for(s=c.length;m>>0)-97&65535)<26||r===95||r===36||r===124))q=r>=48&&r<=57 +else q=!0 +if(!q)break}}p=c.substring(b,m) +if(e){s=a.u +o=a.e +if(o.w===9)o=o.x +n=A.lI(s,o.x)[p] +if(n==null)A.u('No "'+p+'" in "'+A.l5(o)+'"') +d.push(A.cE(s,o,n))}else d.push(p) +return m}, +lu(a,b){var s,r=a.u,q=A.ja(a,b),p=b.pop() +if(typeof p=="string")b.push(A.cC(r,p,q)) +else{s=A.bk(r,a.e,p) +switch(s.w){case 11:b.push(A.hX(r,s,q,a.n)) +break +default:b.push(A.hW(r,s,q)) +break}}}, +lr(a,b){var s,r,q,p=a.u,o=b.pop(),n=null,m=null +if(typeof o=="number")switch(o){case-1:n=b.pop() +break +case-2:m=b.pop() +break +default:b.push(o) +break}else b.push(o) +s=A.ja(a,b) +o=b.pop() +switch(o){case-3:o=b.pop() +if(n==null)n=p.sEA +if(m==null)m=p.sEA +r=A.bk(p,a.e,o) +q=new A.dN() +q.a=s +q.b=n +q.c=m +b.push(A.jd(p,r,q)) +return +case-4:b.push(A.jg(p,b.pop(),s)) +return +default:throw A.d(A.cP("Unexpected state under `()`: "+A.n(o)))}}, +lt(a,b){var s=b.pop() +if(0===s){b.push(A.cD(a.u,1,"0&")) +return}if(1===s){b.push(A.cD(a.u,4,"1&")) +return}throw A.d(A.cP("Unexpected extended operation "+A.n(s)))}, +ja(a,b){var s=b.splice(a.p) +A.jc(a.u,a.e,s) +a.p=b.pop() +return s}, +bk(a,b,c){if(typeof c=="string")return A.cC(a,c,a.sEA) +else if(typeof c=="number"){b.toString +return A.lv(a,b,c)}else return c}, +jc(a,b,c){var s,r=c.length +for(s=0;sn)return!1 +m=n-o +l=s.b +k=r.b +j=l.length +i=k.length +if(o+j=d)return!1 +a1=f[b] +b+=3 +if(a00?new Array(q):v.typeUniverse.sEA +for(o=0;o0?new Array(a):v.typeUniverse.sEA}, +al:function al(a,b){var _=this +_.a=a +_.b=b +_.r=_.f=_.d=_.c=null +_.w=0 +_.as=_.Q=_.z=_.y=_.x=null}, +dN:function dN(){this.c=this.b=this.a=null}, +fH:function fH(a){this.a=a}, +dM:function dM(){}, +bG:function bG(a){this.a=a}, +lf(){var s,r,q +if(self.scheduleImmediate!=null)return A.mz() +if(self.MutationObserver!=null&&self.document!=null){s={} +r=self.document.createElement("div") +q=self.document.createElement("span") +s.a=null +new self.MutationObserver(A.bN(new A.fe(s),1)).observe(r,{childList:true}) +return new A.fd(s,r,q)}else if(self.setImmediate!=null)return A.mA() +return A.mB()}, +lg(a){self.scheduleImmediate(A.bN(new A.ff(t.M.a(a)),0))}, +lh(a){self.setImmediate(A.bN(new A.fg(t.M.a(a)),0))}, +li(a){t.M.a(a) +A.ly(0,a)}, +ly(a,b){var s=new A.fF() +s.cb(a,b) +return s}, +aM(a){return new A.dF(new A.F($.w,a.h("F<0>")),a.h("dF<0>"))}, +aK(a,b){a.$2(0,null) +b.b=!0 +return b.a}, +R(a,b){A.lQ(a,b)}, +aJ(a,b){b.aQ(a)}, +aI(a,b){b.aR(A.I(a),A.bp(a))}, +lQ(a,b){var s,r,q=new A.fP(b),p=new A.fQ(b) +if(a instanceof A.F)a.bp(q,p,t.z) +else{s=t.z +if(a instanceof A.F)a.b0(q,p,s) +else{r=new A.F($.w,t.d) +r.a=8 +r.c=a +r.bp(q,p,s)}}}, +aN(a){var s=function(b,c){return function(d,e){while(true){try{b(d,e) +break}catch(r){e=r +d=c}}}}(a,1) +return $.w.bM(new A.h7(s),t.H,t.S,t.z)}, +hx(a){var s +if(t.Q.b(a)){s=a.ga0() +if(s!=null)return s}return B.k}, +m6(a,b){if($.w===B.d)return null +return null}, +m7(a,b){if($.w!==B.d)A.m6(a,b) +if(b==null)if(t.Q.b(a)){b=a.ga0() +if(b==null){A.iU(a,B.k) +b=B.k}}else b=B.k +else if(t.Q.b(a))A.iU(a,b) +return new A.ad(a,b)}, +hV(a,b,c){var s,r,q,p,o={},n=o.a=a +for(s=t.d;r=n.a,(r&4)!==0;n=a){a=s.a(n.c) +o.a=a}if(n===b){s=A.l8() +b.aw(new A.ad(new A.ah(!0,n,null,"Cannot complete a future with itself"),s)) +return}q=b.a&1 +s=n.a=r|q +if((s&24)===0){p=t.F.a(b.c) +b.a=b.a&1|4 +b.c=n +n.bh(p) +return}if(!c)if(b.c==null)n=(s&16)===0||q!==0 +else n=!1 +else n=!0 +if(n){p=b.ag() +b.af(o.a) +A.bE(b,p) +return}b.a^=2 +A.e1(null,null,b.b,t.M.a(new A.fs(o,b)))}, +bE(a,b){var s,r,q,p,o,n,m,l,k,j,i,h,g,f,e,d={},c=d.a=a +for(s=t.n,r=t.F;;){q={} +p=c.a +o=(p&16)===0 +n=!o +if(b==null){if(n&&(p&1)===0){m=s.a(c.c) +A.h5(m.a,m.b)}return}q.a=b +l=b.a +for(c=b;l!=null;c=l,l=k){c.a=null +A.bE(d.a,c) +q.a=l +k=l.a}p=d.a +j=p.c +q.b=n +q.c=j +if(o){i=c.c +i=(i&1)!==0||(i&15)===8}else i=!0 +if(i){h=c.b.b +if(n){p=p.b===h +p=!(p||p)}else p=!1 +if(p){s.a(j) +A.h5(j.a,j.b) +return}g=$.w +if(g!==h)$.w=h +else g=null +c=c.c +if((c&15)===8)new A.fw(q,d,n).$0() +else if(o){if((c&1)!==0)new A.fv(q,j).$0()}else if((c&2)!==0)new A.fu(d,q).$0() +if(g!=null)$.w=g +c=q.c +if(c instanceof A.F){p=q.a.$ti +p=p.h("ai<2>").b(c)||!p.y[1].b(c)}else p=!1 +if(p){f=q.a.b +if((c.a&24)!==0){e=r.a(f.c) +f.c=null +b=f.ah(e) +f.a=c.a&30|f.a&1 +f.c=c.c +d.a=c +continue}else A.hV(c,f,!0) +return}}f=q.a.b +e=r.a(f.c) +f.c=null +b=f.ah(e) +c=q.b +p=q.c +if(!c){f.$ti.c.a(p) +f.a=8 +f.c=p}else{s.a(p) +f.a=f.a&1|16 +f.c=p}d.a=f +c=f}}, +mo(a,b){var s +if(t.R.b(a))return b.bM(a,t.z,t.K,t.l) +s=t.x +if(s.b(a))return s.a(a) +throw A.d(A.ax(a,"onError",u.c))}, +mk(){var s,r +for(s=$.bK;s!=null;s=$.bK){$.cK=null +r=s.b +$.bK=r +if(r==null)$.cJ=null +s.a.$0()}}, +mu(){$.i2=!0 +try{A.mk()}finally{$.cK=null +$.i2=!1 +if($.bK!=null)$.id().$1(A.jE())}}, +jB(a){var s=new A.dG(a),r=$.cJ +if(r==null){$.bK=$.cJ=s +if(!$.i2)$.id().$1(A.jE())}else $.cJ=r.b=s}, +mr(a){var s,r,q,p=$.bK +if(p==null){A.jB(a) +$.cK=$.cJ +return}s=new A.dG(a) +r=$.cK +if(r==null){s.b=p +$.bK=$.cK=s}else{q=r.b +s.b=q +$.cK=r.b=s +if(q==null)$.cJ=s}}, +nn(a,b){A.h9(a,"stream",t.K) +return new A.dZ(b.h("dZ<0>"))}, +h5(a,b){A.mr(new A.h6(a,b))}, +jy(a,b,c,d,e){var s,r=$.w +if(r===c)return d.$0() +$.w=c +s=r +try{r=d.$0() +return r}finally{$.w=s}}, +jz(a,b,c,d,e,f,g){var s,r=$.w +if(r===c)return d.$1(e) +$.w=c +s=r +try{r=d.$1(e) +return r}finally{$.w=s}}, +mq(a,b,c,d,e,f,g,h,i){var s,r=$.w +if(r===c)return d.$2(e,f) +$.w=c +s=r +try{r=d.$2(e,f) +return r}finally{$.w=s}}, +e1(a,b,c,d){t.M.a(d) +if(B.d!==c){d=c.cC(d) +d=d}A.jB(d)}, +fe:function fe(a){this.a=a}, +fd:function fd(a,b,c){this.a=a +this.b=b +this.c=c}, +ff:function ff(a){this.a=a}, +fg:function fg(a){this.a=a}, +fF:function fF(){}, +fG:function fG(a,b){this.a=a +this.b=b}, +dF:function dF(a,b){this.a=a +this.b=!1 +this.$ti=b}, +fP:function fP(a){this.a=a}, +fQ:function fQ(a){this.a=a}, +h7:function h7(a){this.a=a}, +ad:function ad(a,b){this.a=a +this.b=b}, +dI:function dI(){}, +cq:function cq(a,b){this.a=a +this.$ti=b}, +bj:function bj(a,b,c,d,e){var _=this +_.a=null +_.b=a +_.c=b +_.d=c +_.e=d +_.$ti=e}, +F:function F(a,b){var _=this +_.a=0 +_.b=a +_.c=null +_.$ti=b}, +fp:function fp(a,b){this.a=a +this.b=b}, +ft:function ft(a,b){this.a=a +this.b=b}, +fs:function fs(a,b){this.a=a +this.b=b}, +fr:function fr(a,b){this.a=a +this.b=b}, +fq:function fq(a,b){this.a=a +this.b=b}, +fw:function fw(a,b,c){this.a=a +this.b=b +this.c=c}, +fx:function fx(a,b){this.a=a +this.b=b}, +fy:function fy(a){this.a=a}, +fv:function fv(a,b){this.a=a +this.b=b}, +fu:function fu(a,b){this.a=a +this.b=b}, +dG:function dG(a){this.a=a +this.b=null}, +dZ:function dZ(a){this.$ti=a}, +cG:function cG(){}, +dV:function dV(){}, +fD:function fD(a,b){this.a=a +this.b=b}, +fE:function fE(a,b,c){this.a=a +this.b=b +this.c=c}, +h6:function h6(a,b){this.a=a +this.b=b}, +d9(a,b){return new A.c3(a.h("@<0>").E(b).h("c3<1,2>"))}, +hI(a){var s,r +if(A.i9(a))return"{...}" +s=new A.cm("") +try{r={} +B.b.t($.a9,a) +s.a+="{" +r.a=!0 +a.aU(0,new A.eD(r,s)) +s.a+="}"}finally{if(0>=$.a9.length)return A.b($.a9,-1) +$.a9.pop()}r=s.a +return r.charCodeAt(0)==0?r:r}, +k:function k(){}, +a_:function a_(){}, +eD:function eD(a,b){this.a=a +this.b=b}, +lK(a,b,c){var s,r,q,p,o,n=c-b +if(n<=4096)s=$.kh() +else s=new Uint8Array(n) +for(r=a.length,q=0;q=0&&p=16)return null +r=r*16+o}n=h-1 +if(!(h>=0&&h=0&&s=16)return null +r=r*16+o}m=n-1 +if(!(n>=0&&n=j)return A.b(i,0) +l=i[0]===0}else l=!1 +if(l)return $.E() +l=A.a1(j,i) +return new A.z(l===0?!1:c,i,l)}, +lp(a,b){var s,r,q,p,o,n +if(a==="")return null +s=$.kd().a3(a) +if(s==null)return null +r=s.b +q=r.length +if(1>=q)return A.b(r,1) +p=r[1]==="-" +if(4>=q)return A.b(r,4) +o=r[4] +n=r[3] +if(5>=q)return A.b(r,5) +if(o!=null)return A.lm(o,p) +if(n!=null)return A.ln(n,2,p) +return null}, +a1(a,b){var s,r=b.length +for(;;){if(a>0){s=a-1 +if(!(s=0&&q>>0)+(r[6]>>>4)-1075 +n=new Uint16Array(4) +n[0]=(r[1]<<8>>>0)+r[0] +n[1]=(r[3]<<8>>>0)+r[2] +n[2]=(r[5]<<8>>>0)+r[4] +n[3]=r[6]&15|16 +m=new A.z(!1,n,4) +if(o<0)l=m.Z(0,-o) +else l=o>0?m.O(0,o):m +if(s)return l.D(0) +return l}, +hT(a,b,c,d){var s,r,q,p,o +if(b===0)return 0 +if(c===0&&d===a)return b +for(s=b-1,r=a.length,q=d.$flags|0;s>=0;--s){p=s+c +if(!(s=0&&p=0;--s){q&2&&A.l(d) +if(!(s=0;--s){if(!(s=0&&n>>0 +p=B.a.O((o&i)>>>0,k)}q&2&&A.l(d) +if(!(l>=0&&l=0;){r&2&&A.l(d) +if(!(q=0&&r=0&&m>>0,k) +q&2&&A.l(d) +if(!(p>>0 +s=B.a.Z(n,l)}q&2&&A.l(d) +if(!(r>=0&&r=0;--s){if(!(s=0&&o=0&&b=0&&o=0;e=l,c=o){o=c+1 +if(!(c=0&&e=0&&e=0&&c=0&&r>>0,a) +if(q>65535)return 65535 +return q}, +fo(a,b){var s=$.ke() +s=s==null?null:new s(A.bN(A.n7(a,b),1)) +return new A.ct(s,b.h("ct<0>"))}, +aa(a){var s=A.iS(a,null) +if(s!=null)return s +throw A.d(A.ev(a,null,null))}, +kC(a,b){a=A.C(a,new Error()) +if(a==null)a=A.bl(a) +a.stack=b.i(0) +throw a}, +iJ(a,b,c,d){var s,r=J.iH(a,d) +if(a!==0&&b!=null)for(s=0;s")) +for(s=a.length,r=0;r")) +for(s=J.cN(a);s.p();)B.b.t(r,s.gv()) +return r}, +db(a,b){var s=A.hE(a,!1,b) +s.$flags=3 +return s}, +la(a,b,c){var s,r +A.bA(b,"start") +s=c-b +if(s<0)throw A.d(A.P(c,b,null,"end",null)) +if(s===0)return"" +r=A.lb(a,b,c) +return r}, +lb(a,b,c){var s=a.length +if(b>=s)return"" +return A.kY(a,b,c==null||c>s?s:c)}, +cd(a,b){return new A.d7(a,A.kP(a,!1,b,!1,!1,""))}, +iW(a,b,c){var s=J.cN(b) +if(!s.p())return a +if(c.length===0){do a+=A.n(s.gv()) +while(s.p())}else{a+=A.n(s.gv()) +while(s.p())a=a+c+A.n(s.gv())}return a}, +l8(){return A.bp(new Error())}, +it(a,b,c,d,e,f,g,h){var s=A.kZ(a,b,c,d,e,f,g,h,!0) +if(s==null)s=new A.eu(a,b,c,d,e,f,g,h).$0() +return new A.a3(s,B.a.n(h,1000),!0)}, +kA(a){var s=Math.abs(a),r=a<0?"-":"" +if(s>=1000)return""+a +if(s>=100)return r+"0"+s +if(s>=10)return r+"00"+s +return r+"000"+s}, +iu(a){if(a>=100)return""+a +if(a>=10)return"0"+a +return"00"+a}, +cZ(a){if(a>=10)return""+a +return"0"+a}, +iy(a){return new A.aR(a)}, +bX(a){if(typeof a=="number"||A.cI(a)||a==null)return J.b4(a) +if(typeof a=="string")return JSON.stringify(a) +return A.iT(a)}, +kD(a,b){A.h9(a,"error",t.K) +A.h9(b,"stackTrace",t.l) +A.kC(a,b)}, +cP(a){return new A.cO(a)}, +J(a,b){return new A.ah(!1,null,b,a)}, +ax(a,b,c){return new A.ah(!0,a,b,c)}, +hw(a,b,c){return a}, +aV(a){var s=null +return new A.aC(s,s,!1,s,s,a)}, +P(a,b,c,d,e){return new A.aC(b,c,!0,a,d,"Invalid value")}, +dp(a,b,c){if(0>a||a>c)throw A.d(A.P(a,0,c,"start",null)) +if(b!=null){if(a>b||b>c)throw A.d(A.P(b,a,c,"end",null)) +return b}return c}, +bA(a,b){if(a<0)throw A.d(A.P(a,0,null,b,null)) +return a}, +iD(a,b){var s=b.b +return new A.bZ(s,!0,a,null,"Index out of range")}, +ew(a,b,c,d,e){return new A.bZ(b,!0,a,e,"Index out of range")}, +aY(a){return new A.co(a)}, +iZ(a){return new A.dv(a)}, +at(a){return new A.be(a)}, +aQ(a){return new A.cV(a)}, +iz(a){return new A.fn(a)}, +ev(a,b,c){return new A.v(a,b,c)}, +kM(a,b,c){var s,r +if(A.i9(a)){if(b==="("&&c===")")return"(...)" +return b+"..."+c}s=A.x([],t.s) +B.b.t($.a9,a) +try{A.mj(a,s)}finally{if(0>=$.a9.length)return A.b($.a9,-1) +$.a9.pop()}r=A.iW(b,t.hf.a(s),", ")+c +return r.charCodeAt(0)==0?r:r}, +iF(a,b,c){var s,r +if(A.i9(a))return b+"..."+c +s=new A.cm(b) +B.b.t($.a9,a) +try{r=s +r.a=A.iW(r.a,a,", ")}finally{if(0>=$.a9.length)return A.b($.a9,-1) +$.a9.pop()}s.a+=c +r=s.a +return r.charCodeAt(0)==0?r:r}, +mj(a,b){var s,r,q,p,o,n,m,l=a.gG(a),k=0,j=0 +for(;;){if(!(k<80||j<3))break +if(!l.p())return +s=A.n(l.gv()) +B.b.t(b,s) +k+=s.length+2;++j}if(!l.p()){if(j<=5)return +if(0>=b.length)return A.b(b,-1) +r=b.pop() +if(0>=b.length)return A.b(b,-1) +q=b.pop()}else{p=l.gv();++j +if(!l.p()){if(j<=4){B.b.t(b,A.n(p)) +return}r=A.n(p) +if(0>=b.length)return A.b(b,-1) +q=b.pop() +k+=r.length+2}else{o=l.gv();++j +for(;l.p();p=o,o=n){n=l.gv();++j +if(j>100){for(;;){if(!(k>75&&j>3))break +if(0>=b.length)return A.b(b,-1) +k-=b.pop().length+2;--j}B.b.t(b,"...") +return}}q=A.n(p) +r=A.n(o) +k+=r.length+q.length+4}}if(j>b.length+2){k+=5 +m="..."}else m=null +for(;;){if(!(k>80&&b.length>3))break +if(0>=b.length)return A.b(b,-1) +k-=b.pop().length+2 +if(m==null){k+=5 +m="..."}}if(m!=null)B.b.t(b,m) +B.b.t(b,q) +B.b.t(b,r)}, +eG(a,b,c,d){var s +if(B.f===c){s=J.ag(a) +b=J.ag(b) +return A.hL(A.aX(A.aX($.hu(),s),b))}if(B.f===d){s=J.ag(a) +b=J.ag(b) +c=J.ag(c) +return A.hL(A.aX(A.aX(A.aX($.hu(),s),b),c))}s=J.ag(a) +b=J.ag(b) +c=J.ag(c) +d=J.ag(d) +d=A.hL(A.aX(A.aX(A.aX(A.aX($.hu(),s),b),c),d)) +return d}, +z:function z(a,b,c){this.a=a +this.b=b +this.c=c}, +fi:function fi(){}, +fj:function fj(){}, +ct:function ct(a,b){this.a=a +this.$ti=b}, +eu:function eu(a,b,c,d,e,f,g,h){var _=this +_.a=a +_.b=b +_.c=c +_.d=d +_.e=e +_.f=f +_.r=g +_.w=h}, +a3:function a3(a,b,c){this.a=a +this.b=b +this.c=c}, +aR:function aR(a){this.a=a}, +fm:function fm(){}, +q:function q(){}, +cO:function cO(a){this.a=a}, +aE:function aE(){}, +ah:function ah(a,b,c,d){var _=this +_.a=a +_.b=b +_.c=c +_.d=d}, +aC:function aC(a,b,c,d,e,f){var _=this +_.e=a +_.f=b +_.a=c +_.b=d +_.c=e +_.d=f}, +bZ:function bZ(a,b,c,d,e){var _=this +_.f=a +_.a=b +_.b=c +_.c=d +_.d=e}, +co:function co(a){this.a=a}, +dv:function dv(a){this.a=a}, +be:function be(a){this.a=a}, +cV:function cV(a){this.a=a}, +dk:function dk(){}, +cl:function cl(){}, +fn:function fn(a){this.a=a}, +v:function v(a,b,c){this.a=a +this.b=b +this.c=c}, +d2:function d2(){}, +f:function f(){}, +M:function M(){}, +e:function e(){}, +e_:function e_(){}, +cm:function cm(a){this.a=a}, +d0:function d0(a,b){this.a=a +this.$ti=b}, +iG(a,b){var s,r,q,p,o +if(b.length===0)return!1 +s=b.split(".") +r=v.G +for(q=s.length,p=0;p=1)return a.$1(b) +return a.$0()}, +lS(a,b,c,d){t.Z.a(a) +A.c(d) +if(d>=2)return a.$2(b,c) +if(d===1)return a.$1(b) +return a.$0()}, +lT(a,b,c,d,e){t.Z.a(a) +A.c(e) +if(e>=3)return a.$3(b,c,d) +if(e===2)return a.$2(b,c) +if(e===1)return a.$1(b) +return a.$0()}, +lU(a,b,c,d,e,f){t.Z.a(a) +A.c(f) +if(f>=4)return a.$4(b,c,d,e) +if(f===3)return a.$3(b,c,d) +if(f===2)return a.$2(b,c) +if(f===1)return a.$1(b) +return a.$0()}, +lV(a,b,c,d,e,f,g){t.Z.a(a) +A.c(g) +if(g>=5)return a.$5(b,c,d,e,f) +if(g===4)return a.$4(b,c,d,e) +if(g===3)return a.$3(b,c,d) +if(g===2)return a.$2(b,c) +if(g===1)return a.$1(b) +return a.$0()}, +mL(a,b,c){return c.a(a[b])}, +h8(a,b,c,d){return d.a(a[b].apply(a,c))}, +bP(a,b){var s=new A.F($.w,b.h("F<0>")),r=new A.cq(s,b.h("cq<0>")) +a.then(A.bN(new A.hl(r,b),1),A.bN(new A.hm(r),1)) +return s}, +hl:function hl(a,b){this.a=a +this.b=b}, +hm:function hm(a){this.a=a}, +dQ:function dQ(a){this.a=a}, +dj:function dj(){}, +dx:function dx(){}, +eV:function eV(a,b){this.a=a +this.b=b}, +cj:function cj(a,b,c){this.a=a +this.b=b +this.c=c}, +mX(){var s={},r=v.G,q=new A.F($.w,t.cd) +q.av(null) +s.a=q +r.onmessage=A.bH(new A.ho(s,new A.fO(),r))}, +ho:function ho(a,b,c){this.a=a +this.b=b +this.c=c}, +hn:function hn(a,b,c){this.a=a +this.b=b +this.c=c}, +fO:function fO(){var _=this +_.e=_.d=_.c=_.b=_.a=null}, +mD(a,b,c,d){var s,r,q,p,o,n +A.mn(a) +A.mp(a) +a.a9("PRAGMA foreign_keys = ON") +s=a.ap("PRAGMA foreign_keys") +if(!J.ab(B.b.ga_(s.ga_(s).b),1))throw A.d(B.an) +a.a9("PRAGMA busy_timeout = "+B.a.l(b.a,1000)) +s=a.ap("PRAGMA journal_mode = "+d) +r=B.b.ga_(s.ga_(s).b) +s=J.ab(r,d) +if(!s)throw A.d(A.iQ("DRIVER.JOURNAL","Requested "+d+", received "+A.n(r)+".",null)) +a.a9("PRAGMA synchronous = FULL") +for(s=a.ap("PRAGMA compile_options").d,q=t.X,p=-1;++p,p9007199254740991 +if(s)A.u(B.ai) +if(a instanceof A.ci){s=A.x(["real",a.a],t.f) +break A}if(typeof a=="number"){s=a +break A}if(typeof a=="string"){s=a +break A}if(t.I.b(a)){s=a +break A}if(a instanceof A.z){s=A.x(["bigint",a.i(0)],t.s) +break A}s=A.u(B.ak)}return s}, +mY(a){var s +if(a==null)return null +if(typeof a==="string")return A.A(a) +if(typeof a==="boolean")return A.e0(a) +if(typeof a==="number"){A.Q(a) +s=!1 +if(isFinite(a))if(Math.abs(a)<=9007199254740991)s=a===(a<0?Math.ceil(a):Math.floor(a)) +return s?B.l.bP(a):a}s=A.iG(a,"Uint8Array") +if(s)return t.bm.a(a) +s=A.iG(a,"Array") +if(s){t.c.a(a) +if(A.c(a.length)===2){s=a[0] +s.toString +s=A.A(s)==="bigint"}else s=!1 +if(s){s=a[1] +s.toString +return A.hU(A.A(s),null)}if(A.c(a.length)===2){s=a[0] +s.toString +s=A.A(s)==="real"}else s=!1 +if(s){s=a[1] +s.toString +return new A.ci(A.Q(s))}}throw A.d(B.aj)}, +jO(a){var s,r,q +a.toString +s=t.c +s.a(a) +r=a[0] +r.toString +A.A(r) +q=a[1] +q.toString +s.a(q) +s=t.X +q=B.b.ak(q,A.n5(),s) +q=A.da(q,q.$ti.h("V.E")) +return new A.eV(r,A.db(q,s))}, +jP(a){var s,r=a.b,q=A.am(r),p=q.h("a5<1,h>") +r=A.da(new A.a5(r,q.h("h(1)").a(new A.hp()),p),p.h("V.E")) +q=a.a +p=A.am(q) +s=p.h("a5<1,r>") +q=A.da(new A.a5(q,p.h("r(1)").a(new A.hq()),s),s.h("V.E")) +return A.x([r,q,a.c],t.f)}, +hp:function hp(){}, +hq:function hq(){}, +ky(a){var s +A:{if(a instanceof A.aj){s=a +break A}if(typeof a=="string"){s=A.eC(a) +break A}s=A.u(B.a1)}return s}, +kx(a){var s +A:{if(a instanceof A.aq){s=a +break A}if(typeof a=="string"){s=A.iK(a) +break A}s=A.u(B.a5)}return s}, +ay(a){var s +A:{if(typeof a=="string"){s=A.iw(a) +break A}if(A.bJ(a)){s=A.bt(A.O(a),0) +break A}if(a instanceof A.z){s=A.bt(a,0) +break A}s=A.u(B.a7)}return s}, +iQ(a,b,c){return new A.a0(a,b)}, +kB(a,b){return new A.az(a,b)}, +bt(a,b){var s=a.m(0,$.E()) +if(s===0)return $.ib() +s=a.a +return A.iv((s?a.D(0):a).i(0),s,b)}, +iw(a){var s,r,q,p,o=a.length +if(o>147487)throw A.d(B.a2) +s=$.jY().a3(a) +r=!0 +if(s!=null)if(s.ga8()===o){o=s.b +r=o.length +if(2>=r)return A.b(o,2) +if(o[2].length===0){if(3>=r)return A.b(o,3) +o=o[3] +o=(o==null?"":o).length===0}else o=!1}else o=r +else o=r +if(o)throw A.d(B.X) +o=s.b +if(4>=o.length)return A.b(o,4) +r=o[4] +q=A.iS(r==null?"0":r,null) +if(q==null||q<-147455||q>147455)throw A.d(B.a6) +if(3>=o.length)return A.b(o,3) +p=o[3] +if(p==null)p="" +return A.iv(A.n(o[2])+p,o[1]==="-",p.length-q)}, +hA(a){var s,r +try{s=A.iw(a) +return s}catch(r){s=A.I(r) +if(s instanceof A.v)return null +else if(t.G.b(s))return null +else throw r}}, +iv(a,b,c){var s,r,q,p=a.length,o=0 +for(;;){if(!(o16383+p)throw A.d(A.aV("Decimal scale is out of range.")) +s=p +for(;;){r=s-1 +if(!(r>=0))return A.b(a,r) +if(!(a.charCodeAt(r)===48))break;--c +s=r}if(c>16383||s-o-c>131072)throw A.d(A.aV("Decimal exceeds the supported finite NUMERIC range.")) +q=b?"-":"" +return new A.az(A.hU(q+B.c.a1(a,o,s),null),c)}, +hB(a){if(a<-131072||a>16383)throw A.d(A.P(a,-131072,16383,"scale",null))}, +ix(a,b){if(a<1||a>1000||b<-1000||b>1000)throw A.d(A.J("Decimal precision must be 1..1000 and scale -1000..1000.",null))}, +hz(a,b,c,d){var s,r,q=a.ad(0,b),p=a.dl(0,b),o=p.m(0,$.E()) +if(o!==0){s=a.gU(0)*b.gU(0) +o=p.a?p.D(0):p +o=o.q(0,$.ih()) +r=o.m(0,b.a?b.D(0):b) +o=!1 +switch(d.a){case 0:o=A.u(B.W) +break +case 1:break +case 2:o=s<0 +break +case 3:o=s>0 +break +case 4:o=r>=0 +break +case 5:if(r<=0){if(r===0){if(q.c!==0){o=q.b +if(0>=o.length)return A.b(o,0) +o=(o[0]&1)===0}else o=!0 +o=!o}}else o=!0 +break +default:o=null}if(o)q=q.S(0,A.O(s))}return A.bt(q,c)}, +hY(a){var s +A:{if(a instanceof A.a3){s=A.fR(a) +break A}if(typeof a=="string"){s=A.ml(a) +break A}s=A.u(B.a4)}return s}, +fR(a){var s=a.dt(),r=a.a,q=!0 +if(r>=-2108668032e5)if(r<=864e13)q=r===864e13&&s.b!==0 +if(q)throw A.d(B.w) +return s}, +jq(a){var s=A.fR(t.k.a(a)),r=A.hG(A.eO(s),A.eM(s),A.eJ(s)),q=A.eB(A.eK(s),A.eL(s),A.eN(s),A.hJ(s)*1000+s.b),p=r.gaB(),o=q.i(0),n=r.a<=0?" BC":"" +return p+" "+o+"+00"+n}, +ml(a3){var s,r,q,p,o,n,m,l,k,j,i,h,g,f,e,d,c,b,a,a0=1e6,a1=864e8,a2=$.ki().a3(a3) +if(a2==null||a2.ga8()!==a3.length)throw A.d(B.a_) +try{e=a2.b +if(1>=e.length)return A.b(e,1) +e=e[1] +e.toString +s=A.aa(e) +e=a2.b +if(6>=e.length)return A.b(e,6) +if(e[6]!=null){e=s +if(typeof e!=="number")return e.e9() +if(e<=0)throw A.d(B.V) +e=s +if(typeof e!=="number")return A.he(e) +s=1-e}e=a2.b +if(2>=e.length)return A.b(e,2) +e=e[2] +e.toString +r=A.aa(e) +e=a2.b +if(3>=e.length)return A.b(e,3) +e=e[3] +e.toString +q=A.aa(e) +e=r +if(typeof e!=="number")return e.b1() +d=!0 +if(!(e<1)){e=r +if(typeof e!=="number")return e.a5() +if(!(e>12)){e=q +if(typeof e!=="number")return e.b1() +if(!(e<1)){e=q +d=A.iN(s,r) +if(typeof e!=="number")return e.a5() +d=e>d +e=d}else e=d}else e=d}else e=d +if(e)throw A.d(B.T) +e=a2.b +if(5>=e.length)return A.b(e,5) +p=e[5] +o=0 +if(p!=null&&p.toUpperCase()!=="Z"){e=B.c.b8(p,1) +n=A.mZ(e,":","") +m=A.aa(J.hv(n,0,2)) +l=J.ac(n)>=4?A.aa(J.hv(n,2,4)):0 +k=J.ac(n)===6?A.aa(J.hv(n,4,6)):0 +e=m +if(typeof e!=="number")return e.a5() +d=!0 +if(!(e>15)){e=l +if(typeof e!=="number")return e.a5() +if(!(e>59)){e=k +if(typeof e!=="number")return e.a5() +e=e>59}else e=d}else e=d +if(e)throw A.d(B.a0) +e=m +if(typeof e!=="number")return e.q() +d=l +if(typeof d!=="number")return A.he(d) +c=k +if(typeof c!=="number")return A.he(c) +b=p +if(0>=b.length)return A.b(b,0) +b=b[0]==="-"?-1:1 +o=((e*60+d)*60+c)*1e6*b}e=a2.b +if(4>=e.length)return A.b(e,4) +e=e[4] +e.toString +e=A.eC(e) +d=o +if(typeof d!=="number")return A.he(d) +j=e.a-d +i=A.ju(s,r,q)+A.fS(j,a1) +d=j +if(typeof d!=="number")return d.n() +h=B.l.n(d,a1) +e=i +if(typeof e!=="number")return e.b1() +d=!0 +if(!(e<0)){e=i +if(typeof e!=="number")return e.a5() +if(!(e>102440588))e=J.ab(i,102440588)&&!J.ab(h,0) +else e=d}else e=d +if(e)throw A.d(B.w) +g=A.iL(i) +f=A.hH(h) +e=A.it(g.a,g.b,g.c,B.a.l(f.a,36e8),B.a.n(B.a.l(f.a,6e7),60),B.a.n(B.a.l(f.a,a0),60),B.a.l(B.a.n(f.a,a0),1000),B.a.n(B.a.n(f.a,a0),1000)) +return e}catch(a){if(t.G.b(A.I(a)))throw A.d(B.S) +else throw a}}, +fS(a,b){var s=B.a.ad(a,b) +return s-(a<0&&B.a.n(a,b)!==0?1:0)}, +ju(a,b,c){var s=a-(b<=2?1:0),r=A.fS(s,400),q=s-r*400,p=b>2?-3:9 +return r*146097+q*365+B.a.l(q,4)-B.a.l(q,100)+B.a.l(153*(b+p)+2,5)+c-1+1721120}, +hG(a,b,c){var s +if(a<-4713||a>5874897||b<1||b>12||c<1||c>A.iN(a,b))throw A.d(A.aV("Invalid Gregorian date.")) +s=A.ju(a,b,c) +if(s<0||s>2147483493)throw A.d(A.aV("Date exceeds the finite PostgreSQL DATE range.")) +return new A.ap(a,b,c,s)}, +iL(a){var s,r,q,p,o,n,m,l,k +if(a<0||a>2147483493)throw A.d(A.P(a,0,2147483493,"julianDay",null)) +s=a-1721120 +r=A.fS(s,146097) +q=s-r*146097 +p=B.a.l(q-B.a.l(q,1460)+B.a.l(q,36524)-B.a.l(q,146096),365) +o=q-(365*p+B.a.l(p,4)-B.a.l(p,100)) +n=B.a.l(5*o+2,153) +m=B.a.l(153*n+2,5) +l=n+(n<10?3:-9) +k=l<=2?1:0 +return new A.ap(p+r*400+k,l,o-m+1,a)}, +iM(a){var s,r,q,p=$.k_().a3(a) +if(p==null||p.ga8()!==a.length)throw A.d(B.Y) +s=p.b +if(1>=s.length)return A.b(s,1) +r=s[1] +r.toString +q=A.aa(r) +if(4>=s.length)return A.b(s,4) +if(s[4]!=null){if(q<=0)throw A.d(B.a3) +q=1-q}r=s[2] +r.toString +r=A.aa(r) +if(3>=s.length)return A.b(s,3) +s=s[3] +s.toString +return A.hG(q,r,A.aa(s))}, +kS(a){var s,r +try{s=A.iM(a) +return s}catch(r){s=A.I(r) +if(s instanceof A.v)return null +else if(t.G.b(s))return null +else throw r}}, +iN(a,b){var s +A:{if(2===b){if(B.a.n(a,4)===0)s=B.a.n(a,100)!==0||B.a.n(a,400)===0 +else s=!1 +s=s?29:28 +break A}if(4===b||6===b||9===b||11===b){s=30 +break A}s=31 +break A}return s}, +eB(a,b,c,d){var s=!0 +if(a>=0)if(a<=24)if(b>=0)if(b<=59)if(c>=0)if(c<=59)if(d>=0)if(d<=999999)if(a===24)s=b!==0||c!==0||d!==0 +else s=!1 +if(s)throw A.d(A.aV("Invalid time of day.")) +return new A.aj(((a*60+b)*60+c)*1e6+d)}, +hH(a){if(a<0||a>864e8)throw A.d(A.P(a,0,864e8,"microseconds",null)) +return new A.aj(a)}, +eC(a){var s,r,q,p,o=$.k0().a3(a) +if(o==null||o.ga8()!==a.length)throw A.d(B.Z) +s=o.b +if(1>=s.length)return A.b(s,1) +r=s[1] +r.toString +r=A.aa(r) +if(2>=s.length)return A.b(s,2) +q=s[2] +q.toString +q=A.aa(q) +if(3>=s.length)return A.b(s,3) +p=s[3] +p=A.aa(p==null?"0":p) +if(4>=s.length)return A.b(s,4) +s=s[4] +return A.eB(r,q,p,A.aa(B.c.dk(s==null?"":s,6,"0")))}, +kT(a){var s,r +try{s=A.eC(a) +return s}catch(r){s=A.I(r) +if(s instanceof A.v)return null +else if(t.G.b(s))return null +else throw r}}, +hF(a,b){if(B.a.l(b.a,36e8)===24){a=a.bs(1) +b=A.eB(0,0,0,0)}if(a.d>109203527)throw A.d(A.aV("Local timestamp exceeds the finite PostgreSQL TIMESTAMP range.")) +return new A.aq(a,b)}, +iK(a){var s,r,q,p=$.jZ().a3(a) +if(p==null||p.ga8()!==a.length)throw A.d(B.U) +s=p.b +r=s.length +if(1>=r)return A.b(s,1) +q=s[1] +if(3>=r)return A.b(s,3) +r=s[3] +if(r==null)r="" +r=A.iM(A.n(q)+r) +if(2>=s.length)return A.b(s,2) +s=s[2] +s.toString +return A.hF(r,A.eC(s))}, +kR(a){var s,r +try{s=A.iK(a) +return s}catch(r){s=A.I(r) +if(s instanceof A.v)return null +else if(t.G.b(s))return null +else throw r}}, +jC(a,b,c){var s,r,q +if(b<0||b>6)A.u(A.P(b,0,6,"digits",null)) +if(!(b>=0&&b<7))return A.b(B.x,b) +s=B.x[b] +r=B.a.n(a,s) +q=r*2 +if(q<=s)q=q===s&&!c +else q=!0 +return q?s-r:-r}, +ci:function ci(a){this.a=a}, +hy:function hy(a,b,c){this.c=a +this.d=b +this.$ti=c}, +a0:function a0(a,b){this.a=a +this.b=b}, +aA:function aA(a,b){this.a=a +this.b=b}, +az:function az(a,b){this.a=a +this.b=b}, +ap:function ap(a,b,c,d){var _=this +_.a=a +_.b=b +_.c=c +_.d=d}, +aj:function aj(a){this.a=a}, +aq:function aq(a,b){this.a=a +this.b=b}, +l7(a,b,c,d,e,f,g){return new A.ck(d,b,c,e,f,a,g)}, +ck:function ck(a,b,c,d,e,f,g){var _=this +_.a=a +_.b=b +_.c=c +_.d=d +_.e=e +_.f=f +_.r=g}, +eY:function eY(){}, +ao:function ao(a,b){this.a=a +this.$ti=b}, +b5:function b5(a){this.a=a}, +hZ(a,b,c){var s,r,q=new A.dz(c,A.iJ(c.b,null,!1,t.X)) +try{A.i_(a,b.$1(q))}catch(r){s=A.I(r) +a.b6(A.bX(s))}finally{}}, +js(a,b){var s,r +try{A.i_(a,b.$0())}catch(r){s=A.I(r) +a.b6(A.bX(s))}}, +i_(a,b){var s,r,q,p +A:{s=null +if(b==null){a.a.d.sqlite3_result_null(a.b) +break A}if(A.bJ(b)){a.a.d.sqlite3_result_int64(a.b,t.C.a(v.G.BigInt(A.O(b).i(0)))) +break A}if(b instanceof A.z){a.a.d.sqlite3_result_int64(a.b,t.C.a(v.G.BigInt(A.il(b).i(0)))) +break A}if(typeof b=="number"){a.a.d.sqlite3_result_double(a.b,b) +break A}if(A.cI(b)){a.a.d.sqlite3_result_int64(a.b,t.C.a(v.G.BigInt(A.O(b?1:0).i(0)))) +break A}if(typeof b=="string"){r=B.h.P(b) +q=a.a +p=q.a2(r) +q=q.d +q.sqlite3_result_text(a.b,p,r.length,-1) +q.dart_sqlite3_free(p) +break A}q=t.L +if(q.b(b)){q.a(b) +q=a.a +p=q.a2(b) +q=q.d +q.sqlite3_result_blob64(a.b,p,t.C.a(v.G.BigInt(J.ac(b))),-1) +q.dart_sqlite3_free(p) +break A}if(t.u.b(b)){A.i_(a,b.a) +a.a.d.sqlite3_result_subtype(a.b,b.b) +break A}s=A.u(A.ax(b,"result","Unsupported type"))}return s}, +cY:function cY(a,b,c){var _=this +_.a=a +_.b=b +_.c=c +_.r=!1}, +eq:function eq(a,b){this.a=a +this.b=b}, +er:function er(a,b){this.a=a +this.b=b}, +es:function es(a,b){this.a=a +this.b=b}, +eo:function eo(a,b){this.a=a +this.b=b}, +ep:function ep(a,b,c){this.a=a +this.b=b +this.c=c}, +en:function en(a,b){this.a=a +this.b=b}, +ek:function ek(a,b,c){this.a=a +this.b=b +this.c=c}, +em:function em(a,b){this.a=a +this.b=b}, +el:function el(a,b){this.a=a +this.b=b}, +et:function et(a){this.a=a}, +ej:function ej(a,b){this.a=a +this.b=b}, +dz:function dz(a,b){this.a=a +this.b=b}, +eX:function eX(){}, +bB:function bB(a,b,c){var _=this +_.a=a +_.b=b +_.d=c +_.e=null +_.f=!0 +_.r=!1 +_.w=null}, +dE:function dE(a,b,c){var _=this +_.r=a +_.w=-1 +_.x=$ +_.y=!1 +_.a=b +_.c=c}, +iC(){var s=$.hs() +return new A.d1(A.d9(t.N,t.fN),s,"dart-memory")}, +d1:function d1(a,b,c){this.d=a +this.b=b +this.a=c}, +dO:function dO(a,b,c){var _=this +_.a=a +_.b=b +_.c=c +_.d=0}, +mV(a){return new A.cp(A.x(A.A(A.B(new v.G.URL(a,"file:///")).pathname).split("/"),t.s),t.bB.a(new A.hk()),t.cc)}, +hk:function hk(){}, +bs:function bs(){}, +c_:function c_(){}, +dq:function dq(a,b,c){this.d=a +this.a=b +this.c=c}, +N:function N(a,b){this.a=a +this.b=b}, +dS:function dS(a){this.a=a +this.b=-1}, +dT:function dT(){}, +dU:function dU(){}, +dW:function dW(){}, +dX:function dX(){}, +eH:function eH(a,b){this.a=a +this.b=b}, +cU:function cU(){}, +b7:function b7(a){this.a=a}, +f3(a){return new A.aZ(a)}, +ik(a,b){var s,r,q +if(b==null)b=$.hs() +for(s=a.length,r=0;r")),l=null +case 4:if(!n.p()){s=6 +break}s=7 +return A.R(A.bP(A.B(o.getDirectoryHandle(m.gv(),{create:!0})),p),$async$ds) +case 7:k=d +case 5:l=o,o=k +s=4 +break +case 6:q=new A.cy(l,o) +s=1 +break +case 1:return A.aJ(q,r)}}) +return A.aK($async$ds,r)}, +eU(a){var s=0,r=A.aM(t.m),q +var $async$eU=A.aN(function(b,c){if(b===1)return A.aI(c,r) +for(;;)switch(s){case 0:s=3 +return A.R(A.ds(a,!0),$async$eU) +case 3:q=c.b +s=1 +break +case 1:return A.aJ(q,r)}}) +return A.aK($async$eU,r)}, +eS(a){var s=0,r=A.aM(t.h),q,p +var $async$eS=A.aN(function(b,c){if(b===1)return A.aI(c,r) +for(;;)switch(s){case 0:if(A.jR()==null)throw A.d(A.f3(1)) +p=A +s=3 +return A.R(A.eU(a),$async$eS) +case 3:q=p.eR(c,!1,"simple-opfs") +s=1 +break +case 1:return A.aJ(q,r)}}) +return A.aK($async$eS,r)}, +eR(a,b,c){var s=0,r=A.aM(t.h),q,p,o,n +var $async$eR=A.aN(function(d,e){if(d===1)return A.aI(e,r) +for(;;)switch(s){case 0:p=A.iC() +o=$.hs() +n=new A.cg(p,o,c) +s=3 +return A.R(n.a4(a,!1),$async$eR) +case 3:q=n +s=1 +break +case 1:return A.aJ(q,r)}}) +return A.aK($async$eR,r)}, +bu:function bu(a,b,c){this.c=a +this.a=b +this.b=c}, +cg:function cg(a,b,c){var _=this +_.d=null +_.e=a +_.b=b +_.a=c}, +eT:function eT(a,b){this.a=a +this.b=b}, +dY:function dY(a,b,c){var _=this +_.a=a +_.b=b +_.c=c +_.d=0}, +fB:function fB(a,b,c,d){var _=this +_.a=a +_.b=b +_.c=c +_.d=d}, +le(a,b){var s=A.B(A.B(a.exports).memory) +b.b!==$&&A.jS() +b.b=s +s=new A.f4(s,b,A.B(a.exports)) +s.c9(a,b) +return s}, +hO(a,b){var s=A.ar(t.a.a(a.buffer),b,null),r=s.length,q=0 +for(;;){if(!(q0)throw A.d(A.iz("BigInt value exceeds the range of 64 bits")) +return a}, +i7(a,b,c){var s=a?2049:1 +if(b)s|=524288 +return s}, +l3(a){var s,r,q=a.a,p=a.b,o=q.d,n=A.c(o.sqlite3_value_type(p)) +A:{s=null +if(1===n){q=A.c(A.Q(v.G.Number(t.C.a(o.sqlite3_value_int64(p))))) +break A}if(2===n){q=A.Q(o.sqlite3_value_double(p)) +break A}if(3===n){r=A.c(o.sqlite3_value_bytes(p)) +q=A.av(q.b,A.c(o.sqlite3_value_text(p)),r) +break A}if(4===n){r=A.c(o.sqlite3_value_bytes(p)) +q=A.j_(q.b,A.c(o.sqlite3_value_blob(p)),r) +break A}q=s +break A}return q}, +kH(a,b){var s,r,q,p="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012346789" +for(s=b,r=0;r<16;++r,s=q){q=a.bK(61) +if(!(q<61))return A.b(p,q) +q=s+A.bz(p.charCodeAt(q))}return s.charCodeAt(0)==0?s:s}, +jR(){var s=A.B(v.G.navigator) +if("storage" in s)return A.B(s.storage) +return null}, +iA(a,b,c){var s=A.c(a.read(b,c)) +return s}, +iB(a,b,c){var s=A.c(a.write(b,c)) +return s}, +mT(){return A.mX()}},B={} +var w=[A,J,B] +var $={} +A.hC.prototype={} +J.d3.prototype={ +I(a,b){return a===b}, +gu(a){return A.dm(a)}, +i(a){return"Instance of '"+A.dn(a)+"'"}, +gF(a){return A.bn(A.i1(this))}} +J.d5.prototype={ +i(a){return String(a)}, +gu(a){return a?519018:218159}, +gF(a){return A.bn(t.v)}, +$io:1, +$iaO:1} +J.c1.prototype={ +I(a,b){return null==b}, +i(a){return"null"}, +gu(a){return 0}, +$io:1} +J.c2.prototype={$ip:1} +J.aT.prototype={ +gu(a){return 0}, +i(a){return String(a)}} +J.dl.prototype={} +J.bg.prototype={} +J.aB.prototype={ +i(a){var s=a[$.jX()] +if(s==null)s=a[$.bQ()] +if(s==null)return this.c8(a) +return"JavaScript function for "+J.b4(s)}, +$ib6:1} +J.U.prototype={ +gu(a){return 0}, +i(a){return String(a)}} +J.bw.prototype={ +gu(a){return 0}, +i(a){return String(a)}} +J.r.prototype={ +t(a,b){A.am(a).c.a(b) +a.$flags&1&&A.l(a,29) +a.push(b)}, +ak(a,b,c){var s=A.am(a) +return new A.a5(a,s.E(c).h("1(2)").a(b),s.h("@<1>").E(c).h("a5<1,2>"))}, +aq(a,b){return A.iX(a,b,null,A.am(a).c)}, +H(a,b){if(!(b>=0&&b0)return a[0] +throw A.d(A.ex())}, +gda(a){var s=a.length +if(s>0)return a[s-1] +throw A.d(A.ex())}, +ga_(a){var s=a.length +if(s===1){if(0>=s)return A.b(a,0) +return a[0]}if(s===0)throw A.d(A.ex()) +throw A.d(A.iE())}, +dc(a,b){var s,r=a.length,q=r-1 +if(q<0)return-1 +q=0;--s){if(!(s"))}, +gu(a){return A.dm(a)}, +gk(a){return a.length}, +j(a,b){if(!(b>=0&&b=0&&b=p){r.d=null +return!1}r.d=q[s] +r.c=s+1 +return!0}, +$iK:1} +J.bv.prototype={ +m(a,b){var s +A.jn(b) +if(ab)return 1 +else if(a===b){if(a===0){s=this.gaY(b) +if(this.gaY(a)===s)return 0 +if(this.gaY(a))return-1 +return 1}return 0}else if(isNaN(a)){if(isNaN(b))return 0 +return 1}else return-1}, +gaY(a){return a===0?1/a<0:a<0}, +bP(a){var s +if(a>=-2147483648&&a<=2147483647)return a|0 +if(isFinite(a)){s=a<0?Math.ceil(a):Math.floor(a) +return s+0}throw A.d(A.aY(""+a+".toInt()"))}, +cE(a){var s,r +if(a>=0){if(a<=2147483647){s=a|0 +return a===s?s:s+1}}else if(a>=-2147483648)return a|0 +r=Math.ceil(a) +if(isFinite(r))return r +throw A.d(A.aY(""+a+".ceil()"))}, +i(a){if(a===0&&1/a<0)return"-0.0" +else return""+a}, +gu(a){var s,r,q,p,o=a|0 +if(a===o)return o&536870911 +s=Math.abs(a) +r=Math.log(s)/0.6931471805599453|0 +q=Math.pow(2,r) +p=s<1?s/q:q/s +return((p*9007199254740992|0)+(p*3542243181176521|0))*599197+r*1259&536870911}, +n(a,b){var s=a%b +if(s===0)return 0 +if(s>0)return s +return s+b}, +ad(a,b){if((a|0)===a)if(b>=1||b<-1)return a/b|0 +return this.bo(a,b)}, +l(a,b){return(a|0)===a?a/b|0:this.bo(a,b)}, +bo(a,b){var s=a/b +if(s>=-2147483648&&s<=2147483647)return s|0 +if(s>0){if(s!==1/0)return Math.floor(s)}else if(s>-1/0)return Math.ceil(s) +throw A.d(A.aY("Result of truncating division is "+A.n(s)+": "+A.n(a)+" ~/ "+b))}, +O(a,b){if(b<0)throw A.d(A.i4(b)) +return b>31?0:a<>>0}, +Z(a,b){var s +if(b<0)throw A.d(A.i4(b)) +if(a>0)s=this.bm(a,b) +else{s=b>31?31:b +s=a>>s>>>0}return s}, +B(a,b){var s +if(a>0)s=this.bm(a,b) +else{s=b>31?31:b +s=a>>s>>>0}return s}, +bm(a,b){return b>31?0:a>>>b}, +gF(a){return A.bn(t.o)}, +$it:1, +$im:1, +$ia2:1} +J.c0.prototype={ +gbw(a){var s,r=a<0?-a-1:a,q=r +for(s=32;q>=4294967296;){q=this.l(q,4294967296) +s+=32}return s-Math.clz32(q)}, +gF(a){return A.bn(t.S)}, +$io:1, +$ia:1} +J.d6.prototype={ +gF(a){return A.bn(t.i)}, +$io:1} +J.aS.prototype={ +c7(a,b){var s=b.length +if(s>a.length)return!1 +return b===a.substring(0,s)}, +a1(a,b,c){return a.substring(b,A.dp(b,c,a.length))}, +b8(a,b){return this.a1(a,b,null)}, +q(a,b){var s,r +if(0>=b)return"" +if(b===1||a.length===0)return a +if(b!==b>>>0)throw A.d(B.J) +for(s=a,r="";;){if((b&1)===1)r=s+r +b=b>>>1 +if(b===0)break +s+=s}return r}, +R(a,b,c){var s=b-a.length +if(s<=0)return a +return this.q(c,s)+a}, +dk(a,b,c){var s=b-a.length +if(s<=0)return a +return a+this.q(c,s)}, +m(a,b){var s +A.A(b) +if(a===b)s=0 +else s=a>6}r=r+((r&67108863)<<3)&536870911 +r^=r>>11 +return r+((r&16383)<<15)&536870911}, +gF(a){return A.bn(t.N)}, +gk(a){return a.length}, +$io:1, +$it:1, +$ieI:1, +$ih:1} +A.bx.prototype={ +i(a){return"LateInitializationError: "+this.a}} +A.eP.prototype={} +A.i.prototype={} +A.V.prototype={ +gG(a){var s=this +return new A.b9(s,s.gk(s),A.S(s).h("b9"))}, +bJ(a,b){var s,r,q,p=this,o=p.gk(p) +if(b.length!==0){if(o===0)return"" +s=A.n(p.H(0,0)) +if(o!==p.gk(p))throw A.d(A.aQ(p)) +for(r=s,q=1;qs)return s +return r}, +gcz(){var s=J.ac(this.a),r=this.b +if(r>s)return s +return r}, +gk(a){var s,r=J.ac(this.a),q=this.b +if(q>=r)return 0 +s=this.c +if(s==null||s>=r)return r-q +return s-q}, +H(a,b){var s=this,r=s.gcz()+b +if(b<0||r>=s.gcm())throw A.d(A.ew(b,s.gk(0),s,null,"index")) +return J.ij(s.a,r)}, +bQ(a,b){var s,r,q,p=this,o=p.b,n=p.a,m=J.cL(n),l=m.gk(n),k=p.c +if(k!=null&&k=o){r.d=null +return!1}r.d=p.H(q,s);++r.c +return!0}, +$iK:1} +A.bb.prototype={ +gG(a){var s=this.a +return new A.c6(s.gG(s),this.b,A.S(this).h("c6<1,2>"))}, +gk(a){var s=this.a +return s.gk(s)}} +A.bV.prototype={$ii:1} +A.c6.prototype={ +p(){var s=this,r=s.b +if(r.p()){s.a=s.c.$1(r.gv()) +return!0}s.a=null +return!1}, +gv(){var s=this.a +return s==null?this.$ti.y[1].a(s):s}, +$iK:1} +A.a5.prototype={ +gk(a){return J.ac(this.a)}, +H(a,b){return this.b.$1(J.ij(this.a,b))}} +A.cp.prototype={ +gG(a){return new A.bh(J.cN(this.a),this.b,this.$ti.h("bh<1>"))}} +A.bh.prototype={ +p(){var s,r +for(s=this.a,r=this.b;s.p();)if(r.$1(s.gv()))return!0 +return!1}, +gv(){return this.a.gv()}, +$iK:1} +A.bd.prototype={ +gG(a){var s=this.a +return new A.ch(s.gG(s),this.b,A.S(this).h("ch<1>"))}} +A.bW.prototype={ +gk(a){var s=this.a,r=s.gk(s)-this.b +if(r>=0)return r +return 0}, +$ii:1} +A.ch.prototype={ +p(){var s,r +for(s=this.a,r=0;r>>0}, +i(a){return"Closure '"+this.$_name+"' of "+("Instance of '"+A.dn(this.a)+"'")}} +A.dr.prototype={ +i(a){return"RuntimeError: "+this.a}} +A.c3.prototype={ +gk(a){return this.a}, +gaZ(){return new A.c5(this,A.S(this).h("c5<1>"))}, +a6(a){var s=this.b +if(s==null)return!1 +return s[a]!=null}, +j(a,b){var s,r,q,p,o=null +if(typeof b=="string"){s=this.b +if(s==null)return o +r=s[b] +q=r==null?o:r.b +return q}else if(typeof b=="number"&&(b&0x3fffffff)===b){p=this.c +if(p==null)return o +r=p[b] +q=r==null?o:r.b +return q}else return this.d7(b)}, +d7(a){var s,r,q=this.d +if(q==null)return null +s=this.cq(q,a) +r=this.aW(s,a) +if(r<0)return null +return s[r].b}, +A(a,b,c){var s,r,q,p,o,n,m=this,l=A.S(m) +l.c.a(b) +l.y[1].a(c) +if(typeof b=="string"){s=m.b +m.ba(s==null?m.b=m.aL():s,b,c)}else if(typeof b=="number"&&(b&0x3fffffff)===b){r=m.c +m.ba(r==null?m.c=m.aL():r,b,c)}else{q=m.d +if(q==null)q=m.d=m.aL() +p=m.aV(b) +o=q[p] +if(o==null)q[p]=[m.aM(b,c)] +else{n=m.aW(o,b) +if(n>=0)o[n].b=c +else o.push(m.aM(b,c))}}}, +am(a,b){var s=this +if(typeof b=="string")return s.bl(s.b,b) +else if(typeof b=="number"&&(b&0x3fffffff)===b)return s.bl(s.c,b) +else return s.d8(b)}, +d8(a){var s,r,q,p,o=this,n=o.d +if(n==null)return null +s=o.aV(a) +r=n[s] +q=o.aW(r,a) +if(q<0)return null +p=r.splice(q,1)[0] +o.br(p) +if(r.length===0)delete n[s] +return p.b}, +aU(a,b){var s,r,q=this +A.S(q).h("~(1,2)").a(b) +s=q.e +r=q.r +while(s!=null){b.$2(s.a,s.b) +if(r!==q.r)throw A.d(A.aQ(q)) +s=s.c}}, +ba(a,b,c){var s,r=A.S(this) +r.c.a(b) +r.y[1].a(c) +s=a[b] +if(s==null)a[b]=this.aM(b,c) +else s.b=c}, +bl(a,b){var s +if(a==null)return null +s=a[b] +if(s==null)return null +this.br(s) +delete a[b] +return s.b}, +aK(){this.r=this.r+1&1073741823}, +aM(a,b){var s=this,r=A.S(s),q=new A.ez(r.c.a(a),r.y[1].a(b)) +if(s.e==null)s.e=s.f=q +else{r=s.f +r.toString +q.d=r +s.f=r.c=q}++s.a +s.aK() +return q}, +br(a){var s=this,r=a.d,q=a.c +if(r==null)s.e=q +else r.c=q +if(q==null)s.f=r +else q.d=r;--s.a +s.aK()}, +aV(a){return J.ag(a)&1073741823}, +cq(a,b){return a[this.aV(b)]}, +aW(a,b){var s,r +if(a==null)return-1 +s=a.length +for(r=0;r"]=s +delete s[""] +return s}} +A.ez.prototype={} +A.c5.prototype={ +gk(a){return this.a.a}, +gG(a){var s=this.a +return new A.c4(s,s.r,s.e,this.$ti.h("c4<1>"))}} +A.c4.prototype={ +gv(){return this.d}, +p(){var s,r=this,q=r.a +if(r.b!==q.r)throw A.d(A.aQ(q)) +s=r.c +if(s==null){r.d=null +return!1}else{r.d=s.a +r.c=s.c +return!0}}, +$iK:1} +A.eA.prototype={ +gk(a){return this.a.a}, +gG(a){var s=this.a +return new A.b8(s,s.r,s.e,this.$ti.h("b8<1>"))}} +A.b8.prototype={ +gv(){return this.d}, +p(){var s,r=this,q=r.a +if(r.b!==q.r)throw A.d(A.aQ(q)) +s=r.c +if(s==null){r.d=null +return!1}else{r.d=s.b +r.c=s.c +return!0}}, +$iK:1} +A.hf.prototype={ +$1(a){return this.a(a)}, +$S:41} +A.hg.prototype={ +$2(a,b){return this.a(a,b)}, +$S:33} +A.hh.prototype={ +$1(a){return this.a(A.A(a))}, +$S:43} +A.aH.prototype={ +i(a){return this.bq(!1)}, +bq(a){var s,r,q,p,o,n=this.co(),m=this.bg(),l=(a?"Record ":"")+"(" +for(s=n.length,r="",q=0;q0;){--q;--s +B.b.A(k,q,r[s])}}return A.db(k,t.K)}} +A.b0.prototype={ +bg(){return[this.a,this.b]}, +I(a,b){if(b==null)return!1 +return b instanceof A.b0&&this.$s===b.$s&&J.ab(this.a,b.a)&&J.ab(this.b,b.b)}, +gu(a){return A.eG(this.$s,this.a,this.b,B.f)}} +A.d7.prototype={ +i(a){return"RegExp/"+this.a+"/"+this.b.flags}, +a3(a){var s=this.b.exec(a) +if(s==null)return null +return new A.fA(s)}, +$ieI:1, +$il4:1} +A.fA.prototype={ +ga8(){var s=this.b +return s.index+s[0].length}} +A.fk.prototype={ +K(){var s=this.b +if(s===this)throw A.d(A.iI(this.a)) +return s}} +A.aU.prototype={ +gF(a){return B.aq}, +bu(a,b,c){A.cH(a,b,c) +return c==null?new Uint8Array(a,b):new Uint8Array(a,b,c)}, +cB(a,b,c){var s +A.cH(a,b,c) +s=new DataView(a,b) +return s}, +bt(a){return this.cB(a,0,null)}, +$io:1, +$iaU:1} +A.by.prototype={$iby:1} +A.c9.prototype={ +gaP(a){if(((a.$flags|0)&2)!==0)return new A.fJ(a.buffer) +else return a.buffer}, +cr(a,b,c,d){var s=A.P(b,0,c,d,null) +throw A.d(s)}, +bc(a,b,c,d){if(b>>>0!==b||b>c)this.cr(a,b,c,d)}} +A.fJ.prototype={ +bu(a,b,c){var s=A.ar(this.a,b,c) +s.$flags=3 +return s}, +bt(a){var s=A.iO(this.a,0,null) +s.$flags=3 +return s}} +A.c7.prototype={ +gF(a){return B.ar}, +$io:1, +$iir:1} +A.L.prototype={ +gk(a){return a.length}, +cw(a,b,c,d,e){var s,r,q=a.length +this.bc(a,b,q,"start") +this.bc(a,c,q,"end") +if(b>c)throw A.d(A.P(b,0,c,null,null)) +s=c-b +if(e<0)throw A.d(A.J(e,null)) +r=d.length +if(r-e").b(a))s.bb(a) +else s.bd(a)}}, +aR(a,b){var s=this.a +if(this.b)s.aD(new A.ad(a,b)) +else s.aw(new A.ad(a,b))}} +A.fP.prototype={ +$1(a){return this.a.$2(0,a)}, +$S:6} +A.fQ.prototype={ +$2(a,b){this.a.$2(1,new A.bY(a,t.l.a(b)))}, +$S:45} +A.h7.prototype={ +$2(a,b){this.a(A.c(a),b)}, +$S:48} +A.ad.prototype={ +i(a){return A.n(this.a)}, +$iq:1, +ga0(){return this.b}} +A.dI.prototype={ +aR(a,b){var s=this.a +if((s.a&30)!==0)throw A.d(A.at("Future already completed")) +s.aw(A.m7(a,b))}, +bx(a){return this.aR(a,null)}} +A.cq.prototype={ +aQ(a){var s,r=this.$ti +r.h("1/?").a(a) +s=this.a +if((s.a&30)!==0)throw A.d(A.at("Future already completed")) +s.av(r.h("1/").a(a))}} +A.bj.prototype={ +dh(a){if((this.c&15)!==6)return!0 +return this.b.b.b_(t.bO.a(this.d),a.a,t.v,t.K)}, +d6(a){var s,r=this,q=r.e,p=null,o=t.z,n=t.K,m=a.a,l=r.b.b +if(t.R.b(q))p=l.dn(q,m,a.b,o,n,t.l) +else p=l.b_(t.x.a(q),m,o,n) +try{o=r.$ti.h("2/").a(p) +return o}catch(s){if(t.eK.b(A.I(s))){if((r.c&1)!==0)throw A.d(A.J("The error handler of Future.then must return a value of the returned future's type","onError")) +throw A.d(A.J("The error handler of Future.catchError must return a value of the future's type","onError"))}else throw s}}} +A.F.prototype={ +b0(a,b,c){var s,r,q,p=this.$ti +p.E(c).h("1/(2)").a(a) +s=$.w +if(s===B.d){if(b!=null&&!t.R.b(b)&&!t.x.b(b))throw A.d(A.ax(b,"onError",u.c))}else{c.h("@<0/>").E(p.c).h("1(2)").a(a) +if(b!=null)b=A.mo(b,s)}r=new A.F(s,c.h("F<0>")) +q=b==null?1:3 +this.au(new A.bj(r,q,a,b,p.h("@<1>").E(c).h("bj<1,2>"))) +return r}, +ds(a,b){return this.b0(a,null,b)}, +bp(a,b,c){var s,r=this.$ti +r.E(c).h("1/(2)").a(a) +s=new A.F($.w,c.h("F<0>")) +this.au(new A.bj(s,19,a,b,r.h("@<1>").E(c).h("bj<1,2>"))) +return s}, +cv(a){this.a=this.a&1|16 +this.c=a}, +af(a){this.a=a.a&30|this.a&1 +this.c=a.c}, +au(a){var s,r=this,q=r.a +if(q<=3){a.a=t.F.a(r.c) +r.c=a}else{if((q&4)!==0){s=t.d.a(r.c) +if((s.a&24)===0){s.au(a) +return}r.af(s)}A.e1(null,null,r.b,t.M.a(new A.fp(r,a)))}}, +bh(a){var s,r,q,p,o,n,m=this,l={} +l.a=a +if(a==null)return +s=m.a +if(s<=3){r=t.F.a(m.c) +m.c=a +if(r!=null){q=a.a +for(p=a;q!=null;p=q,q=o)o=q.a +p.a=r}}else{if((s&4)!==0){n=t.d.a(m.c) +if((n.a&24)===0){n.bh(a) +return}m.af(n)}l.a=m.ah(a) +A.e1(null,null,m.b,t.M.a(new A.ft(l,m)))}}, +ag(){var s=t.F.a(this.c) +this.c=null +return this.ah(s)}, +ah(a){var s,r,q +for(s=a,r=null;s!=null;r=s,s=q){q=s.a +s.a=r}return r}, +bd(a){var s,r=this +r.$ti.c.a(a) +s=r.ag() +r.a=8 +r.c=a +A.bE(r,s)}, +cg(a){var s,r,q=this +if((a.a&16)!==0){s=q.b===a.b +s=!(s||s)}else s=!1 +if(s)return +r=q.ag() +q.af(a) +A.bE(q,r)}, +aD(a){var s=this.ag() +this.cv(a) +A.bE(this,s)}, +av(a){var s=this.$ti +s.h("1/").a(a) +if(s.h("ai<1>").b(a)){this.bb(a) +return}this.cc(a)}, +cc(a){var s=this +s.$ti.c.a(a) +s.a^=2 +A.e1(null,null,s.b,t.M.a(new A.fr(s,a)))}, +bb(a){A.hV(this.$ti.h("ai<1>").a(a),this,!1) +return}, +aw(a){this.a^=2 +A.e1(null,null,this.b,t.M.a(new A.fq(this,a)))}, +$iai:1} +A.fp.prototype={ +$0(){A.bE(this.a,this.b)}, +$S:0} +A.ft.prototype={ +$0(){A.bE(this.b,this.a.a)}, +$S:0} +A.fs.prototype={ +$0(){A.hV(this.a.a,this.b,!0)}, +$S:0} +A.fr.prototype={ +$0(){this.a.bd(this.b)}, +$S:0} +A.fq.prototype={ +$0(){this.a.aD(this.b)}, +$S:0} +A.fw.prototype={ +$0(){var s,r,q,p,o,n,m,l,k=this,j=null +try{q=k.a.a +j=q.b.b.dm(t.fO.a(q.d),t.z)}catch(p){s=A.I(p) +r=A.bp(p) +if(k.c&&t.n.a(k.b.a.c).a===s){q=k.a +q.c=t.n.a(k.b.a.c)}else{q=s +o=r +if(o==null)o=A.hx(q) +n=k.a +n.c=new A.ad(q,o) +q=n}q.b=!0 +return}if(j instanceof A.F&&(j.a&24)!==0){if((j.a&16)!==0){q=k.a +q.c=t.n.a(j.c) +q.b=!0}return}if(j instanceof A.F){m=k.b.a +l=new A.F(m.b,m.$ti) +j.b0(new A.fx(l,m),new A.fy(l),t.H) +q=k.a +q.c=l +q.b=!1}}, +$S:0} +A.fx.prototype={ +$1(a){this.a.cg(this.b)}, +$S:13} +A.fy.prototype={ +$2(a,b){A.bl(a) +t.l.a(b) +this.a.aD(new A.ad(a,b))}, +$S:60} +A.fv.prototype={ +$0(){var s,r,q,p,o,n,m,l +try{q=this.a +p=q.a +o=p.$ti +n=o.c +m=n.a(this.b) +q.c=p.b.b.b_(o.h("2/(1)").a(p.d),m,o.h("2/"),n)}catch(l){s=A.I(l) +r=A.bp(l) +q=s +p=r +if(p==null)p=A.hx(q) +o=this.a +o.c=new A.ad(q,p) +o.b=!0}}, +$S:0} +A.fu.prototype={ +$0(){var s,r,q,p,o,n,m,l=this +try{s=t.n.a(l.a.a.c) +p=l.b +if(p.a.dh(s)&&p.a.e!=null){p.c=p.a.d6(s) +p.b=!1}}catch(o){r=A.I(o) +q=A.bp(o) +p=t.n.a(l.a.a.c) +if(p.a===r){n=l.b +n.c=p +p=n}else{p=r +n=q +if(n==null)n=A.hx(p) +m=l.b +m.c=new A.ad(p,n) +p=m}p.b=!0}}, +$S:0} +A.dG.prototype={} +A.dZ.prototype={} +A.cG.prototype={$ij0:1} +A.dV.prototype={ +dq(a){var s,r,q +t.M.a(a) +try{if(B.d===$.w){a.$0() +return}A.jy(null,null,this,a,t.H)}catch(q){s=A.I(q) +r=A.bp(q) +A.h5(A.bl(s),t.l.a(r))}}, +dr(a,b,c){var s,r,q +c.h("~(0)").a(a) +c.a(b) +try{if(B.d===$.w){a.$1(b) +return}A.jz(null,null,this,a,b,t.H,c)}catch(q){s=A.I(q) +r=A.bp(q) +A.h5(A.bl(s),t.l.a(r))}}, +cC(a){return new A.fD(this,t.M.a(a))}, +cD(a,b){return new A.fE(this,b.h("~(0)").a(a),b)}, +dm(a,b){b.h("0()").a(a) +if($.w===B.d)return a.$0() +return A.jy(null,null,this,a,b)}, +b_(a,b,c,d){c.h("@<0>").E(d).h("1(2)").a(a) +d.a(b) +if($.w===B.d)return a.$1(b) +return A.jz(null,null,this,a,b,c,d)}, +dn(a,b,c,d,e,f){d.h("@<0>").E(e).E(f).h("1(2,3)").a(a) +e.a(b) +f.a(c) +if($.w===B.d)return a.$2(b,c) +return A.mq(null,null,this,a,b,c,d,e,f)}, +bM(a,b,c,d){return b.h("@<0>").E(c).E(d).h("1(2,3)").a(a)}} +A.fD.prototype={ +$0(){return this.a.dq(this.b)}, +$S:0} +A.fE.prototype={ +$1(a){var s=this.c +return this.a.dr(this.b,s.a(a),s)}, +$S(){return this.c.h("~(0)")}} +A.h6.prototype={ +$0(){A.kD(this.a,this.b)}, +$S:0} +A.k.prototype={ +gG(a){return new A.b9(a,this.gk(a),A.aw(a).h("b9"))}, +H(a,b){return this.j(a,b)}, +ga_(a){if(this.gk(a)===0)throw A.d(A.ex()) +if(this.gk(a)>1)throw A.d(A.iE()) +return this.j(a,0)}, +ak(a,b,c){var s=A.aw(a) +return new A.a5(a,s.E(c).h("1(k.E)").a(b),s.h("@").E(c).h("a5<1,2>"))}, +aq(a,b){return A.iX(a,b,null,A.aw(a).h("k.E"))}, +bE(a,b,c,d){var s +A.aw(a).h("k.E?").a(d) +A.dp(b,c,this.gk(a)) +for(s=b;s").a(d) +A.dp(b,c,this.gk(a)) +s=c-b +if(s===0)return +A.bA(e,"skipCount") +if(t.b.b(d)){r=e +q=d}else{q=J.kp(d,e).bQ(0,!1) +r=0}p=J.cL(q) +if(r+s>p.gk(q))throw A.d(A.kL()) +if(r=0;--o)this.A(a,b+o,p.j(q,r+o)) +else for(o=0;o").a(c) +this.T(a,b,b+c.length,c)}, +i(a){return A.iF(a,"[","]")}, +$ii:1, +$if:1, +$ij:1} +A.a_.prototype={ +aU(a,b){var s,r,q,p=A.S(this) +p.h("~(a_.K,a_.V)").a(b) +for(s=J.cN(this.gaZ()),p=p.h("a_.V");s.p();){r=s.gv() +q=this.j(0,r) +b.$2(r,q==null?p.a(q):q)}}, +gk(a){return J.ac(this.gaZ())}, +i(a){return A.hI(this)}, +$iba:1} +A.eD.prototype={ +$2(a,b){var s,r=this.a +if(!r.a)this.b.a+=", " +r.a=!1 +r=this.b +s=A.n(a) +r.a=(r.a+=s)+": " +s=A.n(b) +r.a+=s}, +$S:22} +A.fL.prototype={ +$0(){var s,r +try{s=new TextDecoder("utf-8",{fatal:true}) +return s}catch(r){}return null}, +$S:11} +A.fK.prototype={ +$0(){var s,r +try{s=new TextDecoder("utf-8",{fatal:false}) +return s}catch(r){}return null}, +$S:11} +A.bS.prototype={} +A.cW.prototype={} +A.d_.prototype={} +A.dy.prototype={ +bB(a){t.L.a(a) +return new A.cF(!1).aE(a,0,null,!0)}} +A.f2.prototype={ +P(a){var s,r,q,p,o=a.length,n=A.dp(0,null,o) +if(n===0)return new Uint8Array(0) +s=n*3 +r=new Uint8Array(s) +q=new A.fM(r) +if(q.cp(a,0,n)!==n){p=n-1 +if(!(p>=0&&p>>18|240 +q=n.b=p+1 +if(!(p>>12&63|128 +p=n.b=q+1 +if(!(q>>6&63|128 +n.b=p+1 +if(!(p=0&&s=q)break +k.b=m+1 +r&2&&A.l(s) +s[m]=n}else{m=n&64512 +if(m===55296){if(k.b+4>q)break +m=o+1 +if(!(mq)break +k.aO()}else if(n<=2047){m=k.b +l=m+1 +if(l>=q)break +k.b=l +r&2&&A.l(s) +if(!(m>>6|192 +k.b=l+1 +s[l]=n&63|128}else{m=k.b +if(m+2>=q)break +l=k.b=m+1 +r&2&&A.l(s) +if(!(m>>12|224 +m=k.b=l+1 +if(!(l>>6&63|128 +k.b=m+1 +if(!(m=15){o=l.a +n=A.lJ(o,q,b,s) +if(n!=null){if(!o)return n +if(n.indexOf("\ufffd")<0)return n}}n=l.aF(q,b,s,!0) +o=l.b +if((o&1)!==0){m=A.lL(o) +l.b=0 +throw A.d(A.ev(m,a,p+l.c))}return n}, +aF(a,b,c,d){var s,r,q=this +if(c-b>1000){s=B.a.l(b+c,2) +r=q.aF(a,b,s,!1) +if((q.b&1)!==0)return r +return r+q.aF(a,s,c,d)}return q.cG(a,b,c,d)}, +cG(a,b,a0,a1){var s,r,q,p,o,n,m,l,k=this,j="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFFFFFFFFFFFFFFFFGGGGGGGGGGGGGGGGHHHHHHHHHHHHHHHHHHHHHHHHHHHIHHHJEEBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBKCCCCCCCCCCCCDCLONNNMEEEEEEEEEEE",i=" \x000:XECCCCCN:lDb \x000:XECCCCCNvlDb \x000:XECCCCCN:lDb AAAAA\x00\x00\x00\x00\x00AAAAA00000AAAAA:::::AAAAAGG000AAAAA00KKKAAAAAG::::AAAAA:IIIIAAAAA000\x800AAAAA\x00\x00\x00\x00 AAAAA",h=65533,g=k.b,f=k.c,e=new A.cm(""),d=b+1,c=a.length +if(!(b>=0&&b=0&&s<256))return A.b(j,s) +q=j.charCodeAt(s)&31 +f=g<=32?s&61694>>>q:(s&63|f<<6)>>>0 +p=g+q +if(!(p>=0&&p<144))return A.b(i,p) +g=i.charCodeAt(p) +if(g===0){p=A.bz(f) +e.a+=p +if(d===a0)break A +break}else if((g&1)!==0){if(r)switch(g){case 69:case 67:p=A.bz(h) +e.a+=p +break +case 65:p=A.bz(h) +e.a+=p;--d +break +default:p=A.bz(h) +e.a=(e.a+=p)+p +break}else{k.b=g +k.c=d-1 +return""}g=0}if(d===a0)break A +o=d+1 +if(!(d>=0&&d=0&&d=0&&o=128){n=m-1 +o=m +break}o=m}if(n-d<20)for(l=d;l32)if(r){c=A.bz(h) +e.a+=c}else{k.b=77 +k.c=a0 +return""}k.b=g +k.c=f +c=e.a +return c.charCodeAt(0)==0?c:c}} +A.z.prototype={ +D(a){var s,r,q=this,p=q.c +if(p===0)return q +s=!q.a +r=q.b +p=A.a1(p,r) +return new A.z(p===0?!1:s,r,p)}, +ck(a){var s,r,q,p,o,n,m,l=this.c +if(l===0)return $.E() +s=l+a +r=this.b +q=new Uint16Array(s) +for(p=l-1,o=r.length;p>=0;--p){n=p+a +if(!(p=0&&n=0&&o=0&&r>>0!==0)return l.ac(0,$.Z()) +for(k=0;k=0)return q.ae(b,r) +return b.ae(q,!r)}, +ac(a,b){var s,r,q=this,p=q.c +if(p===0)return b.D(0) +s=b.c +if(s===0)return q +r=q.a +if(r!==b.a)return q.ar(b,r) +if(A.fh(q.b,p,b.b,s)>=0)return q.ae(b,r) +return b.ae(q,!r)}, +q(a,b){var s,r,q,p,o,n,m,l=this.c,k=b.c +if(l===0||k===0)return $.E() +s=l+k +r=this.b +q=b.b +p=new Uint16Array(s) +for(o=q.length,n=0;n0?p.D(0):p}, +bk(a){var s,r,q,p=this +if(p.c0)q=q.Z(0,$.hR.K()) +return p.a&&q.c>0?q.D(0):q}, +bf(a){var s,r,q,p,o,n,m,l,k,j,i,h,g,f,e,d,c=this,b=c.c +if(b===$.j4&&a.c===$.j6&&c.b===$.j3&&a.b===$.j5)return +s=a.b +r=a.c +q=r-1 +if(!(q>=0&&q0){o=new Uint16Array(r+5) +n=A.j2(s,r,p,o) +m=new Uint16Array(b+5) +l=A.j2(c.b,b,p,m)}else{m=A.hS(c.b,0,b,b+2) +n=r +o=s +l=b}q=n-1 +if(!(q>=0&&q=0){q&2&&A.l(m) +if(!(l>=0&&l=0&&l=0&&n0;){d=A.ll(k,m,e);--j +A.j8(d,f,0,m,j,n) +if(!(e>=0&&e=m.length)return A.b(m,0) +return B.a.i(-m[0])}m=n.b +if(0>=m.length)return A.b(m,0) +return B.a.i(m[0])}s=A.x([],t.s) +m=n.a +r=m?n.D(0):n +while(r.c>1){q=$.ie() +if(q.c===0)A.u(B.m) +p=r.bk(q).i(0) +B.b.t(s,p) +o=p.length +if(o===1)B.b.t(s,"000") +if(o===2)B.b.t(s,"00") +if(o===3)B.b.t(s,"0") +r=r.be(q)}q=r.b +if(0>=q.length)return A.b(q,0) +B.b.t(s,B.a.i(q[0])) +if(m)B.b.t(s,"-") +return new A.ce(s,t.bJ).d9(0)}, +$ie4:1, +$it:1} +A.fi.prototype={ +$2(a,b){a=a+b&536870911 +a=a+((a&524287)<<10)&536870911 +return a^a>>>6}, +$S:26} +A.fj.prototype={ +$1(a){a=a+((a&67108863)<<3)&536870911 +a^=a>>>11 +return a+((a&16383)<<15)&536870911}, +$S:28} +A.ct.prototype={ +bv(a,b,c){var s +this.$ti.c.a(b) +s=this.a +if(s!=null)s.register(a,b,c)}, +bC(a){var s=this.a +if(s!=null)s.unregister(a)}, +$ikE:1} +A.eu.prototype={ +$0(){var s=this +return A.u(A.J("("+s.a+", "+s.b+", "+s.c+", "+s.d+", "+s.e+", "+s.f+", "+s.r+", "+s.w+")",null))}, +$S:29} +A.a3.prototype={ +I(a,b){if(b==null)return!1 +return b instanceof A.a3&&this.a===b.a&&this.b===b.b&&this.c===b.c}, +gu(a){return A.eG(this.a,this.b,B.f,B.f)}, +m(a,b){var s +t.k.a(b) +s=B.a.m(this.a,b.a) +if(s!==0)return s +return B.a.m(this.b,b.b)}, +dt(){var s=this +if(s.c)return s +return new A.a3(s.a,s.b,!0)}, +i(a){var s=this,r=A.kA(A.eO(s)),q=A.cZ(A.eM(s)),p=A.cZ(A.eJ(s)),o=A.cZ(A.eK(s)),n=A.cZ(A.eL(s)),m=A.cZ(A.eN(s)),l=A.iu(A.hJ(s)),k=s.b,j=k===0?"":A.iu(k) +k=r+"-"+q +if(s.c)return k+"-"+p+" "+o+":"+n+":"+m+"."+l+j+"Z" +else return k+"-"+p+" "+o+":"+n+":"+m+"."+l+j}, +$it:1} +A.aR.prototype={ +I(a,b){if(b==null)return!1 +return b instanceof A.aR&&this.a===b.a}, +gu(a){return B.a.gu(this.a)}, +m(a,b){return B.a.m(this.a,t.J.a(b).a)}, +i(a){var s,r,q,p,o,n=this.a,m=B.a.l(n,36e8),l=n%36e8 +if(n<0){m=0-m +n=0-l +s="-"}else{n=l +s=""}r=B.a.l(n,6e7) +n%=6e7 +q=r<10?"0":"" +p=B.a.l(n,1e6) +o=p<10?"0":"" +return s+m+":"+q+r+":"+o+p+"."+B.c.R(B.a.i(n%1e6),6,"0")}, +$it:1} +A.fm.prototype={ +i(a){return this.aH()}} +A.q.prototype={ +ga0(){return A.kW(this)}} +A.cO.prototype={ +i(a){var s=this.a +if(s!=null)return"Assertion failed: "+A.bX(s) +return"Assertion failed"}} +A.aE.prototype={} +A.ah.prototype={ +gaJ(){return"Invalid argument"+(!this.a?"(s)":"")}, +gaI(){return""}, +i(a){var s=this,r=s.c,q=r==null?"":" ("+r+")",p=s.d,o=p==null?"":": "+A.n(p),n=s.gaJ()+q+o +if(!s.a)return n +return n+s.gaI()+": "+A.bX(s.gaX())}, +gaX(){return this.b}} +A.aC.prototype={ +gaX(){return A.jo(this.b)}, +gaJ(){return"RangeError"}, +gaI(){var s,r=this.e,q=this.f +if(r==null)s=q!=null?": Not less than or equal to "+A.n(q):"" +else if(q==null)s=": Not greater than or equal to "+A.n(r) +else if(q>r)s=": Not in inclusive range "+A.n(r)+".."+A.n(q) +else s=qe.length +else s=!1 +if(s)f=null +if(f==null){if(e.length>78)e=B.c.a1(e,0,75)+"..." +return g+"\n"+e}for(r=e.length,q=1,p=0,o=!1,n=0;n1?g+(" (at line "+q+", character "+(f-p+1)+")\n"):g+(" (at character "+(f+1)+")\n") +for(n=f;n=0))return A.b(e,n) +m=e.charCodeAt(n) +if(m===10||m===13){r=n +break}}l="" +if(r-p>78){k="..." +if(f-p<75){j=p+75 +i=p}else{if(r-f<75){i=r-75 +j=r +k=""}else{i=f-36 +j=f+36}l="..."}}else{j=r +i=p +k=""}return g+l+B.c.a1(e,i,j)+k+"\n"+B.c.q(" ",f-i+l.length)+"^\n"}else return f!=null?g+(" (at offset "+A.n(f)+")"):g}} +A.d2.prototype={ +ga0(){return null}, +i(a){return"IntegerDivisionByZeroException"}, +$iq:1} +A.f.prototype={ +ak(a,b,c){var s=A.S(this) +return A.kU(this,s.E(c).h("1(f.E)").a(b),s.h("f.E"),c)}, +bQ(a,b){var s=A.da(this,A.S(this).h("f.E")) +s.$flags=1 +return s}, +gk(a){var s,r=this.gG(this) +for(s=0;r.p();)++s +return s}, +aq(a,b){return A.l6(this,b,A.S(this).h("f.E"))}, +H(a,b){var s,r +A.bA(b,"index") +s=this.gG(this) +for(r=b;s.p();){if(r===0)return s.gv();--r}throw A.d(A.ew(b,b-r,this,null,"index"))}, +i(a){return A.kM(this,"(",")")}} +A.M.prototype={ +gu(a){return A.e.prototype.gu.call(this,0)}, +i(a){return"null"}} +A.e.prototype={$ie:1, +I(a,b){return this===b}, +gu(a){return A.dm(this)}, +i(a){return"Instance of '"+A.dn(this)+"'"}, +gF(a){return A.mM(this)}, +toString(){return this.i(this)}} +A.e_.prototype={ +i(a){return""}, +$iaW:1} +A.cm.prototype={ +gk(a){return this.a.length}, +i(a){var s=this.a +return s.charCodeAt(0)==0?s:s}} +A.d0.prototype={ +i(a){return"Expando:null"}} +A.eE.prototype={ +i(a){return"Promise was rejected with a value of `"+(this.a?"undefined":"null")+"`."}} +A.hl.prototype={ +$1(a){return this.a.aQ(this.b.h("0/?").a(a))}, +$S:6} +A.hm.prototype={ +$1(a){if(a==null)return this.a.bx(new A.eE(a===undefined)) +return this.a.bx(a)}, +$S:6} +A.dQ.prototype={ +ca(){var s=self.crypto +if(s!=null)if(s.getRandomValues!=null)return +throw A.d(A.aY("No source of cryptographically secure random numbers available."))}, +bK(a){var s,r,q,p,o,n,m,l +if(a<=0||a>4294967296)throw A.d(A.aV("max must be in range 0 < max \u2264 2^32, was "+a)) +if(a>255)if(a>65535)s=a>16777215?4:3 +else s=2 +else s=1 +r=this.a +r.$flags&2&&A.l(r,11) +r.setUint32(0,0,!1) +q=4-s +p=A.c(Math.pow(256,s)) +for(o=a-1,n=(a&o)===0;;){crypto.getRandomValues(J.ii(B.ae.gaP(r),q,s)) +m=r.getUint32(0,!1) +if(n)return(m&o)>>>0 +l=m%a +if(m-l+a"));r.p();)r.d.C() +if(s.a>0){s.b=s.c=s.d=s.e=s.f=null +s.a=0 +s.aK()}q.a.C()}} +A.dJ.prototype={ +d4(a){var s,r,q,p=this +if(p.c)return new A.cj(B.ab,p.d,0) +s=A.x([],t.D) +for(r=p.b;s.length=0&&p<6))return A.b(B.i,p) +p=B.i[p] +t.U.a(r) +A.hB(q) +o=r.a +n=o.m(0,$.E()) +if(n===0)A.u(A.aY("Division by zero")) +m=r.b-s.b+q +s=s.a +s=s.q(0,m>0?A.O(10).J(m):$.Z()) +s=A.hz(s,o.q(0,m<0?A.O(10).J(-m):$.Z()),q,p).i(0)}return s}, +$S:3} +A.fV.prototype={ +$1(a){var s,r,q +if(a.j(0,0)==null)s=null +else{s=A.ay(a.j(0,0)) +r=A.c(a.j(0,1)) +q=A.c(a.j(0,2)) +if(!(q>=0&&q<6))return A.b(B.i,q) +q=s.bO(r,B.i[q]).i(0) +s=q}return s}, +$S:3} +A.fW.prototype={ +$1(a){var s,r,q,p +if(a.j(0,0)==null)s=null +else{s=A.ay(a.j(0,0)) +r=A.c(a.j(0,1)) +q=A.c(a.j(0,2)) +A.ix(r,q) +p=s.bO(q,B.t) +if(!p.bG(r,q))A.u(A.aV("Decimal does not fit NUMERIC("+r+", "+q+").")) +s=p.i(0)}return s}, +$S:3} +A.fX.prototype={ +$1(a){var s +if(typeof a.j(0,0)=="string"){s=A.hA(A.A(a.j(0,0))) +s=s==null?null:s.bG(A.c(a.j(0,1)),A.c(a.j(0,2))) +s=s===!0}else s=!1 +return s?1:0}, +$S:39} +A.fY.prototype={ +$2(a,b){var s,r,q +if(a==b)return 0 +if(a==null)return-1 +if(b==null)return 1 +s=A.hA(a) +r=A.hA(b) +q=s!=null +if(q&&r!=null)return s.m(0,r) +if(q)return-1 +if(r!=null)return 1 +return B.c.m(a,b)}, +$S:16} +A.fZ.prototype={ +$1(a){var s,r,q,p +if(a.j(0,0)==null||a.j(0,1)==null)return null +s=A.ay(a.j(0,0)) +r=A.ay(a.j(0,1)) +q=this.a +A:{if("add"===q){p=s.S(0,r) +break A}if("sub"===q){p=s.S(0,new A.az(r.a.D(0),r.b)) +break A}p=A.bt(s.a.q(0,r.a),s.b+r.b) +break A}return p.i(0)}, +$S:3} +A.bi.prototype={ +ai(a,b,c){var s,r=this +if(r.c===0){r.a=b.a +r.b=b.b}else{s=b.b +if(s>r.b){r.a=r.a.q(0,A.O(10).J(s-r.b)) +r.b=s}r.a=r.a.S(0,b.a.q(0,A.O(c)).q(0,A.O(10).J(r.b-s)))}if((r.c+=c)===0){r.a=$.E() +r.b=0}}, +an(){return this.c===0?null:A.bt(this.a,this.b).i(0)}} +A.dL.prototype={ +aS(){return new A.ao(new A.bi($.E()),t.y)}, +b7(a,b){t.y.a(b) +if(a.j(0,0)==null)return +b.a.ai(0,A.ay(a.j(0,0)),1)}, +bI(a,b){t.y.a(b) +if(a.j(0,0)==null)return +b.a.ai(0,A.ay(a.j(0,0)),-1)}, +bS(a){var s=t.y.a(a).a +return s.c===0?null:A.bt(s.a,s.b).i(0)}, +bF(a){return t.y.a(a).a.an()}, +$ie3:1} +A.bD.prototype={ +an(){var s,r,q,p,o,n=this.a +if(n.c===0)return null +s=A.O(10).J(Math.abs(n.b)) +r=n.a +r=r.q(0,n.b<0?s:$.Z()) +q=A.O(n.c) +q=q.q(0,n.b>0?s:$.Z()) +p=this.b +p.toString +o=this.c +o.toString +A.hB(p) +n=q.m(0,$.E()) +if(n===0)A.u(A.aY("Division by zero")) +n=r.q(0,p>0?A.O(10).J(p):$.Z()) +return A.hz(n,q.q(0,p<0?A.O(10).J(-p):$.Z()),p,o).i(0)}} +A.dK.prototype={ +aS(){return new A.ao(new A.bD(new A.bi($.E())),t._)}, +b7(a,b){var s,r=t._.a(b).a,q=A.c(a.j(0,1)),p=A.c(a.j(0,2)) +if(!(p>=0&&p<6))return A.b(B.i,p) +s=B.i[p] +p=r.b +if(p!=null)p=p!==q||r.c!==s +else p=!1 +if(p)A.u(A.J("Average scale and rounding must be constant.",null)) +r.b=q +r.c=s +if(a.j(0,0)!=null)r.a.ai(0,A.ay(a.j(0,0)),1)}, +bI(a,b){t._.a(b) +if(a.j(0,0)!=null)b.a.a.ai(0,A.ay(a.j(0,0)),-1)}, +bS(a){return t._.a(a).a.an()}, +bF(a){return t._.a(a).a.an()}, +$ie3:1} +A.dR.prototype={} +A.h4.prototype={ +$3(a,b,c){var s,r,q,p,o,n,m +A:{if("time"===b){s=A.ky(a).a +s=A.hH(s+A.jC(s,c,!1)) +break A}if("local_datetime"===b){s=A.kx(a).bT(c) +break A}if("instant"===b){r=A.fR(A.hY(a)) +q=A.hF(A.hG(A.eO(r),A.eM(r),A.eJ(r)),A.eB(A.eK(r),A.eL(r),A.eN(r),A.hJ(r)*1000+r.b)).bT(c) +p=q.a +s=q.b.a +o=B.a.l(s,36e8) +n=B.a.n(B.a.l(s,6e7),60) +m=B.a.n(B.a.l(s,1e6),60) +s=B.a.n(s,1e6) +s=A.fR(A.it(p.a,p.b,p.c,o,n,m,B.a.l(s,1000),B.a.n(s,1000))) +break A}s=A.u(A.J("Unknown temporal storage.",null))}return s}, +$S:49} +A.h_.prototype={ +$1(a){var s,r,q,p,o +if(a.j(0,0)==null)return null +s=A.A(a.j(0,1)) +r=A.c(a.j(0,2)) +q=this.a +p=a.j(0,0) +p.toString +o=q.$3(p,s,r) +if(this.b){p=a.j(0,0) +p.toString +return J.ab(o,q.$3(p,s,6))?1:0}return o instanceof A.a3?A.jq(o):J.b4(o)}, +$S:12} +A.h0.prototype={ +$1(a){return a.j(0,0)==null?null:A.jq(t.k.a(A.hY(a.j(0,0))))}, +$S:12} +A.h2.prototype={ +$1$2(a,b,c){A.mC(c,c.h("t<0>"),"T","call") +this.a.bz(new A.h3(c.h("0?(h)").a(b)),"orm_"+a+"_v1")}, +$2(a,b){return this.$1$2(a,b,t.q)}, +$S:23} +A.h3.prototype={ +$2(a,b){var s,r,q +if(a==b)return 0 +if(a==null)return-1 +if(b==null)return 1 +s=this.a +r=s.$1(a) +q=s.$1(b) +s=r!=null +if(s&&q!=null)return J.km(r,q) +if(s)return-1 +if(q!=null)return 1 +return B.c.m(a,b)}, +$S:16} +A.h1.prototype={ +$1(a){var s,r +try{s=A.hY(a) +return s}catch(r){if(A.I(r) instanceof A.v)return null +else throw r}}, +$S:24} +A.hp.prototype={ +$1(a){return A.A(a)}, +$S:25} +A.hq.prototype={ +$1(a){var s=J.ko(t.W.a(a),A.n6(),t.X) +s=A.da(s,s.$ti.h("V.E")) +return s}, +$S:21} +A.ci.prototype={} +A.hy.prototype={} +A.a0.prototype={ +i(a){return"OrmException("+this.a+"): "+this.b}} +A.aA.prototype={ +aH(){return"DecimalRounding."+this.b}} +A.az.prototype={ +S(a,b){var s=this.b,r=b.b,q=Math.max(s,r) +return A.bt(this.a.q(0,A.O(10).J(q-s)).S(0,b.a.q(0,A.O(10).J(q-r))),q)}, +bO(a,b){var s +A.hB(a) +s=this.b +if(a>=s)return this +return A.hz(this.a,A.O(10).J(s-a),a,b)}, +bG(a,b){var s,r +A.ix(a,b) +s=this.a +r=s.m(0,$.E()) +if(r!==0){r=this.b +if(r<=b)s=(s.a?s.D(0):s).i(0).length-r<=a-b +else s=!1}else s=!0 +return s}, +m(a,b){var s,r,q,p,o,n,m +t.U.a(b) +s=this.a +r=b.a +if(s.gU(0)!==r.gU(0))return B.a.m(s.gU(0),r.gU(0)) +q=s.m(0,$.E()) +if(q===0)return 0 +q=this.b +p=(s.a?s.D(0):s).i(0).length-q +o=b.b +n=(r.a?r.D(0):r).i(0).length-o +if(p!==n)return B.a.m(p,n)*s.gU(0) +m=Math.max(q,o) +return s.q(0,A.O(10).J(m-q)).m(0,r.q(0,A.O(10).J(m-o)))}, +I(a,b){var s +if(b==null)return!1 +if(b instanceof A.az){s=this.a.m(0,b.a) +s=s===0&&this.b===b.b}else s=!1 +return s}, +gu(a){return A.eG(this.a,this.b,B.f,B.f)}, +i(a){var s,r=this.a,q=r.a,p=(q?r.D(0):r).i(0),o=q?"-":"" +r=this.b +if(r<=0)return o+p+B.c.q("0",-r) +q=p.length +if(r>=q)return o+"0."+B.c.q("0",r-q)+p +s=q-r +return o+B.c.a1(p,0,s)+"."+B.c.b8(p,s)}, +$it:1} +A.ap.prototype={ +bs(a){var s=this.d +if(a<0-s||a>2147483493-s)throw A.d(A.aV("Date addition exceeds the supported range.")) +return A.iL(s+a)}, +gaB(){var s=this.a +return B.c.R(B.a.i(s<=0?1-s:s),4,"0")+"-"+B.c.R(B.a.i(this.b),2,"0")+"-"+B.c.R(B.a.i(this.c),2,"0")}, +i(a){var s=this.gaB() +return s+(this.a<=0?" BC":"")}, +m(a,b){return B.a.m(this.d,t.A.a(b).d)}, +I(a,b){if(b==null)return!1 +return b instanceof A.ap&&this.d===b.d}, +gu(a){return B.a.gu(this.d)}, +$it:1} +A.aj.prototype={ +i(a){var s=this.a +return B.c.R(B.a.i(B.a.l(s,36e8)),2,"0")+":"+B.c.R(B.a.i(B.a.n(B.a.l(s,6e7),60)),2,"0")+":"+B.c.R(B.a.i(B.a.n(B.a.l(s,1e6),60)),2,"0")+"."+B.c.R(B.a.i(B.a.n(s,1e6)),6,"0")}, +m(a,b){return B.a.m(this.a,t.t.a(b).a)}, +I(a,b){if(b==null)return!1 +return b instanceof A.aj&&this.a===b.a}, +gu(a){return B.a.gu(this.a)}, +$it:1} +A.aq.prototype={ +bT(a){var s=864e8,r=this.b.a,q=this.a,p=A.iy(A.jC(r,a,q.d<2451545)).a,o=A.fS(p,s),n=r+B.a.n(p,s) +return A.hF(q.bs(o+B.a.l(n,s)),A.hH(B.a.n(n,s)))}, +i(a){var s=this.a,r=s.gaB(),q=this.b.i(0) +s=s.a<=0?" BC":"" +return r+" "+q+s}, +m(a,b){var s +t.B.a(b) +s=B.a.m(this.a.d,b.a.d) +return s===0?B.a.m(this.b.a,b.b.a):s}, +I(a,b){var s,r +if(b==null)return!1 +s=!1 +if(b instanceof A.aq){r=b.a +if(this.a.d===r.d)s=this.b.a===b.b.a}return s}, +gu(a){return A.eG(this.a,this.b,B.f,B.f)}, +$it:1} +A.ck.prototype={ +i(a){var s,r,q=this,p=q.e +p=p==null?"":"while "+p+", " +p="SqliteException("+q.c+"): "+p+q.a +s=q.b +if(s!=null)p=p+", "+s +s=q.f +if(s!=null){r=q.d +r=r!=null?" (at position "+A.n(r)+"): ":": " +s=p+"\n Causing statement"+r+s +p=q.r +if(p!=null){r=A.am(p) +r=s+(", parameters: "+new A.a5(p,r.h("h(1)").a(new A.eY()),r.h("a5<1,h>")).bJ(0,", ")) +p=r}else p=s}return p.charCodeAt(0)==0?p:p}} +A.eY.prototype={ +$1(a){if(t.I.b(a))return"blob ("+a.length+" bytes)" +else return J.b4(a)}, +$S:27} +A.ao.prototype={} +A.b5.prototype={} +A.cY.prototype={ +aN(a){var s=B.h.P(a) +if(s.length>255)throw A.d(A.ax(a,"functionName","Must not exceed 255 bytes when utf-8 encoded")) +return new Uint8Array(A.lZ(s))}, +by(a,b,c,d,e){var s,r,q,p,o,n,m,l,k,j,i=null +e.h("e3<0>").a(c) +s=this.aN(d) +r=A.i7(!0,!0,!1) +q=new A.eq(e,c) +p=this.b +o=t.fB +n=o.a(new A.eo(e,c)) +m=t.bN +l=m.a(new A.em(q,c)) +m=m.a(new A.er(q,c)) +q=o.a(new A.en(c,q)) +o=p.a +k=o.V(s,1) +o=o.d +j=A.h8(o,"dart_sqlite3_create_window_function",[p.b,k,a.a,r,new A.a7(i,m,n,q,l,i)],t.S) +o.dart_sqlite3_free(k) +if(j!==0)A.cM(this,j,i,i,i)}, +a7(a,b,c,d,e){var s,r +t.e.a(d) +s=this.aN(e) +r=this.b.c5(A.i7(!0,c,!1),s,a.a,new A.et(d)) +if(r!==0)A.cM(this,r,null,null,null)}, +aT(a,b,c,d){return this.a7(a,b,!0,c,d)}, +bz(a,b){var s,r,q,p,o,n,m=null +t.aa.a(a) +s=this.b +r=this.aN(b) +q=A.i7(!1,!1,!1) +p=s.a +o=p.V(r,1) +p=p.d +n=A.c(p.dart_sqlite3_create_collation(s.b,o,q,new A.a7(m,m,m,m,m,a))) +p.dart_sqlite3_free(o) +if(n!==0)A.cM(this,n,m,m,m)}, +C(){var s,r,q,p=this +if(p.r)return +p.r=!0 +s=p.b +r=s.b4() +q=r!==0?A.i6(p.a,s,r,"closing database",null,null):null +if(q!=null)throw A.d(q)}, +a9(a){var s,r,q,p=this,o=B.y +if(J.ac(o)===0){if(p.r)A.u(A.at("This database has already been closed")) +r=p.b +q=r.a +s=q.V(B.h.P(a),1) +q=q.d +r=A.h8(q,"sqlite3_exec",[r.b,s,0,0,0],t.S) +q.dart_sqlite3_free(s) +if(r!==0)A.cM(p,r,"executing",a,o)}else{s=p.al(a,!0) +try{r=s +q=t.W.a(o) +r.aG() +r.Y() +r.az(new A.b7(q)) +r.cn()}finally{s.C()}}}, +ct(a,b,a0,a1,a2){var s,r,q,p,o,n,m,l,k,j,i,h,g,f,e,d,c=this +if(c.r)A.u(A.at("This database has already been closed")) +s=B.h.P(a) +r=c.b +t.L.a(s) +q=r.a +p=q.a2(s) +o=q.d +n=A.c(o.dart_sqlite3_malloc(4)) +o=A.c(o.dart_sqlite3_malloc(4)) +m=new A.fc(r,p,n,o) +l=A.x([],t.bb) +k=new A.ej(m,l) +for(r=s.length,q=q.b,n=t.a,j=0;j?").a(a.gbA()) +if(o==null){o=this.b.aS() +s=a.gbi() +r=a.c +q=r.c++ +r.d.A(0,q,A.hw(o,null,t.E)) +r=A.ak(t.a.a(a.a.b.buffer),0,null) +p=B.a.B(s,2) +r.$flags&2&&A.l(r) +if(!(p(aD)")}} +A.er.prototype={ +$2(a,b){t.w.a(b) +A.hZ(a,new A.es(this.b,this.a.$1(a)),b)}, +$S:8} +A.es.prototype={ +$1(a){return this.a.b7(a,this.b)}, +$S:15} +A.eo.prototype={ +$1(a){A.js(a,new A.ep(a,this.a,this.b))}, +$S:9} +A.ep.prototype={ +$0(){var s=this.b.h("ao<0>?").a(this.a.gbA()),r=this.c +return r.bF(s==null?r.aS():s)}, +$S:17} +A.en.prototype={ +$1(a){A.js(a,new A.ek(this.a,this.b,a))}, +$S:9} +A.ek.prototype={ +$0(){return this.a.bS(this.b.$1(this.c))}, +$S:17} +A.em.prototype={ +$2(a,b){t.w.a(b) +A.hZ(a,new A.el(this.b,this.a.$1(a)),b)}, +$S:8} +A.el.prototype={ +$1(a){return this.a.bI(a,this.b)}, +$S:15} +A.et.prototype={ +$2(a,b){A.hZ(a,this.a,t.w.a(b))}, +$S:8} +A.ej.prototype={ +$0(){var s,r,q,p,o,n +this.a.C() +for(s=this.b,r=s.length,q=0;qb||b>=p)A.u(A.ew(b,p,this,null,"index")) +s=this.b +if(!(b>=0&&b."))}, +az(a){A:{this.ce(a.a) +break A}}, +Y(){var s,r=this +if(!r.f){s=r.a +A.c(s.c.d.sqlite3_reset(s.b)) +r.f=!0}r.w=null}, +C(){var s,r,q=this +if(!q.r){q.r=!0 +q.Y() +s=q.a +r=s.c +A.c(r.d.sqlite3_finalize(s.b)) +r=r.w +if(r!=null)r.bC(s.d)}}, +b2(a){var s=this +s.aG() +s.Y() +s.az(a) +return s.cu()}} +A.dE.prototype={ +gv(){var s=this.x +s===$&&A.D() +return s}, +p(){var s,r,q,p,o=this,n=o.r +if(n.r||n.w!==o)return!1 +s=n.a +r=s.b +s=s.c.d +q=A.c(s.sqlite3_step(r)) +if(q===100){if(!o.y){o.w=A.c(s.sqlite3_column_count(r)) +o.a=t.dy.a(n.gaC()) +o.aA() +o.y=!0}s=[] +for(p=0;p=2?1:0}, +bW(){if(this.c)this.a.d.am(0,this.b)}, +bY(){return this.a.d.j(0,this.b).b}, +c_(a){this.d=a}, +c1(a){}, +c2(a){var s=this.a.d,r=this.b,q=s.j(0,r) +if(q==null){s.A(0,r,new A.bf(new Uint8Array(0),0)) +s.j(0,r).sk(0,a)}else q.sk(0,a)}, +c3(a){this.d=a}, +c4(a,b){var s,r=this.a.d,q=this.b,p=r.j(0,q) +if(p==null){p=new A.bf(new Uint8Array(0),0) +r.A(0,q,p)}s=b+a.length +if(s>p.b)p.sk(0,s) +p.T(0,b,s,a)}} +A.hk.prototype={ +$1(a){return A.A(a).length!==0}, +$S:32} +A.bs.prototype={ +aA(){var s,r,q,p,o=A.d9(t.N,t.S) +for(s=this.a,r=s.length,q=0;q=0&&b>>0!==b||b>=s.length)return A.b(s,b) +return s[b]}return null}r=this.a.c.j(0,b) +if(r==null)return null +s=this.b +if(r>>>0!==r||r>=s.length)return A.b(s,r) +return s[r]}, +gaZ(){return this.a.a}, +$iba:1} +A.dS.prototype={ +gv(){var s=this.a,r=s.d,q=this.b +if(!(q>=0&&q864e13)A.u(A.P(s,-864e13,864e13,"millisecondsSinceEpoch",null)) +A.h9(!1,"isUtc",t.v) +r=new A.a3(s,0,!1) +q=this.b +q===$&&A.D() +p=A.kV(t.a.a(q.buffer),b,8) +p.$flags&2&&A.l(p) +q=p.length +if(0>=q)return A.b(p,0) +p[0]=A.eN(r) +if(1>=q)return A.b(p,1) +p[1]=A.eL(r) +if(2>=q)return A.b(p,2) +p[2]=A.eK(r) +if(3>=q)return A.b(p,3) +p[3]=A.eJ(r) +if(4>=q)return A.b(p,4) +p[4]=A.eM(r)-1 +if(5>=q)return A.b(p,5) +p[5]=A.eO(r)-1900 +o=B.a.n(A.kX(r),7) +if(6>=q)return A.b(p,6) +p[6]=o}, +dS(a,b,c,d,e){var s,r,q,p,o,n,m,l,k,j=null +t.j.a(a) +A.c(b) +A.c(c) +A.c(d) +A.c(e) +p=this.b +p===$&&A.D() +s=new A.eW(A.hN(p,b,j)) +try{r=a.ao(s,d) +if(e!==0){o=r.b +n=A.ak(t.a.a(p.buffer),0,j) +m=B.a.B(e,2) +n.$flags&2&&A.l(n) +if(!(mq.d)throw A.d(A.f3(14)) +s=q.a.b +s===$&&A.D() +s=A.ar(t.a.a(s.buffer),0,null) +r=q.e +B.e.b3(s,r,p) +o=r+o +s.$flags&2&&A.l(s) +if(!(o>=0&&o0) +n.aa(B.v,A.c(o.getSize())>0)}return A.aJ(null,r)}}) +return A.aK($async$a4,r)}} +A.eT.prototype={ +$1(a){var s=0,r=A.aM(t.m),q,p=this,o,n,m +var $async$$1=A.aN(function(b,c){if(b===1)return A.aI(c,r) +for(;;)switch(s){case 0:o=t.m +m=A +s=3 +return A.R(A.bP(A.B(p.a.getFileHandle(a,{create:!0})),o),$async$$1) +case 3:n=m.B(c.createSyncAccessHandle()) +s=4 +return A.R(A.bP(n,o),$async$$1) +case 4:q=c +s=1 +break +case 1:return A.aJ(q,r)}}) +return A.aK($async$$1,r)}, +$S:55} +A.dY.prototype={ +bL(a,b){return A.iA(this.a.M().X(this.b),a,{at:b})}, +bV(){return this.d>=2?1:0}, +bW(){var s=this.a,r=this.b +s.M().X(r).flush() +if(this.c)s.M().aa(r,!1)}, +bY(){return A.c(this.a.M().X(this.b).getSize())}, +c_(a){this.d=a}, +c1(a){this.a.M().X(this.b).flush()}, +c2(a){this.a.M().X(this.b).truncate(a)}, +c3(a){this.d=a}, +c4(a,b){if(A.iB(this.a.M().X(this.b),a,{at:b})=this.b)throw A.d(A.iD(b,this)) +s=this.a +if(!(b>=0&&b=s.b)throw A.d(A.iD(b,s)) +B.e.A(s.a,b,c)}, +sk(a,b){var s,r,q,p,o=this,n=o.b +if(b=0&&qn){if(n===0)p=new Uint8Array(b) +else p=o.cj(b) +B.e.T(p,0,o.b,o.a) +o.a=p}}o.b=b}, +cj(a){var s=this.a.length*2 +if(a!=null&&s").a(d) +s=this.b +if(c>s)throw A.d(A.P(c,0,s,null,null)) +B.e.L(this.a,b,c,d,e)}, +T(a,b,c,d){return this.L(0,b,c,d,0)}} +A.dP.prototype={} +A.bf.prototype={};(function aliases(){var s=J.aT.prototype +s.c8=s.i +s=A.k.prototype +s.b9=s.L})();(function installTearOffs(){var s=hunkHelpers._static_1,r=hunkHelpers._static_0,q=hunkHelpers.installStaticTearOff,p=hunkHelpers._instance_1u,o=hunkHelpers._instance_2u,n=hunkHelpers.installInstanceTearOff +s(A,"mz","lg",4) +s(A,"mA","lh",4) +s(A,"mB","li",4) +r(A,"jE","mu",0) +q(A,"n6",1,null,["$2$parameter","$1"],["jQ",function(a){return A.jQ(a,!1)}],56,0) +s(A,"n5","mY",57) +s(A,"n3","kS",58) +s(A,"n4","kT",42) +s(A,"n2","kR",40) +var m +p(m=A.cX.prototype,"gdf","dg",1) +o(m,"gdd","de",34) +n(m,"gdR",0,5,null,["$5"],["dS"],35,0,0) +n(m,"gdF",0,3,null,["$3"],["dG"],36,0,0) +n(m,"gdv",0,4,null,["$4"],["dw"],18,0,0) +n(m,"gdN",0,4,null,["$4"],["dO"],18,0,0) +n(m,"gdT",0,3,null,["$3"],["dU"],38,0,0) +o(m,"gdZ","e_",19) +o(m,"gdD","dE",19) +p(m,"gdB","dC",7) +n(m,"gdV",0,4,null,["$4"],["dW"],20,0,0) +n(m,"ge6",0,4,null,["$4"],["e7"],20,0,0) +o(m,"ge2","e3",59) +o(m,"ge0","e1",2) +o(m,"gdL","dM",2) +o(m,"gdP","dQ",2) +o(m,"ge4","e5",2) +o(m,"gdz","dA",2) +p(m,"gdH","dI",7) +n(m,"gdJ",0,3,null,["$3"],["dK"],44,0,0) +p(m,"gdX","dY",7) +p(m,"gcR","cS",4) +p(m,"gcN","cO",46) +n(m,"gcP",0,5,null,["$5"],["cQ"],47,0,0) +n(m,"gcX",0,4,null,["$4"],["cY"],5,0,0) +n(m,"gd0",0,4,null,["$4"],["d1"],5,0,0) +n(m,"gcZ",0,4,null,["$4"],["d_"],5,0,0) +o(m,"gd2","d3",10) +o(m,"gcV","cW",10) +n(m,"gcT",0,5,null,["$5"],["cU"],50,0,0) +o(m,"gcL","cM",51) +o(m,"gcJ","cK",52) +n(m,"gcH",0,3,null,["$3"],["cI"],53,0,0)})();(function inheritance(){var s=hunkHelpers.mixin,r=hunkHelpers.inherit,q=hunkHelpers.inheritMany +r(A.e,null) +q(A.e,[A.hC,J.d3,A.cf,J.bR,A.q,A.eP,A.f,A.b9,A.c6,A.bh,A.ch,A.T,A.aH,A.bT,A.f_,A.eF,A.bY,A.cA,A.aP,A.a_,A.ez,A.c4,A.b8,A.d7,A.fA,A.fk,A.fJ,A.al,A.dN,A.fH,A.fF,A.dF,A.ad,A.dI,A.bj,A.F,A.dG,A.dZ,A.cG,A.k,A.bS,A.cW,A.fM,A.cF,A.z,A.ct,A.a3,A.aR,A.fm,A.dk,A.cl,A.fn,A.v,A.d2,A.M,A.e_,A.cm,A.d0,A.eE,A.dQ,A.dj,A.dx,A.eV,A.cj,A.fO,A.eZ,A.dJ,A.bi,A.dL,A.bD,A.dK,A.dR,A.ci,A.hy,A.a0,A.az,A.ap,A.aj,A.aq,A.ck,A.ao,A.b5,A.cY,A.eX,A.cU,A.bs,A.H,A.cQ,A.dW,A.dS,A.b7,A.aZ,A.eW,A.dC,A.dA,A.fc,A.dD,A.b_,A.au,A.cX,A.a7,A.f9,A.fB,A.f4]) +q(J.d3,[J.d5,J.c1,J.c2,J.U,J.bw,J.bv,J.aS]) +q(J.c2,[J.aT,J.r,A.aU,A.c9]) +q(J.aT,[J.dl,J.bg,J.aB]) +r(J.d4,A.cf) +r(J.ey,J.r) +q(J.bv,[J.c0,J.d6]) +q(A.q,[A.bx,A.aE,A.d8,A.dw,A.dr,A.dM,A.cO,A.ah,A.co,A.dv,A.be,A.cV]) +q(A.f,[A.i,A.bb,A.cp,A.bd]) +q(A.i,[A.V,A.c5,A.eA]) +q(A.V,[A.cn,A.a5,A.ce]) +r(A.bV,A.bb) +r(A.bW,A.bd) +r(A.b0,A.aH) +q(A.b0,[A.cy,A.bF,A.cz]) +r(A.bU,A.bT) +r(A.cb,A.aE) +q(A.aP,[A.cS,A.cT,A.du,A.hf,A.hh,A.fe,A.fd,A.fP,A.fx,A.fE,A.fj,A.hl,A.hm,A.ho,A.hn,A.fU,A.fV,A.fW,A.fX,A.fZ,A.h4,A.h_,A.h0,A.h2,A.h1,A.hp,A.hq,A.eY,A.eq,A.es,A.eo,A.en,A.el,A.hk,A.eT,A.f5,A.f6,A.f7,A.f8]) +q(A.du,[A.dt,A.br]) +r(A.c3,A.a_) +q(A.cT,[A.hg,A.fQ,A.h7,A.fy,A.eD,A.fi,A.fY,A.h3,A.er,A.em,A.et]) +r(A.by,A.aU) +q(A.c9,[A.c7,A.L]) +q(A.L,[A.cu,A.cw]) +r(A.cv,A.cu) +r(A.c8,A.cv) +r(A.cx,A.cw) +r(A.a6,A.cx) +q(A.c8,[A.dc,A.dd]) +q(A.a6,[A.de,A.df,A.dg,A.dh,A.di,A.ca,A.bc]) +r(A.bG,A.dM) +q(A.cS,[A.ff,A.fg,A.fG,A.fp,A.ft,A.fs,A.fr,A.fq,A.fw,A.fv,A.fu,A.fD,A.h6,A.fL,A.fK,A.eu,A.ep,A.ek,A.ej,A.e8,A.e5,A.ea,A.ec,A.ee,A.e7,A.ed,A.ei,A.eg,A.ef,A.e9,A.eb,A.eh,A.e6,A.fa]) +r(A.cq,A.dI) +r(A.dV,A.cG) +r(A.d_,A.bS) +r(A.dy,A.d_) +r(A.f2,A.cW) +q(A.ah,[A.aC,A.bZ]) +q(A.fm,[A.aA,A.eH,A.bu]) +q(A.k,[A.dz,A.bC,A.aG]) +r(A.bB,A.cU) +q(A.bs,[A.c_,A.dT]) +r(A.dE,A.c_) +r(A.cR,A.H) +q(A.cR,[A.d1,A.cg]) +q(A.cQ,[A.dO,A.dY]) +r(A.dU,A.dT) +r(A.dq,A.dU) +r(A.dX,A.dW) +r(A.N,A.dX) +r(A.dB,A.eX) +r(A.dP,A.aG) +r(A.bf,A.dP) +s(A.cu,A.k) +s(A.cv,A.T) +s(A.cw,A.k) +s(A.cx,A.T) +s(A.dT,A.k) +s(A.dU,A.dj) +s(A.dW,A.dx) +s(A.dX,A.a_)})() +var v={G:typeof self!="undefined"?self:globalThis,typeUniverse:{eC:new Map(),tR:{},eT:{},tPV:{},sEA:[]},mangledGlobalNames:{a:"int",m:"double",a2:"num",h:"String",aO:"bool",M:"Null",j:"List",e:"Object",ba:"Map",p:"JSObject"},mangledNames:{},types:["~()","~(a)","a(X,a)","h?(as)","~(~())","~(a7,a,a,a)","~(@)","a(X)","~(aD,j)","~(aD)","~(a7,a)","@()","e?(as)","M(@)","M()","~(as)","a(h?,h?)","e?()","a(H,a,a,a)","a(H,a)","a(X,a,a,U)","r(j)","~(e?,e?)","~(h,0^?(h))>","a3?(h)","h(h)","a(a,a)","h(e?)","a(a)","0&()","M(p)","ai<~>(~)","aO(h)","@(@,h)","~(U,a)","X?(H,a,a,a,a)","a(H,a,a)","M(~())","a(H?,a,a)","a(as)","aq?(h)","@(@)","aj?(h)","@(h)","a(X,a,a)","M(@,aW)","a(a())","~(~(a,h,a),a,a,a,U)","~(a,@)","e(e,h,a)","a(a7,a,a,a,a)","a(a(a),a)","a(eQ,a)","a(eQ,a,a)","p()","ai

(h)","e?(e?{parameter:aO})","e?(e?)","ap?(h)","a(X,U)","M(e,aW)"],interceptorsByTag:null,leafTags:null,arrayRti:Symbol("$ti"),rttc:{"2;":(a,b)=>c=>c instanceof A.cy&&a.b(c.a)&&b.b(c.b),"2;file,outFlags":(a,b)=>c=>c instanceof A.bF&&a.b(c.a)&&b.b(c.b),"2;result,resultCode":(a,b)=>c=>c instanceof A.cz&&a.b(c.a)&&b.b(c.b)}} +A.lG(v.typeUniverse,JSON.parse('{"aB":"aT","dl":"aT","bg":"aT","nk":"aU","r":{"j":["1"],"i":["1"],"p":[],"f":["1"]},"d5":{"aO":[],"o":[]},"c1":{"o":[]},"c2":{"p":[]},"aT":{"p":[]},"d4":{"cf":[]},"ey":{"r":["1"],"j":["1"],"i":["1"],"p":[],"f":["1"]},"bR":{"K":["1"]},"bv":{"m":[],"a2":[],"t":["a2"]},"c0":{"m":[],"a":[],"a2":[],"t":["a2"],"o":[]},"d6":{"m":[],"a2":[],"t":["a2"],"o":[]},"aS":{"h":[],"t":["h"],"eI":[],"o":[]},"bx":{"q":[]},"i":{"f":["1"]},"V":{"i":["1"],"f":["1"]},"cn":{"V":["1"],"i":["1"],"f":["1"],"V.E":"1","f.E":"1"},"b9":{"K":["1"]},"bb":{"f":["2"],"f.E":"2"},"bV":{"bb":["1","2"],"i":["2"],"f":["2"],"f.E":"2"},"c6":{"K":["2"]},"a5":{"V":["2"],"i":["2"],"f":["2"],"V.E":"2","f.E":"2"},"cp":{"f":["1"],"f.E":"1"},"bh":{"K":["1"]},"bd":{"f":["1"],"f.E":"1"},"bW":{"bd":["1"],"i":["1"],"f":["1"],"f.E":"1"},"ch":{"K":["1"]},"ce":{"V":["1"],"i":["1"],"f":["1"],"V.E":"1","f.E":"1"},"cy":{"b0":[],"aH":[]},"bF":{"b0":[],"aH":[]},"cz":{"b0":[],"aH":[]},"bT":{"ba":["1","2"]},"bU":{"bT":["1","2"],"ba":["1","2"]},"cb":{"aE":[],"q":[]},"d8":{"q":[]},"dw":{"q":[]},"cA":{"aW":[]},"aP":{"b6":[]},"cS":{"b6":[]},"cT":{"b6":[]},"du":{"b6":[]},"dt":{"b6":[]},"br":{"b6":[]},"dr":{"q":[]},"c3":{"a_":["1","2"],"ba":["1","2"],"a_.K":"1","a_.V":"2"},"c5":{"i":["1"],"f":["1"],"f.E":"1"},"c4":{"K":["1"]},"eA":{"i":["1"],"f":["1"],"f.E":"1"},"b8":{"K":["1"]},"b0":{"aH":[]},"d7":{"l4":[],"eI":[]},"by":{"aU":[],"p":[],"o":[]},"aU":{"p":[],"o":[]},"c9":{"p":[]},"c7":{"ir":[],"p":[],"o":[]},"L":{"a4":["1"],"p":[]},"c8":{"k":["m"],"L":["m"],"j":["m"],"a4":["m"],"i":["m"],"p":[],"f":["m"],"T":["m"]},"a6":{"k":["a"],"L":["a"],"j":["a"],"a4":["a"],"i":["a"],"p":[],"f":["a"],"T":["a"]},"dc":{"k":["m"],"y":["m"],"L":["m"],"j":["m"],"a4":["m"],"i":["m"],"p":[],"f":["m"],"T":["m"],"o":[],"k.E":"m"},"dd":{"k":["m"],"y":["m"],"L":["m"],"j":["m"],"a4":["m"],"i":["m"],"p":[],"f":["m"],"T":["m"],"o":[],"k.E":"m"},"de":{"a6":[],"k":["a"],"y":["a"],"L":["a"],"j":["a"],"a4":["a"],"i":["a"],"p":[],"f":["a"],"T":["a"],"o":[],"k.E":"a"},"df":{"a6":[],"k":["a"],"y":["a"],"L":["a"],"j":["a"],"a4":["a"],"i":["a"],"p":[],"f":["a"],"T":["a"],"o":[],"k.E":"a"},"dg":{"a6":[],"k":["a"],"y":["a"],"L":["a"],"j":["a"],"a4":["a"],"i":["a"],"p":[],"f":["a"],"T":["a"],"o":[],"k.E":"a"},"dh":{"a6":[],"hM":[],"k":["a"],"y":["a"],"L":["a"],"j":["a"],"a4":["a"],"i":["a"],"p":[],"f":["a"],"T":["a"],"o":[],"k.E":"a"},"di":{"a6":[],"k":["a"],"y":["a"],"L":["a"],"j":["a"],"a4":["a"],"i":["a"],"p":[],"f":["a"],"T":["a"],"o":[],"k.E":"a"},"ca":{"a6":[],"k":["a"],"y":["a"],"L":["a"],"j":["a"],"a4":["a"],"i":["a"],"p":[],"f":["a"],"T":["a"],"o":[],"k.E":"a"},"bc":{"a6":[],"f1":[],"k":["a"],"y":["a"],"L":["a"],"j":["a"],"a4":["a"],"i":["a"],"p":[],"f":["a"],"T":["a"],"o":[],"k.E":"a"},"dM":{"q":[]},"bG":{"aE":[],"q":[]},"ad":{"q":[]},"cq":{"dI":["1"]},"F":{"ai":["1"]},"cG":{"j0":[]},"dV":{"cG":[],"j0":[]},"k":{"j":["1"],"i":["1"],"f":["1"]},"a_":{"ba":["1","2"]},"d_":{"bS":["h","j"]},"dy":{"bS":["h","j"]},"e4":{"t":["e4"]},"a3":{"t":["a3"]},"m":{"a2":[],"t":["a2"]},"aR":{"t":["aR"]},"a":{"a2":[],"t":["a2"]},"j":{"i":["1"],"f":["1"]},"a2":{"t":["a2"]},"h":{"t":["h"],"eI":[]},"z":{"e4":[],"t":["e4"]},"ct":{"kE":["1"]},"cO":{"q":[]},"aE":{"q":[]},"ah":{"q":[]},"aC":{"q":[]},"bZ":{"aC":[],"q":[]},"co":{"q":[]},"dv":{"q":[]},"be":{"q":[]},"cV":{"q":[]},"dk":{"q":[]},"cl":{"q":[]},"d2":{"q":[]},"e_":{"aW":[]},"dQ":{"l_":[]},"dL":{"e3":["bi"]},"dK":{"e3":["bD"]},"az":{"t":["az"]},"ap":{"t":["ap"]},"aj":{"t":["aj"]},"aq":{"t":["aq"]},"as":{"j":["e?"],"i":["e?"],"f":["e?"]},"cY":{"kz":[]},"dz":{"k":["e?"],"as":[],"j":["e?"],"i":["e?"],"f":["e?"],"k.E":"e?"},"bB":{"cU":[]},"dE":{"c_":[],"bs":[],"K":["N"]},"d1":{"H":[]},"dO":{"X":[]},"N":{"dx":["h","@"],"a_":["h","@"],"ba":["h","@"],"a_.K":"h","a_.V":"@"},"c_":{"bs":[],"K":["N"]},"dq":{"k":["N"],"dj":["N"],"j":["N"],"i":["N"],"bs":[],"f":["N"],"k.E":"N"},"dS":{"K":["N"]},"b7":{"l9":[]},"cR":{"H":[]},"cQ":{"X":[]},"au":{"cc":[]},"dC":{"l0":[]},"dA":{"l1":[]},"dD":{"l2":[]},"b_":{"aD":[]},"bC":{"k":["au"],"j":["au"],"i":["au"],"f":["au"],"k.E":"au"},"cg":{"H":[]},"dY":{"X":[]},"bf":{"aG":["a"],"k":["a"],"j":["a"],"i":["a"],"f":["a"],"k.E":"a","aG.E":"a"},"aG":{"k":["1"],"j":["1"],"i":["1"],"f":["1"]},"dP":{"aG":["a"],"k":["a"],"j":["a"],"i":["a"],"f":["a"]},"kK":{"y":["a"],"j":["a"],"i":["a"],"f":["a"]},"f1":{"y":["a"],"j":["a"],"i":["a"],"f":["a"]},"ld":{"y":["a"],"j":["a"],"i":["a"],"f":["a"]},"kI":{"y":["a"],"j":["a"],"i":["a"],"f":["a"]},"hM":{"y":["a"],"j":["a"],"i":["a"],"f":["a"]},"kJ":{"y":["a"],"j":["a"],"i":["a"],"f":["a"]},"lc":{"y":["a"],"j":["a"],"i":["a"],"f":["a"]},"kF":{"y":["m"],"j":["m"],"i":["m"],"f":["m"]},"kG":{"y":["m"],"j":["m"],"i":["m"],"f":["m"]}}')) +A.lF(v.typeUniverse,JSON.parse('{"L":1,"cW":2}')) +var u={c:"Error handler must accept one Object or one Object and a StackTrace as arguments, and return a value of the returned future's type"} +var t=(function rtii(){var s=A.b2 +return{_:s("ao"),y:s("ao"),E:s("ao"),n:s("ad"),q:s("t<@>"),k:s("a3"),U:s("az"),J:s("aR"),O:s("i<@>"),Q:s("q"),Z:s("b6"),bM:s("f"),hf:s("f<@>"),Y:s("f"),D:s("r>"),f:s("r"),bb:s("r"),s:s("r"),eQ:s("r"),gn:s("r<@>"),c:s("r"),T:s("c1"),m:s("p"),C:s("U"),g:s("aB"),aU:s("a4<@>"),w:s("j"),dy:s("j"),b:s("j<@>"),L:s("j"),W:s("j"),A:s("ap"),B:s("aq"),t:s("aj"),a:s("by"),eB:s("a6"),bm:s("bc"),P:s("M"),K:s("e"),G:s("aC"),gT:s("nm"),bQ:s("+()"),cf:s("+(p?,p)"),u:s("+(e?,a)"),V:s("a7"),bJ:s("ce"),fI:s("N"),p:s("eQ"),h:s("cg"),l:s("aW"),N:s("h"),dm:s("o"),eK:s("aE"),I:s("f1"),ak:s("bg"),j:s("H"),r:s("X"),ab:s("dB"),gV:s("au"),cc:s("cp"),gB:s("bD"),cl:s("z"),al:s("dJ"),aW:s("bi"),d:s("F<@>"),cd:s("F<~>"),v:s("aO"),bO:s("aO(e)"),bB:s("aO(h)"),i:s("m"),z:s("@"),fO:s("@()"),x:s("@(e)"),R:s("@(e,aW)"),S:s("a"),ez:s("a()"),aa:s("a(h?,h?)"),f5:s("a(a)"),eH:s("ai?"),an:s("p?"),X:s("e?"),e:s("e?(as)"),dk:s("h?"),fN:s("bf?"),bx:s("H?"),F:s("bj<@,@>?"),fQ:s("aO?"),cD:s("m?"),h6:s("a?"),cg:s("a2?"),cN:s("~(aD,j)?"),ci:s("~(aD)?"),o:s("a2"),H:s("~"),M:s("~()"),bN:s("~(aD,j)"),fB:s("~(aD)"),hd:s("~(a,h,a)")}})();(function constants(){var s=hunkHelpers.makeConstList +B.a8=J.d3.prototype +B.b=J.r.prototype +B.a=J.c0.prototype +B.l=J.bv.prototype +B.c=J.aS.prototype +B.a9=J.aB.prototype +B.aa=J.c2.prototype +B.ae=A.c7.prototype +B.e=A.bc.prototype +B.A=J.dl.prototype +B.n=J.bg.prototype +B.o=new A.b5(1) +B.B=new A.b5(2) +B.j=new A.b5(3) +B.C=new A.b5(4) +B.aF=new A.b5(-1) +B.m=new A.d2() +B.p=function getTagFallback(o) { + var s = Object.prototype.toString.call(o); + return s.substring(8, s.length - 1); +} +B.D=function() { + var toStringFunction = Object.prototype.toString; + function getTag(o) { + var s = toStringFunction.call(o); + return s.substring(8, s.length - 1); + } + function getUnknownTag(object, tag) { + if (/^HTML[A-Z].*Element$/.test(tag)) { + var name = toStringFunction.call(object); + if (name == "[object Object]") return null; + return "HTMLElement"; + } + } + function getUnknownTagGenericBrowser(object, tag) { + if (object instanceof HTMLElement) return "HTMLElement"; + return getUnknownTag(object, tag); + } + function prototypeForTag(tag) { + if (typeof window == "undefined") return null; + if (typeof window[tag] == "undefined") return null; + var constructor = window[tag]; + if (typeof constructor != "function") return null; + return constructor.prototype; + } + function discriminator(tag) { return null; } + var isBrowser = typeof HTMLElement == "function"; + return { + getTag: getTag, + getUnknownTag: isBrowser ? getUnknownTagGenericBrowser : getUnknownTag, + prototypeForTag: prototypeForTag, + discriminator: discriminator }; +} +B.I=function(getTagFallback) { + return function(hooks) { + if (typeof navigator != "object") return hooks; + var userAgent = navigator.userAgent; + if (typeof userAgent != "string") return hooks; + if (userAgent.indexOf("DumpRenderTree") >= 0) return hooks; + if (userAgent.indexOf("Chrome") >= 0) { + function confirm(p) { + return typeof window == "object" && window[p] && window[p].name == p; + } + if (confirm("Window") && confirm("HTMLElement")) return hooks; + } + hooks.getTag = getTagFallback; + }; +} +B.E=function(hooks) { + if (typeof dartExperimentalFixupGetTag != "function") return hooks; + hooks.getTag = dartExperimentalFixupGetTag(hooks.getTag); +} +B.H=function(hooks) { + if (typeof navigator != "object") return hooks; + var userAgent = navigator.userAgent; + if (typeof userAgent != "string") return hooks; + if (userAgent.indexOf("Firefox") == -1) return hooks; + var getTag = hooks.getTag; + var quickMap = { + "BeforeUnloadEvent": "Event", + "DataTransfer": "Clipboard", + "GeoGeolocation": "Geolocation", + "Location": "!Location", + "WorkerMessageEvent": "MessageEvent", + "XMLDocument": "!Document"}; + function getTagFirefox(o) { + var tag = getTag(o); + return quickMap[tag] || tag; + } + hooks.getTag = getTagFirefox; +} +B.G=function(hooks) { + if (typeof navigator != "object") return hooks; + var userAgent = navigator.userAgent; + if (typeof userAgent != "string") return hooks; + if (userAgent.indexOf("Trident/") == -1) return hooks; + var getTag = hooks.getTag; + var quickMap = { + "BeforeUnloadEvent": "Event", + "DataTransfer": "Clipboard", + "HTMLDDElement": "HTMLElement", + "HTMLDTElement": "HTMLElement", + "HTMLPhraseElement": "HTMLElement", + "Position": "Geoposition" + }; + function getTagIE(o) { + var tag = getTag(o); + var newTag = quickMap[tag]; + if (newTag) return newTag; + if (tag == "Object") { + if (window.DataView && (o instanceof window.DataView)) return "DataView"; + } + return tag; + } + function prototypeForTagIE(tag) { + var constructor = window[tag]; + if (constructor == null) return null; + return constructor.prototype; + } + hooks.getTag = getTagIE; + hooks.prototypeForTag = prototypeForTagIE; +} +B.F=function(hooks) { + var getTag = hooks.getTag; + var prototypeForTag = hooks.prototypeForTag; + function getTagFixed(o) { + var tag = getTag(o); + if (tag == "Document") { + if (!!o.xmlVersion) return "!Document"; + return "!HTMLDocument"; + } + return tag; + } + function prototypeForTagFixed(tag) { + if (tag == "Document") return null; + return prototypeForTag(tag); + } + hooks.getTag = getTagFixed; + hooks.prototypeForTag = prototypeForTagFixed; +} +B.q=function(hooks) { return hooks; } + +B.J=new A.dk() +B.f=new A.eP() +B.r=new A.dy() +B.h=new A.f2() +B.K=new A.dK() +B.L=new A.dL() +B.d=new A.dV() +B.k=new A.e_() +B.M=new A.aA(0,"exact") +B.t=new A.aA(4,"halfAwayFromZero") +B.R=new A.aR(0) +B.u=new A.bu("/database",0,"database") +B.v=new A.bu("/database-journal",1,"journal") +B.S=new A.v("Invalid timestamp components.",null,null) +B.T=new A.v("Invalid calendar date in instant.",null,null) +B.U=new A.v("Expected a local date and time without a timezone.",null,null) +B.V=new A.v("BC requires a positive era year.",null,null) +B.W=new A.v("Decimal result requires rounding.",null,null) +B.X=new A.v("Expected a finite decimal string.",null,null) +B.Y=new A.v("Expected a Gregorian date without a timezone.",null,null) +B.Z=new A.v("Expected a time without a timezone, with at most six fractional digits.",null,null) +B.a_=new A.v("Expected a timestamp with at most six fractional digits.",null,null) +B.a0=new A.v("Invalid UTC offset.",null,null) +B.w=new A.v("Instant exceeds the common DateTime/PostgreSQL range.",null,null) +B.a1=new A.v("Expected a local time or time text.",null,null) +B.a2=new A.v("Decimal input is too long.",null,null) +B.a3=new A.v("BC dates require a positive era year.",null,null) +B.a4=new A.v("Expected a UTC instant or timestamp text.",null,null) +B.a5=new A.v("Expected a local timestamp or timestamp text.",null,null) +B.a6=new A.v("Decimal exponent is out of range.",null,null) +B.a7=new A.v("Exact decimal storage requires text or an integer.",null,null) +B.N=new A.aA(1,"towardZero") +B.O=new A.aA(2,"floor") +B.P=new A.aA(3,"ceiling") +B.Q=new A.aA(5,"halfEven") +B.i=s([B.M,B.N,B.O,B.P,B.t,B.Q],A.b2("r")) +B.x=s([1e6,1e5,1e4,1000,100,10,1],A.b2("r")) +B.ab=s([],t.D) +B.ac=s([],t.s) +B.y=s([],t.c) +B.ad=s([B.u,B.v],A.b2("r")) +B.af={} +B.z=new A.bU(B.af,[],A.b2("bU")) +B.aG=new A.eH(2,"readWriteCreate") +B.ag=new A.a0("DRIVER.OPEN","Worker is already open.") +B.ah=new A.a0("CURSOR.READ_ONLY","Streaming requires a read-only query.") +B.ai=new A.a0("CODEC.INTEGER","Browser int transport requires a safe integer; use BigInt for wider integers or SqlReal for floating values.") +B.aj=new A.a0("DRIVER.PROTOCOL","Invalid SQLite worker value.") +B.ak=new A.a0("CODEC.PARAMETER","Unsupported SQLite wire value.") +B.al=new A.a0("DRIVER.STORAGE","Unknown browser storage.") +B.am=new A.a0("DRIVER.CLOSED","SQLite worker is not open.") +B.an=new A.a0("DRIVER.FOREIGN_KEYS","SQLite foreign keys could not be enabled.") +B.ao=new A.a0("CURSOR.CLOSED","Cursor has ended.") +B.ap=new A.a0("DRIVER.PROTOCOL","Unknown SQLite worker command.") +B.aq=A.an("nb") +B.ar=A.an("ir") +B.as=A.an("kF") +B.at=A.an("kG") +B.au=A.an("kI") +B.av=A.an("kJ") +B.aw=A.an("kK") +B.ax=A.an("e") +B.ay=A.an("hM") +B.az=A.an("lc") +B.aA=A.an("ld") +B.aB=A.an("f1") +B.aC=new A.aZ(14) +B.aD=new A.aZ(522) +B.aE=new A.aZ(778)})();(function staticFields(){$.fz=null +$.a9=A.x([],t.f) +$.iR=null +$.ip=null +$.io=null +$.jH=null +$.jD=null +$.jL=null +$.hb=null +$.hi=null +$.i8=null +$.fC=A.x([],A.b2("r?>")) +$.bK=null +$.cJ=null +$.cK=null +$.i2=!1 +$.w=B.d +$.j3=null +$.j4=null +$.j5=null +$.j6=null +$.hP=A.fl("_lastQuoRemDigits") +$.hQ=A.fl("_lastQuoRemUsed") +$.cs=A.fl("_lastRemUsed") +$.hR=A.fl("_lastRem_nsh")})();(function lazyInitializers(){var s=hunkHelpers.lazyFinal,r=hunkHelpers.lazy +s($,"nd","jX",()=>A.hc("_$dart_dartClosure")) +s($,"nc","bQ",()=>A.hc("_$dart_dartClosure_dartJSInterop")) +s($,"nN","kj",()=>A.x([new J.d4()],A.b2("r"))) +s($,"no","k2",()=>A.aF(A.f0({ +toString:function(){return"$receiver$"}}))) +s($,"np","k3",()=>A.aF(A.f0({$method$:null, +toString:function(){return"$receiver$"}}))) +s($,"nq","k4",()=>A.aF(A.f0(null))) +s($,"nr","k5",()=>A.aF(function(){var $argumentsExpr$="$arguments$" +try{null.$method$($argumentsExpr$)}catch(q){return q.message}}())) +s($,"nu","k8",()=>A.aF(A.f0(void 0))) +s($,"nv","k9",()=>A.aF(function(){var $argumentsExpr$="$arguments$" +try{(void 0).$method$($argumentsExpr$)}catch(q){return q.message}}())) +s($,"nt","k7",()=>A.aF(A.iY(null))) +s($,"ns","k6",()=>A.aF(function(){try{null.$method$}catch(q){return q.message}}())) +s($,"nx","kb",()=>A.aF(A.iY(void 0))) +s($,"nw","ka",()=>A.aF(function(){try{(void 0).$method$}catch(q){return q.message}}())) +s($,"nz","id",()=>A.lf()) +s($,"nK","kh",()=>A.iP(4096)) +s($,"nI","kf",()=>new A.fL().$0()) +s($,"nJ","kg",()=>new A.fK().$0()) +s($,"nG","E",()=>A.cr(0)) +s($,"nE","Z",()=>A.cr(1)) +s($,"nF","ih",()=>A.cr(2)) +s($,"nC","ig",()=>$.Z().D(0)) +s($,"nA","ie",()=>A.cr(1e4)) +r($,"nD","kd",()=>A.cd("^\\s*([+-]?)((0x[a-f0-9]+)|(\\d+)|([a-z0-9]+))\\s*$",!1)) +s($,"nB","kc",()=>A.iP(8)) +s($,"nH","ke",()=>typeof FinalizationRegistry=="function"?FinalizationRegistry:null) +s($,"nL","hu",()=>A.jJ(B.ax)) +s($,"nl","k1",()=>{var q=new A.dQ(new DataView(new ArrayBuffer(A.lW(8)))) +q.ca() +return q}) +s($,"nf","ib",()=>A.kB($.E(),0)) +s($,"ne","jY",()=>A.cd("^([+-]?)([0-9]*)(?:\\.([0-9]*))?(?:[eE]([+-]?[0-9]+))?$",!0)) +s($,"nM","ki",()=>A.cd("^(-?\\d{4,7})-(\\d{2})-(\\d{2})[ T](\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d{1,6})?)?)(Z|[+-]\\d{2}(?::?\\d{2})?(?::?\\d{2})?)?( BC)?$",!1)) +s($,"ni","k_",()=>A.cd("^(-?\\d{4,7})-(\\d{2})-(\\d{2})( BC)?$",!1)) +s($,"nj","k0",()=>A.cd("^(\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.(\\d{1,6}))?)?$",!0)) +s($,"nh","jZ",()=>A.cd("^(-?\\d{4,7}-\\d{2}-\\d{2})[ T](\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d{1,6})?)?)( BC)?$",!1)) +s($,"na","jW",()=>$.Z().O(0,63).D(0)) +s($,"n9","jV",()=>{var q=$.Z() +return q.O(0,63).ac(0,q)}) +s($,"n8","hs",()=>$.k1()) +s($,"ny","ic",()=>new A.d0(new WeakMap(),A.b2("d0"))) +s($,"ng","ht",()=>{var q,p,o=A.d9(t.N,A.b2("bu")) +for(q=0;q<2;++q){p=B.ad[q] +o.A(0,p.c,p)}return o})})();(function nativeSupport(){!function(){var s=function(a){var m={} +m[a]=1 +return Object.keys(hunkHelpers.convertToFastObject(m))[0]} +v.getIsolateTag=function(a){return s("___dart_"+a+v.isolateTag)} +var r="___dart_isolate_tags_" +var q=Object[r]||(Object[r]=Object.create(null)) +var p="_ZxYxX" +for(var o=0;;o++){var n=s(p+"_"+o+"_") +if(!(n in q)){q[n]=1 +v.isolateTag=n +break}}v.dispatchPropertyName=v.getIsolateTag("dispatch_record")}() +hunkHelpers.setOrUpdateInterceptorsByTag({SharedArrayBuffer:A.aU,ArrayBuffer:A.by,ArrayBufferView:A.c9,DataView:A.c7,Float32Array:A.dc,Float64Array:A.dd,Int16Array:A.de,Int32Array:A.df,Int8Array:A.dg,Uint16Array:A.dh,Uint32Array:A.di,Uint8ClampedArray:A.ca,CanvasPixelArray:A.ca,Uint8Array:A.bc}) +hunkHelpers.setOrUpdateLeafTags({SharedArrayBuffer:true,ArrayBuffer:true,ArrayBufferView:false,DataView:true,Float32Array:true,Float64Array:true,Int16Array:true,Int32Array:true,Int8Array:true,Uint16Array:true,Uint32Array:true,Uint8ClampedArray:true,CanvasPixelArray:true,Uint8Array:false}) +A.L.$nativeSuperclassTag="ArrayBufferView" +A.cu.$nativeSuperclassTag="ArrayBufferView" +A.cv.$nativeSuperclassTag="ArrayBufferView" +A.c8.$nativeSuperclassTag="ArrayBufferView" +A.cw.$nativeSuperclassTag="ArrayBufferView" +A.cx.$nativeSuperclassTag="ArrayBufferView" +A.a6.$nativeSuperclassTag="ArrayBufferView"})() +Function.prototype.$1=function(a){return this(a)} +Function.prototype.$2=function(a,b){return this(a,b)} +Function.prototype.$0=function(){return this()} +Function.prototype.$1$1=function(a){return this(a)} +Function.prototype.$3=function(a,b,c){return this(a,b,c)} +Function.prototype.$4=function(a,b,c,d){return this(a,b,c,d)} +Function.prototype.$1$2=function(a,b){return this(a,b)} +Function.prototype.$5=function(a,b,c,d,e){return this(a,b,c,d,e)} +convertAllToFastObject(w) +convertToFastObject($);(function(a){if(typeof document==="undefined"){a(null) +return}if(typeof document.currentScript!="undefined"){a(document.currentScript) +return}var s=document.scripts +function onLoad(b){for(var q=0;q main(List arguments) => runOrmCli(arguments); diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..2edf7c54 --- /dev/null +++ b/build.yaml @@ -0,0 +1,15 @@ +builders: + orm: + import: "package:orm/builder.dart" + builder_factories: ["ormBuilder"] + build_extensions: + ".dart": [".orm.dart", ".orm.json"] + auto_apply: none + build_to: source + queries: + import: "package:orm/builder.dart" + builder_factories: ["ormQueryBuilder"] + build_extensions: + ".dart": [".queries.dart", ".queries.json"] + auto_apply: none + build_to: source diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 00000000..e4c737d2 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,7 @@ +# These suites launch Dart CLI processes in the same package. On macOS the SDK +# rewrites/codesigns one shared SQLite native-asset file during their startup. +# Serialize suites there; explicit concurrent database operations inside tests +# still run concurrently. +on_os: + mac-os: + concurrency: 1 diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 00000000..eadf3f8b --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,116 @@ +# Design acceptance map + +The current redesign adds independently usable modules, nominal model results, +typed project configuration and MySQL/MariaDB support. Its current acceptance state +is recorded in [the final local acceptance](../research/validation/standalone-redesign.md) +and [progress](progress.md); engine-specific limitations are in +[capabilities](capabilities.md). The table below also preserves earlier evidence +for unchanged feature families. + +The earlier [API/correctness audit](../research/api-correctness-audit.md) passes +872 native tests and 20 real Chrome checks for each of JavaScript and Dart WASM. +[Its validation record](../research/validation/api-audit.md) pins the runtime source +and preserves platform/performance limits. The [API guide](api.md) documents the +reviewed import, selection, execution and history boundaries. + +The common ORM lifecycle in +[`new-dart-orm-design.md`](../research/new-dart-orm-design.md) is implemented and +accepted within the [reviewed capability boundaries](capabilities.md). The original +native baseline passed 835 tests with both databases enabled; the documented usage +run and static analysis also passed. This map identifies the checked scenarios +and separately captured platforms, rather than certifying every database feature +or deployment target. Historical measurements remain pinned to their source. + +## Implementation with direct test coverage + +| Design requirement | Current evidence | +| --- | --- | +| §§2, 4–5: nominal class and Record declarations, separate table identity, restricted selectors, static input/result types | `lib/schema.dart`, `lib/src/generate/`; `generator_test`, `types_test`, `nominal_generation_test`, `nominal_database_test`, generated fixtures and `generated_database_test` | +| Standalone driver, runtime, metadata and typed SQL libraries | `layer_boundaries_test`, `runtime_test`, `runtime_lifecycle_review_test`, `sql_builder_test`; offline generated queries and independent raw sessions | +| MySQL/MariaDB typed writes, values, sessions, import and migration recovery | `mysql_driver_test`, `mysql_database_test`, `mysql_import_test`, `mysql_transaction_boundary_test`, `mysql_migration_test`; both engines run against separate real servers | +| Typed project CLI and four-engine generation | `cli_workflow_test`, `generator_dialects_test`; offline initialization, current-source regeneration and reviewed Dart migration lifecycle | +| §§4–5: compound keys, physical names, indexes, FK navigation, domain codecs | Generator, `relation_test`, `integer_test`, `custom_codec_test`, catalog/import tests | +| §§5, 10–11: temporal column precision | `temporal_precision_test`: generated declarations and SQL coercion, epoch ties, finite ranges, defaults/computed values, rounded relation keys, import/catalog drift and reviewed migration rollback; real JS/WASM worker scenario; [semantics](types.md#temporal-precision) | +| §4.2: explicit navigation without database FKs | `relatesTo`; `unconstrained_relation_test` checks missing/duplicate targets, composite/self/inverse edges, no FK/index/cascade, watch and constraint transitions; generator/type negatives | +| §12 declaration table: general CHECK constraints | `check_test`, generator/CLI tests and browser scenario: declaration, native enforcement/NULL, catalog/import, drift, add/change/remove/rename and atomic rollback; limits in [checks](checks.md) | +| §§4.3, 5, 8: client-generated values separate from database defaults | `client_default_test`, generator/type negatives and browser scenario: typed factories, omission/value/null/DEFAULT, batches, conflicts, preparation/reuse and rollback; snapshots/imports exclude client code; [defaults](defaults.md) | +| §§4.3, 5, 8: database computed/read-only columns | `computed_test`, generator/type negatives, CLI and browser scenario: stored/virtual declaration, read-only fields, CRUD/relations, catalog/import, expression/type/add/drop/materialization/rename and atomic rollback; [native limits](computed.md) | +| §6: scalar, positional/named Record and DTO projections; dynamic fields and lazy immutable queries | `database_test`, `generated_database_test`, `union_test`; mappings execute after rows arrive | +| §6: joins, grouping/HAVING, subqueries, CTEs, windows and UNION | `union_test`, `relation_test`, `watch_test`, decimal/temporal query suites; exported-column and scope checks | +| §6: named SQL with typed parameters/results and database structure checking | `named_sql_test`, `named_sql_generation_test`, real CLI/build_runner workflows | +| §§6–7: stable cursors, optional/required to-one, batched lists and per-parent limits | `database_test`, `relation_test`, cursor checks in numeric/temporal suites; actual query counts and nullable presence | +| §§4.2, 7, 12: many-to-many business records | `example/teams`, `many_to_many_test`, generated-type negatives and browser scenario: two FKs, composite membership keys, role/time payloads, bidirectional and nested projections, per-parent limits, parameter chunks, transaction/upsert/cascade/watch and actual query/row counts; [usage](relations.md#many-to-many-with-business-fields) | +| §8: create/patch/delete, absent versus NULL/default, atomic expression writes, batch/upsert/returning | Generated client and `database_test`; later-chunk failure rolls back earlier chunks | +| §9: explicit sessions, savepoints, lifetime, cancellation, unknown commits, retries and acquisition limits | `transaction_test`, `retry_test`, `acquisition_test`, `stream_test`; real database interruption and state readback | +| §10: reviewed snapshots/diffs, explicit rename/conversion, history, baseline/drift and recoverable migrations | `migration_test`, `migration_target_test`, `migration_recovery_test`, `backfill_test`, `schema_version_test`, import and CLI suites | +| §11: distinct PostgreSQL/native SQLite configuration and connection ownership | `lib/postgres.dart`, `lib/sqlite.dart`; native suites exercise both backends, capability rejection, borrowed pools and disposal | +| §§11–12: worker/persistence, real cursor streaming and commit-driven observation | Native `stream_test`/`watch_test`; real JS and Dart WASM browser reports include OPFS reload recovery and upgrade | +| §§11–12: native Flutter | `example/flutter`, `tool/test_flutter.dart` and [Android capture](flutter.md): version 1 debug APK → version 2 AOT release APK → independent process restart; 4/17/11 assertions cover background SQLite, persistence, live migration/catalog, generated CRUD/relations, rollback/watch and cancellation/reuse | +| §12: inspectable query structure and execution phases | `plan_test`, `observation_test` and real JS/WASM teams scenario: non-executing SQL/column/key/join/batch descriptions, composite parameter capacity, real acquisition waits/cancellation, decode/stream/RETURNING scopes and observer exception isolation; [measurement limits](observability.md) | +| §13.3: runtime cost comparison | `tool/benchmark_runtime.dart`, captured `research/benchmarks/runtime.json` and [method/results](performance.md): same Driver/SQL/parameters/shape, four read workloads, 200 samples per lane, concurrent throughput, byte/row counts, acquisition distributions, live heap and selected allocation traces; native JIT with actual echo calibration of controlled TCP latency, not a remote deployment or total-allocation-byte census | +| §13.3: editor protocol measurements | `tool/benchmark_editor.dart`, `research/benchmarks/editor.json` and [method/results](generation.md#editor-completion-diagnostics-and-rename): real generated APIs at 10/100/1000 models, 300 warm samples across five completion probes, twelve exact-code/range diagnostic checks, language-symbol rename edits and final consumer analysis; the subsequent `research/validation/editor-recovery.json` records a same-session timeout at ten models and a successful restart path, without claiming GUI or general IDE reliability | +| §13.2: declaration-form experiment | `tool/compare_authoring.dart`, `research/benchmarks/authoring.json` and [scope/results](authoring.md): actual Record/primary-class/table inputs for User/Post/Profile/Follow, identical canonical schema/client/snapshot for base/type-edit/rename variants, six stale-consumer failures and repaired analyses, complex constraints, schema LSP edits and fourteen original-source error locations; experimental class/table adapters emit Record rows and do not implement nominal object materialization | + +The filenames in this table refer to `test/.dart` unless another location +is shown. The browser report is +[`research/validation/browser.json`](../research/validation/browser.json), with +commands and limits in [SQLite web](sqlite-web.md). + +The subsequent [selection cost capture](../research/benchmarks/runtime-selection.json) +uses runtime `d0a8c9a`. Exact SQL, parameters and result/transport volumes match the +baseline. It records fewer selected List allocations, without establishing a +latency or throughput gain; the [comparison](performance.md#direct-selection-decoding) +also documents the PostgreSQL protocol lifecycle tradeoff. `selection_test` adds +direct coverage for deferred/ordered mapping, repeated fields, all typed arities, +streaming, failures and dynamic field snapshots on both native backends. + +## Final lifecycle audit + +| Design §3 scenario / acceptance gate | Verification | +| --- | --- | +| Add a defaulted field without rewriting existing create calls | Generated type/default tests and actual Android v1 → v2 migration preserve rows and populate the new field | +| Select emails only, returning `List` | Native projection/plan tests and executable cookbook scalar selection | +| Root pagination plus each parent's latest three titles | Native relation checks, `three_posts` runtime workload and browser generated-relation scenario; actual SQL counts and parameter chunks | +| Create a parent and children in one transaction | README and teams examples, native generated tests and actual Android relation write | +| Clear a nullable field and increment atomically | Generated patch/expression-write tests, NULL/default negative type checks and transaction suites | +| Separate Dart rename from physical column rename | Generated snapshot/authoring tests, CLI migration diff and real migration/recovery tests; Record field rename remains manual | +| Switch SQLite memory/file/worker storage | Native persistence and read-only tests, Chrome JS/WASM OPFS reload/upgrade, Android APK upgrade/restart | +| Keep database-specific configuration and ownership explicit | Separate typed driver inputs, wrong-backend compilation failures, borrowed PostgreSQL pools and cancellation/acquisition tests; unimplemented transports are not certified | +| Process large results and release sessions | Real SQLite/PostgreSQL cursors, batching, early stream cancellation and post-failure connection reuse; browser stream/release scenario | +| Check version, migration state and live structure before deployment | CLI/import/baseline tests, application-version tests, durable backfill and nontransactional recovery, Android bundled migration checks | +| Review query, type and unmanaged catalog coverage | [Capability review](capabilities.md); final native suite includes real triggers and complete policy/RLS metadata checks | +| Run documented onboarding and complex query usage | [Usage capture](../research/validation/usage.json): dependencies, generation, both existing examples, nine cookbook checks on each backend, migration-chain validation and clean analysis | + +The [single-engine migration correction](../research/validation/migration-targets.md) +records a full run with 845 passes and two incorrectly configured crash-test +registries, then 21 passing target/recovery tests after correcting them. It also +verifies native AOT bundles, real Chrome JS/WASM and the public CLI workflow. +Each history now fixes one engine, selected expressions alone affect fingerprints, +and wrong connections fail before migration SQL. Android runtime was +not rerun for that correction; its earlier capture remains tied to its original revision. + +The [Dart migration acceptance](../research/validation/dart-migrations.json) records +the replacement of JSON artifacts with Dart snapshots, fixed history and static +registration. Its full native run passed 837 tests and found one old fixture path; +the correction and final generator changes pass a 76-test follow-up. The record +retains both logs instead of claiming a single all-green full invocation. AOT +checks execute compiled history without consumer source files and resume historical +backfills. The original [835-test capture](../research/validation/native.json) +remains a historical baseline. + +The earlier Chrome JS/WASM and Android APK captures use the original Dart workflow source. +Android proves the native Flutter lane on its recorded emulator, including compiled +migration definitions, upgrade and process restart; it does not certify Apple +platforms or every server type/scenario. See [Flutter scope](flutter.md) and +[browser scope](sqlite-web.md) for the exact assertions. + +## Extension boundaries in the design + +Design §12 explicitly places PostgreSQL-specific JSON/array/COPY/locking and +SQLite FTS expansion behind real demand. HTTP/serverless transports require +their own session/capability verification. These are not silently provided by a +native driver or raw-SQL escape hatch. Distributed transactions, synchronization, +arbitrary object-graph tracking and a general optimizer remain separately scoped +in the design. Calendar SQL arithmetic, timezone-rule conversion and additional +native types also remain explicit extensions, as recorded in the capability +review. They are not presented as working APIs or hidden behind silent fallbacks. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..eb61af1d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,155 @@ +# API and mental model + +Start with an immutable Dart class, generate its client, choose a database entrypoint, +and use the generated table getters. Each query belongs to that database view. +Migrations have a separate, fixed history for the chosen engine. + +## Imports + +SQLite uses `sqlite.dart` on native platforms and the web. `SqliteOptions.memory()` +is portable. `persistent(name, nativePath: ...)` uses the native path or the named +browser database. Flutter bundles its browser resources automatically; see +[SQLite Web setup](sqlite-web.md). Platform transports are internal. + +| Module or use | Import | Purpose | +| --- | --- | --- | +| Values | `package:orm/values.dart` | Codecs, decimal/temporal/domain value contracts | +| Driver contracts | `package:orm/driver.dart` | SQL commands, raw results, connections and capabilities | +| Database adapters | `package:orm/drivers/sqlite.dart`, `postgres.dart`, `mysql.dart`, `mariadb.dart` under `drivers/` | Raw drivers without ORM models | +| Raw SQL execution | `package:orm/runtime.dart` | `SqlDatabase`, sessions, transactions, cursors and observations | +| Physical schema | `package:orm/schema_model.dart` | Columns, keys, constraints and indexes without execution | +| Typed SQL | `package:orm/sql.dart` | Query construction, typed projections and `SqlBuilder` without a connection runtime | +| ORM execution | `package:orm/orm.dart` | `Database` binds typed queries to `SqlDatabase` and adds subscriptions | +| Schema declaration | `package:orm/schema.dart` | Model annotations, entities, keys and relations | +| Application convenience | Generated `schema.orm.dart` plus `package:orm/sqlite.dart`, `postgres.dart`, `mysql.dart` or `mariadb.dart` | Model types, table getters and the chosen adapter | +| Migration or snapshot | `package:orm/migrate.dart` | Physical history, steps, checksums and execution against `SqlDatabase` | +| Project CLI | `package:orm/cli.dart` | `OrmConfig`, static history and unified project commands | +| Migration-only executable | `package:orm/migrate_cli.dart` | Programmatic commands with a static history and raw connection factory | +| Generation scripts | `package:orm/generate.dart` | Analyze declarations and write derived Dart files | +| build_runner configuration | `package:orm/builder.dart` | Builder factories only | + +Generated clients export their declared model classes (or explicit Record aliases), but not `entity()` declaration +values or unrelated application helpers. Types used inside rows, such as a custom +ID or enum, remain owned by their defining library. Import that library when +constructing those values. Use import prefixes for multiple generated clients. + +These are real dependency boundaries within one package. `sql.dart` does not +import the connection runtime or ORM, and `runtime.dart` does not import the +query builder. `migrate.dart` uses raw sessions and physical metadata without +model declarations or generated query code. None of these runtime/schema imports +pull in the analyzer, build system or a concrete database adapter. `sqlite.dart` +selects its platform implementation; each server engine has its own options. +The single package still declares tooling dependencies for its CLI and builders; +`pub get` resolves them. This import boundary avoids runtime initialization and +compilation dependencies, not the dependency-download cost of one package. + +For raw SQL, construct `SqlDatabase(await SqliteDriver.open(options))` from +`runtime.dart` and `drivers/sqlite.dart`. Add typed execution later with +`Database.fromSql(raw)`; it uses the same runtime rather than another pool. +`db.sql` exposes that runtime to migrations and catalog operations. + +For offline compilation, import `sql.dart` and a generated client: + +```dart +final command = SqlBuilder(SqlDialect.postgres) + .users.where((u) => u.email.eq('seven@example.com')) + .select((u) => u.id) + .compile(); +// command.sql and separately bound command.parameters; no connection opened. +``` + +Executing an unbound builder fails with `QUERY.UNBOUND`. Generated table getters +and named SQL bindings target `QueryContext`, which both `SqlBuilder` and the ORM +implement. Field and result types remain static; SQL capabilities and a missing +named-query engine variant are checked against the chosen context before I/O. + +## Three kinds of Dart files + +`schema.dart` is the current declaration. `schema.orm.dart` and +`schema.snapshot.dart` are reproducible outputs; regenerate them after editing the +declaration. `migrations/m0001_*.dart` is reviewed history. It contains its own frozen +physical schema and fixed fingerprint, independent of the latest models. + +Generation validates the structure and types. A migration history selects SQLite, +PostgreSQL, MySQL or MariaDB and validates that engine's capabilities. A SQLite-only declaration +may use a virtual indexed column; PostgreSQL restrictions should not prevent its +generation. The selected engine still rejects unsupported DDL before applying it. +Changing a connection URL does not convert a migration history. + +## Shape, identity and selection + +The declared class is the complete row type. A `User` and an unrelated class with +identical fields remain different Dart types. Record projections remain +structural. Two tables can use the same class or Record shape while remaining +distinct SQL sources. `table.alias()` creates a new occurrence for joins +and self joins. Capturing a field from an unrelated query is a scope error. + +`select((u) => u.email)` returns `List` from `get()`. `.row` composes SQL +expressions into a positional Record; `.map(...)` shapes decoded values into a +named Record or DTO. A Dart mapper does not become a SQL expression. Runtime +`fields({...})` selection deliberately returns `Map`. + +`alias.optional(selection)` checks that alias's presence. It does not prove another +left-joined alias exists. Give each optional alias its own guard, or use nullable +expressions. Relationships follow the same rule: `one()` can be absent, `required()` +checks presence, and `many()` loads a collection. [Relationship execution](relations.md) +documents joins, batched queries and consistency boundaries. + +## Preparing and executing + +`where`, `select`, `orderBy`, `take`, `skip` and `map` return new descriptions. +Repeated `where` adds predicates; `orderBy`, `take` and `skip` replace their own +settings. `compile()`/`inspect()` perform no I/O. `get()`/`first()`/`single()` execute +reads; a stream acquires a cursor when listened to. + +Generated `create(...)` and `patch(...)` execute immediately. `insert(...)`, +`insertMany(...)`, `update(...)` and `delete()` prepare mutations; call `execute()` +or `returning(...).get()`. Prepared inserts evaluate client defaults once when +constructed. Re-executing the prepared insert reuses those values. + +`Change.keep()` omits an assignment, `.set(null)` writes SQL NULL to a nullable +field, and `.defaultValue()` requests the database default. An empty update is an +error. `RETURNING` returns scalar SQL projections; query relationships afterward. +For statement deadlines or cancellation, use the prepared mutation's execution +options. [Defaults](defaults.md) and [execution](execution.md) specify the limits. + +## Connection and transaction ownership + +The root `SqlDatabase` owns its driver; `Database` uses that runtime. `session` borrows one connection; `transaction` +borrows one connection and commits or rolls back the callback's writes. Only use +the callback's `tx` to construct that transaction's queries. Subqueries, CTEs and +UNION operands must all use the same view. Use `savepoint` for a recoverable nested +unit; borrowed views expire at callback completion. + +Pass `tx` into helper functions. Calling the captured root `db` separately inside +the callback requests separate work: a pool may run it outside the transaction, +and a single-connection SQLite driver may wait on the callback's own lease. +Composition checks reject mixed query descriptions; they do not rewrite unrelated +root calls into transaction calls. + +```dart +await db.transaction((tx) async { + final user = await tx.users.create(email: 'seven@example.com'); + await tx.posts.create( + authorId: user.id, + title: 'First post', + createdAt: DateTime.now(), + ); +}); +``` + +Await every operation before returning from the callback. Batch inserts use one +transaction across chunks. A normal query with batched relationships reuses its +connection but does not automatically create a snapshot transaction. Choose an +appropriate explicit transaction when a consistent multi-query snapshot matters. +Query observations expose actual SQL and transaction statements. + +Raw SQL remains explicit: bind values in `SqlCommand` or typed `sql` expressions, +declare affected/read tables for subscriptions, and use reviewed migration steps +for schema changes. The ORM does not infer an object graph, external writes, +automatic transaction replay or destructive renames. + +Custom codecs, manual Table definitions and Driver implementations are explicit +extension contracts. Their authors supply the correct storage mapping and native +behavior. Dart types cannot prove that a live external schema still matches those +definitions; use catalog verification when that boundary matters. diff --git a/docs/authoring.md b/docs/authoring.md new file mode 100644 index 00000000..9b9810b0 --- /dev/null +++ b/docs/authoring.md @@ -0,0 +1,173 @@ +# Models and typed queries + +Declare an immutable Dart class once. The generated client returns instances of +that class, including from inserts and relationships. It does not generate a +second row type or convert the class into a Record. + +```dart +import 'package:orm/schema.dart'; + +final class User({ + @Id.generated() required final int id, + @Unique() required final String email, + required final String? nickname, + @Default.sql('false') required final bool active, +}); + +final class Post( + @Id.generated() final int id, + final int authorId, + final String title, +); + +final users = entity(table: 'users'); +final posts = entity(table: 'posts'); +final author = posts + .key((p) => p.authorId) + .references(users.key((u) => u.id), inverse: 'posts', onDelete: .cascade); +``` + +Primary constructors require Dart 3.13. They declare fields and constructor +parameters together; they are ordinary Dart classes with ordinary nominal type +identity. See the [Dart language documentation](https://dart.dev/language/primary-constructors). + +`entity()` gives the table its own identity and physical name. Two tables +can use the same row class, and a self-join still has separate table occurrences. +Row identity does not determine SQL scope. + +Generate with `dart run orm generate lib/schema.dart`, then import the generated +client and the chosen driver. [Generation](generation.md) describes standalone +and build_runner workflows. New projects can start with `dart run orm init +--database sqlite`; after initialization, `dart run orm generate` reads the typed +project configuration. + +```dart +final User user = await db.users.create(email: 'seven@example.com'); +await db.users.byId(user.id).patch(nickname: .set('Seven')); +await db.users.byId(user.id).patch(nickname: .set(null)); + +final List active = await db.users + .where((u) => u.active.eq(true)) + .get(); + +final List posts = await db.users + .byId(user.id) + .select((u) => u.posts.many()) + .single(); +``` + +The same field declarations determine constructor values, typed query fields, +create parameters, patch parameters, codecs and the physical schema. Application +code does not repeat column types in a table class or a generated row interface. +The generated client imports `sql.dart` and binds to `QueryContext`. It can be used +with an offline `SqlBuilder` as well as a connected ORM `Database`; the model +declaration itself only depends on the schema/value layer. + +## Full rows, writes and projections + +The model constructor represents a complete database row. Every constructor +parameter is required, including nullable columns and generated IDs. `User.id` +stays `int`: creating a user does not require weakening the stored row to `int?`. + +The generated `create` method has its own insert contract: + +- Ordinary non-null columns are required. +- Nullable columns without defaults can be omitted and are inserted as NULL. +- Generated values, SQL defaults and client defaults use `Change` with + `.keep()` as the default. `.set(value)` explicitly supplies a value. +- Computed columns are absent from writes. + +Patch parameters use `Change` to distinguish omission from explicit NULL. +`patch()` keeps an omitted field; `patch(nickname: .set(null))` clears it. +Generated identities and computed columns are absent from patch parameters. + +Use `@Default.sql` for database defaults and `@ClientDefault(factory)` for Dart +insert defaults. Constructor defaults are rejected: they would otherwise suggest +an insert behavior that the database does not implement. Neither constructors nor +client factories run during generation. Client factories run when building the +insert; the model constructor runs when decoding the resulting row. + +Selected shapes remain independent of complete models. Project a scalar, a typed +Record, or a separate application DTO: + +```dart +final cards = await db.users.select((u) => ( + u.id, + u.email, + u.posts.take(3).select((p) => p.title).many(), +).map((id, email, posts) => (id: id, email: email, posts: posts))).get(); +// List<({int id, String email, List posts})> +``` + +Selecting two columns does not produce a partly populated `User`. Relationships +are explicit query selections; they do not add hidden lazy-loading properties or +queries to the row class. A foreign key remains a separate typed declaration, +including for composite keys and multiple edges to the same table. + +## Supported declarations and errors + +Generated entity classes must be public, final, non-generic classes declared in +the selected schema file. Their unnamed primary constructor accepts public, +explicitly typed `final` declaring parameters. Required positional parameters, +required named parameters, and a mixture of both are supported. A constant +primary constructor is also valid. Classes cannot have inheritance, mixins, +implemented interfaces or members in their body. Add application behavior with +Dart extensions; arbitrary constructor logic is outside the database decoder's +contract. + +Annotations use the same validation for classes and Records. Built-in values, +enums and explicit `@UseCodec` domain values retain their resolved Dart types. +The generator checks codec compatibility without executing codecs. It rejects +ambiguous field names, duplicate physical columns, invalid generated identities, +nullable primary keys, computed-column/default conflicts, invalid selectors and +foreign keys without a matching target key. + +Dart analysis rejects wrong create/patch value types, unknown query fields and +assignment of an unrelated model with the same fields. Generation rejects +unsupported declaration forms and database-schema conflicts. Database-specific +capabilities are checked when selecting the migration engine. + +These are ordinary classes: the ORM does not generate equality, `copyWith`, JSON +serialization or a global identity map. Two separately read rows are separate +instances. Serialization remains application code, while column codecs control +database encoding. In particular, SQL NULL, an omitted insert value and a missing +JSON property are different concepts. + +## Records and schema history + +Named Record typedefs remain an explicit structural data form: + +```dart +typedef Coordinate = ({@Id() int id, double x, double y}); +final coordinates = entity(); +``` + +They share the same schema validation and query runtime. A Record typedef does +not create nominal identity; another typedef with the same shape is assignable. +Classes are the default for models and catalog-import drafts. Records are useful +for projections and fixed SQL result shapes. + +Changing a row declaration from a Record to a class does not change its physical +schema. Snapshots and saved migrations contain standalone physical metadata, +without imports of current model classes, constructors, codecs or client-default +functions. A class field rename can retain the database column with +`@ColumnName('old_name')`; absent that explicit name, normal schema diff rules +apply and never infer a destructive rename. + +Class fields support Dart's normal symbol navigation and rename tools. +Regenerate after edits and repair affected generated-API references; a model +rename does not promise that an IDE will edit the regenerated client or all its +consumers automatically. + +## Earlier authoring experiment + +`dart run tool/compare_authoring.dart` retains the controlled comparison of Record, +primary-constructor and typed-table-field declarations. Its adapter deliberately +normalizes all three forms to Records so their output can be compared. It is an +experiment, not the production class reader. + +The historical [report](../research/benchmarks/authoring.json) records sources, +LSP edits, byte-identical physical snapshots and stale-consumer errors on Dart +3.13.3. Its measurements establish neither universal brevity nor a generation +speed ranking. The production generator now reads class parameters directly and +constructs the declared model type. diff --git a/docs/backfills.md b/docs/backfills.md new file mode 100644 index 00000000..df3a9a50 --- /dev/null +++ b/docs/backfills.md @@ -0,0 +1,138 @@ +# Resumable backfills + +`Backfill` is an explicit migration step for a long data update. It commits small +batches with durable primary-key cursors and verifies completion before recording +the migration as applied. Import `package:orm/migrate.dart` alongside your database +entry point. +The examples use a raw `SqlDatabase` named `db`; for an ORM `Database`, pass +`db.sql` to `Migrator`. MySQL/MariaDB recovery details are documented in +[their migration guide](mysql-migrations.md). + +```dart +// Use the snapshot committed with the expand migration, not current app models. +final historical = expand.snapshot!; +final users = historical.tables.singleWhere((t) => t.name == 'users'); +final backfill = Migration.steps( + '0003_display_names', + [ + Backfill( + users, + set: {'display_name': 'name'}, + where: 'display_name IS NULL', + doneWhen: 'SELECT NOT EXISTS(SELECT 1 FROM users WHERE display_name IS NULL)', + batchSize: 1000, + ), + ], + dialect: expand.dialect, + previous: expand.checksum, + snapshot: historical, +); +``` + +Save this migration with `writeMigration` from `package:orm/generate.dart`, review +the emitted Dart file, and import it through the static history on subsequent runs. Its table definition, assignments, filter, completion SQL and +batch size are covered by the migration checksum. Expressions are trusted migration +SQL for this history's chosen database. +No latest generated client or application codec callback is needed to resume. + +## Running a bounded amount of work + +```dart +final appliedIds = await Migrator(db).apply( + migrations, + maxBackfillBatches: 10, +); +final pending = await Migrator(db).plan(migrations); +final checkpoints = await Migrator(db).progress(); +``` + +The default completes all pending work. `maxBackfillBatches` limits the number of +nonempty data batches performed by this call, shared across backfill steps. It +does not change the saved migration or its checksum. The call returns IDs of +migrations it completed; a paused migration remains pending. Completion verification +may need the next call after the final permitted data batch. A batch limit is not +a wall-clock or statement timeout. + +```sh +dart run bin/migrate.dart plan +dart run bin/migrate.dart apply --max-backfill-batches 10 +dart run bin/migrate.dart status +``` + +Configure PostgreSQL in the Dart entrypoint as described in [migrations](migrations.md). +Plans report `atomic: false` when pending work includes a backfill. Bounded CLI +runs additionally return `complete: false` until the entire migration list is +applied. Status includes `backfill.rows`, `batches`, `last` and `upper`. Keys are +stored as lossless strings; binary keys use base64, and integer keys never pass +through floating-point JSON. These records contain primary-key data. A `running` +checkpoint describes unfinished durable work; it is not proof of a live process. + +## Transaction and recovery behavior + +The runner checks the historical schema, captures an upper primary-key boundary, +and scans matching keys in ascending order from the last committed key. Composite +primary keys use row comparisons. There is no growing OFFSET. Actual batch sizes +are reduced to fit the driver's parameter limit. SQLite documents this use of +[row values for scrolling queries](https://www.sqlite.org/rowvalue.html). + +Each batch selects keys, updates exactly those rows and records its new cursor +in one transaction. PostgreSQL locks selected rows without skipping locked rows; +SQLite uses BEGIN IMMEDIATE for the batch. Skipping locked rows would be unsafe +for a cursor that advances past them. See PostgreSQL's +[row-locking semantics](https://www.postgresql.org/docs/current/sql-select.html). +The whole invocation retains one leased connection, while other connections can +operate between batch commits. SQLite runners recheck history and progress inside +their write transaction; PostgreSQL runners retain the existing migration advisory +lock. Competing runners do not repeat committed batches. + +A process exit during a batch rolls back both its writes and cursor. An exit after +commit preserves both. A lost commit acknowledgement remains an error; a later +invocation reads durable state to decide where to resume. It never assumes that +an uncertain batch should be replayed. Successful chunks invalidate relevant typed +query subscriptions after commit; rolled-back chunks do not publish their writes. + +An explicit backfill makes its containing migration recoverable rather than atomic +as a whole. Other steps in that migration have individual checkpoints. Adjacent +ordinary migrations retain their atomic grouping. SQLite table rebuilds remain +whole-table operations with foreign-key verification and setting restoration; +`batchSize` does not make a rebuild incremental. + +## Completion and constraints + +`doneWhen` must return exactly one boolean: a PostgreSQL boolean, or SQLite's integer +0/1 representation. NULL, floating-point values and wrong result shapes are errors. +A false condition leaves the migration pending with a failed verification checkpoint. +Already committed batches stay committed. Repair the condition or missing data and +resume the unchanged migration; the runner does not rewind and risk repeating +non-idempotent updates. + +Primary keys must be non-null, unique across the scan and immutable throughout the +operation, including application writes and triggers. Assigning a primary key is +rejected. The runner checks updated/retained keys and rejects detected key movement. +Integer, bigint, text, finite real, boolean, UTC instant, historical timestamp, local date/time and binary cursors are +supported, including composite keys. JSON keys are rejected because the current +driver boundary cannot promise lossless cursor representation for every document. +Ordering follows the actual database storage type and collation. + +Use expand → compatible application writes → backfill → contract. New writes must +already satisfy the intended invariant, including rows inserted behind the cursor +or above the captured boundary. Completion SQL must cover that invariant. Keep +the historical table/key definition stable while the job runs. Temporary tables +cannot shadow the data target or durable migration metadata. PostgreSQL backfills +reject active row security rather than letting hidden rows satisfy a false +completion claim; the check uses the current role's +[row-security state](https://www.postgresql.org/docs/current/functions-info.html). + +The historical catalog check happens at the start of each backfill attempt; normal +chunks do not rescan the catalog. The runner bounds returned key rows and update +parameters. Filter and verification SQL may still scan substantial data: inspect +their query plans and add appropriate indexes as reviewed migrations. This feature +does not promise a particular throughput, zero downtime, or automatic repair of an +incorrect transformation. Statement timeouts and lock waits retain driver behavior. + +Reproduce the standalone SQLite native acceptance check with: + +```sh +dart build cli --target test/support/native_backfill.dart --output .dart_tool/backfill-aot +.dart_tool/backfill-aot/bundle/bin/native_backfill +``` diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 00000000..8b81d1f9 --- /dev/null +++ b/docs/capabilities.md @@ -0,0 +1,114 @@ +# Capability review + +The acceptance scope comes from the [original design](../research/new-dart-orm-design.md). +Its common relational lifecycle is implemented in one package. Optional backend +extensions and untested deployment targets are not implied by a generic driver +or a successful test on another platform. + +## Values and physical storage + +| Design §5 requirement | Implementation and evidence | Explicit boundary | +| --- | --- | --- | +| Integer widths and web-safe exact values | `IntegerBits`, integer/BigInt codecs, integer/native/browser checks, catalog metadata and reviewed migrations | Native `int` is signed 64-bit; JS-safe `int` is narrower. BigInt uses exact digits, but SQLite BigInt text does not promise numeric ordering/arithmetic. | +| Exact decimal | Exact storage, comparisons, keys and generated/imported metadata on all four engines; arithmetic, aggregates/windows and rounding on SQLite/PostgreSQL | MySQL/MariaDB reject operations that can silently lose precision, including arithmetic, SUM, explicit narrowing and decimal set operations. See [engine limits](mysql.md) and [reproductions](../research/mysql-precision-boundaries.md). | +| Distinct instant/date/time/local timestamp | UTC DateTime, LocalDate/LocalTime/LocalDateTime, explicit temporal precision, native binary decoding, SQLite collations, catalog/import/migration tests and browser transport | An IANA zone name is a separate application value, not an instant or local clock value. Calendar SQL arithmetic and timezone-rule conversion have no typed API. | +| JSON versus arbitrary objects | SqlJson distinguishes JSON null from SQL NULL; explicit domain codecs validate custom objects, including generated create/result/relationship types | A Dart Map is not an automatic entity mapping. Backend-specific JSON path APIs are extensions. | +| Stable enum text | EnumValue labels and checked enum codecs; generated/native/negative tests | Native PostgreSQL enums require separate metadata and migration support. | +| Binary and custom IDs | Uint8List and public const UseCodec declarations, checked domain codecs, generated type failures, native/browser reads/writes/relations | A codec's semantic change is not automatically a DDL change. Custom equality/order must match the declared storage. | + +The authoritative contracts and tests are indexed in [types](types.md), +[decimals](decimals.md) and the [acceptance map](acceptance.md). The design's time +contract specifies storage distinctions and precise codecs; it does not define a +timezone database or a calendar SQL function family. Earlier progress notes +listed those possible extensions as open-ended follow-up work. They remain +explicitly unimplemented rather than being counted as delivered functionality. + +Catalog import recognizes supported physical representations without sampling +data to guess semantics. Ordinary SQLite INTEGER/TEXT cannot prove a boolean, +enum, timestamp, BigInt or JSON domain. Unsupported VARCHAR/arrays/domains and +unsupported identity/table forms produce explicit issues instead of silently +changing their meaning. Reviewed annotations/codecs and migrations remain part +of adopting an existing database; import is not a universal database backup. + +## Query and execution contract + +| Required family | Evidence and available behavior | Boundary | +| --- | --- | --- | +| Scalars, Records, DTOs and dynamic selection | `selection_test`, generated/native tests; mappers run only after rows arrive | Dynamic field sets return a dynamic map, not invented static fields. | +| Conditions, ordering and pagination | Database/type/cursor suites, stable unique tie breakers, explicit NULL order | Offset pages do not provide a snapshot; cursor order needs declared keys. | +| Joins, grouping/HAVING, subqueries, CTEs, windows, UNION | `test/support/advanced.dart`, set and numeric suites on both backends; SQL scope and aggregate validation | INTERSECT/EXCEPT and arbitrary database functions do not have dedicated typed methods. Parameterized raw/named SQL is the formal extension path. | +| Relations and relation ordering | Joined or batched to-one; batched collections, composite/self/multiple edges, many-to-many payloads, per-parent limits, any/none/every/count | Order root rows through a typed alias or correlated count. Loaded Record fields are not SQL expressions. No cross-database relation or invisible lazy I/O. | +| Writes and transactions | Generated create/patch, explicit NULL/default, computed/client defaults, batch/upsert/RETURNING, savepoints, rollback, escaped-session rejection | No graph tracking, implicit flush or distributed transaction. | +| Failure handling and streaming | Real interruption, unknown commit, bounded acquisition/retries, real cursors and awaited cleanup | An external side effect is not rolled back by SQL or safe to repeat automatically. Web interruption and MySQL/MariaDB streaming/cancellation are unsupported and rejected. | +| Plans and observations | SQL/column/key/join/batch descriptions and actual acquisition/query/decode events | No general optimizer, result cache or hidden EXPLAIN ANALYZE. Timing scopes are documented, not total CPU attribution. | + +See [query examples](queries.md), [relationships](relations.md), +[execution](execution.md) and [observability](observability.md). + +SQLite cancellation is a build capability, not a promise for every native target. +The default Linux asset in `sqlite3` 3.6.0 hides `sqlite3_interrupt`; it rejects +statement cancellation, execution deadlines and bounded retries before SQL starts. +Ordinary transactions and streaming still work. macOS and Android interruption +have separate runtime evidence; see [execution](execution.md). + +## Migration and database adoption + +Each history fixes one database engine, even when empty. A saved migration contains +one step list and only that engine's frozen physical expressions. Mixed histories +and mismatched connections fail before migration SQL. PostgreSQL migration +execution requires 18+, and SQLite opening requires 3.35+; arbitrary reviewed SQL +can require additional capabilities. + +MySQL 8.4+ and MariaDB 10.6+ use separate driver identities. Migration execution +requires MySQL 8.4+ or MariaDB 11.8+. Their DDL can commit implicitly: checked +before/after table metadata and durable checkpoints support recovery, while +backfill writes and completion checkpoints +share a transaction. See [MySQL/MariaDB migrations](mysql-migrations.md). Current +live acceptance uses MySQL 8.4 and MariaDB 11.8; the MariaDB driver's lower +connection minimum has not received that full acceptance suite. + +Saved migrations contain immutable snapshots, operations, predecessor checksums +and explicit renames/conversions. Tests exercise fresh replay, older-version +upgrades, safe SQLite rebuilds, PostgreSQL constraint/catalog behavior, baseline, +drift, version compatibility, durable backfills and nontransactional recovery. +The migration CLI and real build_runner process workflows test application-facing +commands, including failure repair and stale-output cleanup. + +Unmanaged views, triggers, specialized indexes, policies and table options require +review. PostgreSQL policy reports now retain roles, command, mode and expressions, +plus enabled/forced row-security flags. Actual triggers are created and exercised +after read-only import/verification on both backends. Baseline verifies declared +schema facts and returns unmanaged metadata; it does not recreate or certify +grants, every extension, authorization behavior or the whole database environment. + +## Platform and tooling scope + +Native SQLite and PostgreSQL are checked against SQLite 3.53.4/PostgreSQL 18.4. +MySQL 8.4 and MariaDB 11.8 have separate live driver, typed query, import, +transaction and migration-recovery suites. +Browser reports cover real Chrome JS and Dart WASM, worker memory/OPFS storage, +reopen, interruption recovery, upgrade and watch. Android Flutter checks cover +actual debug-to-AOT APK replacement and independent-process restart. These do +not certify every database version, browser, physical device, iOS or macOS Flutter. +Current source/validation revisions are recorded in [progress](progress.md). +Each platform capture is tied to its recorded source revision; consult the latest +acceptance record before relying on a historical platform result. + +Driver configuration is typed and explicit. PostgreSQL reuses its driver's pool, +offers borrowed-pool ownership and certificate-verifying TLS by default; SQLite +owns one background connection behind a unified native/browser entrypoint. +Memory and named persistent settings are shared; native paths remain explicit. +Flutter Web bundles default assets, with optional browser resource overrides. URL options are +not silently merged with typed settings. MySQL/MariaDB own one queued physical +connection each, with certificate-verifying TLS by default and explicit capability +limits. Unsupported transports, serverless sessions, replica routing and additional +connection-pool variants need their own adapters and verification. + +Generation and completion measurements cover 10/100/1000 models. The declaration +experiment normalized three input forms to identical generated APIs. The current +package supports ordinary immutable model classes with nominal results, alongside +Record declarations; see [authoring](authoring.md). Named Record field rename is unavailable +in the checked SDK. The captured same-session class rename timeout has a checked +server-restart path; it is not described as a working general IDE workflow. +Runtime cost reports compare identical SQL/driver/result workloads, including a +controlled TCP delay, with their sampling and allocation limits stated explicitly. diff --git a/docs/checks.md b/docs/checks.md new file mode 100644 index 00000000..43b918f7 --- /dev/null +++ b/docs/checks.md @@ -0,0 +1,94 @@ +# Row CHECK constraints + +Declare a row constraint beside its entity. Expressions use physical SQL column +names; generation reads literal SQL and never executes a sample Dart record. + +```dart +typedef Product = ({ + @Id.generated() int id, + double price, + double? discount, + String label, +}); +final products = entity(); +final nonnegativePrice = products.check('price >= 0'); +final validDiscount = products.check('discount >= 0 AND discount <= price'); +final shortLabel = products.check( + 'length(label) <= 20', + postgres: 'char_length(label) <= 20', +); +``` + +`name` defaults to the declaration's snake_case name, for example +`nonnegative_price`. Set an explicit stable name to rename the Dart declaration +without renaming its database constraint. `name: null` deliberately emits an +unnamed constraint. Names must be nonempty and unique per table. SQLite compares +ASCII identifier case without distinction; PostgreSQL preserves quoted case. +Expressions and optional overrides are string literals. An override replaces the +common expression for that backend; the expression selected by the migration +target must be nonempty. + +These are trusted schema SQL fragments, like `@Default.sql`; do not construct +them from user input. Dart checks the declaration API, while the database checks +SQL syntax and enforces the predicate. This feature does not type-check SQL column +names or translate arbitrary Dart boolean closures. + +SQL NULL is accepted by CHECK. A nullable `discount` can be absent in this example; +requiring a value needs a non-nullable field as well. Constraints concern the +current row and should use stable functions. PostgreSQL assumes a CHECK condition +does not change for the same row; changing a function implementation requires +explicit revalidation. See [PostgreSQL constraints](https://www.postgresql.org/docs/18/ddl-constraints.html) +and [SQLite CHECK semantics](https://www.sqlite.org/lang_createtable.html#check_constraints). + +## Snapshots, verification and import + +The current generated `TableSchema.checks` retains overrides in `CheckSchema`. +Saved migration schemas freeze only their chosen engine's expressions. Changing +an unused override does not change that migration target's physical fingerprint. + +`inspectTable` and `orm db inspect` expose native names and expressions. +`verifySchema` compares declared checks, including duplicate unnamed constraints. +An extra check is reported as an unmanaged object; a missing or different check +is drift. PostgreSQL unvalidated, unenforced, inherited and NO INHERIT constraints +stay unmanaged because they do not meet this declaration's ordinary enforced-row +contract. + +SQLite comparison tokenizes SQL, ignores comments, identifier quoting and outer +parentheses, and retains string literals, casts and precedence. It preserves +SQLite's non-ASCII identifier case distinctions. This is conservative comparison, +not a general SQL parser or proof that differently written predicates are equivalent. +The exact unnamed range/precision expressions emitted for `@IntegerBits` and +`@DecimalDigits` remain reserved storage metadata; named row checks are separate. + +PostgreSQL rewrites SQL when storing a constraint. Verification uses one +`EXPLAIN (VERBOSE, FORMAT JSON)` projection per checked table to render declared +and catalog expressions in the same typed context. It has planning/catalog cost +and does not execute the SELECT or scan application rows. The planner can evaluate +immutable constant expressions, so this is not a promise of zero function +evaluation. No `ANALYZE` option is used. See [EXPLAIN](https://www.postgresql.org/docs/18/sql-explain.html). +SQL/functions must be available in the selected database schema during verification. + +[Import](importing.md) retains native check SQL and names, including explicit null +names on SQLite, and emits a nonblocking `IMPORT.CHECK_SQL` review note. Check +other-dialect overrides before deploying an imported declaration elsewhere. + +## Reviewed migrations and cost + +`Migration.diff` includes additions, removals and expression/name changes. +PostgreSQL drops changed constraints and adds replacements. SQLite rebuilds the +table. Adding a check validates existing rows; a violation rolls back the ordinary +migration batch and its history. Repair data explicitly, then retry the unchanged +migration. A change to only the PostgreSQL expression produces no SQLite DDL. + +For an explicit table/column rename, update physical names in the declared SQL. +The diff removes old declared checks before native renames and adds the new +expressions afterward. It never guesses how to rewrite SQL text. On SQLite a +checked rename with target checks uses two visible rebuild steps, potentially +copying the table twice; inspect the migration and budget disk space and write +lock time. Native renames preserve view, trigger and foreign-key references. +Unclaimed checks block a SQLite rebuild instead of being silently removed. + +Checks can make writes and schema upgrades more expensive. This API provides +reviewed, transactional changes; an online zero-lock upgrade is not implied. +Function definitions, cross-table invariants and specialized constraint modes +remain explicit native migration work. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..93a273de --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,135 @@ +# Project CLI + +Start inside a Dart project with the `orm` dependency and Dart 3.13 or newer: + +```sh +dart run orm init --database sqlite +dart run orm migrate create 0001_initial +# Review migrations/m0001_initial.dart before applying it. +dart run orm migrate apply +dart run orm migrate verify +``` + +`init` also accepts `postgres`, `mysql` and `mariadb`. It creates five files: + +| File | Purpose | +| --- | --- | +| `lib/schema.dart` | Editable nominal Dart model and table declaration | +| `lib/schema.orm.dart` | Generated typed query client | +| `lib/schema.snapshot.dart` | Standalone physical schema | +| `migrations/migrations.g.dart` | Static imports and one fixed engine | +| `orm.config.dart` | Typed project configuration and executable entrypoint | + +Initialization refuses an existing destination before writing anything. It never +connects to a database, creates a database file or applies DDL. The first client +and snapshot are generated before writing the config, so its static imports are +valid immediately. No JSON schema loader, reflection, package-specific runtime +registration or build_runner setup is needed for this workflow. + +SQLite defaults to `app.sqlite`; edit its typed options in the config to change +the path. Server connections read `DATABASE_URL` only when connecting. Missing +credentials do not prevent initialization, generation or history validation. +TLS defaults to certificate verification. Connections use the independent driver +and `SqlDatabase` layers rather than importing model/query APIs. + +## Typed configuration + +The generated `orm.config.dart` is normal application-owned Dart: + +```dart +import 'package:orm/cli.dart'; +import 'package:orm/drivers/sqlite.dart'; +import 'lib/schema.snapshot.dart' as target; +import 'migrations/migrations.g.dart'; + +Future main(List args) => runOrmCli(args, config: OrmConfig( + schema: 'lib/schema.dart', + migrations: 'migrations', + history: migrationHistory, + snapshot: target.schema, + connect: ({required bool readOnly}) async => SqlDatabase( + await SqliteDriver.open(readOnly + ? const SqliteOptions.readOnly('app.sqlite') + : const SqliteOptions.file('app.sqlite')), + ), +)); +``` + +The package command runs this explicit entrypoint in a child Dart process. +History is statically imported and checked; it is not discovered by executing +every Dart file in a directory. Run from the project root so relative schema, +history and connection paths are consistent. Use `--config other.config.dart` +for another explicitly selected entrypoint, or run +`dart run orm.config.dart migrate check` directly. + +Config is trusted project source and should not perform I/O at the top level. +Place connection setup inside `connect`. Migration commands establish their own +transaction and locking behavior; `readOnly` lets a SQLite factory avoid creating +a file during inspection. + +## Commands and side effects + +| Command | Behavior | +| --- | --- | +| `generate` | Generate client and physical snapshot using the config | +| `migrate create ` | Regenerate the current schema, then save its diff as fixed Dart history | +| `migrate check` | Check compiled history and fingerprints without a connection | +| `migrate plan` | Read applied history and report pending operations | +| `migrate apply` | Apply pending operations explicitly | +| `migrate status` | Read migration history and recovery checkpoints | +| `migrate verify` | Compare the catalog with the generated snapshot | +| `migrate baseline` | Verify an existing schema and register its history | +| `migrate record ` | Record a reviewed edit to the latest unpublished migration | +| `migrate inspect ` | Read physical metadata for one table | + +`create` always uses the current source, including edits since the last +`generate`. Other commands use the statically imported snapshot/history; they do +not silently regenerate or change deployment artifacts. Run `generate` before +checking a deliberately edited target with `verify`. New migrations never +overwrite existing files. Database changes happen through explicit `apply` or +`baseline`; initialization, generation and diff creation are offline. + +An initial SQLite `plan` needs an existing file and fails without creating one. +The first `apply` can create that file. `create --allow-destructive` permits +writing reviewed drop operations, not executing them. `apply +--max-backfill-batches ` bounds a resumable backfill invocation. + +See [migrations](migrations.md) for immutable history, engine boundaries, reviewed +renames/conversions, baseline and recovery behavior. + +## Explicit tools + +These commands also work without project configuration: + +```sh +dart run orm generate lib/schema.dart lib/generated/database.dart +dart run orm migration registry migrations --dialect sqlite +dart run orm db inspect --sqlite app.sqlite --table tasks +dart run orm db import --sqlite app.sqlite --output lib/imported.dart +dart run orm queries generate lib/queries.dart +dart run orm queries check --source lib/queries.dart --sqlite app.sqlite +dart run orm web-assets web/orm +``` + +Server database flags are `--postgres-env NAME`, `--mysql-env NAME` and +`--mariadb-env NAME`. Choose exactly one database option. `--tls +verifyFull|require|disable` configures server TLS; `--database-schema` is specific +to PostgreSQL. Catalog import writes a Dart draft and a separate review report. +It does not migrate an existing database. + +Without a config, `generate` uses `lib/schema.dart`. If a config's generated +snapshot was deleted, recover with the explicit source command +`dart run orm generate lib/schema.dart`, which does not load that config. + +## Help and automation + +`dart run orm --help`, `dart run orm help migrate` and `dart run orm migrate +apply --help` describe command groups. Add `--json` to emit machine-readable +reports on stdout and error objects on stderr. JSON is a reporting format only; +schema snapshots and migration history remain Dart source. + +Exit codes are `0` for success, `1` for execution/generation failure, `2` for +catalog drift or blocking import issues, and `64` for invalid arguments or +configuration. The Dart launcher may print native build-hook diagnostics on +stderr before the CLI starts; automation should parse successful stdout reports +and use the process exit code for failures. diff --git a/docs/computed.md b/docs/computed.md new file mode 100644 index 00000000..c11d96ed --- /dev/null +++ b/docs/computed.md @@ -0,0 +1,100 @@ +# Database computed columns + +Declare SQL over physical column names. The generated Record includes the result; +creation and patch inputs omit it. + +```dart +typedef Line = ({ + @Id.generated() int id, + int price, + int quantity, + String label, + @Computed.sql('price * quantity') int total, + @Computed.sql('length(label)', postgres: 'char_length(label)', + storage: ComputedStorage.virtual) int labelSize, +}); +final lines = entity(); + +final row = await db.lines.create(price: 4, quantity: 3, label: 'cat'); +print(row.total); // 12 +await db.lines.byId(row.id).patch(quantity: .set(5)); // total becomes 20 +final totals = await db.lines.select((r) => r.total).get(); +``` + +`ComputedStorage.stored` is the ORM default and computes on writes; `virtual` +computes on reads. Both modes emit an explicit SQL storage clause. Select based +on expression cost, read/write frequency and storage needs. PostgreSQL 18 and +SQLite support both, but PostgreSQL 18 rejects indexes/unique keys on virtual +columns. Use stored columns for that portable schema. No performance advantage +is assumed from these correctness tests. + +Generated computed fields are `ReadField`: they support expressions, ordering, +selection and relationship keys, but have no `set`, `increment`, `change` or +`defaultValue` method. They cannot coexist with identity, SQL defaults or client +factories. SQLite cannot use computed primary keys; PostgreSQL permits stored +computed primary keys. Every table needs an ordinary column. Generation preserves +these declarations and the migration target enforces the selected engine's rules. +Raw SQL retains the database's own write rules. + +SQL is trusted schema code. Generation reads the literal; it does not translate +Dart closures or validate SQL function volatility. The database validates names, +operators and determinism. PostgreSQL disallows references to another generated +column and restricts virtual expressions to built-in functions/types. SQLite has +its own deterministic-expression rules. Review overrides for each backend. +[PostgreSQL rules](https://www.postgresql.org/docs/18/ddl-generated-columns.html), +[SQLite rules](https://www.sqlite.org/gencol.html). + +## Migration behavior + +Current generated snapshots retain expression overrides and the storage mode. +Each saved migration freezes only its selected engine's expressions. + +| Change | PostgreSQL | SQLite | +| --- | --- | --- | +| Add a computed column | Native ADD COLUMN; old rows get computed values | Virtual: ADD COLUMN. Stored: rebuild | +| Change expression | SET EXPRESSION AS; stored values are rewritten | Rebuild, recomputing target columns | +| Change computed result type | Native TYPE without USING, followed by target expression | Rebuild with target expression | +| Drop a computed column | Explicit destructive diff, native DROP COLUMN | Explicit destructive diff, rebuild | +| Stored to ordinary | DROP EXPRESSION retains existing values | Rebuild copies the old computed values | +| Ordinary to computed, storage-mode change, virtual to ordinary | Automatic diff requires a reviewed replacement/materialization plan | Automatic rebuild; discarding ordinary stored values requires `allowDestructive` | + +The PostgreSQL diff refuses mode transitions that cannot be emitted natively. +Use reviewed `Migration.steps` with explicit operations for that history's engine; +preserve indexes, constraints, views and triggers when replacing a column. Changing +types of columns used by generated expressions can also require a manual plan due +to native dependency rules. Automatic TYPE assumes the database accepts the cast; +it is not a promise of a lossless type conversion. + +Stored-to-ordinary type changes still require explicit `using` conversions. A +computed target derives values from its declared expression and rejects `using`. +Rebuild copy maps and Backfill assignments must omit computed target columns. +Adding a computed non-null column needs no invented default/backfill: the +expression supplies values for old rows, and invalid results abort the migration. + +Explicit renames require the target declaration's updated SQL; the ORM never +rewrites arbitrary expression text. PostgreSQL performs native renames and updates +the target expression. SQLite first materializes old computed values, performs +the rename, then rebuilds using the target expressions. A renamed computed table +can be copied twice; related CHECK preparation shares the first copy. Plan this +I/O and lock time before applying a large migration. + +Tests run against PostgreSQL 18.4 and the pinned SQLite engine. PostgreSQL migration +execution requires server 18 or newer. Unsupported native syntax fails the +migration; there is no silent emulation. Stored expression changes rewrite rows and PostgreSQL +discards their column statistics, so review post-migration ANALYZE needs. +[Native ALTER TABLE behavior](https://www.postgresql.org/docs/18/sql-altertable.html). + +## Catalogs and imports + +`inspectColumns` exposes `ColumnInfo.computed`; `db inspect` includes expression +and mode. `verifyColumns` and `verifySchema` compare computation metadata in +addition to existing column facts. PostgreSQL uses a single EXPLAIN projection +per computed table, including assignment casts, to compare native typed output. +This plans SQL without selecting rows; immutable constant functions may run during +planning. SQLite compares tokenized SQL and preserves precedence, comments and +quoted identifiers. Semantically equivalent rewrites are not always recognized. + +`db import` emits `@Computed.sql` and a nonblocking `IMPORT.COMPUTED_SQL` review +note. SQL from one database is not evidence of portability to the other. Managed +Decimal coercion is unwrapped for declarations and emitted once on regeneration. +Import does not run application code or sample data values. diff --git a/docs/decimals.md b/docs/decimals.md new file mode 100644 index 00000000..46074184 --- /dev/null +++ b/docs/decimals.md @@ -0,0 +1,308 @@ +# Exact decimals + +`Decimal` stores a normalized BigInt coefficient and decimal scale. Construction, +typed writes and reads never pass through `double`. Equal values such as `2`, +`2.00` and `20e-1` have the same equality and hash code. Display precision is not +retained; `toString()` produces ordinary canonical decimal text. + +The SQL arithmetic examples below apply to SQLite and PostgreSQL. MySQL/MariaDB +support exact values, storage, comparisons and MIN/MAX within 65-digit/30-scale +limits, require declared column precision, and reject typed operations that can +silently lose precision. See [engine-specific boundaries](mysql.md). Pure Dart +`Decimal` value arithmetic is independent of the selected database. + +```dart +import 'package:orm/schema.dart'; + +typedef Invoice = ({ + @Id.generated() int id, + Decimal total, + Decimal? discount, +}); +final invoices = entity(); + +// After generation: +await db.invoices.create(total: Decimal.parse('9007199254740993.01')); +final total = await db.invoices.select((i) => i.total.sum()).single(); +``` + +Use `Decimal.parse(String)` or `Decimal.fromBigInt(value, scale: 2)`. +Scientific notation is accepted. Whitespace, NaN, Infinity and floating-point +codec inputs are rejected. The supported finite range is at most 131072 integer +digits and 16383 fractional digits, matching unconstrained PostgreSQL +[NUMERIC limits](https://www.postgresql.org/docs/current/datatype-numeric.html). +Parsing also bounds input length and exponents. Out-of-range arithmetic throws; +there is no saturation or automatic precision reduction. + +## Arithmetic + +Value operations `+`, `-`, `*`, comparisons and sorting are exact. Division and +rounding require an explicit scale and default to rejecting a discarded nonzero +remainder: + +```dart +final third = Decimal.parse('1').divide( + Decimal.parse('3'), + scale: 8, + rounding: DecimalRounding.halfEven, +); // 0.33333333 +final cents = Decimal.parse('2.345').rounded( + 2, + rounding: DecimalRounding.halfEven, +); // 2.34 +``` + +Other modes are `exact`, `towardZero`, `floor`, `ceiling` and +`halfAwayFromZero`. Negative scales round integer digits. Rounding changes the +value, not its display padding. + +SQL fields provide `plus`, `minus`, `times`, `plusExpression`, `minusExpression` +and `timesExpression`. Combining expressions can produce SQL NULL and returns a +nullable decimal expression. `sum(distinct: true)` is optional; empty/all-null +sums return null. Aggregate accumulation permits temporary overflow that cancels +before a valid final result; range checks apply when producing the result. `min`, `max`, `count`, grouping and aggregate windows compose +with the existing query API. Predicates, IN/subqueries, computed CTE references, +UNIONs, streaming and transported cursors preserve decimal comparison semantics. + +SQL expressions also provide `divide`, `divideExpression` and `rounded`, with the +same explicit scale and six rounding modes as values: + +```dart +final installments = await db.invoices.select( + (i) => i.total.divide( + Decimal.parse('3'), + scale: 2, + rounding: .halfEven, + ), +).get(); +final roundedTotal = await db.invoices.select( + (i) => i.total.sum().rounded(2, rounding: .halfEven), +).single(); +``` + +Scale is -131072..16383. The default `exact` rejects discarded nonzero digits; +division by zero and a result outside the finite range fail the statement. +`divideExpression` accepts a nullable decimal expression and returns a nullable +result. SQL NULL propagates, including a null numerator with a zero divisor. +Each operand is evaluated once for a division evaluation, including volatile +PostgreSQL expressions. Expression assignments participate in ordinary transaction +rollback. These methods also compose with grouping, windows, CTEs, sets, streamed +results and per-parent relationship pagination. + +PostgreSQL division uses [integer quotients and exact remainders](https://www.postgresql.org/docs/current/functions-math.html), rather than +rounding native `/`, whose chosen result scale can already have discarded digits. +Large remainders use a bounded decomposition to avoid overflowing an intermediate +product. Scalar stages use `MATERIALIZED` CTEs (PostgreSQL 12+); window operands +are evaluated in the original query before the final scalar projection. This +remains one statement and installs no database functions. It requires more SQL +work than native limited-scale division; cost depends on row count, operand size +and requested scale. SQLite calls the native worker's BigInt-based decimal +functions. No floating-point fallback is used. + +Trusted raw SQL remains the caller's responsibility, including its result type. + +## Exact averages + +`average(scale: ..., rounding: ...)` returns `Expr`. It ignores SQL NULL +and returns null for empty/all-null input, following normal +[aggregate semantics](https://www.postgresql.org/docs/current/functions-aggregate.html). +Scale is required; the default `exact` rejects a nonzero discarded remainder. +All six rounding modes work across the full supported scale range. + +```dart +final average = await db.invoices.select( + (i) => i.total.average(scale: 2, rounding: .halfEven), +).single(); +final running = await db.invoices.orderBy((i) => [i.id.asc()]).select( + (i) => i.total.average(scale: 2, rounding: .halfEven).over( + orderBy: [i.id.asc()], + frame: .rowsToCurrent, + ), +).get(); +``` + +The result rounds once. It does not use a previously rounded native AVG or require +the total to fit Decimal first: averaging two `9e131071` values succeeds even +though their sum is outside the finite range. PostgreSQL splits each input into +an integer high part and a low remainder at base 10^20, sums those parts and counts +non-null inputs, then computes the exact quotient/remainder before rounding. +The base exceeds the maximum signed 64-bit count; both component sums fit NUMERIC. +This uses three native aggregates and no array of input rows or installed server +function. SQLite maintains one BigInt accumulator plus count and uses an integer +fraction to produce the final value. Sliding frames remove values incrementally. + +An input expression is evaluated once per contributing row for each average, +including volatile PostgreSQL expressions. Ordinary PostgreSQL aggregation uses +a correlated lateral input; window averages share their input, partition and order +values in a preceding projection. Grouping/HAVING happens before the window; +DISTINCT, final ordering and pagination apply to final results. Averages also work +in CTEs, correlated scalar subqueries, batched relation windows and streamed results. +Use a scalar subquery when assigning an aggregate result in a mutation. + +To average unique values, deduplicate the input projection first: + +```dart +final unique = db.invoices.select((i) => i.total).distinct().asCte('unique_totals'); +final mean = await unique.query.select( + (i) => i.ref((source) => source.total).average(scale: 2, rounding: .halfEven), +).single(); +``` + +`distinct()` after the average instead deduplicates result rows. Window results +must similarly become CTE/subquery columns before another aggregate consumes them. + +For application-side integer fractions, use +`Decimal.fromFraction(numerator, denominator, scale: ..., rounding: ...)`. +The numerator and denominator are BigInt values; only the final rounded result +must fit the Decimal range. A zero denominator is rejected. This avoids constructing +an out-of-range Decimal just to divide it back into range. + +## Column precision and scale + +Declare column precision independently of the Decimal value codec: + +```dart +typedef Balance = ({ + @Id.generated() int id, + @DecimalDigits(12, 2) Decimal amount, + @DecimalDigits(12, 2) @Default.sql("'1.235'") Decimal initial, +}); +``` + +Precision is 1..1000 and scale is -1000..1000. Omitting scale means zero. +These are [PostgreSQL NUMERIC declarations](https://www.postgresql.org/docs/current/datatype-numeric.html): +negative scale rounds integer digits, and scale may exceed precision. For example, +`DecimalDigits(3, -2)` stores multiples of 100 through 99900 in magnitude; +`DecimalDigits(3, 5)` stores up to 0.00999 in magnitude. Negative/excess scales +require PostgreSQL 15 or newer. Native verification uses PostgreSQL 18.4. + +ORM writes round ties away from zero and then check the range. `1.235` becomes +`1.24`, `-1.235` becomes `-1.24`; `999.995` overflows NUMERIC(5,2) after rounding. +This applies to generated creation/patches, expression assignments, batches, +upserts and defaults. Generated records contain the stored value; use the +returned key if a decimal primary key was rounded. Query parameters are not +implicitly rounded to a column's scale. + +Computed values and aggregates retain the unconstrained Decimal codec. A sum may +therefore exceed the column's precision. To coerce a SQL expression explicitly, +use `amount.sum().constrained(12, 2)`; this composes with predicates and CTEs. +For an application value, `value.constrained(12, 2)` performs the same rounding +and range check, while `value.fits(12, 2)` checks whether it fits without rounding. +Choose `rounded(..., rounding: ...)` before a write when another rounding policy +is needed. Column coercion then preserves the already rounded value. + +PostgreSQL uses native NUMERIC(p,s). SQLite uses the existing numeric TEXT +collation plus a managed precision CHECK. ORM assignments and defaults call the +exact coercion function. A raw SQLite writer must supply an already fitting value +or explicitly call `orm_decimal_cast_v1(value, precision, scale)`; the CHECK +rejects values requiring rounding, out-of-range values and invalid decimal text. +A raw PostgreSQL writer receives PostgreSQL's native coercion. As with the native +NUMERIC type, NaN remains a PostgreSQL write possibility and fails typed decoding. + +Precision/scale are included in snapshots, CLI inspection and import drafts. +Changing either requires explicit migration conversion expressions. SQLite +rebuilds and coerces the copied values; PostgreSQL changes the column type. +Rounding can turn distinct keys into duplicates, which rolls back structure and +history together. Imported SQLite declarations remove the managed outer default +coercion before generation; verification still detects a missing coercion in the +actual database default. Custom decimal-backed codecs retain their Dart types. + +## Storage and other database writers + +PostgreSQL uses native NUMERIC and its native arithmetic. SQLite uses TEXT with +the versioned `orm_decimal_v1` numeric collation. Each native SQLite worker +registers pure exact arithmetic functions and a window-capable sum. This is +necessary because ordinary SQLite numeric operations can coerce text to +[approximate floating point](https://www.sqlite.org/floatingpoint.html). +Arithmetic and sum functions are restricted to direct SQL. Column coercion and +validation functions additionally run in defaults and CHECK constraints. The +current SQLite binding does not mark these two functions innocuous, so SQLite +rejects their use in schema expressions with `trusted_schema = OFF`. The driver +preserves that setting; constrained decimal schemas currently require it ON. + +The collation also governs indexes, primary/unique keys and foreign keys: `2.00` +conflicts with `2`. Batch relationship keys normalize before matching returned +rows. Applications opening these SQLite files through another driver must provide +the same collation before using affected keys, sorting or schema changes. Raw +decimal arithmetic additionally needs the corresponding functions. The native +ORM driver registers these per connection; browser support has not been verified. + +Always bind strings for externally written decimal values. SQLite TEXT affinity +cannot recover precision already lost in an incoming REAL value. SQL defaults +should be quoted decimal strings, for example `@Default.sql("'0.10'")`. +Ordinary numeric literals can be evaluated approximately by SQLite first. + +Unconstrained decimal columns have no generated finite-value CHECK. PostgreSQL +permits special numeric values; unconstrained SQLite columns permit arbitrary text. Typed decoders +reject these. SQLite orders invalid external text after valid decimals, using +lexical order between invalid values, because a collation callback cannot report +a SQL error safely. Arithmetic on invalid text fails. Do not treat the absence +of a write error from another client as validation of a `Decimal` value. + +## Schema evolution and verification + +Snapshots store the `decimal` tag. Catalog verification checks SQLite column +collation as well as storage type, nullability and other declared facts. Import +recognizes PostgreSQL NUMERIC, optional precision/scale, and SQLite TEXT columns +carrying the exact managed collation and optional managed precision check. Plain SQLite NUMERIC is not imported as an exact decimal. +Quoted defaults or nested SQL fragments do not count as column collations. + +Changing text to decimal requires reviewed conversion expressions. SQLite rebuilds +the table with the new collation; PostgreSQL changes its native type. Existing +text keys `2` and `2.00` become duplicate numeric keys, so the migration fails and +rolls back data, structure and history together. Repair the data before retrying. +Historical backfills retain exact decimal keys in their resumable checkpoints. + +See the [generated fixture](../test/support/decimals/schema.dart), +[database checks](../test/decimal_test.dart), +[division/window checks](../test/decimal_division_test.dart), +[average checks](../test/decimal_average_test.dart) and the +[native acceptance executable](../test/support/decimals/native.dart). +These establish correctness on the tested native databases. The real +[JS/WASM capture](sqlite-web.md) additionally checks exact Decimal transport. +It does not rerun this complete arithmetic/precision suite, and the Android +Flutter acceptance does not include Decimal-specific scenarios. + +## Native cost probe + +The [benchmark script](../tool/benchmark_decimal.dart) fetches and decodes 10000 +integer-valued Decimal rows. Each case has one warmup and three measured runs. +The checked-in [raw report](../research/benchmarks/decimal-division.json) was +recorded with a native macOS ARM64 AOT executable, Dart 3.13.3, in-memory SQLite +3.51.0 and local PostgreSQL 18.4. Median elapsed milliseconds: + +| Operation | SQLite | PostgreSQL | +| --- | ---: | ---: | +| Read Decimal values | 10.13 | 22.72 | +| Divide by 3, scale 2, half-even | 30.09 | 32.63 | +| Round to tens, half-even | 21.64 | 25.25 | +| Running sum then divide by 3 | 48.17 | 36.85 | + +The expanded [average report](../research/benchmarks/decimal-average.json) uses +the same native AOT method and database versions. All cases consume 10000 rows; +the ordinary average returns one row, while the running average returns 10000: + +| Operation | Returned rows | SQLite ms | PostgreSQL ms | +| --- | ---: | ---: | ---: | +| Average, scale 2, half-even | 1 | 8.78 | 2.74 | +| Running average, scale 2, half-even | 10000 | 29.12 | 47.25 | + +Both reports retain individual samples; the expanded script also reruns the +read/divide/round controls. The earlier division table above refers to its original +report, rather than mixing results from separate runs. + +These are single-client end-to-end samples, including query compilation, database +work, row transport and Decimal decoding. They are not latency percentiles, +concurrency measurements, or estimates for large coefficients/high scales. +No other ORM test/build workload ran during measurement. + +```sh +dart build cli --target=tool/benchmark_decimal.dart --output=/tmp/orm-decimal-benchmark +/tmp/orm-decimal-benchmark/bundle/bin/benchmark_decimal > /tmp/decimal-division.json +``` + +Set `ORM_TEST_POSTGRES` to an accessible disposable local PostgreSQL URL to include +PostgreSQL. The script disables TLS for that local connection, creates a uniquely +named schema and drops its schema afterward; the account needs schema-creation +permission. Without the variable it measures SQLite only. Run separately from +tests and builds. Compilation happens before timing. diff --git a/docs/defaults.md b/docs/defaults.md new file mode 100644 index 00000000..23f2ce9d --- /dev/null +++ b/docs/defaults.md @@ -0,0 +1,82 @@ +# Client values and database defaults + +Use `@ClientDefault(factory)` when Dart should generate an omitted insert value. +Keep `@Default.sql(...)` for values generated by the database. + +```dart +String initialLabel() => 'draft'; + +typedef Entry = ({ + @Id.generated() int id, + @ClientDefault(DateTime.now) DateTime createdAt, + @ClientDefault(initialLabel) @Default.sql("'server'") String label, +}); +final entries = entity(); + +final entry = await db.entries.create(); // Dart timestamp/label; database id. +await db.entries.create(label: .set('manual')); +await db.entries.create(label: .defaultValue()); // SQL supplies "server". +``` + +Factories can be public top-level functions, static methods, constructors or +public const function references. They must accept a call without required +arguments and return a type compatible with the field, including domain types +and nullability. Optional arguments keep their Dart defaults. Instantiate generic +functions explicitly, for example `@ClientDefault(empty)`. Factory results +are not awaited: an async `Future` is not a value for a `String` field. +Generation reads the reference and type without calling it. + +## Creation and updates + +Generated creation parameters with client defaults use `Change`, just like +fields with SQL defaults: + +| Creation input | Behavior | +| --- | --- | +| Omitted or `.keep()` | Generate the client value if declared; otherwise omit the SQL column | +| `.set(value)` | Use the explicit value without calling that column's factory | +| `.set(null)` on a nullable field | Store SQL NULL without calling the factory | +| `.defaultValue()` | Use the database default or identity without calling the factory | + +When both defaults exist, omission chooses the client value. `.defaultValue()` +requires a database default or identity; it does not rerun the client factory. +A nullable factory returning null stores NULL even if the database has another +default. Patches and conflict updates keep omitted fields unchanged. SQLite's +existing restriction on UPDATE SET DEFAULT still applies. + +`create`, `createRow`, `insert` and `insertMany` share this behavior. Factories run +once per omitted column per input row while constructing an insert. Batch values +are prepared in input order. Compilation, conflict-clause construction and repeated +execution of that prepared mutation retain those values. Construct a fresh insert +when fresh values are needed. An INSERT prepares defaults even if ON CONFLICT +later skips or updates the row; only explicit conflict assignments change stored +values. Raw `db.execute` SQL bypasses client factories. + +Factory errors propagate before that INSERT is sent, but earlier factories may +already have run. Rollback does not reverse Dart side effects or return consumed +identifiers. Rebuilding an insert inside a transaction retry calls its factories +again. Keep factories short; external effects need their own idempotency handling. +They run in the caller's Dart isolate, including with background SQLite or a +browser worker. Tables cache their default-bearing columns; tables without any +client defaults use the direct insertion path. + +## Physical schema + +`Column.clientDefault` is runtime metadata. It adds no database DEFAULT and is +absent from saved snapshots, catalog imports and migration checksums. Changing only +a client factory produces no DDL. A required column with only a client default +still needs an explicit value from raw-SQL writers. + +Adding a non-null column with only a client factory still needs a database default +or an explicit backfill for existing rows. Migrations never run the client factory +to populate historical data. + +A database identity may also have a client factory. Omission inserts the client +ID explicitly; `.defaultValue()` asks the database to generate one. PostgreSQL +does not advance its identity sequence for an explicit ID; SQLite rowid allocation +considers existing rowids. Coordinate uniqueness when mixing these sources. + +`DateTime.now` is application time encoded by the UTC instant codec, not SQL +CURRENT_TIMESTAMP. Ordinary Dart constructor defaults do not declare database +defaults. Use [computed columns](computed.md) for expressions the database +recomputes when their source values change. diff --git a/docs/execution.md b/docs/execution.md new file mode 100644 index 00000000..8c0688fa --- /dev/null +++ b/docs/execution.md @@ -0,0 +1,359 @@ +# Streaming and execution + +`get()` materializes a complete result. `stream()` keeps a database cursor open +and fetches the next batch when the consumer requests more rows: + +```dart +await for (final user in db.users + .orderBy((u) => [u.id.asc()]) + .stream(batchSize: 128)) { + await exportUser(user); +} +``` + +`await for` pauses while its body awaits. With `listen`, pause/resume the +subscription yourself; an asynchronous `onData` callback does not create demand +control. `toList()` deliberately accumulates the whole stream in the consumer. + +The default batch size is 128. SQLite advances a native prepared cursor in its +background isolate. PostgreSQL declares a server cursor and issues bounded +`FETCH` statements. Neither adapter first materializes all root rows in Dart. +The SQL plan can still sort or aggregate before it returns its first row. + +Relations are loaded once per root batch on the same connection, including +nested selections and per-parent limits. Memory includes the selected related +rows: use relation `take()` when a parent can have a large collection. Root +batch size alone does not bound that collection. + +A stream holds one connection until completion or cancellation. Outside an +explicit transaction it opens a read transaction, committing after normal +exhaustion and rolling back on early exit or failure. PostgreSQL uses a +non-holdable, forward-only cursor. For a consistent snapshot across root and +relationship queries, stream inside a transaction with the required isolation: + +```dart +await db.transaction((tx) async { + await for (final row in tx.users.stream()) { + await consume(row); + } +}, options: const PostgresTransaction( + isolation: Isolation.repeatableRead, + readOnly: true, +)); +``` + +Finish/cancel subscriptions inside a transaction or leased session callback. +An escaping active stream is stopped and the callback fails with +`TRANSACTION.UNAWAITED` or `SESSION.UNAWAITED`. `Database.close()` also stops +active cursors before releasing its driver. A leased connection executes one +statement at a time; overlapping statements fail with `SESSION.BUSY`. +Concurrent `close()` callers all await the same drain and driver shutdown. + +## Connection acquisition + +```dart +final options = ExecutionOptions( + acquireTimeout: const Duration(seconds: 1), + timeout: const Duration(seconds: 5), + cancellation: token, +); +final rows = await db.users.select((u) => u.email).get(options: options); + +await db.transaction((tx) async { + await tx.users.create(email: 'within-a-lease@example.com'); +}, acquire: const AcquisitionOptions(timeout: Duration(seconds: 2))); + +await db.session((session) async { + // This session already owns its connection. +}, acquire: AcquisitionOptions( + timeout: const Duration(seconds: 2), + cancellation: acquisitionToken, +)); +``` + +`ExecutionOptions.acquireTimeout` spans waiting for the driver to supply a +connection, including connection establishment and initialization. It ends before +SQL starts. The same query cancellation token covers both waiting and statement +execution. Stream and watch options propagate this acquisition bound; batch +inserts apply it before opening their transaction. An existing leased session +needs no further acquisition. + +`AcquisitionOptions` is the corresponding session/transaction entry setting. +Its cancellation token affects acquisition only: after the callback begins it +does not interrupt that callback or its statements. It is not a transaction +execution deadline. Non-positive timeouts and already-cancelled requests are +rejected before queuing. + +A [Dart Future timeout](https://api.dart.dev/dart-async/Future/timeout.html) does +not cancel its source computation. Expired acquisitions report `CONNECTION.TIMEOUT`; requested cancellation reports +`OPERATION.CANCELLED`. The ORM does not merely abandon a Future: a late driver +lease is returned without entering the query, mutation or transaction callback. +A monotonic clock also checks the deadline at entry, so a delayed Dart Timer +cannot allow expired work to start. The underlying pool can retain its reservation +until it grants a lease or fails; the ORM does not pretend that it can remove an +entry from a driver queue that exposes no removal API. `Database.close()` waits +for these reservations to drain. A caller holding a session must still release it. + +PostgreSQL has separate driver settings: + +| Setting | Default | Scope | +| --- | --- | --- | +| `PostgresOptions.connectTimeout` | 10 seconds | Establishing a new connection | +| `PostgresOptions.poolTimeout` | 30 seconds | Native driver's pool queue waits | +| `PostgresOptions.queryTimeout` | 30 seconds | Each SQL statement | +| `ExecutionOptions.acquireTimeout` | unset | Entire acquisition for this operation | + +The per-operation bound can shorten acquisition; it does not enlarge the native +pool limit. Borrowed pools retain their owner's connection and queue settings. +Driver timeouts before callback entry are classified as `CONNECTION.TIMEOUT`; +a `TimeoutException` thrown by application code after entry is preserved. +SQLite's `busyTimeout` controls database lock waits, not its ORM lease queue. +Neither backend's connection settings substitute for a total transaction deadline. + +## Cancellation and statement deadlines + +```dart +final token = CancellationToken(); +final options = ExecutionOptions( + cancellation: token, + timeout: const Duration(seconds: 5), +); +final pending = db.users.where((u) => u.email.eq(email)).get(options: options); +// Another event can call token.cancel(). +final users = await pending; +``` + +Options are accepted by query `get/first/single/count/exists/stream`, mutation +`execute`, returning `get/single/first`, `createRow`, batch execution/returning, +and `Database.execute`. They propagate into relation loads and batch chunks. +Generated named `create/patch` shortcuts currently use default execution +options; use the underlying insert/update builder for explicit options. + +`timeout` applies to each SQL statement or cursor fetch. It excludes time spent +waiting for a connection and consuming already fetched rows. It is not a total +transaction deadline. Use `acquireTimeout` for the separate acquisition phase. +`capabilities.statementTimeout` reports statement deadline support, while +`capabilities.cancellation` reports token-based interruption. MySQL/MariaDB support +the former by discarding their connection; they do not support the latter. +Cancellation while acquiring a connection now completes promptly and prevents +the SQL callback from ever entering, including when the driver grants a lease +later. Cancellation of an executing statement still awaits database cleanup. + +A token is a sticky request, not proof that a write was rolled back. Always await +the operation's result: a completed statement may win the cancellation race and +return success. `OPERATION.TIMEOUT` reports an expired statement deadline; it is +not proof of rollback. MySQL/MariaDB discard the socket and close the driver, so a +write's outcome can remain unknown and further work needs a newly opened driver. +Connection loss and uncertain commits likewise require checking the outcome. +Writes are retried only inside an explicitly opted-in +transaction, subject to the confirmed-rollback rules below. + +SQLite uses `sqlite3_interrupt` from the exact native asset backing the open +database. `capabilities.cancellation` is false if that build does not export the +symbol. Such builds reject cancellation/deadlines instead of merely abandoning +a running future. In particular, the default Linux build of `sqlite3` 3.6.0 hides +that symbol: statement cancellation, execution deadlines and bounded transaction +retries are unavailable there. Ordinary reads, writes, transactions and streaming +remain available. Native macOS JIT and AOT interruption have been verified. +The native acceptance program checks interruption when available and explicit +capability rejection otherwise: + +```sh +dart build cli --target=test/support/native_execution.dart --output=/tmp/orm-native-check +/tmp/orm-native-check/bundle/bin/native_execution +``` + +Distribute the complete `bundle` directory, including its native libraries. +`dart compile exe` alone does not bundle dependency build-hook assets; see +[Dart CLI builds](https://dart.dev/tools/dart-build). + +PostgreSQL opens a separate control connection only when cancellation is +requested and calls `pg_cancel_backend` for the leased backend. It waits for +control cleanup before permitting another statement. This may temporarily use +one connection beyond the pool limit. The control role must be allowed to cancel +the original backend; the default uses the same connection credentials. + +The deadline also covers the first backend PID lookup. Cancellation before that +lookup completes discards the connection without sending the application SQL. +Successful lookups are cached for reuse of the same physical connection. + +Owned PostgreSQL drivers default to a 30-second statement deadline, configurable +with `PostgresOptions.queryTimeout`. `PostgresDriver.borrow` needs a +`cancellationConnection` factory for cancellation or deadlines, and accepts its +own optional `queryTimeout`. The borrowed pool's statement timeout is not used: +the ORM controls cancellation completion itself. Closing a borrowed ORM driver +does not close the caller's pool. + +An interrupted statement marks an explicit transaction failed even if its error +is caught. Cancellation between batch chunks also fails the transaction, so +catching that error cannot commit earlier chunks. Use a savepoint for recoverable work. Cancelling a stream while a +PostgreSQL fetch is executing can abort that transaction; ending a stream between +fetches just closes its cursor. + +`onQuery` reports `execute`, `cursorOpen`, `cursorFetch` and `cursorClose` events. +Fetch events include actual batch row counts; they carry the original query SQL +for attribution. Parameters are counted but their values are not recorded. + +Optional `onAcquire` and `onDecode` callbacks separately report lease acquisition +and synchronous ORM result processing. See [plans and observations](observability.md) +for their scopes, inheritance and limits, and non-executing `query.inspect()`. + + +## Transaction deadlines and failure outcomes + +```dart +final cancel = CancellationToken(); +await db.transaction((tx) async { + await tx.users.create(email: 'one@example.com'); + await tx.savepoint((child) async { + await child.users.create(email: 'two@example.com'); + }); +}, + acquire: const AcquisitionOptions(timeout: Duration(seconds: 1)), + timeout: const Duration(seconds: 5), + cancellation: cancel, +); +``` + +`transaction(timeout: ...)` starts after acquiring its connection, immediately +before BEGIN. It covers BEGIN, application callback time, all statements and +savepoints, cursor consumption, and COMMIT. It does not restart for each query or +savepoint. A monotonic check prevents new SQL or COMMIT after a CPU-bound callback +has exceeded the deadline even when its Timer could not run promptly. The +transaction cancellation token also cancels acquisition; `acquire.cancellation` +continues to apply only to acquisition. + +Expiration rejects new operations, cancels active SQL and cursors, drains pending +work, and rolls back using the underlying connection without the expired token. +The outer Future completes after this cleanup. Cleanup can exceed the requested +duration: stopping a statement, waiting for SQLite's busy handler, and confirming +rollback take time. A driver without actual statement cancellation rejects these +options before starting the transaction. + +Dart cannot forcibly stop an arbitrary asynchronous callback or interrupt Dart +code currently monopolizing its isolate. A callback waiting on another Future +may resume later; its expired database view rejects further operations. Its late +errors are observed. External effects performed by callback code are not rolled +back by the ORM. Use transaction/outbox patterns for effects that must follow +committed data. + +Successful COMMIT acknowledgement wins a cancellation race. Failure outcomes are +explicit: + +| Outcome | Error | +| --- | --- | +| Transaction deadline expires before confirmed commit | `TRANSACTION.TIMEOUT` | +| Transaction cancellation during execution | `TRANSACTION.CANCELLED` | +| Cancellation while still acquiring | `OPERATION.CANCELLED` | +| Shorter statement timeout | `OPERATION.TIMEOUT` | +| Database has already ended the transaction | `TRANSACTION.ENDED` for further calls; the enclosing transaction fails | +| Rollback cannot be confirmed | `TRANSACTION.ROLLBACK`; the connection is discarded | +| COMMIT outcome cannot be established | `TRANSACTION.COMMIT`; never assume callback replay is safe | +| Server explicitly rejects COMMIT | Original classified `SqlFailure`, or the elapsed transaction deadline with that failure as its cause | + +PostgreSQL can replace a discarded physical connection from its pool. SQLite +uses one worker connection; discarding it requires reopening the database, and +an in-memory database cannot preserve its contents across that reopen. + +`SqlFailure` exposes conservative `retryTransaction`, `retryCommit` and `commitRejected` +classification. Native PostgreSQL errors use `PostgresFailure`, retaining the +SQLSTATE in `code` and the original `package:postgres` exception in `cause`. +SQLite retains `SqliteFailure.code`, `extendedCode` and `message`. + +| Database error | Possible transaction retry after confirmed rollback | Known COMMIT rejection | +| --- | --- | --- | +| PostgreSQL `40001` or `40P01` | Yes | Yes | +| PostgreSQL integrity constraints, SQLSTATE class `23` | No | Yes | +| PostgreSQL `40003` | No | No | +| SQLite `BUSY`, including extended busy codes | Yes | Yes | +| SQLite constraint errors | No | Yes | +| Other errors | No automatic eligibility | Conservatively unknown | + +These flags do not enable retries by themselves. Without `retry:`, the ORM makes +one attempt, rolls back a rejected transaction and returns its failure. +PostgreSQL serialization failures require repeating the whole transaction logic, +including reads and application decisions. +[PostgreSQL retry guidance](https://www.postgresql.org/docs/current/mvcc-serialization-failure-handling.html) +and the [SQLSTATE catalog](https://www.postgresql.org/docs/current/errcodes-appendix.html) +explain these distinctions. + +SQLite reports actual autocommit state from the worker after every completed +request, including errors. If an interrupt or `OR ROLLBACK` has already rolled +back the whole transaction, the ORM skips a redundant ROLLBACK. A vanished +savepoint marks its parent failed and prevents subsequent writes from silently +running in autocommit mode. Healthy SQLite connections remain usable. PostgreSQL's +current Dart driver does not expose transaction status publicly, so its adapter +reports an unknown state and confirms cleanup with ROLLBACK. +[SQLite autocommit status](https://www.sqlite.org/c3ref/get_autocommit.html) +and [transaction error handling](https://www.sqlite.org/lang_transaction.html) +describe why statement errors alone cannot establish SQLite transaction state. + + +## Explicit bounded retries + +```dart +final result = await db.transaction((tx) async { + final user = await tx.users.byId(userId).single(); + await tx.users.where((u) => u.id.eq(userId)) + .update((u) => [u.nickname.set(user.email)]).execute(); + return user.email; +}, + retry: const TransactionRetry( + maxAttempts: 3, + timeout: Duration(seconds: 5), + delay: Duration(milliseconds: 10), + maxDelay: Duration(milliseconds: 250), + ), +); +``` + +The callback must be safe to repeat after database rollback. Each callback attempt +receives a fresh transaction view and re-executes its reads and decisions. The +previous view closes before another attempt can enter. Application errors, +constraint violations, cancelled statements and caught transaction failures do +not trigger replay. A failed attempt's write notifications are discarded; only a +confirmed commit publishes them. Unknown COMMIT outcomes and unconfirmed rollback +always prohibit replay, even when a retry policy is supplied. + +Defaults are three attempts, a 30-second total budget, a 10-millisecond initial +backoff and a 250-millisecond cap. Backoff grows exponentially, with jitter between +approximately half and all of the current delay. Setting `delay: Duration.zero` +disables backoff. The initial attempt consumes one slot; **each callback replay or +commit-only retry consumes one additional slot from the same `maxAttempts`**. +For example, with three slots, one callback replay leaves room for only one +commit-only retry. Attempts are finite even when repeated failures happen quickly. +Exhausting slots returns the final database failure; expiry reports +`TRANSACTION.TIMEOUT`. Cancellation interrupts backoff and reports the transaction +cancellation error without starting another attempt. + +SQLite COMMIT returning BUSY is retried in place only if the adapter confirms the +same transaction is still active. The successful callback is not re-entered, so +its in-memory result is retained. A SQLite busy snapshot error during a statement +instead requires confirmed rollback and replay from BEGIN. PostgreSQL serialization +failures and deadlocks also require whole-transaction replay. The current PostgreSQL +adapter never retries COMMIT in place. + +`TransactionRetry.timeout` starts at the transaction call and includes acquisition, +all attempts, callback time, SQL and backoff. `transaction(timeout: ...)` still +starts after acquisition and spans all attempts; the shorter remaining budget wins. +A shorter explicit acquisition timeout reports `CONNECTION.TIMEOUT`, as do native +pool/connection failures. An ORM acquisition timer limited by the retry budget +reports `TRANSACTION.TIMEOUT`. These error classes are tied to the source of expiry, +not inferred from timing after the error. Native cancellation support is required. +The cleanup and confirmed-COMMIT rules above also apply to retries. + +One connection remains leased for the bounded attempt series, including backoff. +This preserves session state and avoids reacquisition on each retry, but holds a +pool slot while waiting. Keep budgets short under contention. The ORM does not +reconnect and replay after a transport failure, and external callback effects are +not rolled back. This API makes no exactly-once guarantee for such effects. + +The real-database retry checks include PostgreSQL serialization/deadlock conflicts, +SQLite WAL snapshot conflicts and DELETE-journal COMMIT lock contention. Additional +driver-injected failures exercise rollback, budgets and lost acknowledgements. +Native SQLite retry acceptance also runs without the JIT/test runner: + +```sh +dart build cli --target=test/support/native_retry.dart --output=/tmp/orm-native-retry +/tmp/orm-native-retry/bundle/bin/native_retry +``` diff --git a/docs/flutter.md b/docs/flutter.md new file mode 100644 index 00000000..057e5d5e --- /dev/null +++ b/docs/flutter.md @@ -0,0 +1,59 @@ +# Native Flutter + +The standalone runtime has a fresh [Android acceptance capture](../research/validation/standalone-flutter.json) +at `7a54553`: 4/17/11 checks pass across installation, AOT upgrade and process +restart. See the [current validation record](../research/validation/standalone-redesign.md). +The detailed historical capture below remains tied to its original revision. + +[`example/flutter`](../example/flutter/README.md) is an actual Android application +using generated ORM APIs and `SqliteOptions.file` in the app's private files +directory. SQLite runs in the native driver's worker isolate. The small Android +method channel supplies the directory, launch phase, API level and process ID; +all database operations and migration checks run in Dart through the public ORM. + +The current example statically imports fixed Dart migrations and their recorded +fingerprints. Migration JSON assets and runtime asset loading have been removed. +The current APK capture verifies this workflow at source `85c4e42`. + +The [captured report](../research/validation/flutter.json) records Flutter 3.47.4 +stable, Dart 3.13.3 and SQLite 3.53.4 on an Android API 35 arm64 emulator. It includes +the exact framework/engine revisions, APK hashes, source hashes and device build. + +| Installed application | Checks | Verified behavior | +| --- | --- | --- | +| Version code 1, debug APK | 4 | Empty database, initial migration, generated transactional inserts, close and read-only reopen | +| Version code 2, release AOT APK, `adb install -r` | 17 | Existing file/history, only the pending migration, live catalog, original values/microseconds, new default, generated relations, committed watch refresh, rollback without persistence/notification, typed patch, worker progress, native cancellation and reuse, application version and read-only reopen | +| Same version 2 APK after force-stop/relaunch | 11 | New process, retained database/rows/relations/history, no migration replay, initial watch, compatibility and read-only reopen | + +These are 32 assertions across three phases, not 32 independent platform scenarios. +The host independently checks installed APK version codes, distinct live process +IDs, an unchanged database path and identical final rows/history after restart. +The old physical `body` column remains in place when Dart exposes it as `text`. +The upgrade adds a defaulted `done` column and a comments table without dropping +or rebuilding the existing notes table. + +During the final captured release run, summing a recursive sequence of two million +integers took 335,844 microseconds. The main isolate's 16 ms timer advanced 20 +times and its Flutter animation listener advanced 12 times during that awaited SQL +operation. The report's `flutterFrames` field counts those animation frame +callbacks; it is not a raster timing or delivered-FPS measurement. This establishes +that database work does not occupy the UI isolate for the entire query. It does +not establish a frame-rate target or physical-device performance. + +![Restarted release application](../research/validation/flutter-android.png) + +Build and run commands are in the [example instructions](../example/flutter/README.md). +`tool/test_flutter.dart` requires a dedicated emulator and refuses an already +installed acceptance application. Both APKs use the example's debug signing key +to permit a real upgrade with retained data. Reports travel through bounded log +chunks; Android's Flutter log sink truncated larger chunks during development, +so the application uses 700-character payloads. The host rejects incomplete or +conflicting chunks and waits for the activity transition before its screenshot. + +This verifies Android arm64 on the recorded emulator. Physical devices, iOS and +macOS Flutter need their own acceptance runs. Process force-stop/relaunch does not +simulate sudden power loss. Watch refreshes cover writes made through the same +database instance; external writes still need [explicit invalidation](watch.md). +The [native Dart migration regression](../research/validation/dart-migrations.json) +and 19 scenarios in each Chrome JS/WASM run are separate evidence. The compiled +APK histories have fixed fingerprints and need no schema or migration JSON assets. diff --git a/docs/generation.md b/docs/generation.md new file mode 100644 index 00000000..ca0651dd --- /dev/null +++ b/docs/generation.md @@ -0,0 +1,242 @@ +# Generation and incremental builds + +The ORM supports a standalone CLI and the standard +[Dart build_runner workflow](https://dart.dev/tools/build_runner). Both use the +same schema validation, emission and in-process formatter. Generation reads +declarations and produces source/metadata; it does not connect to or migrate a +database. + +Generated clients export their nominal model classes or explicit Record aliases +and an immutable `appSchema`; entity +declaration values remain in the source library. Import the generated client and +the chosen driver in application code. Programmatic generation is in +`generate.dart`; build_runner factories are exported only by `builder.dart`. +See [API boundaries](api.md). + +Generation validates declaration structure and generated symbol names. Database +member collisions such as an entity called `close` fail here; keep the physical +name with `table:` when renaming its Dart declaration. Database-specific computed +and identifier restrictions are checked when selecting the migration engine. + +## Standalone command + +```sh +dart run orm init --database sqlite +dart run orm generate +# Or select a source explicitly: +dart run orm generate lib/schema.dart +``` + +Initialization creates typed `orm.config.dart`, a model and static migration +history without connecting. The path-free command uses `OrmConfig.schema` (or +`lib/schema.dart` when no config exists). See [project CLI](cli.md). + +This writes `lib/schema.orm.dart` and `lib/schema.snapshot.dart`. The latter is the +physical schema snapshot used by migration tools. It is a standalone Dart library +with no application imports. An explicit `database.dart` output gets an adjacent +`database.snapshot.dart`. For an explicitly chosen +output path: + +```sh +dart run orm generate lib/schema.dart lib/generated/database.dart +``` + +The CLI checks source errors and resolves imports using the project's package +configuration. Use it when a single explicit generation step suits the project. + +## build_runner + +Schema libraries use Dart language version 3.13. Set the application's SDK +constraint to `'>=3.13.0 <4.0.0'` when adopting the declaration syntax. + +Add `build_runner` as a development dependency in the application using `orm`: + +```sh +dart pub add dev:build_runner +``` + +Select the schema roots in the application's `build.yaml`: + +```yaml +targets: + $default: + builders: + orm:orm: + enabled: true + generate_for: + - lib/schema.dart +``` + +Then run a build or keep a watcher open: + +```sh +dart run build_runner build +dart run build_runner watch +``` + +The builder is opt-in and uses the explicit `generate_for` list. Select schema +libraries containing `entity()` declarations, rather than every Dart +file or a `part of` file. Additional roots produce their own clients and snapshots +next to their input files. Import generated clients with prefixes if their +declaration names overlap. + +The output of `lib/schema.dart` is always `lib/schema.orm.dart` and +`lib/schema.snapshot.dart`. There are no ORM-specific builder options. Use the CLI's +output argument for other locations. `build_runner` owns its build cache and output +cleanup; do not manually edit that cache or generated source. + +## What triggers regeneration + +The builder resolves source through `BuildStep.resolver` and writes through +`BuildStep.writeAsString`. It shares build_runner's analysis infrastructure and +asset dependency tracking. No independent filesystem analyzer is launched for +each build step. Schema imports, exports and parts reachable during resolution +are tracked; this includes imported enums, const defaults and custom codecs. +Types generated by an earlier builder can also be resolved. + +Changes to a schema field regenerate its client and snapshot. Imported metadata +changes also regenerate them. The process test verifies that an unrelated source +file edit does not start a build or touch the generated client. Deleting a schema +root removes its generated outputs; recreating it regenerates them. + +A new `build_runner build` process with unchanged inputs reports zero outputs. +An unrelated edit in watch mode can remain completely silent, with no completion +event because no build was needed. Standard build configuration is described in +the [build_runner documentation](https://pub.dev/packages/build_runner). + +## Errors and generated output + +Both entry points validate the schema's Dart semantic errors before emission. +The builder also rejects non-library roots, unknown codec mappings and duplicate +enum storage labels. Formatting of client and snapshot source finish before it +writes either output. A failed build reports an error; repairing the input lets +the same watcher generate again. + +Application encoders and decoders are not executed. Const annotations provide +the metadata; source static types preserve domain IDs even when constant +evaluation erases an extension type's representation. + +Generated source stays reviewable and can be committed with the application. +Schema snapshot changes are migration inputs, not permission to apply DDL +automatically. Follow the [migration workflow](migrations.md) to review and apply +database changes. + +## Measurements and verification + +Run the reproducible consumer-project benchmark from the ORM repository: + +```sh +dart run tool/benchmark_generation.dart +``` + +It creates disposable projects containing 10, 100 and 1000 four-field record +models. For each, it measures the first build, an unchanged build, watcher startup, +a field edit, an imported metadata edit and static analysis. It also verifies +that an unrelated edit leaves the watcher idle and output timestamps unchanged. +Its [JSON report](../research/benchmarks/generation.json) records SDK/package +versions, hardware, source/output sizes and measurement conditions. + +These are one-sample development-machine observations. Dependency download is +excluded, shared SDK/pub/native caches are warm, and process/watch startup costs +are included where applicable. They are not p50/p95 results or IDE completion +measurements. The first build includes build_runner's builder compilation. Run +the benchmark independently of test suites or other builds, with the generator +source fixed for the entire run. + +Historical measurements before the Dart snapshot workflow, recorded on Apple M3 Max with Dart 3.13.3 and build_runner 2.16.1 (seconds): + +| Models | First build | Unchanged build | Watch field edit | Watch import edit | Analyze | +| --- | ---: | ---: | ---: | ---: | ---: | +| 10 | 16.20 | 0.57 | 0.52 | 0.40 | 0.48 | +| 100 | 16.26 | 0.57 | 0.65 | 0.66 | 0.50 | +| 1000 | 17.96 | 0.58 | 2.17 | 2.22 | 3.48 | + +All three unchanged builds wrote zero outputs. Unrelated edits stayed idle during +the one-second observation window. The 1000-model client contains 2,343,598 bytes +after the edit; its result shape is a single schema library with four fields per +model and one added field on the first model. + +Tests compare builder and CLI output, validate in-memory assets and prior-builder +outputs, and run real build/watch processes for dependency changes, failure +recovery and output cleanup. Static analysis of the generated consumer project +is part of that process test. + +## Editor completion, diagnostics and rename + +Run `dart run tool/benchmark_editor.dart` to exercise the installed Dart Analysis +Server over stdio LSP in disposable consumer projects. `--smoke` checks ten models +with two warm samples and writes only under `.dart_tool/`. The full run writes +[`editor.json`](../research/benchmarks/editor.json), including the runtime commit, +harness hashes, raw samples, diagnostic ranges, rename edits and resulting source. +The protocol is documented by the [Dart 3.13.3 SDK](https://github.com/dart-lang/sdk/blob/3.13.3/pkg/analysis_server/tool/lsp_spec/README.md). + +On Apple M3 Max / Dart 3.13.3, warm completion p50 / p95 in **milliseconds**: + +| Models | Table entries | Schema Record fields | Query fields | Create arguments | Projected Record fields | +| --- | ---: | ---: | ---: | ---: | ---: | +| 10 | 0.809 / 1.117 | 0.439 / 0.511 | 0.484 / 1.126 | 0.238 / 0.293 | 0.518 / 0.590 | +| 100 | 1.497 / 2.812 | 0.428 / 0.492 | 0.484 / 0.559 | 0.250 / 0.315 | 0.568 / 0.600 | +| 1000 | 11.572 / 17.094 | 0.705 / 0.788 | 0.780 / 0.850 | 0.226 / 0.307 | 1.330 / 2.863 | + +Each cell uses twenty unchanged-document requests after two unrecorded warmups; +quantiles use nearest rank. These numbers include client transport/JSON handling +but exclude editor UI/plugin rendering. Dependency installation is offline and +outside timing; shared SDK/pub/OS caches are not cleared. They are local protocol +measurements, not a ranking against another ORM or declaration frontend. + +Warm completion excludes opening/editing and analysis readiness. Initial server +startup plus project analysis took 0.311 / 0.487 / 3.405 seconds respectively. At +1000 models, the query-field probe waited 128.488 ms for a diagnostic after opening +the incomplete expression, then its first completion took 4.109 ms. The schema +field probe waited 165.844 ms. The report separates these observations from warm +samples; they must not be described as sub-millisecond edit-to-suggestion latency. + +All fifteen probe shapes returned their required labels. A two-field named Record +projection offered `id` and `title` but did not offer the unselected `score` or +`status`. Each scale also checked four deliberate type errors: create input, +predicate value, scalar result assignment and access to an unselected field. Each +diagnostic must match both its code and the current token's exact source range. +Changes are serialized because this SDK omits diagnostic document versions. A +fresh diagnostic clears the repaired client error. A separate CLI analysis checks +the final edited consumer, rather than treating an absent notification as success. + +The language-symbol rename results are the same at all three scales: + +| Declaration | Observed result | +| --- | --- | +| Named Record field | Both prepare and rename return `null`; unavailable | +| Primary-constructor class field | Declaration and typed member reference updated | +| Class-based table field | Dart field and typed reference updated; SQL column string retained | +| Record typedef name | Declaration and both type references updated; separate Record alias retained | + +These are language-symbol probes. They do not compare three ORM authoring +frontends generating identical APIs. Renames use a fresh server after the error +probes. A subsequent [recovery capture](../research/validation/editor-recovery.json) +reproduces the same-session failure using `dart run tool/benchmark_editor.dart +--same-session`: at ten models, five completion probes, four exact-code/range +diagnostics and their repair finish, then `textDocument/rename` for the primary +constructor field times out after 60 seconds. The process exits nonzero before +attempting the larger scales; the [original log](../research/validation/editor-recovery.log) +is retained. `--smoke` on the same harness restarts the server before rename and +completes the class/table/alias edits plus final analysis. Server PIDs and source +hashes are recorded. The underlying SDK/harness interaction is not diagnosed; +this is a reproducible limitation with a checked restart path, not general IDE +reliability or a claim that the SDK alone caused it. + +Record field edits therefore require reviewing declaration/selector references, +regenerating, and repairing application references with the type checker. Do not +edit generated files to perform a schema rename. To change only a Dart field name, +keep its physical column explicit, for example +`@ColumnName('name') String displayName`. A physical database rename still needs +the reviewed [migration workflow](migrations.md). + +ORM selector rules are a separate diagnostic boundary: the valid Dart expression +`rows0.index((r) => r.id + 1)` passes CLI analysis but fails generation with a +direct-field-selector error and source offset. Stock LSP does not supply that ORM +diagnostic. Keep generation/watch running for schema rule validation. + +The separate [declaration-form experiment](authoring.md) now compares Record, +primary-constructor and table-class inputs for the same four related models. +It checks identical generated APIs/snapshots across field edits and renames, +stale-client type errors, schema refactor propagation and original error locations. +Its class/table adapters are experimental tools, not supported package entrypoints. diff --git a/docs/importing.md b/docs/importing.md new file mode 100644 index 00000000..814ce86c --- /dev/null +++ b/docs/importing.md @@ -0,0 +1,174 @@ +# Import an existing database + +Import reads the catalog and produces an editable nominal model declaration plus a review +report. It does not sample rows, create migration history, change tables, or copy +data. Generate the client and baseline the reviewed schema as separate steps. + +```sh +dart run orm db import --sqlite app.sqlite --output lib/schema.dart +# PostgreSQL alternative: +dart run orm db import --postgres-env DATABASE_URL --database-schema public --output lib/schema.dart +# MySQL/MariaDB alternatives: +dart run orm db import --mysql-env DATABASE_URL --output lib/schema.dart +dart run orm db import --mariadb-env DATABASE_URL --output lib/schema.dart + +# Review lib/schema.dart and lib/schema.import.json first. +dart run orm generate lib/schema.dart +# Add the Dart entrypoint described in migrations.md, then: +dart run orm migration registry migrations --dialect sqlite +dart run bin/migrate.dart create 0001_baseline +dart run bin/migrate.dart baseline +dart run bin/migrate.dart verify +``` + +Use `--dialect postgres`, `mysql` or `mariadb` for the matching server's registry. +Configure the same database in the [Dart migration entrypoint](migrations.md). Existing database-specific defaults and objects need reviewed migrations; +an imported declaration is not automatically a portable creation script for the +other dialect. Baseline verifies the declared schema before recording history and +does not execute the initial creation SQL. + +`--output` must name a new `.dart` file. Neither it nor the adjacent `.import.json` +report may already exist. SQLite opens read-only and requires an existing file. +PostgreSQL uses a read-only, repeatable-read transaction in the selected schema. +Each invocation uses one catalog snapshot. Existing outer transactions are rejected; +a borrowed session without an active transaction is allowed. + +To import one table, use `--table accounts`. For a selected related group, use +`importSchema(db, tables: ['accounts', 'notes'])` in Dart. Without a selection, +discovery covers SQLite main, the current PostgreSQL schema or the selected +MySQL/MariaDB database. `db` is a raw `SqlDatabase`; use `ormDatabase.sql` for an +ORM connection. Internal ORM history +tables are omitted. Include the referenced tables when importing relationships; +missing targets and temporary objects shadowing selected tables produce issues. + +## Declarations and reports + +```dart +import 'package:orm/schema.dart'; + +final class AccountsRow({ + @ColumnName('id') @Id.generated() required final int id, + @ColumnName('email') required final String email, + @ColumnName('display_name') required final String? displayName, +}); +final accounts = entity(table: 'accounts'); +final accountsUnique = accounts.unique((row) => row.email); +``` + +Every table and field retains an explicit physical name. Dart keywords, punctuation, +Unicode-only names and collisions get valid names recorded in the report's +`entities` and `fields` maps. Rename Dart symbols to match your domain while keeping +the physical mappings. Primary/composite keys, unique constraints, simple named +indexes and representable foreign keys become declarations. Relationships get names +and inverse selections; rename these before generation if a domain name is clearer. +Foreign-key actions include SET DEFAULT on supporting engines; MySQL/MariaDB +reject it. Unsupported unsigned and binary storage must be reviewed explicitly; +see [MySQL/MariaDB limits](mysql.md). + +Ordinary enforced [CHECK constraints](checks.md) become `.check(...)` declarations +with their native names and SQL. A nonblocking `IMPORT.CHECK_SQL` note requests +review of backend-specific expressions before deployment on another dialect. +PostgreSQL unvalidated, unenforced and inheritance-specific checks remain unmanaged. + +The JSON report records the dialect, schema, name maps, and issues: + +- Blocking issues exclude an unsupported table or relation. CLI exit code is 2; + the partial draft and report are still written for review. +- Nonblocking issues preserve descriptions of unmanaged objects, such as views, + triggers and specialized indexes. Exit code remains 0, but those objects still + need separate review and database-specific migration handling. +- Invalid options or existing output paths use exit code 64. Existing files remain. + +An empty issue list is not proof of complete database equivalence. Catalog support +and `verifySchema` cover declared facts; they do not prove that grants, extensions, +database settings or every native object can be recreated from a Record schema. + +## Types and boundaries + +Import uses exact physical mappings. It never infers a semantic type from example +rows or a column name. + +| SQLite | PostgreSQL | Draft Dart type | +|---|---|---| +| INTEGER | SMALLINT / INTEGER / BIGINT | int, with width metadata where needed | +| TEXT | TEXT | String | +| TEXT COLLATE orm_decimal_v1, optional precision CHECK | NUMERIC, NUMERIC(p,s) | Decimal, optional DecimalDigits | +| REAL | DOUBLE PRECISION | double | +| BLOB | BYTEA | Uint8List | +| — | BOOLEAN | bool | +| TEXT COLLATE orm_instant_v1 | TIMESTAMPTZ | DateTime | +| TEXT COLLATE orm_date_v1 | DATE | LocalDate | +| TEXT COLLATE orm_time_v1 | TIME WITHOUT TIME ZONE | LocalTime | +| TEXT COLLATE orm_local_datetime_v1 | TIMESTAMP WITHOUT TIME ZONE | LocalDateTime | +| — | JSONB | SqlJson with Codecs.jsonDocument | + +Nullability is preserved. SQLite TEXT may contain timestamps, enums, bigints or +JSON, and INTEGER may hold application booleans. Add reviewed codecs when needed; +the catalog does not establish those meanings. PostgreSQL NUMERIC +imports as finite `Decimal`, never as BigInt; constrained columns retain +`@DecimalDigits(precision, scale)`. Import does not sample values; +existing NaN or Infinity values will fail typed decoding. SQLite requires the +recognized column collation to establish decimal storage and recognizes only the +emitted precision CHECK. Varchar, arrays, domains and other native types still +need explicit support. See +[decimal boundaries](decimals.md) before reviewing a draft. + +SQLite rowid integer primary keys and PostgreSQL BY DEFAULT integer primary-key +identities get `@Id.generated()`. [Computed expressions](computed.md) become +`@Computed.sql` with their stored/virtual mode and an `IMPORT.COMPUTED_SQL` +portability review note. ALWAYS identities, +non-primary identities and nullable primary keys require explicit write semantics +and are currently blocking. Sequence defaults such as a BIGSERIAL default retain +their catalog SQL; import does not convert them to identities or reset sequences. +Review their dependencies before baselining. + +The draft targets the inspected engine. SQLite indexed virtual columns and +PostgreSQL stored computed primary keys remain importable; the other engine's +restrictions do not block them. Imported names that collide with Database members +receive a distinct Dart name while retaining the physical SQL name. + +PostgreSQL SMALLINT/INTEGER columns and SQLite INTEGER columns with recognized +ORM range checks retain their `@IntegerBits(16)` or `@IntegerBits(32)` declaration. +See [integer widths](types.md#signed-integer-column-widths) for range enforcement, +aggregation and migration behavior. + +SQLite import requires 3.37 or later to distinguish ordinary, virtual and shadow +tables using [table_list](https://www.sqlite.org/pragma.html#pragma_table_list). +Virtual tables and their backing tables are reported rather than emitted as ordinary +models. PostgreSQL partitioned/inherited and foreign tables need explicit declarations. +Identity modes and generated expressions have separate +[catalog attributes](https://www.postgresql.org/docs/current/catalog-pg-attribute.html). + +Specialized index semantics remain in the report: partial/expression indexes and +PostgreSQL nondefault operator classes, collation overrides, storage options and +NULLS NOT DISTINCT. These differ from plain unique keys; see the +[PostgreSQL index catalog](https://www.postgresql.org/docs/current/catalog-pg-index.html). + +PostgreSQL policy objects stay unmanaged. Their `definition` is JSON containing +`permissive`, `roles`, `command`, `using` and `withCheck`, preserving the fields in +[`pg_policies`](https://www.postgresql.org/docs/18/view-pg-policies.html). +A separate `row_security` object contains the table's `enabled` and `forced` +flags from [`pg_class`](https://www.postgresql.org/docs/18/catalog-pg-class.html). +It is reported when either flag is set or a policy exists, including disabled +policies. These metadata descriptions are not executable migration SQL. +`verifySchema` and baseline return them for review without claiming that the +Record snapshot models access-control policy. Neither operation changes those +settings. Trigger definitions likewise remain unmanaged; import/verification +tests exercise real triggers on both databases and verify they still execute. + +## Tooling API + +```dart +import 'package:orm/generate.dart'; + +final draft = await importSchema(db, tables: ['accounts', 'notes']); +print(draft.dart); +print(draft.toJson()); +if (draft.hasBlockingIssues) { + // Resolve the reported native types or relationships before baselining. +} +``` + +The API returns values and writes no files. It belongs to the generator/tooling +entry point. Generated applications continue using the normal ORM and driver +entry points. The CLI does not automatically generate a client or apply a baseline. diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 00000000..2e184bad --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,377 @@ +# Migrations + +Schema snapshots, saved migrations and their registry are Dart libraries. Start +with one typed project configuration and one fixed database engine: + +```sh +dart run orm init --database sqlite +dart run orm migrate create 0001_initial +# Review migrations/m0001_initial.dart. +dart run orm migrate check +dart run orm migrate apply +dart run orm migrate plan +dart run orm migrate status +dart run orm migrate verify +dart run orm migrate inspect tasks +``` + +`init` also accepts `postgres`, `mysql` and `mariadb`. It generates a nominal model, +client, physical snapshot, empty static registry and `orm.config.dart`. Existing +files are never replaced, and initialization does not connect or execute DDL. +See the [project CLI](cli.md) for the generated typed configuration and all options. + +The config is an explicit Dart entrypoint with `runOrmCli(args, config: OrmConfig(...))`. +It statically imports its current snapshot and migration history. Connection +factories return the independent `SqlDatabase` runtime, built with the selected +low-level driver; migrations do not need ORM models or typed query generation. +Server connection factories read environment variables only when connecting. + +Run from the project root. `migrate create` first regenerates the model's physical +snapshot before diffing, so an edited declaration cannot use a stale snapshot. +Other commands use their statically imported snapshot/history without changing +source artifacts. Standalone generation remains `dart run orm generate +lib/schema.dart`, and registry regeneration is `dart run orm migration registry +migrations --dialect sqlite` for a new empty registry. + +Projects can also call the lower-level `runMigrationCli` in their own executable: + +```dart +import 'package:orm/migrate_cli.dart'; +import 'package:orm/runtime.dart'; +import 'package:orm/drivers/sqlite.dart'; +import '../lib/schema.snapshot.dart'; +import '../migrations/migrations.g.dart'; + +Future main(List args) => runMigrationCli( + args, + directory: 'migrations', + history: migrationHistory, + schema: schema, + connect: ({required readOnly}) async => SqlDatabase( + await SqliteDriver.open(readOnly + ? const SqliteOptions.readOnly('app.sqlite') + : const SqliteOptions.file('app.sqlite')), + ), +); +``` + +Offline commands never call the connection factory. SQLite inspection requires +an existing read-only file; `apply` can create one. Database migration/verification +operations enforce their own transaction and locking boundaries. + +Each new migration is an `m.dart` file with fixed operations, an independent +historical schema and a recorded `migrationChecksum`. The adjacent +`migrations.g.dart` statically imports these definitions. Check both into Git. +The current `.snapshot.dart` contains only physical database metadata and imports +no application models, codec callbacks or Flutter libraries. + +`create` compares the current physical schema with the last historical snapshot +and freezes the resulting SQL/steps in a new Dart file. It does not execute DDL. +No-change generation writes no migration. Repeated IDs, overwritten files, stale +registries and broken history chains are rejected when saving a new migration. +`check` validates the compiled history, fixed fingerprints and its declared engine +without connecting. The registry records one `migrationDialect`, including before +the first migration. Every migration has the same explicit `dialect` and one flat +step list. Rebuilding with `dart run orm migration registry migrations` preserves +that target and never refreshes fingerprints. Attempting to regenerate with a +different target fails without writing files. + +A connection for another engine is rejected before migration SQL, including CLI +`status`, `verify`, `inspect` and an empty history. The connection factory itself +runs first and may open/create a SQLite file; it must honor `readOnly`. Environment +variables configure locations and credentials for the chosen engine, not select +a different engine for the same history. + +Shared model declarations may contain per-engine expressions. Generation resolves +only the selected engine; saved snapshots and operation tables freeze that SQL. +Changes to another engine's override cannot change this history's plan or checksum. +Database-specific restrictions apply only to the selected engine. Ordinary +single-engine declarations can use `ComputedColumn(sql)` and `CheckSchema(name, sql)`. + +Multi-database applications own separate directories, registries and connections. +Tests for a PostgreSQL application use PostgreSQL for migration acceptance. Switching +to SQLite is a separate schema/data transfer and baseline operation; it does not +translate, replay or rewrite the PostgreSQL history. Changing servers or credentials +within the same engine preserves the history; the destination's applied hashes and +catalog still need checking. + +The migration executor supports PostgreSQL 18+ and SQLite 3.35+. PostgreSQL apply, +baseline and standalone planning read the actual server version before migration +locks or journal writes; the SQLite driver checks its library version when opening. +Older PostgreSQL migrations are deliberately rejected instead of attempting DDL +whose catalog/generated-column behavior has not been verified. This does not claim +that every older server feature is unsupported by the query driver. See the native +[PostgreSQL ALTER TABLE](https://www.postgresql.org/docs/18/sql-altertable.html) and +[SQLite ALTER TABLE](https://www.sqlite.org/lang_altertable.html) boundaries. + +Edit the latest **unpublished** migration when a generated change needs a reviewed +backfill or manual SQL, then run `dart run orm migrate record 0002_name`. +This explicit command updates its fingerprint while preserving the surrounding +source. It cannot know whether a migration was deployed. Never edit or re-record +an applied migration: database history also records checksums and rejects the +change. Add a new migration instead. Earlier historical files remain fixed even +when the current application model changes. + +Declare physical renames in the entrypoint's `renames` argument: + +```dart +renames: const SchemaRenames( + tables: {'users': 'members'}, + columns: {'members': {'nickname': 'display_name'}}, +), +``` + +Declare conversions in its `using` argument, keyed by table and column for the chosen database: + +```dart +using: { + 'members': {'score': 'CAST(score AS TEXT)'}, +}, +``` + +Import `package:orm/migrate.dart` for `SchemaRenames`. Renames and conversions are +inputs to the next `create`; remove them after generating that change. Pass +`create --allow-destructive` to generate reviewed drop steps or replace +ordinary SQLite column values with a computed expression. Computed-to-ordinary +SQLite changes materialize the existing result instead. Neither offline +checking nor generation applies them. + +Start with [catalog import](importing.md) for an existing database, then run +`baseline` to verify and register its final historical schema without replaying +creation SQL. Add `--json` for machine reports (not migration artifacts). The lower-level `runMigrationCli` defaults to JSON reports. Exit code 2 +indicates catalog drift, 64 invalid CLI arguments, and 1 an execution failure. +Dart's launcher may print build-hook progress on stderr. + +Application startup uses the same static history without the CLI/tooling imports: +Here and below, `db` is a `SqlDatabase`; use `ormDatabase.sql` when starting from +the typed ORM `Database`. + +```dart +import 'migrations/migrations.g.dart'; + +final migrations = migrationHistory.checked; +final pending = await Migrator(db).plan(migrations); +final appliedIds = await Migrator(db).apply(migrations); +``` + +For programmatic generation, `writeMigration(migration, directory: 'migrations', +history: migrationHistory)` saves fixed Dart operations and the registry. +`migrationSource`, `schemaSource` and `migrationHistorySource` also return Dart +source without writing files. A saved migration contains frozen SQL and typed +steps; it must not call `Migration.diff` against current models when it runs. +Fingerprints cover the engine, SQL, operation data, historical schema and predecessor, while +formatting and comments do not affect them. + +SQLite and PostgreSQL `apply` run a pending batch without `CheckedSql` or `Backfill` in one transaction. A failed copy, required +column, unique constraint, or foreign key rolls back earlier steps and history +records from that invocation. PostgreSQL uses a dedicated connection and a session advisory lock scoped to the +current database/schema; SQLite obtains an immediate write transaction. PostgreSQL +lock acquisition uses short `pg_try_advisory_lock` queries outside a transaction, +with `Migrator(db, lockTimeout: ...)` defaulting to 30 seconds. Waiting does not +hold an old SQL snapshot that could deadlock concurrent index creation. +MySQL/MariaDB DDL commits independently and uses exact catalog preconditions, +postconditions and durable checkpoints; see [MySQL/MariaDB migrations](mysql-migrations.md). + +Migration planning checks applied history and checksums. Catalog verification is +an explicit separate operation; SQL history alone does not prove schema equality. + +## Deployment bundle + +The project entrypoint can be compiled with the SDK's native-asset-aware CLI build: + +```sh +dart build cli --target orm.config.dart --output build/migration +./build/migration/bundle/bin/orm.config migrate check +./build/migration/bundle/bin/orm.config migrate apply +``` + +Deploy the complete `bundle` directory, including its native libraries. Migration +history and snapshots are compiled into the executable; runtime `check`, `plan`, +`apply`, `status` and `verify` do not read Dart source or migration assets. Supply +the same environment configuration used by your connection factory. Build for the +actual deployment OS/architecture. + +Use source execution during authoring. A compiled executable contains the history +from its build and cannot discover a newly generated migration. Rebuild after +reviewing and committing new history. The application imports `migrate.dart` and +its registry; it does not need the generator or CLI entrypoint. + +## Application startup compatibility + +```dart +// Default: the database must have completed the latest bundled migration. +final version = await Migrator(db).requireVersion(migrations); + +// This release can run on either side of a compatible expand migration. +await Migrator(db).requireVersion( + migrations, + minimum: '0001_initial', + maximum: '0002_expand', +); +``` + +The range is inclusive and uses the ordered bundled history. Without bounds, the +requirement is exactly its latest migration. With only `minimum`, the upper bound +is the latest bundled migration; with only `maximum`, that version is required +exactly. Bounds must name an ordered, nonempty range in the supplied history. +The list is copied before asynchronous work begins. + +The check validates every applied ID/checksum in the history prefix, not just its +last ID. An unversioned database, an older required version, a database outside the +range, or a database newer than the bundled history reports `MIGRATION.VERSION`. +Changed checksums or missing history entries report `MIGRATION.CHECKSUM`; corrupt +checkpoint sequences report `MIGRATION.HISTORY`. Any recovery checkpoint for a +migration not yet recorded as applied reports `MIGRATION.INCOMPLETE`, including +checkpoints unknown to an older application. Complete or recover that migration +before accepting application traffic. + +Call this on a root database or a leased session, outside an existing transaction. +The check opens a short read transaction: PostgreSQL uses REPEATABLE READ READ ONLY, +and SQLite uses a deferred read snapshot, including on an explicitly read-only +file. It reads catalog/history/checkpoints without creating metadata or applying +migrations. The returned `MigrationStatus` records the accepted ID and checksum. +An existing caller transaction is rejected with `MIGRATION.SESSION` so the method +can establish its own consistent snapshot. + +This is a point-in-time startup check. It does not prevent a later deployment +from changing the database, check the actual catalog for drift, or prove that old +application processes have stopped. Use `verifySchema` for catalog checks and +coordinate incompatible contract migrations with application deployment. A +rejected downgrade does not delete or rebuild tables. + +## Schema evolution + +`diff` generates operations for the history's selected engine only: + +- New nullable/defaulted columns use `ALTER TABLE` when SQLite permits the default. +- SQLite changes to existing columns or keys use an explicit `RebuildTable` step. +- Renames must be declared. Drops require `allowDestructive: true` when generating + the file; the resulting file makes each removal visible to review. +- A new required column without a default needs a separate add/backfill/constrain + sequence. Identity changes require a manual migration. +- Type changes require SQL conversion expressions for the selected dialect. Expressions + are trusted migration code and use column names after declared renames. + +[Computed target columns](computed.md) use their own expressions to populate old +rows and derive values on type changes. They are excluded from rebuild copy maps; +their addition does not require a default or application backfill. Mode conversions +and native expression dependencies have separate documented migration limits. + +For long data transformations, use a reviewed [resumable backfill](backfills.md). +It carries a historical table definition, commits bounded batches with their +primary-key cursors, supports bounded runs, and verifies completion. Both SQLite +and PostgreSQL support it; general `CheckedSql` autocommit operations still require +PostgreSQL. + +```dart +final change = Migration.diff( + '0003_score_text', + dialect: previousMigration.dialect, + from: previousSnapshot, + to: nextSnapshot, + previous: previousMigration.checksum, + using: { + 'members': {'score': 'CAST(score AS TEXT)'}, + }, +); +``` + +SQLite rebuilds borrow one connection, disable foreign keys before `BEGIN`, copy +explicit columns into a new table, replace the old table, and recreate indexes +and triggers. They check foreign keys before commit and restore the connection's +original foreign-key setting even on failure. Restore failure discards the +connection. Incoming cascade relations therefore do not delete child rows during +replacement. Existing views are retained and checked after replacement. + +The runner preserves unmanaged indexes and triggers whose SQL can be restored. +Unmodeled table constraints/options (undeclared `CHECK`, collations, generated expressions, +`STRICT`, `WITHOUT ROWID`, and similar features) require a manual migration; +they are never silently discarded. Removing columns from a table with triggers +also requires explicit manual handling. A new managed index cannot silently +replace an unmanaged index of the same name. + +[Declared CHECK constraints](checks.md) participate in schema diffs, inspection +and import. SQLite adds/changes/removes them through a rebuild; PostgreSQL uses +constraint DDL. Checked renames explicitly remove and re-add the declared SQL, +which can require two SQLite copies. Existing rows are validated before commit. + +Manual migrations use `Migration(id, ['SQL', ...], dialect: .sqlite)`, or +`Migration.steps` with `ExecuteSql`, `DropTable`, and `RebuildTable` operations. +For a custom SQLite copy-and-replace migration, an explicit `DropTable` enables +outer foreign-key handling; preserve dependent SQL objects in the reviewed steps. +Transaction control is owned by the runner. Use `CheckedSql` for PostgreSQL +commands that must run outside a transaction; ordinary `ExecuteSql` rejects them. + +# Recoverable PostgreSQL operations + +```dart +final migration = Migration.steps( + '0004_email_index', + [ + CheckedSql.createIndex( + 'members', + const IndexSchema('members_email_lookup', ['email']), + ), + ], + dialect: .postgres, + previous: previousMigration.checksum, + snapshot: nextSnapshot, +); +``` + +`CheckedSql.createIndex` builds a concurrent B-tree index using default column +collations and operator classes. Its completion check compares the table, ordered +columns, uniqueness, predicate/expression absence, index options and validity. +A same-name object or an INVALID index is not proof of completion. PostgreSQL's +[concurrent index documentation](https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY) +explains its nontransactional execution and possible INVALID leftovers. + +Other operations use `CheckedSql(sql, readyWhen: 'SELECT ...', doneWhen: 'SELECT ...')`. +Each condition must return exactly one boolean and accurately describe durable +state. SQL and probes are reviewed migration code. The runner first checks +`doneWhen`; if false, it requires `readyWhen`, executes SQL in autocommit mode, +and checks `doneWhen` again. If neither state matches, it stops for explicit +inspection/repair. It does not guess that an existing object should be dropped. +Retry the unchanged migration after repairing the actual database. + +A migration containing `CheckedSql` uses durable checkpoints for every step. +Ordinary SQL within that migration commits its effect and completion checkpoint +in the same transaction. Checked SQL records its phase before execution and checks +actual state on restart. An interrupted success therefore need not execute twice. +Attempted migration checksums are frozen even before the whole migration completes. + +This mode is not one atomic transaction: earlier successful steps remain committed +when a later step fails. Contiguous ordinary migrations outside a mixed migration +still form an atomic batch. The runner lock covers all these phases on the same +connection; baseline and ordinary migration runners use the same lock protocol. +Session lock behavior is documented by +[PostgreSQL](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS). + +Use `Migrator(db).progress()` or `dart run orm migrate status` to inspect step states, failed +phases and failure codes. `dart run orm migrate plan` reports `atomic: false` for pending +nontransactional work and includes progress. A `MIGRATION.STEP` exception retains +the underlying error as `cause`. A failed lock release discards its connection. +SQLite rejects `CheckedSql`; its schema rebuild path remains transactional. + +Tests terminate real Dart processes immediately after a concurrent DDL succeeds +and after an ordinary step commits. Both resume from unchanged files without +replaying committed work. They also exercise INVALID unique indexes, explicit +repair, definition mismatches, lock timeout and simultaneous runners. + +# Existing databases + +```dart +final result = await Migrator(db).baseline( + migrations, + expected: migrations.last.snapshot!, +); +``` + +Baseline verifies declared columns, storage types, nullability, defaults, primary +and unique keys, foreign keys, and simple indexes before recording history. It +never replays creation SQL and refuses existing nonempty migration history. +`verifySchema(...).matches` covers these supported facts; inspect `unmanaged` +separately for triggers, policies, expression/partial indexes and unmodeled +constraints/options. This catalog currently inspects the declared tables, not all +objects in a database. PostgreSQL constraint removal resolves actual names by +signature, including names chosen before an ORM baseline. diff --git a/docs/mysql-migrations.md b/docs/mysql-migrations.md new file mode 100644 index 00000000..8c559ca4 --- /dev/null +++ b/docs/mysql-migrations.md @@ -0,0 +1,134 @@ +# MySQL and MariaDB migrations + +Use one fixed `mysql` or `mariadb` migration history with its matching driver. +The migration runner checks MySQL 8.4+ or MariaDB 11.8+ before taking a lock or +creating its journal. The MySQL driver has the same 8.4 minimum; the MariaDB +driver's 10.6 connection minimum does not extend migration support to that +version. The normal Dart project workflow applies: + +```sh +dart run orm init --database mysql +dart run orm migrate create 0001_initial +# Review migrations/m0001_initial.dart. +dart run orm migrate check +dart run orm migrate apply +dart run orm migrate verify +``` + +`init --database mariadb` selects MariaDB instead. Models, physical snapshots, +reviewed migration operations and the static registry are all Dart source. A saved +migration contains SQL for its selected engine only; current application models +are not imported by historical migration files. See [migrations](migrations.md) +for configuration, fingerprints, deployment bundles, baseline and version checks. + +## DDL and recovery + +MySQL and MariaDB DDL commits independently of application transactions. MySQL's +atomic DDL guarantees do not make a sequence of DDL statements transactional. +The runner consequently executes each reviewed `CheckedTableSql` outside a +transaction, holding one session lock for the selected database. The step records +its SQL and complete physical table definitions before and after the operation. +A null `before` means creation; a null `after` means removal. Different table names +mean an explicit rename. [MySQL atomic DDL](https://dev.mysql.com/doc/refman/8.4/en/atomic-ddl.html) + +For each unfinished step, the runner: + +1. Records an attempted step with the immutable migration fingerprint. +2. Compares the live catalog with the complete `after` definition. An exact match + means the DDL is already complete, including when its response was lost. +3. Otherwise requires an exact `before` definition, executes the SQL, and verifies + the complete `after` definition before recording completion. +4. Stops if neither definition matches. Repair the database, then retry the same + unchanged history. An object with the right name and wrong shape is rejected. + +Checks cover column storage, nullability, identity/default/computed expressions, +primary and unique keys, indexes, foreign keys and checks. Unmodeled table options, +triggers, unsigned columns, unsupported indexes and other catalog objects block +checked DDL and backfill. The runner does not silently discard them. Expression comparison understands +common arithmetic, comparison, boolean and function forms and preserves operator +precedence. Other SQL syntax uses strict token comparison; a different server +rendering is reported as drift instead of being assumed equivalent. + +Completed checkpoints form a durable prefix. A later failure does not roll back +earlier DDL. `migrate status` exposes the step, phase and failure; retrying `apply` +continues the same history. Applied or attempted migrations with changed +fingerprints are rejected. The final declared snapshot is verified before the +migration itself is recorded as applied. + +The runner uses `GET_LOCK`/`RELEASE_LOCK` with a database-specific key and +`Migrator(..., lockTimeout: ...)`. Lock ownership stays with one physical session. +A failed acquisition or uncertain release discards that connection. These locks +coordinate ORM migration runners; they do not block independent administrative +DDL or normal application writes. Use deployment coordination for those changes. +[MySQL named locks](https://dev.mysql.com/doc/refman/8.4/en/locking-functions.html) + +## Data changes + +`ExecuteSql` accepts `INSERT`, `UPDATE`, `DELETE` and `REPLACE` for these engines. +Each such step and its completion checkpoint share an InnoDB transaction. Raw +DDL, transaction control and session administration are rejected during validation; +use a reviewed `CheckedTableSql` for DDL. Keep manual DML within managed InnoDB +tables: arbitrary SQL targeting external nontransactional tables cannot inherit +InnoDB rollback guarantees. + +`Backfill` keeps its historical table definition and immutable primary-key cursor. +It locks a bounded batch with `SELECT ... FOR UPDATE`, updates exactly those keys, +and commits the row changes and cursor together. This does not require MySQL +`UPDATE ... RETURNING`. Triggers or other unmanaged table behavior are rejected, +and retained primary keys are checked before advancing. `maxBackfillBatches` +bounds one invocation; a later `apply` resumes its saved cursor. + +Add a nullable column, backfill it, then make it required in a later migration. +The diff generator will not add a populated required column without a default or +backfill stage. Primary keys must stay immutable while the backfill runs. +Application writes must preserve the completion condition during deployment. + +## Supported schema changes + +Creation, explicit table/column renames, column addition/removal, defaults, +nullability, signed integer/decimal/temporal widening, primary and unique keys, +ordinary indexes, foreign keys and named checks produce reviewed table steps. +Foreign keys are created after their tables, including cyclic relationships. +Their required supporting indexes have deterministic names and appear in the +frozen schema, so InnoDB does not create invisible schema drift on the ORM's behalf. +Destructive drops require `allowDestructive`; renames are never inferred. + +Automatic narrowing, codec changes, identity changes, generated-to-ordinary +changes and changes between virtual/stored generated columns are rejected during +planning. Use explicit replacement columns and backfill, or author a reviewed +`CheckedTableSql` with accurate historical definitions. MySQL has no PostgreSQL +`ALTER ... USING`; the `using` diff option is rejected. Give checks explicit names +if they may need to be removed or changed later. + +Physical defaults are signed `BIGINT`, `VARCHAR(255)` with `utf8mb4_bin`, `DOUBLE`, +`TINYINT(1)`, `LONGBLOB`, `DATE`, `TIME(6)` and `DATETIME(6)`. Decimal columns require +explicit precision 1–65 and scale 0–30 not exceeding precision. Tables use InnoDB +and `utf8mb4_bin`. MariaDB JSON is its native JSON alias, including the generated +validity check; catalog inspection recognizes that representation. `TINYINT(1)` +does not itself restrict values to zero and one. DATETIME catalog metadata does +not identify whether an application intends a UTC instant or a local date/time. + +A declared temporal precision below six delegates storage coercion to the engine; +rounding/truncation can differ by engine and SQL mode. The ORM does not promise +cross-engine rounding for these columns. The default six digits preserve Dart's +microsecond precision. Computed expressions and checks can specify separate +`mysql:` and `mariadb:` SQL; each history freezes only its selected expression. + +Catalog verification reports unsupported features rather than guessing a mapping: +unsigned/zerofill and binary-string types, prefix/expression/descending/invisible +indexes, nondefault table options, cross-database foreign keys and custom foreign +constraint names. An imported database can be verified and baselined while +reporting unmanaged objects; automated checked changes require resolving them +first. [MySQL column metadata](https://dev.mysql.com/doc/refman/8.4/en/information-schema-columns-table.html), +[MariaDB column metadata](https://mariadb.com/docs/server/reference/system-tables/information-schema/information-schema-tables/information-schema-columns-table) + +## Verification + +`test/mysql_migration_test.dart` uses `ORM_TEST_MYSQL` and `ORM_TEST_MARIADB` and +optional matching `_TLS` variables. It creates an isolated disposable database +per test and therefore needs `CREATE DATABASE` privileges. Tests cover catalog +round trips, lost DDL acknowledgements, drift rejection, transactional checkpoint +failures, unknown commit outcomes, lock release, bounded backfill, explicit +renames, relationships and baseline. A compiled consumer imports generated Dart +migrations and checks their fingerprints for both engines. An unset +engine environment variable skips its live tests; static planning tests still run. diff --git a/docs/mysql.md b/docs/mysql.md new file mode 100644 index 00000000..41c92eed --- /dev/null +++ b/docs/mysql.md @@ -0,0 +1,209 @@ +# MySQL and MariaDB + +The native drivers use `mysql_client_plus` and require MySQL 8.4+ or MariaDB +10.6+. SQLite, PostgreSQL, MySQL and MariaDB remain distinct database engines. +Opening a MySQL driver against MariaDB, or the reverse, fails before use. + +## Use the driver alone + +```dart +import 'package:orm/drivers/mysql.dart'; + +final driver = await MysqlDriver.open( + MysqlOptions(url: Uri.parse('mysql://user:password@localhost/app')), +); +try { + final result = await driver.run( + (connection) => connection.execute( + SqlCommand('SELECT name FROM users WHERE id = ?', [42]), + ), + ); + print(result.rows); +} finally { + await driver.close(); +} +``` + +For MariaDB, import `package:orm/drivers/mariadb.dart` and use +`MariadbDriver.open(MariadbOptions(...))`. Its URL may use `mariadb://` or +`mysql://`; the server identity must still be MariaDB. These libraries do not +import ORM models, query builders, generation, or migration tooling. + +All parameterized statements use the server's binary prepared-statement +protocol. Statements without parameters use the text protocol; the upstream +client's prepare operation cannot complete zero-parameter, zero-column +statements such as DDL and `ROLLBACK`. The driver never uses the client's named +parameter interpolation. Result columns preserve their order and duplicate +names; generated identities come from the server response as `lastInsertId`. + +## Typed queries and mutations + +Import `package:orm/mysql.dart` and open `mysql(MysqlOptions(...))` for a +`Database`, or use `package:orm/mariadb.dart` and +`mariadb(MariadbOptions(...))`. The independent `SqlBuilder` can also compile +the same queries without opening a connection. Bind values separately; the +compiler orders positional parameters by their final SQL occurrence. + +Neither adapter advertises `RETURNING`. `createRow` instead inserts and reads +the result by primary key on the same transaction connection. It starts a +transaction when necessary and reuses an existing transaction. Primary keys +must be supplied as literal assignments, with at most one omitted generated +identity; tables without a primary key and keys computed by arbitrary SQL are +rejected before insertion. Precision-coerced primary-key assignments are also +currently rejected because the stored key can differ from the input. A failed +read or decode marks the transaction failed, even if application code catches +the exception. Plain `insert(...).execute()` remains available when no returned +row is needed. + +Use `onDuplicateKeyUpdate` explicitly for native MySQL/MariaDB upserts. Any +duplicate primary or unique key can cause the update; callers cannot select a +particular conflict target. `onConflictUpdate` and `onConflictDoNothing` are +rejected. A self-assignment would still run update triggers, so it is not a +substitute for doing nothing. Affected-row counts follow the server's insert +and update semantics. + +Typed JSON parameters are parsed as JSON expressions, and final projections +convert JSON to character text only when crossing the driver decoding +boundary. CTEs, scalar subqueries, and UNION operands retain native SQL values. +Use `Codecs.jsonDocument.nullable()` to distinguish `SqlJson(null)` from SQL +`null`. Direct `.distinct()` with a JSON result is rejected: deduplicate native +values inside a CTE, then read that CTE so the character conversion happens +afterwards: + +```dart +final unique = db.table(documents) + .select((row) => row.document) + .distinct() + .asCte('unique_documents'); +final values = await unique.query.get(); +``` + +JSON equality follows the selected engine. MySQL has native JSON storage; +MariaDB stores JSON as validated text. The ORM does not promise identical +object ordering, duplicate-key, or comparison behavior across engines. + +Exact decimals use at most 65 digits and scale 0–30, with scale no larger +than precision. Big integers use `DECIMAL(65,0)`. Typed parameters are cast to +decimal storage so comparisons do not fall back to floating point. Column +constraints and MIN/MAX are available within engine limits. Explicit decimal `rounded`, `divide`, +`divideExpression`, and `average` reject unsupported exact-rounding semantics +instead of substituting approximate arithmetic. Decimal addition, subtraction and +multiplication and SUM reject `CAPABILITY.DECIMAL_PRECISION`: native arithmetic can +silently truncate fractional digits even when neither input exceeds the storage +limit, and a native SUM can saturate when materialized by a subquery. The +`exactDecimal` capability describes lossless values and comparisons, not all +arithmetic operators. Explicit expression `constrained` and decimal UNION/UNION ALL +are also rejected because the engines may silently clamp or narrow precision. +Assignments rely on the declared column and strict SQL mode rather than first +casting into a narrower intermediate value. Explicit `sql(...)` expressions remain available for applications that +accept the selected engine's bounded arithmetic semantics. + +Temporal column precision is enforced by the selected engine on assignment; +rounding or truncation follows that engine and its session modes. Explicit +expression `withPrecision` is rejected because its cross-engine rounding +contract is not implemented for these engines. + +## Connection ownership and limits + +Each driver owns one physical connection. It queues complete `run` callbacks, +so transactions cannot interleave with another lease. Statements inside one +callback must be awaited before the next statement starts. A connection +cannot be used after its callback completes. `close()` waits for already +accepted work, and every concurrent close caller waits for the same result. + +Lease release runs `ROLLBACK`, including after a successful callback, because +raw callers may leave a transaction open. Commit explicitly to persist a +transaction. This cleanup is one additional database round trip per lease; +several statements can share a lease. Session variables and temporary tables +belong to the connection and are not recreated for each lease. +Raw callers that change `autocommit`, `sql_mode` or `time_zone` must restore +the driver's documented settings before leaving that lease. In particular, +leaving autocommit disabled makes later writes part of an uncommitted transaction +that lease cleanup rolls back. Use managed transactions for application writes. + +Results are materialized. `streaming` and `cancellation` capabilities are +false; requesting either reports an unsupported capability. `statementTimeout` +is true: `ExecutionOptions(timeout: ...)` bounds an individual statement through +the raw runtime and ORM, overriding the driver's `queryTimeout` default. +Query timeouts discard the connection. A timeout or connection loss does not +prove a write failed or rolled back, so an uncertain write must not be retried +automatically. Open a new driver after invalidation or disconnection; there is +no implicit reconnect. Transaction deadlines, cancellation tokens and retry +policies still require actual cancellation and remain unsupported. + +The underlying client does not expose the server transaction status flags, +so `transactionActive` is `null`. The driver does not guess state from SQL +text. `SqlDatabase` and ORM transactions therefore check every statement, +including SQL executed on a connection borrowed through `tx.run`. Only one +SELECT, INSERT, UPDATE, DELETE, REPLACE, WITH, SHOW, DESCRIBE or EXPLAIN statement +is accepted. DDL, manual transaction control, SET, CALL, dynamic execution, +executable comments, multiple statements and ambiguous quoted backslash +escapes fail with `TRANSACTION.STATEMENT` before reaching the server. Use +`transaction()` and `savepoint()` for transaction boundaries, and bind values +separately. A caught statement failure still prevents committing that scope; +a savepoint can isolate a recoverable failure. + +MySQL/MariaDB DDL may implicitly commit and cannot be made atomic by a Dart +callback. Run DDL outside runtime transactions, using the engine-specific +migration workflow. The independent low-level driver executes raw SQL without +this runtime guard and leaves transaction control to its caller. + +## TLS and session behavior + +`MysqlTls.verifyFull` is the default. It rejects invalid certificates rather +than inheriting the upstream client's permissive default. Supply a Dart +`SecurityContext` for private trusted certificate authorities. +`MysqlTls.require` deliberately skips certificate verification and is for +explicitly trusted environments such as local integration fixtures. +`MysqlTls.disable` is unencrypted; the current client requires TLS for +SHA-2 authentication, including MySQL's usual `caching_sha2_password` path. + +Initialization sets the session time zone to UTC. It retains existing SQL +modes and adds `ANSI_QUOTES`, `NO_BACKSLASH_ESCAPES`, `STRICT_ALL_TABLES`, +`NO_ZERO_DATE`, `NO_ZERO_IN_DATE`, `ERROR_FOR_DIVISION_BY_ZERO`, and +`NO_ENGINE_SUBSTITUTION`. Double quotes therefore quote identifiers, and a +backslash in a string literal is an ordinary character. Bind values with +parameters instead of depending on string-literal escaping. + +## Value boundaries + +- Signed integer columns decode exactly into Dart's signed 64-bit `int`. + Decimal columns stay decimal text and pass through `Codecs.decimal` without + rounding through `double`. `BigInt` and `Decimal` inputs bind exact text. +- Boolean columns return integer `0` or `1`, which `Codecs.boolean` decodes. +- `BLOB` columns return bytes. The upstream binary decoder does not safely + support all `BINARY`/`VARBINARY` columns or unsigned integer columns; those + are outside this adapter's supported storage contract. For raw access, + explicitly cast unsigned integers to character text and use `HEX(...)` + for unsupported binary storage rather than relying on its decoder. +- Native `DateTime` parameters are converted to UTC; local date/time types + retain their calendar meaning. Dates are limited to years 1000–9999. + Binary temporal results preserve microseconds, including `.000001`. + MySQL `TIME` can represent signed durations beyond 24 hours; such values + are not `LocalTime` and should remain raw strings. +- Select JSON as `CAST(document AS CHAR CHARACTER SET utf8mb4)` for a + lossless JSON text boundary. This distinguishes SQL `NULL` from JSON + `null`, and works on both engines. The upstream decoder otherwise erases + this distinction for a direct MySQL JSON column. Its parsed non-null JSON + results are wrapped in `SqlJson` so JSON string scalars remain strings. + +## Real database tests + +`test/mysql_driver_test.dart` imports only the low-level driver libraries. +Set `ORM_TEST_MYSQL` and `ORM_TEST_MARIADB` to disposable database URLs. +The corresponding `ORM_TEST_MYSQL_TLS` and `ORM_TEST_MARIADB_TLS` variables +choose `verifyFull`, `require`, or `disable` (default `verifyFull`). + +```sh +dart test test/mysql_driver_test.dart +``` + +The suite covers actual prepared statements, values, commit/rollback, +savepoints, abandoned transaction cleanup, error recovery, engine mismatch, +lease serialization, close draining, invalidation, and timeout handling. +Without a database URL that engine's integration group is explicitly skipped. +See `docs/progress.md` for which server versions have actually been validated. +`test/mysql_database_test.dart` exercises typed queries, JSON and decimal +boundaries, generated-row reads, relations, and transaction behavior. +`test/sql_mysql_review_test.dart` adds compiler and fault-injection regressions; +these synthetic checks do not replace the real database suites. diff --git a/docs/named-sql.md b/docs/named-sql.md new file mode 100644 index 00000000..becee610 --- /dev/null +++ b/docs/named-sql.md @@ -0,0 +1,171 @@ +# Named SQL queries + +Declare parameters and results as Dart records, keep complex SQL in files, and +generate a typed database method. This path uses the same Query, Selection, +connection, transaction and cursor implementation as generated tables. + +```dart +import 'package:orm/schema.dart'; + +typedef AuthorStats = ({String author, int postCount, int points}); +final authorStats = sqlQuery( + sqlite: 'stats.sqlite.sql', + postgres: 'stats.postgres.sql', +); +``` + +The SQLite file: + +```sql +SELECT author, COUNT(*) AS post_count, SUM(points) AS points +FROM posts +WHERE points >= :minimum AND (:author IS NULL OR author = :author) +GROUP BY author; +``` + +Use `SUM(points)::bigint AS points` in the PostgreSQL file when the result is +intended to fit Dart's signed integer range. The files may differ or both dialects +may reference the same file. Paths are relative to the declaring Dart library. + +```sh +dart run orm queries generate lib/queries.dart +dart run orm queries check --source lib/queries.dart --sqlite app.db +dart run orm queries check --source lib/queries.dart \ + --postgres-env DATABASE_URL --database-schema public +``` + +Generation writes only `queries.queries.dart`. It works +offline, analyzes source types and codecs, and verifies that every dialect's +parameter names match the declared record. Commit this output with the SQL and +source declaration. `queries check` first re-analyzes the source and compares the +generated Dart code; stale SQL, declarations or edited generated code require regeneration. + +For a custom generated location, add `--output path.dart` to `queries check`. +The programmatic equivalent is `checkGeneratedQueries(source, output: path)`. + +Import the generated file to use the method: + +```dart +final stats = await db.authorStats(minimum: 2, author: 'a').single(); +final names = await db.authorStats(minimum: 0) + .where((s) => s.points.gt(10)) + .orderBy((s) => [s.author.asc()]) + .select((s) => s.author) + .get(); // List + +await db.transaction((tx) async { + final rows = await tx.authorStats(minimum: 0).get(); + // This query uses the transaction's connection and sees its pending writes. +}); +``` + +Non-null parameter fields generate required named arguments. Nullable parameters +default to SQL NULL. Use `()` for no parameters. The result must be a public, +non-generic named-record typedef in the declaring file. Parameters may use such a +typedef or an inline named record. Enums and `@UseCodec` use the ordinary generator's +type and codec analysis. Result names use snake_case by default; `@ColumnName` +selects an explicit SQL alias. Query declarations do not accept identity, default, +index, integer-width or decimal-precision column metadata. + +A declaration containing only `postgres:` generates an extension on +`Database`, so SQLite code cannot call it. A declaration containing both +targets works on either backend. Database checks validate only the selected target; +run both commands when shipping both files. + +## What the checks establish + +SQLite compiles `EXPLAIN SELECT FROM ()`. PostgreSQL +uses SQL `PREPARE` for that projection, reads the prepared statement's native +result types, then deallocates it on the same leased connection. Neither path +executes the application SELECT. Missing tables, missing declared aliases and +invalid SQL fail. PostgreSQL also checks storage-type families against the declared +codecs. Its checker is verified against PostgreSQL 18.4, including the +`pg_prepared_statements.result_types` catalog column. These paths follow the +[PostgreSQL PREPARE lifecycle](https://www.postgresql.org/docs/current/sql-prepare.html), +[prepared-statement metadata](https://www.postgresql.org/docs/current/view-pg-prepared-statements.html), +and [SQLite EXPLAIN behavior](https://www.sqlite.org/lang_explain.html). The checker +does not parse SQLite's version-dependent EXPLAIN output. + +MySQL and MariaDB use SQL `PREPARE` and `DEALLOCATE PREPARE` on one session, +without `EXECUTE`. The checker binds its SQL text through a temporary session +variable and clears it afterwards. It validates syntax, table references and +declared result aliases, but reports storage types and nullability as unchecked. +This follows the [MySQL PREPARE lifecycle](https://dev.mysql.com/doc/refman/8.4/en/prepare.html) +and [MariaDB PREPARE lifecycle](https://mariadb.com/docs/server/reference/sql-statements/prepared-statements/prepare-statement). +Ordinary `EXPLAIN` is unsuitable for this guarantee: our MariaDB 11.8 fixture +executes a stored function with an INSERT side effect during planning. + +These checks do not infer expression nullability, integer range, decimal scale, +enum labels or custom codec correctness. Integer declarations can accept native +NUMERIC metadata, but a fractional or out-of-range value still fails integer +decoding. SQLite expression values may change storage class between rows; their +types are checked by the result codecs at runtime. Declare nullable fields for +nullable SQL expressions. The report explicitly records `nullabilityChecked: false`. +Only emitted projections are checked; source columns not selected by the record +may remain in the SQL. + +Generation itself performs lexical parameter scanning, not full SQL parsing. +Native structure checks belong in development/CI against the intended schema. +They prove acceptance at check time; they do not certify a future migrated schema +or persist an approval flag in runtime code. + +## Composition and execution + +The generated result is an ordinary immutable Query with a fixed SQL CTE source. +Typed filtering, projections, CTEs, subqueries, grouping/windows and streaming +remain available. The SQL source has no inferred key or cardinality: use `take(1)` +for a scalar subquery and supply normal outer ordering/limits. Keyset pagination +cannot invent a unique key for a SQL result. Generated table mutations are rejected +for this CTE source. This declaration path supports SELECT/WITH/VALUES queries; +use the existing mutation APIs or parameterized `Database.execute` for writes. + +As with table queries, select SQL expressions before UNION, then map the result: + +```dart +final a = db.authorStats(minimum: 0, author: 'a').select((s) => s.author); +final b = db.authorStats(minimum: 0, author: 'b').select((s) => s.author); +final authors = await a.unionAll(b).get(); +``` + +Native SQL can call functions with side effects. The query wrapper does not prove +purity. Such SQL retains the database's ordinary execution and transaction rules. +SQL paths and text are application code; user input belongs in bound parameters. +`:name` is the sole placeholder syntax. Repeated names bind once. Quoted strings, +identifiers, comments, PostgreSQL casts and dollar-quoted text are preserved. +PostgreSQL backslashes in strings require explicit `E'...'` or dollar quoting. +Only one statement is accepted, with an optional terminal semicolon. Internal CTE +names use `_orm_sql_`; avoid that namespace in application SQL. + +Physical dependencies are opaque to the ORM. `watch()` therefore requires explicit +tables, including dependencies hidden in views/functions: + +```dart +db.authorStats(minimum: 0).watch(reads: [postsSchema]); +``` + +The subscription follows the same committed-invalidation rules as other queries. +SQL source ordering alone does not establish the ordering of an outer query. + +## Incremental generation + +```yaml +targets: + $default: + builders: + orm:queries: + enabled: true + generate_for: + - lib/queries.dart +``` + +Run `dart run build_runner watch`. Both the Dart library and referenced SQL assets +participate in dependency tracking. Imported enum/codec declarations are tracked +by the analyzer resolver. A SQL parameter mismatch fails the build; fixing the +file restores output. This builder is independent of `orm:orm`, and a library may +contain both entity and named-query declarations when both builders target it. + +The native fixtures cover both dialects, transactions, cursor reads, typed temporal +and Decimal parameters, raw-SQL subscriptions, failed structure checks and absence +of expression evaluation during checking. A compiled macOS executable exercises +the generated SQLite runtime without the analyzer. Browser, Flutter and query +throughput acceptance are separate work. diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 00000000..6f803c6c --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,118 @@ +# Query plans and execution observations + +`query.inspect()` describes the SQL the ORM will request without acquiring a +connection, executing statements or invoking result mappers. It works with the +same query builder used by `get()` and `stream()`: + +```dart +final query = db.users.select((u) => ( + u.name, + u.memberships + .orderBy((m) => [m.joinedAt.desc(), m.teamId.desc()]) + .take(2) + .select((m) => m.team.select((t) => t.name).required()) + .many(), +).map((name, teams) => (name: name, teams: teams))); + +final plan = query.inspect(); +print(plan.sql); +print(plan.loads.single.query!.sql); +print(plan.toJson()); +final rows = await query.get(); +``` + +Run `dart run example/teams/main.dart` for a complete generated-schema example. +Inspection compiles SQL on demand; normal execution does not construct this +description. Query compilation and structural errors can surface during inspection. + +## What the description contains + +Each `QueryPlan` contains one statement and its dependent `RelationLoadPlan`s. +Its immutable lists describe: + +- SQL and parameter count, excluding bound parameter values. Literal text in raw + or named SQL is retained, so this is not a general SQL redaction mechanism. +- Physical output slots, including association keys and joined presence markers. + `codecType` names the logical codec SQL type, not necessarily the database's + physical storage type. Expressions without direct column origins omit table + and column names. Slot indices start at zero. +- Explicit and generated relationship joins, with table/CTE source names and + left/inner join kind. Joins inside subqueries remain visible in the SQL text. +- Known table reads for this statement, including CTEs and subqueries. Child + batch reads belong to their own plans. Raw SQL and named SQL set `opaqueReads` + because the compiler does not parse their dependencies. +- Each batch's parent/child key slots, composite key width, per-parent limit and + offset, fixed parameter count, and maximum parent keys per chunk. + +A child SQL template contains placeholders for **one** parent key tuple. Its +parameter count therefore equals `fixedParameters + keyWidth`. Runtime expands +the key placeholders to match each actual chunk. The capacity is: + +```text +maxKeysPerBatch = (driver.maxParameters - fixedParameters) ~/ keyWidth +``` + +`sqlTemplateCount` counts statement templates in the tree. It is not an execution +count: empty parent results skip child statements, repeated keys share a lookup, +and large key sets require multiple chunks. Composite keys containing NULL do +not join through equality. For one active relationship at one level, child +statement count is `ceil(distinct non-null parent keys / maxKeysPerBatch)`. +Nested loads apply that rule separately to each returned parent batch, and +streaming applies it for each root fetch batch. `take(0)` has a skipped load with +no child SQL. Selected to-one JOINs are already part of their parent's statement. + +This description does not estimate database page reads, optimizer choices, row +volume or elapsed time. Native optimizer inspection is a separate operation: +[PostgreSQL EXPLAIN](https://www.postgresql.org/docs/18/sql-explain.html) and +[SQLite EXPLAIN QUERY PLAN](https://www.sqlite.org/eqp.html) consult the database. +PostgreSQL's `ANALYZE` option actually executes the statement. The ORM does not +issue either command automatically. + +## Measuring execution phases + +```dart +final db = await sqlite( + const SqliteOptions.memory(), + onAcquire: (event) => print((event.elapsed, event.reusedConnection, event.error)), + onQuery: (event) => print((event.operation, event.rowCount, event.elapsed)), + onDecode: (event) => print((event.inputRows, event.elapsed, event.error)), +); +``` + +`Database`, `postgres` and `sqlite` accept the same optional hooks. +Sessions, transaction attempts and savepoints inherit them. + +| Hook | Measured interval | Limits | +| --- | --- | --- | +| `onAcquire` | Driver lease request until callback entry or acquisition failure | Includes pool wait and setup inside that request. Does not separate those driver internals. Initial SQLite/worker opening occurs before the Database exists and is excluded. | +| `onQuery` | Driver statement or cursor operation until completion/failure | Includes driver conversion and transport, not only server CPU. SQL and parameter count are recorded, bound values are excluded. Cursor fetches report their actual returned row counts. | +| `onDecode` | Synchronous ORM codec/mapping and related-row grouping | Excludes SQL, acquisition, compilation, consumer pauses and other client work. `inputRows` counts rows presented to that decode batch, including on failure. | + +An operation within an existing session/transaction reports +`reusedConnection: true` and zero acquisition time. A root query and its collection +loads use one acquisition. Acquisition timeout/cancellation reports a failed +event; returning a later lease does not report a second success or start SQL. +Invalid options and already-cancelled requests fail before acquisition begins. + +Each root result, child chunk and nonempty stream fetch has a decode event. +Nested child results are decoded before their parent result. Returning mutations +use their command SQL; batch RETURNING combines command results and reports +`sql: null`. Raw `Database.execute` returns driver rows without ORM decoding. +Empty `get()` results can report zero input rows; an exhausted stream's empty +fetch needs no decode event. + +Callbacks are synchronous. Their exceptions are ignored, preserving the actual +operation result and original error. Measurement stops before invoking each +callback, but callback work still delays the caller; keep it small. With hooks +omitted the ORM creates no observation events or measurement Stopwatches. + +These intervals are not a complete decomposition of total latency: compilation, +some relation bookkeeping, scheduling and caller work are outside them. Concurrent +operations may interleave events; callbacks do not provide trace correlation IDs. +Neither a successful SQL event nor a decode error alone establishes a transaction's +commit outcome. A result mapper runs in the existing mutation lifecycle and can +fail after an autocommitted write; observation does not move transaction boundaries. + +Real backend and browser coverage checks event scopes, query/row counts, +acquisition waiting/cancellation and observer failures. Runtime throughput and +allocation comparisons remain a separate [acceptance gate](acceptance.md). diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 00000000..289572a9 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,246 @@ +# Runtime cost measurements + +Run the same native SQL and result shapes through the public driver and ORM: + +```sh +ORM_TEST_POSTGRES='postgresql://localhost/orm_bench' dart run tool/benchmark_runtime.dart +``` + +The PostgreSQL account needs permission to create/drop its own temporary schema. +The tool removes that schema and its temporary SQLite WAL file when finished. +It does not alter the application's tables. `--smoke` uses five timing samples +and writes `.dart_tool/runtime-smoke.json`; it validates the harness and is not +a performance result. The normal run records +[`research/benchmarks/runtime.json`](../research/benchmarks/runtime.json). +Use `--output ` to retain a subsequent measurement separately from that +baseline, for example `--output research/benchmarks/runtime-selection.json`. + +## Comparison contract + +Both paths use the **same public Driver**: PostgreSQL pooling/protocol/decoding, +or the native SQLite background isolate and its message transport. The raw path +calls `driver.run` and `connection.execute` directly, then constructs the same +Records/lists using the same public codecs. This isolates the query layer above +the adapter. It is not a comparison against synchronous `package:sqlite3` on the +main isolate or an independently configured `package:postgres` connection. + +The raw path reuses captured, precompiled SQL. Its relationship loader derives +keys from the returned parent rows, fills the same parameter positions, groups +children and returns unmodifiable child lists. The fixture has a fixed root/key +count; this baseline is handwritten for that workload, not a general replacement +for the ORM query planner. Raw SQL, parameter values, statement count and complete +business results are checked against the corresponding generated ORM query. +Independent assertions check root count, per-parent ordering/limits and grouped +counts. All data belongs to the generated [example schema](../example/schema.dart). + +| Case | Result | Expected SQL result volume | +| --- | --- | --- | +| `full_rows` | 100 complete User Records; nullable, long Unicode nicknames | 100 rows, one statement | +| `two_columns` | 100 `(id, email)` Records | 100 rows, one statement | +| `three_posts` | 100 users with their latest three `(id, title)` posts | 100 parent + 300 child rows, two statements | +| `aggregate` | Counts for ten score groups | 10 rows, one statement | + +The database contains 100 users and 1,000 posts. Field selection reduces returned +columns; per-parent pagination returns 300 of those posts. This sample does not +measure arbitrary graph cardinalities, mutation throughput or every codec. + +## Timing and transport + +Each backend runs in a separate native JIT process. Query objects, schema, +connections and database caches are warm. Query object construction is outside +the timing; ORM SQL compilation on each `get()` is inside it. The default hooks, +VM service and profiler are disabled in the timing processes. + +For each case, 20 raw/ORM pairs warm the code before 200 measured pairs. The first +lane alternates in each measured pair. Both lanes retain their actual duration +samples, with nearest-rank p50/p95. In the paired timing section, `wallMicros` +sums measured operation intervals and operations/second is the inverse mean +duration. It excludes the gaps between those intervals. The separate closed-loop +concurrency section runs 200 operations per lane across eight clients, and its +wall time/throughput covers the entire concurrent run. It runs raw then ORM; +repeat measurements to assess order/load/JIT variability. + +SQLite uses one worker connection and a temporary WAL file. PostgreSQL warms all +four pool slots. These backend configurations serve different purposes; their +absolute throughputs are not a driver ranking. + +Both PostgreSQL timing scenarios use an external loopback TCP relay. One forwards +immediately. The other schedules each received chunk after 10 milliseconds in +each direction, preserving order without serially adding a delay for every chunk. +An independent byte echo measures the relay's actual round-trip latency and checks +its byte accounting. The report also measures `SELECT 1` through the complete +driver. That command can involve multiple protocol round trips: a SQL statement +count is not a TCP round-trip count. + +This is controlled transport latency on a local database, not a remote database +deployment. It does not simulate WAN loss, bandwidth limits, TLS or a remote +server's CPU/storage. The relay and its counters run in the controller process, +outside the benchmark process's measured heap. + +## Observation and memory scopes + +Separate probes preserve SQL/parameters, returned-row counts and UTF-8 JSON byte +lengths of the driver rows and normalized result. These JSON lengths describe +logical payload volume, not native heap sizes or SQLite IPC encoding. PostgreSQL +also records bytes received/sent by its relay during each probe, including +protocol messages but excluding TCP/IP headers. Connection setup is already done. + +A separate instrumented eight-client run records 64 acquisition samples per lane +and statement durations. These are driver lease times, including setup inside the +request; they do not isolate the pool implementation's queue time. The ORM probe +also records synchronous decode/grouping times. These probes do not contribute to +the default latency samples. + +Memory profiling uses fresh local processes after timing. The external controller +queries the SDK VM service, requests GC, runs three reads retaining the last result, +then inspects live heap state. Reports include heap/external memory, selected live +class counts, RSS before/after and process-lifetime maximum RSS. The latter can +include startup and previous cases; it is not a per-query peak. Live heap statistics +cover the isolate group, including SQLite's worker, and are not solely result size. + +The SDK's protocol describes allocation accumulators, but +[Dart 3.13.3's implementation](https://github.com/dart-lang/sdk/blob/3.13.3/runtime/vm/class_table.cc#L263) +emits current counts/sizes for both the accumulated and current fields. This +benchmark therefore does not subtract `accumulatedSize` values or describe a live +heap delta as total allocated bytes. The +[service implementation](https://github.com/dart-lang/sdk/blob/3.13.3/runtime/vm/service.cc#L4443) +also establishes the isolate-group scope of its GC/profile operation. + +Instead, a separate +[allocation trace](https://github.com/dart-lang/sdk/blob/3.13.3/runtime/vm/service/service.md#getallocationtraces) +counts observed creations of selected Record, List, Map and selection-plan classes +in the main query isolate, and records allocation frames. It excludes untraced +classes, native allocations and the SQLite worker's allocation traces. Buffer and +stack limits apply; truncated stack counts are retained. These are observed traces, +not an exact census of every allocation or a measurement of total allocated bytes. +Tracing can deoptimize code and changes execution cost; no traced durations are +used as normal throughput results. + +## Interpretation + +Captured on 2026-09-15 with an Apple M3 Max, Dart 3.13.3, SQLite 3.53.4 and +PostgreSQL 18.4. Runtime source is `94d24fe`. Durations below are milliseconds; +throughput is operations/second for the separate eight-client run. + +| Environment | Case | Raw p50 | ORM p50 | ORM p95 | ORM concurrent ops/s | +| --- | --- | ---: | ---: | ---: | ---: | +| SQLite WAL | full rows | 0.111 | 0.160 | 0.242 | 6,919 | +| SQLite WAL | two columns | 0.055 | 0.075 | 0.127 | 14,159 | +| SQLite WAL | three posts | 0.728 | 0.817 | 0.915 | 1,238 | +| SQLite WAL | aggregate | 0.036 | 0.040 | 0.049 | 24,564 | +| PostgreSQL loopback | full rows | 0.931 | 0.994 | 1.126 | 1,838 | +| PostgreSQL loopback | two columns | 0.530 | 0.555 | 0.849 | 2,817 | +| PostgreSQL loopback | three posts | 1.778 | 1.960 | 2.383 | 725 | +| PostgreSQL loopback | aggregate | 0.352 | 0.365 | 0.406 | 5,956 | +| PostgreSQL delayed relay | full rows | 82.706 | 83.526 | 86.316 | 46 | +| PostgreSQL delayed relay | two columns | 82.083 | 81.652 | 85.104 | 47 | +| PostgreSQL delayed relay | three posts | 166.178 | 168.265 | 173.045 | 23 | +| PostgreSQL delayed relay | aggregate | 80.660 | 80.563 | 83.713 | 49 | + +The delayed relay's actual echo RTT p50 was **26.410 ms**, despite the nominal +20 ms delay; timers and scheduling add overhead. Full-driver `SELECT 1` p50 was +82.678 ms. Loopback echo p50 was 0.188 ms. Small reversals where ORM appears faster +under the delayed relay are not evidence of an ORM speedup. + +For PostgreSQL, selecting two columns instead of complete users reduced received +protocol bytes from **27,784 to 4,693** in the probe. Batched relationships returned +100 parent and 300 child rows in two SQL statements. The ORM's separate eight-client +relation probe measured acquisition p50 of 5.530 ms for SQLite, 5.212 ms for local +PostgreSQL and 180.287 ms with the delayed relay. Pool saturation and network costs +matter more than a single idle-connection probe suggests. + +Across three SQLite relationship reads, selected traces recorded **996 raw versus +8,244 ORM List/backing-List allocations** (`_List` plus `_GrowableList`). These are +main-isolate traces with tracing enabled, not all-process allocated bytes. They +identify intermediate collection construction for follow-up review. The measured +local ORM p50 overhead in that case was 0.089 ms. A future optimization should +re-run this harness and preserve exact SQL, results, transaction behavior and +observer semantics before claiming improvement. + +The captured report identifies runtime source commit, SDK/OS/CPU, database versions, +fixture, sample counts and harness hashes. It is one machine/run, without confidence +intervals or a cross-library comparison. It does not establish AOT, browser or Flutter +performance. Correctness checks for those platforms have separate evidence. + +Use absolute overhead, selected bytes, actual statement counts and measured lease +waits together. A small local query can have a large percentage overhead while +remaining short in absolute time. On a delayed connection, protocol round trips +can dominate; fewer selected columns do not eliminate those round trips. Allocation +traces identify work worth investigating but do not by themselves justify a cache +or a new abstraction. + +## Direct selection decoding + +The separate [selection report](../research/benchmarks/runtime-selection.json) +captures runtime `d0a8c9a` on the same machine, SDK and database versions. Field +readers now pass values directly to typed mappers or the requested dynamic Map, +avoiding a temporary values List for each row. Relationship expansion allocates +one fixed-length buffer and copies the original row into it. This preserves +driver-row ownership and adds no cache or dependency. + +The workload, relay and VM-profiling helper hashes match the baseline. The main +harness only adds output-path selection, allowing both reports to be retained. +Each of the twelve scenario/case combinations has 200 latency and concurrent +samples per lane and 64 separate acquisition samples. Independent report readback +confirms identical SQL, parameters, statement/row counts and logical/protocol +bytes between raw/ORM and before/after. Temporary PostgreSQL schemas were removed. + +The selected main-isolate List traces over **three reads** are: + +| Backend | Case | Raw before → after | ORM before → after | ORM reduction | +| --- | --- | ---: | ---: | ---: | +| SQLite WAL | full rows | 39 → 40 | 1,239 → 333 | 73.1% | +| SQLite WAL | two columns | 37 → 37 | 885 → 282 | 68.1% | +| SQLite WAL | three posts | 996 → 996 | 8,244 → 5,220 | 36.7% | +| SQLite WAL | aggregate | 29 → 29 | 371 → 308 | 17.0% | +| PostgreSQL loopback | full rows | 3,386 → 3,382 | 4,598 → 3,695 | 19.6% | +| PostgreSQL loopback | two columns | 2,359 → 2,359 | 3,215 → 2,611 | 18.8% | +| PostgreSQL loopback | three posts | 10,235 → 10,248 | 17,564 → 14,541 | 17.2% | +| PostgreSQL loopback | aggregate | 451 → 451 | 803 → 740 | 7.8% | + +Counts sum `_List` and `_GrowableList`, including backing arrays. All captured +traces report zero truncated stacks; the selected-class and isolate limitations +above still apply. These percentages describe observed List creations, not total +allocated bytes, retained heap, RSS or throughput. + +Normal untraced local latency did **not** establish a speed improvement. Several +raw-driver timings increased too. All local cases are shown in milliseconds: + +| Backend | Case | Raw p50 before → after | ORM p50 before → after | New ORM p95 | +| --- | --- | ---: | ---: | ---: | +| SQLite WAL | full rows | 0.111 → 0.127 | 0.160 → 0.170 | 0.267 | +| SQLite WAL | two columns | 0.055 → 0.057 | 0.075 → 0.075 | 0.164 | +| SQLite WAL | three posts | 0.728 → 0.806 | 0.817 → 0.907 | 1.099 | +| SQLite WAL | aggregate | 0.036 → 0.036 | 0.040 → 0.041 | 0.053 | +| PostgreSQL loopback | full rows | 0.931 → 0.907 | 0.994 → 0.979 | 1.189 | +| PostgreSQL loopback | two columns | 0.530 → 0.553 | 0.555 → 0.593 | 0.827 | +| PostgreSQL loopback | three posts | 1.778 → 1.909 | 1.960 → 2.154 | 2.498 | +| PostgreSQL loopback | aggregate | 0.352 → 0.353 | 0.365 → 0.375 | 0.510 | + +Local ORM eight-client throughput was also lower in this capture; all durations +and throughput samples are retained in the report. Two separate captures cannot +distinguish small runtime regressions from load, scheduling and JIT variability. +The supported conclusion is fewer intermediate List allocations, with unchanged +SQL/result volume and verified correctness, not a latency or throughput gain. + +The delayed relay's echo p50 changed from 26.410 to 24.725 ms and full-driver +`SELECT 1` from 82.678 to 75.129 ms. Relationship raw/ORM p50 changed from +166.178/168.265 to 162.096/162.396 ms. Transport calibration itself changed, so +those lower durations cannot be attributed to selection decoding. + +The [PostgreSQL adapter](../lib/postgres.dart) still prepares each statement, +consumes its bound ResultStream and awaits disposal. Inspection of the locked +`postgres` 3.5.12 implementation shows separate awaited parse and close messages; +`_PreparedStatement` also queues transaction portals for cleanup in `run()` or +`dispose()`. Retaining statements while continuing to use `bind()` would retain +that pending cleanup until disposal. Simply removing disposal is therefore not +a valid optimization. The adapter currently owns cancellation and awaits control +cleanup before allowing reuse. A future connection-scoped statement cache or +protocol change must preserve these lifecycle guarantees and be evaluated with +the delayed-relay harness. No implicit statement cache is introduced here. + +Correctness after this change: 813 native tests with PostgreSQL enabled, clean +static analysis, and 18 real Chrome scenarios for each of JS and Dart WASM. The +browser membership scenario additionally covers six mixed field types and +deferred, ordered mapping. These remain separate from performance evidence and +do not establish native Flutter behavior. diff --git a/docs/progress.md b/docs/progress.md new file mode 100644 index 00000000..b9285702 --- /dev/null +++ b/docs/progress.md @@ -0,0 +1,933 @@ +# Implementation status + +## Standalone ORM redesign + +Implementation branch: `refactor/standalone-orm`. The delivery scope is SQLite (native, +Dart Web and Flutter Web), PostgreSQL, MySQL 8.4 and MariaDB 11.8. The user has +authorized commits, push, a Codex-reviewed PR and merge after verification. + +Independent values, metadata, driver, raw runtime and typed SQL entrypoints are +implemented. Generated clients use the SQL layer; the ORM composes a raw +`SqlDatabase`. Nominal Dart model classes, four-dialect generation, engine-specific +migrations and the typed project CLI are implemented. Raw migration/catalog +operations now use `.sql` when starting from an ORM database. + +The first complete native regression invocation passed 1119 tests and found five +failures in older CLI output expectations and new temporary-metadata tests. All +five have been corrected. The final CLI/import/plan/MySQL migration and SQL review +run passes 71 tests; the session/transaction/stream/retry/acquisition/watch run +passes 282 tests, including 18 new borrowed-connection lifecycle regressions. +A further 176-test PostgreSQL review run passes after bounding initial backend +PID discovery; four real TCP-proxy regressions reproduce the original hang and +verify timeout/cancellation before any application write. Forty MySQL driver and +migration-target checks pass after aligning the minimum MySQL version to 8.4. +MySQL/MariaDB per-operation timeouts are separately advertised from cancellation; +140 real driver/database/transaction checks pass. Timeout closes the driver and +may leave a submitted write outcome unknown; transaction/retry gates stay strict. +Static analysis is clean. Registry engine parsing, non-atomic plan reporting, +temporary-history shadowing and session ownership were corrected during review. + +Fresh Chrome JS/WASM and Flutter Web JS/WASM (with and without isolation) each +pass 21 scenarios. Android debug installation, AOT release upgrade and independent +process reopen pass 4/17/11 checks. Both APKs exclude Web resources. Root and +Flutter analysis pass. [The final local acceptance record](../research/validation/standalone-redesign.md) +pins the runtime source and limits. Complete GitHub CI and Codex review of the +final PR head are the merge gates; earlier captures below retain their revisions. + +Linux CI exposed native-asset packaging and capability assumptions in tests. +The complete run subsequently found 17 remaining interruption assumptions in +transaction/acquisition/watch tests (1129 passed, 25 skipped); those test cases +now cover supported behavior or explicit rejection without changing runtime. +The import AOT fixture now uses a complete `dart build cli` bundle; borrowed +PostgreSQL tests include URL passwords (48 related checks pass). SQLite +interruption-dependent cases honor the actual build capability; default Linux +`sqlite3` 3.6.0 rejects cancellation/deadlines and bounded retries before SQL. +This limitation is documented rather than replaced with a second SQLite library. +The complete Linux native stage now passes 1136 tests with 41 capability skips. +Chrome JS passes 21 scenarios; an exiting-process profile cleanup race in the +runner is corrected with bounded retries. Browser checks run in an independent CI +job. Both jobs and final-head Codex review are required before merge; +[PR #485](https://github.com/medz/dart-orm/pull/485) records their final outcome. + +Design: [standalone redesign](../research/standalone-orm-redesign.md). +Research: [Prisma v8 reassessment](../research/prisma-v8-reassessment.md). + +## Unified SQLite integration + +The current refactor uses `sqlite.dart` for native and browser connections. Shared +sqlite3 statement/cursor execution replaces the duplicated implementations. +`SqliteOptions.persistent(name, nativePath: ...)` keeps storage identity explicit; +Flutter Web automatically bundles its worker and WASM, and plain Dart exports the +same verified resources with `dart run orm web-assets`. Startup verifies worker +build/protocol identity and WASM integrity. Asset names include their content hash. + +The real Flutter release builds pass 21 scenarios each in JS, WASM without +isolation headers and WASM with isolation headers. Plain Dart JS/WASM also pass +21 each. The repository Flutter example builds for Web, resource export succeeds, +and static analysis is clean. The complete native suite passes **875 tests in +09:12**, with real SQLite/PostgreSQL enabled. Five focused native checks and all +Web configurations pass after finalizing the release artifacts. Negative startup +checks reject both an old protocol and a different build using the current +protocol. An Android arm64 asset bundle builds and excludes SQLite Web resources. +See [validation and limits](../research/validation/sqlite-refactor.md). Android +device runtime and cross-browser acceptance were not repeated for this change. + +## Previous Flutter Web integration research + +[The integration study](../research/flutter-web-integration.md) confirms that +`sqlite3` already supplies Web engine/storage support and shared database APIs. +A temporary package with Web-only bundled worker/WASM assets passes actual Flutter +3.47.4 release builds: JS, WASM without isolation headers, and WASM with isolation +headers each pass the 20 browser scenarios. Long SQL also allows Flutter animation +ticks to advance. Deep routes under a subpath, page reload recovery and migrations +are included; [captured evidence](../research/validation/flutter-web-probe.json) +records actual compilation mode and asset requests. + +This is a research probe, not a shipping packaging change. The recommendation is +package-owned resources and a simpler SQLite entry, with the platform transport +kept internal. CDN/CSP, cross-browser behavior, multi-tab ownership, hot restart +and offline deployment still need design/verification. The native Flutter example +and current manual Web options remain unchanged. + +## API and correctness refactor + +The comprehensive review of public exports, generation, SQL/decoding, writes, +session ownership and migration boundaries is recorded in +[the audit](../research/api-correctness-audit.md). It adds reproduced regressions +for cross-session subqueries/CTEs, optional-join presence guards, mutation +validation, concurrent shutdown and cancellation notification. Generated row +exports, immutable schema lists, target-specific declaration checks and native +catalog import round-trips are verified. The complete native suite passes +**872 tests in 10:41**, with real SQLite and PostgreSQL 18.4 enabled. Real Chrome +JavaScript and Dart WASM each pass **20 checks**, including the new query boundaries. +Static analysis has no issues. See [final validation](../research/validation/api-audit.md) +and the [API/mental model guide](api.md). + +The full suite includes static generation, build_runner recovery, source/registry +fingerprints, CLI lifecycle, actual process-exit migration recovery and AOT history +without source assets. Temporary consumer tests reuse existing SQLite downloads; +the normal native-asset hook still verifies their hashes. No driver configuration +or consumer packaging path is substituted. + +The previous acceptance records below remain tied to their recorded revisions. +Android/Apple runtime and performance measurements were not repeated in this +review. Work stays on `next`, with local commits only and no push/publication. + +## Previous migration correction + +The single-engine migration correction is implemented. Each registry fixes one +database engine, including before its first migration; files contain one step list +and only the selected physical expressions. Mixed histories/connections are +rejected before migration SQL. PostgreSQL execution checks server 18+, while +SQLite keeps its 3.35+ driver gate and native rebuild behavior. + +The [current validation](../research/validation/migration-targets.md) records 845 +passing full-suite tests and two PostgreSQL crash-test registry configuration +failures, followed by 21 passing target/recovery tests after correcting those +fixtures. These are separate invocations. Source-independent AOT migration and +historical backfill bundles pass; real Chrome JS/WASM each pass 19 checks; the +public registry and example CLI workflows pass; static analysis is clean. +Android was not rerun for this correction. Earlier platform captures below remain +evidence for their recorded revisions. + +The Dart schema/migration workflow is implemented and verified. Current snapshots, +fixed historical migration definitions and static registries are Dart libraries; +the project entrypoint owns typed connection/rename/conversion inputs. The JSON +artifact readers and CLI options are removed without a compatibility lane. Named +SQL generation also emits only Dart and checks freshness against the declaration +and SQL source. Internal checksum/checkpoint encodings and reports remain JSON. + +The earlier [Dart migration acceptance](../research/validation/dart-migrations.json) records +837 passing tests in the full run and one stale test-fixture path, then a passing +76-test focused run covering that correction and the final generation changes. +This is two invocations, not a single all-green full run. The independent AOT bundle +check passes after deleting consumer Dart sources and build cache; an AOT historical +backfill additionally proves bounded pause and concurrent exact-once resume. +At that revision, Chrome JS/WASM each passed 19 checks. Android debug install, release +AOT APK upgrade and distinct-process restart pass 4/17/11 checks with compiled Dart +history and no migration JSON assets. The runnable project migration entrypoint +also passes check/apply/verify/no-replay against a disposable SQLite file. + +The common lifecycle in `research/new-dart-orm-design.md` is implemented and +accepted. The [design acceptance map](acceptance.md) connects each requirement to +evidence; the [capability review](capabilities.md) states extensions and unverified +targets. This is a local implementation, with no package publication or push. + +Earlier baseline: [835 native tests](../research/validation/native.json) pass +in one invocation with PostgreSQL enabled. The [usage capture](../research/validation/usage.json) +passes dependency resolution, generation, the README and teams examples, nine +query cookbook checks on each backend, migration-chain validation and clean +analysis. Real Chrome JS and Dart WASM each pass 19 +scenarios, including temporal column precision and extended date/instant ranges. +An actual Android Flutter application additionally passes 4/17/11 checks across +legacy installation, AOT release APK upgrade and process restart; see +[native Flutter evidence and limits](flutter.md). + +Editor verification additionally covers real generated APIs at 10/100/1000 models: +fifteen completion probes with 300 warm samples, twelve diagnostic checks matching +exact codes/source ranges, and language-symbol rename responses. See the +[captured protocol measurements and limits](generation.md#editor-completion-diagnostics-and-rename). + +The same-driver runtime cost baseline covers SQLite and PostgreSQL, including +controlled TCP latency, concurrency and separate allocation traces. That baseline +uses runtime source `94d24fe`; see [measurements](performance.md). Selection decoding +has since been optimized, with a separate `d0a8c9a` capture confirming fewer +intermediate List allocations; it does not establish a throughput improvement. + +## Completed + +- Reset the `next` branch contents, preserving the design research and license. +- Establish one Dart 3.13 package and local-only commit policy. +- Single-engine migration histories, flat saved steps, selected schema fingerprints, fixed registry targets, connection/version gates and target-specific destructive-change handling. +- Dart physical snapshots, fixed migration files, per-file fingerprints, static registries and typed project CLI configuration; generated output cannot overwrite its source declaration. +- Direct retirement of schema/migration/named-query JSON artifacts, with source-based named-query freshness checks and compiled application migration history. +- Typed scalar expressions, 2–6-field composable projections and dynamic field maps. +- Immutable filters, order, offset/limit, count/exists, basic aggregation and mutations. +- Parameter binding, table occurrence scope checking, explicit default/null changes. +- Native SQLite worker with verified foreign keys, journal mode and parameter limit. +- Browser SQLite worker with explicit memory/OPFS storage, exclusive ownership, verified journaling/foreign keys and shared native codecs/functions. +- Real Chrome acceptance for JavaScript and Dart WASM clients, including persistent reopen, interrupted-transaction recovery and schema upgrades after page reload. +- Actual Android Flutter acceptance with generated APIs, persistent file storage, old debug APK to new AOT release APK upgrade, distinct-process reopen, watch/rollback behavior and background cancellation/reuse. +- PostgreSQL driver with a single pool owner, TLS settings and explicit borrowed pools. +- Transaction lifecycle, rollback, savepoints, pending work checks and query observation. +- Non-executing SQL/column/key/join/batch inspection and optional acquisition/SQL/decode phase observations with immutable metadata and isolated observer failures. +- Reproducible same-driver/SQL/result runtime comparisons with latency samples, concurrent throughput, logical/protocol bytes, lease distributions, live heap and separate selected allocation traces. +- Transaction-wide deadlines/cancellation with native SQL interruption, callback expiry and awaited cleanup. +- SQLite actual transaction-state reporting, automatic rollback recovery and invalidated-savepoint protection. +- Conservative server failure classification distinguishing known commit rejection from unknown outcome. +- Opt-in transaction replay and SQLite COMMIT-only retry with shared attempt/time budgets and cancellable backoff. +- Analyzer-based record generator, scalar annotations, composite keys, indexes and FK validation. +- Named SQL files with generated Record parameters/results, dialect-specific methods, native structure checks and stale-output detection. +- Explicit named-query build_runner assets, CLI generation/checking and shared query/transaction/stream/watch execution. +- Generated table accessors, named create parameters, byId, typed patches and schema snapshots. +- Database computed expressions with stored/virtual modes, typed read-only fields, catalog/import metadata and reviewed migrations. +- Batched and nested relation projections, per-parent SQL window pagination, optional/required relations. +- Runnable generated many-to-many membership example with role/time payloads, composite keys, bidirectional queries, transactional writes and measured query/row counts. +- Explicit `relatesTo` query navigation without a database FK, including nonunique/composite/self/inverse edges and unchanged physical snapshots. +- Correlated relation any/none/every/count; UNKNOWN fails every, and empty sets satisfy it. +- Dialect DDL, cyclic PostgreSQL FK creation, immutable migration checksums and atomic history. +- Concurrent migration serialization, read-only planning, column inspection and column drift checks. +- Runnable generated-client example with migrations, transactions and relationship selection. +- Atomic upsert with declared unique targets and typed existing/incoming field expressions. +- Batch inserts with parameter-aware chunks, optional field shapes, returning and all-chunk rollback. +- Explicit alias joins/self joins, guarded outer-join projections, scalar/IN/EXISTS subqueries. +- SQL window expressions and pre-execution aggregate/grouping placement checks. +- SQL UNION/UNION ALL with typed 2–6-field SQL records, scalar operands and post-query mapping. +- Positional column/codec/nullability validation, scoped branch clauses, CTE exports, streams and set subscriptions. +- PostgreSQL typed standalone parameter projections and exact integer aggregate decoding. +- CTEs with typed references to exported SQL expressions, mapped row decoding and nullable exports. +- Keyset pagination with unique tie breakers, mixed directions, explicit null ordering and versioned cursor transport. +- Actual generated-API negative type tests and checked codec calls after generic type erasure. +- Connection leases spanning transactions, with scoped lifetime and explicit discard. +- Portable schema snapshots; catalog checks for columns, defaults, keys and simple indexes. +- Existing-database baselines verify declared schema facts before recording immutable history. +- Read-only catalog import drafts Record declarations, physical-name maps and review reports, followed by the normal generator/baseline workflow. +- Import preserves supported defaults, identities, composite/unique keys, indexes and named inverse relations; unsupported structures have explicit issues. +- Signed 16/32/64-bit integer column metadata, native PostgreSQL types and verified SQLite range constraints, independent of value/aggregate codecs. +- Width-aware generation, catalog import, immutable snapshots, rename/type migrations, identity sequences and historical backfills. +- Finite exact Decimal values, typed SQL arithmetic, numeric SQLite collation and native PostgreSQL NUMERIC, including keys, relations, aggregates and windows. +- Decimal generation/import, catalog drift checks, reviewed conversion migrations and exact historical backfill keys. +- Declared decimal precision/scale, consistent ORM write coercion, exact SQL precision casts, constrained defaults and width-preserving import/migrations. +- Exact SQL decimal division and six explicit rounding modes, including full finite-range checks and window projections with preserved filtering/grouping/pagination. +- Exact rounded decimal averages with bounded PostgreSQL component sums, incremental SQLite accumulation and shared window inputs. +- LocalDate, LocalTime and LocalDateTime with microsecond precision and full finite native PostgreSQL ranges, independent of DateTime and session timezone. +- Temporal column precision from zero to six digits, matching PostgreSQL rounding in SQLite, with generated writes, explicit value/SQL rounding, default/computed coercion, catalog/import and reviewed precision migrations. +- Calendar generation/import, SQLite indexed collations, normalized relation keys, typed cursors and resumable historical date-key backfills. +- Explicit UTC instant storage, offset-equivalent SQLite indexing, strict process-independent decoding and overflow-checked native PostgreSQL DateTime decoding. +- Reviewed legacy timestamp upgrades preserve captured migration checksums and roll back invalid values or newly equivalent SQLite unique keys. +- Read-only application startup version checks with explicit compatibility ranges, full history validation and recovery-state rejection. +- Unmodeled constraints, expression/partial indexes, triggers and policies are reported separately. +- Reviewable migration diffs, explicit renames/conversions, destructive-change gates and checksum chains. +- SQLite atomic copy/rebuild with preserved dependency SQL, foreign-key validation and state restoration. +- PostgreSQL constraint changes resolve actual catalog names, including baselined schemas. +- Migration CLI: create/check/plan/apply/status, table inspection, schema verification and baselines. +- Explicit SQLite read-only connections; CLI read operations never create a missing database. +- PostgreSQL recoverable autocommit steps with boolean pre/postconditions and immutable attempt checksums. +- Durable per-step progress, exact concurrent-index state checks, explicit INVALID-index repair and CLI visibility. +- Historical-schema backfills on both databases with bounded primary-key batches, atomic data/cursor commits and completion proofs. +- Shared batch budgets, lossless composite cursors, competing-runner recovery and committed subscription notifications. +- Shared migration/baseline session locks use bounded try-lock polling without retaining waiting snapshots. +- Database cursors with demand-driven batches, per-batch relation loading and scoped stream cleanup. +- Real SQLite native interruption and PostgreSQL control-connection cancellation with awaited cleanup. +- Cancellable, bounded connection acquisition with monotonic entry checks and safe late-lease draining. +- Independent PostgreSQL pool/connection deadlines, with acquisition settings propagated through queries, mutations, batches, streams and watches. +- Per-statement deadlines, poisoned-transaction protection and atomic cancellation between batch chunks. +- Explicit cancellation capabilities, borrowed-pool ownership and failed-control connection disposal. +- To-one JOIN strategy using declared uniqueness, with explicit join/batch choices and cardinality checks. +- Composite-key batch execution, tuple matching, parameter accounting and nested JOIN/window combinations. +- Joined optional/required projections preserve row presence, nullable values, CTE exports and cursor decoding. +- Constant schema codecs retain custom classes, extension IDs, nullable aliases and nested generic/record types. +- Portable enum text labels, generation-time codec matching and stable imports for separately declared domain types. +- JSON scalar decoding and explicit SQL-null/document-null distinction across SQLite and PostgreSQL. +- Typed query subscriptions with commit/savepoint boundaries, relevant-table filtering and declared FK delete effects. +- Dependency discovery across joins, subqueries, CTEs and batch relations; explicit raw/external-write notifications. +- Paused subscription coalescing, stale-read suppression, active-query cancellation and driver-wide subscription cleanup. +- Opt-in build_runner integration with explicit roots, shared generation/formatting and resolver-owned dependency tracking. +- Asset-based and real-process generation checks, imported metadata invalidation, upstream builder inputs and stale-output cleanup. + +## Delivery sequence + +1. Typed schema metadata, SQL expressions, projections, query and mutation execution. +2. Native SQLite worker and PostgreSQL drivers, explicit transactions and savepoints. +3. Record schema generator, typed table entry points and relationship declarations. +4. Batched relationship projections, root and per-parent pagination, nested relations. +5. Migration snapshots, dialect DDL, history/checksums, introspection and drift checks. +6. Advanced SQL, custom codecs, streaming, observation, Web adapter and capabilities. +7. CLI, examples, user documentation, real-database and platform acceptance checks. + +## Validation + +The following paragraphs are the chronological implementation record. References +to pending work describe that stage; the current acceptance decision and remaining +extension boundaries are in the final review below. + +The research type proof was analyzed and ran with JIT and AOT; JavaScript compilation +also passed. These checks do not constitute a working ORM or browser validation. + +Static analysis is clean. The complete suite passes 740 checks with native SQLite +and a disposable PostgreSQL 18.4 instance enabled. It exercises generation, +composite-key source validation, projections, relations, per-parent pagination, +transactions, migration rollback/history and the generated application client. +This includes a SQLite catalog regression for nullable non-rowid primary keys +and three independent CLI process workflows (SQLite lifecycle, SQLite baseline, +and PostgreSQL lifecycle, including autocommit plans/progress). The full suite +passes in one invocation. Thirteen recovery checks include two real process exits +at durable SQL/commit boundaries, INVALID indexes, lock deadlines and concurrent +runners; committed work is not replayed on resume. +`dart run example/main.dart` runs successfully against a real in-memory SQLite DB. +The generated SQLite example also compiles and runs as a native macOS AOT executable. +Thirty-seven streaming/execution checks cover native cursor fetches, pause/resume, +early exit, relation loading, deadlines, cancelled writes, failed control connections +and delayed cancellation races. A batch-cancellation regression was first reproduced +as a partial commit and now verifies rollback on both databases. +`test/support/native_execution.dart` additionally compiles and runs as a macOS AOT +executable, verifying joined records, nullable required values, absent relations, +cursor consumption, `sqlite3_interrupt`, subsequent SQL and committed query subscriptions. +The relationship fixture is generated from a record schema with nullable composite +foreign keys and self-relations. It verifies 1200-key batches, key deduplication, +per-parent windows, nested JOINs, whole-row absence and root pagination on both +backends. Generator checks compare committed client code and schema snapshots. +The domain-type fixture adds 18 real-database checks for extension IDs, custom +classes, enums, structured JSON, nullable document presence, relations, batches, +upsert, cursors and schema catalogs. JSON string scalars are decoded without a +second parse; SQL NULL and JSON null are separately represented, and required JSON +documents reject SQL NULL. Generator checks cover moved outputs, colliding domain names, nullable +aliases and invalid annotations. `test/support/codecs/native.dart` passes as a +macOS AOT program with domain values, relations and streamed cursors. +Forty-one subscription checks cover both backends, including independent SQLite +workers and PostgreSQL connections, transactional notification merging, savepoint +rollback, zero-row writes, multi-level cascade/SET NULL effects, joined CTEs and +batch relations. They also verify paused-listener cleanup, native cancellation, +read/invalidation races and re-reading after a real PostgreSQL commit whose +acknowledgement is deliberately lost. Subscriptions add no browser/Flutter +acceptance claim. +Eight asset-builder checks and one real consumer-process workflow verify the +standard build_runner path, prior-builder inputs, imported metadata invalidation, +error recovery, explicit root selection and deletion/recreation of generated +outputs. CLI and builder code/snapshots match. The generator formats in-process. +The standalone generation benchmark records 10/100/1000-model first builds, +unchanged builds, watch edits, static analysis and output sizes with pinned +environment metadata. On the recorded Apple M3 Max run, single-field watch edits +took 0.52/0.65/2.17 seconds. These are single observations with warm SDK/pub caches, +not latency percentiles, IDE completion measurements or runtime SQL benchmarks. + +Thirty-two set-query checks cover both backends, including repeated positional +columns, nested set association, operand-local CTE names/order/limits, final DTO +mapping, outer-join nullability, shared domain codecs, all built-in constant types, +SQL NULL versus JSON null, scalar subqueries, native streams and committed changes +from either operand. Negative compilation includes mismatched scalar and Record +operand types. The native macOS AOT integration also verifies typed UNION Record +streaming. Whole generated entity/Dart-mapped projections must explicitly select +SQL `.row` fields before UNION; arbitrary mapper equivalence is never inferred. + +Connection acquisition is verified with twenty shared SQLite/PostgreSQL checks, +three additional PostgreSQL checks (initialization, connection versus queue limits, +and native pool timeout classification), and a controlled event-loop starvation +regression using the native SQLite driver. Timed-out mutations, batch transactions, +sessions and queries never execute after their late leases arrive. Stream +cancellation, watch recovery, pool PID reuse, resource draining and application +error propagation are checked. macOS AOT also verifies that an expired queued +write never runs after the held lease is released. Acquisition controls remain distinct from transaction-wide execution deadlines. + +Forty-one transaction-control checks cover SQLite and PostgreSQL plus actual +concurrent connections and failure-injecting driver wrappers. They verify idle +and CPU-bound callbacks, active reads/writes/cursor fetches, paused streams, +borrowed sessions, savepoint cleanup, expired connection acquisition, native +BEGIN/COMMIT lock waits, automatic rollback, deferred constraint rejection, +known COMMIT acknowledgement races, lost acknowledgements and failed rollback. +PostgreSQL serialization failures and deadlocks are produced by real competing +transactions. SQLSTATE 40003 at COMMIT remains conservatively unknown. Native +macOS AOT verifies transaction expiry and automatic rollback across a savepoint. +Twenty-nine retry checks verify explicit opt-in, exact attempt limits, acquisition +and execution time budgets, cancelled backoff, fresh transaction views, discarded +failed-attempt notifications, unknown commits and failed rollback. Actual competing +PostgreSQL connections produce serialization/deadlock conflicts; SQLite workers +produce WAL busy-snapshot and DELETE-journal COMMIT conflicts. Both callback replay +and COMMIT-only retry share one finite budget. The acquisition timer preserves its +own error class rather than inferring the source from an elapsed clock. +`test/support/native_retry.dart` compiles and runs as a macOS AOT executable against +two SQLite workers, verifying snapshot replay and COMMIT retry without replaying +the successful callback. + +Nineteen application-version checks cover both databases: exact latest and explicit +inclusive ranges, unversioned databases, rejected downgrades, old checksum changes, +missing history, baselines, separate catalog drift, borrowed sessions and invalid +ranges. PostgreSQL checks use a real partially completed recoverable migration, +repair/resume and concurrent checkpoint creation during a consistent read snapshot. +The caller's history list is frozen before asynchronous reads. A read-only SQLite +file accepts a compatible version without applying migrations. The native macOS +AOT execution fixture also checks accepted and rejected schema versions. + +Fifty-six backfill checks cover both databases: bounded pause/resume, unchanged +historical JSON/checksums, atomic cursor/data rollback, failed completion proofs, +compatible and incompatible concurrent writes, competing runners and lost COMMIT +acknowledgements. Four actual child-process exits at data-write and commit boundaries +verify that committed chunks are retained and uncommitted chunks are repeated only +after rollback. Native integer, bigint, text, finite real, boolean, timestamp and +binary keys survive resume, including composite keys under a reduced parameter +limit. Additional checks cover ordinary migration grouping, SQLite rebuild/FK +restoration, old checkpoint-table upgrades, invalid cursors, temporary-object +shadowing, PostgreSQL row security and inherited duplicate keys. Independent CLI +processes inspect plans, pause, report progress and finish backfills on both +databases. Committed chunks notify typed subscriptions, and completion probes +reject floating-point truthiness. `test/support/native_backfill.dart` compiles and +runs as a macOS AOT executable, verifying historical declarations, bounded runs, +concurrent SQLite workers and the application startup version gate. The existing +native execution acceptance fixture also passes after this change. These are +correctness checks, not backfill throughput or online availability measurements. + +Seventeen catalog-import checks verify both databases, including physical/Dart +name collisions and escaped identifiers, identities/defaults, composite/self +relations, unique indexes, read-only SQLite, scoped sessions, excluded unsupported +types, nullable primary keys, virtual/shadow tables, +PostgreSQL inheritance and specialized index semantics. Draft declarations pass +the real analyzer/generator and produce matching schema snapshots. An imported +client runs against PostgreSQL, and the SQLite client compiles and runs as a macOS +AOT executable; both preserve existing rows and execute typed relationship queries, +defaulted writes and SET DEFAULT deletes after a verified baseline. Two additional +CLI workflows independently import, generate, baseline and verify real databases, +reject existing output files, preserve missing SQLite files and retain blocking +issues in review reports. The generator no longer emits invalid empty patch +parameters for models containing only generated fields. Physical import currently +uses the exact supported storage mappings documented in `docs/importing.md`; +Unconstrained NUMERIC now imports as finite Decimal; it is never guessed to contain +only integers. Broader native types remain part of the active goal. + +Twenty integer-width checks cover native signed boundaries, nullable/defaulted +columns, exact native 64-bit values, wider aggregate results, mixed-width UNIONs, +relations, transported cursors, generated/imported snapshots and historical +backfills. Reviewed widening/narrowing migrations run on both databases; overflowing +existing rows roll back structure and history together. Physical renames and +identity-width changes preserve generation, including the PostgreSQL sequence +type. SQLite checks distinguish emitted range constraints from quoted defaults, +comments and weaker expressions. Analyzer tests reject invalid or repeated widths +and retain integer-backed domain codec types. The new generated fixture is checked +for deterministic source and snapshot output. `test/support/integers/native.dart` +compiles and runs as a macOS AOT executable, verifying boundaries, wider sums, +relations, overflow rejection and catalog checks. No browser numeric claim is added. + +Twenty-nine exact-decimal checks cover canonical values, scientific input, +finite range limits, signed rounding and independent integer arithmetic, plus +both databases' typed writes/defaults/patches, comparisons, IN, sorting, distinct +values, transported cursors, computed CTEs, UNIONs and streaming. Exact sums, +nullable aggregates, grouping and windows are verified, including a SQLite moving +frame and intermediate overflow that cancels before a valid final result. A +numeric key lookup retains SQLite index use; this is a query-plan check, not a +throughput benchmark. Equivalent numeric spellings match joined/batched relations +and reject duplicate unique keys. Catalog import/generation, quoted and Unicode +column collations, default equivalence, historical backfill keys and conversion +migration rollback run against actual databases. Unsupported drivers reject typed +decimal SQL before execution. `test/support/decimals/native.dart` compiles and runs +as a macOS AOT executable, verifying exact values, sorting, aggregate/window +functions, normalized relation keys and catalog checks. Invalid external SQLite +text and PostgreSQL special numeric values fail typed decoding; no finite-decimal +CHECK constraint or cross-client SQLite function installation is claimed. Browser +acceptance remains open; constrained columns, SQL division/rounding and averages +are covered by the additional acceptance checks below. + +Nineteen precision checks cover signed ties, negative and excess scales, maximum +precision/scale boundaries, nullable values, defaults, expression writes, wider +aggregates, SQL casts, CTEs, batches and upserts. Rounded keys participate in real +foreign-key relationships. Catalogs, snapshots, generated/imported declarations, +renames, default changes and historical backfills retain column constraints. +Scale reduction with duplicate rounded keys rolls back structure and history; +repairing data allows the same migration to resume. SQLite rejects raw values +that do not fit, detects missing default coercion and retains an explicitly +disabled trusted_schema setting. The current SQLite binding requires +trusted_schema ON to evaluate the managed precision CHECK/default functions. +Generator checks reject invalid/repeated annotations and preserve decimal-backed +domain types. The precision fixture compiles and runs as a macOS AOT executable. +Exact SQL averages are covered by the additional acceptance checks below. + +Twenty-three division checks compare all signed rounding modes and positive/negative +scales with exact integer arithmetic. They cover 16383 fractional digits, values +around rounding ties, nulls, zero divisors, finite-range overflow and enormous +remainders whose naive intermediate products overflow PostgreSQL NUMERIC. Exact +defaults reject lost digits. Real queries combine division/rounding with grouped +aggregates, windows, HAVING, DISTINCT, ordering, pagination, outer joins, CTEs, +UNIONs, correlated subqueries and per-parent batched relation pagination. Window +expressions retain frame/partition/order identity when referenced through CTEs. +Volatile PostgreSQL operands are evaluated once each; failed expression writes +roll back their transaction. The updated decimal fixture compiles and runs as a +macOS AOT executable, including exact SQL/window division and configurable rounding. +PostgreSQL uses scalar quotient/remainder stages and an additional projection for +window operands; native arithmetic throughput is not implied. +The reproducible decimal benchmark separately records native AOT 10000-row +read/divide/round/window queries on SQLite 3.51.0 and PostgreSQL 18.4. Its raw +report is `research/benchmarks/decimal-division.json`; methodology and medians +are in `docs/decimals.md`. This is a small single-client end-to-end cost probe, +not the complete runtime performance acceptance or a high-precision stress benchmark. + +Twenty-three average checks cover exact integer fractions, empty/all-null/constant +inputs, signed ties and every rounding mode, maximum fractional scale and explicit +final-result overflow. Means remain valid when an intermediate total exceeds the +finite NUMERIC range, including cancellation between extreme values. Real queries +cover grouping, HAVING, unique-input CTEs, UNIONs, streaming, partitions, mixed +windows, DISTINCT results, pagination and windows over grouped sums. Correlated +averages update fields; failed exact rounding rolls back the containing transaction. +Per-parent relation windows retain limits. PostgreSQL sequence tests prove one +evaluation per contributing input/order row, including repeated source values; +SQLite moving-frame tests remove values, reset empty frames and reject varying +rounding policies. Illegal aggregate assignments and nested windows fail before +execution. The updated decimal AOT fixture verifies ordinary and running averages. +The expanded native benchmark records 10000-input-row averages and running +averages in `research/benchmarks/decimal-average.json`, together with read/divide/ +round controls. It uses the same bounded single-client method and records returned +row counts separately; it does not establish concurrency or large-coefficient cost. + +Twenty-four local temporal checks verify Gregorian era/leap boundaries, random +full-range ordinal round trips, finite PostgreSQL endpoints, microseconds and +24:00. Real SQLite/PostgreSQL checks cover generated writes, defaults and null +patches, ordering/min/max/windows/grouping, CTEs, UNION, streams, equivalent +relation keys, unique indexes, cursor transport, imported declarations and +historical date-key backfill recovery. Reviewed text-to-time conversion rejects +equivalent unique keys and rolls back. A reproduced PostgreSQL default comparison +regression now retains expression casts that discard time. Invalid external dates +and infinities fail typed decoding without damaging the connection. Borrowed +PostgreSQL pools explicitly opt into the configured calendar registry; the +unconfigured path rejects temporal queries before execution. A second negative +compilation check rejects five calendar/instant/string mismatches. + +`test/support/temporals/native.dart` compiles and runs as a macOS AOT executable, +covering endpoints, calendar order, bounded streams, equivalent relation keys and +catalog/default verification. These checks do not establish browser behavior or +temporal query throughput. Column precision, calendar SQL arithmetic and timezone +conversion are still pending. The local-type checks are distinct from the UTC +instant corrections verified below. + +Twenty UTC instant checks reproduce and correct the prior SQLite microsecond +ordering and process-timezone decoding failures. Native SQLite/PostgreSQL tests +cover common DateTime endpoints, microsecond round trips, offset-equivalent +predicates/unique keys/relations, indexed lookups, grouping/windows/CTEs/UNION, +streaming, cursors, imported DateTime declarations, literal defaults and expression +cast drift. Instant-key backfills resume across BC and sub-millisecond boundaries. +Out-of-DateTime PostgreSQL values retain raw text and fail typed decoding without +integer overflow or connection damage. + +The captured `test/support/instants/m0001_legacy.dart` was emitted using commit +`00853b4`. Its original checksum is asserted after decoding with the current +migration reader. Both databases upgrade it through a reviewed timestamp-to-instant +change; SQLite tests prove rollback on equivalent keys and invalid calendar text. +The new instant storage tag leaves old snapshot/DDL meaning unchanged. Native +drivers use one `temporal` capability for local calendar values and UTC instants; +borrowed PostgreSQL pools require the configured registry before opting in. + +`test/support/instants/native.dart` passes JIT in a Shanghai-timezone subprocess +and as a compiled macOS AOT executable under UTC, Asia/Shanghai and America/New_York. +It checks UTC defaults, local DateTime input conversion, microsecond sorting and +relations. These checks do not prove browser precision, timezone-name resolution, +column precision policies or temporal throughput. + +Twenty-four named SQL checks cover deterministic generation, bound/repeated/nullable +parameters, injection strings, SQL quotes/comments, typed projections, CTEs, UNION, +scalar subqueries, Decimal/calendar/instant codecs, streaming, transaction rollback, +explicit watch dependencies, and native structure failures. Seven additional +invalid API uses fail Dart analysis, including wrong result types and calling a +PostgreSQL-only query on SQLite. The CLI refuses stale generated files and opens +SQLite checks read-only without creating missing database files. + +SQLite EXPLAIN and PostgreSQL PREPARE checks do not execute the application SELECT; +throwing expressions and an unchanged PostgreSQL sequence verify that boundary. +PostgreSQL prepared statements are deallocated after storage checks. Result +nullability, value ranges and custom codec semantics remain explicit declarations +with runtime decoding, rather than claimed database inference. + +The real build_runner build/watch process also tracks named SQL assets and imported +enum changes, recovers from SQL parameter errors, and removes both query outputs +when their source is deleted. The native named-query fixture compiles and runs as +a macOS AOT executable with SQLite. See +`docs/named-sql.md` for the API and validation limits. + +Sixteen browser acceptance scenarios pass in Chrome 153 with a JavaScript client, +and the same sixteen pass with a Dart WASM client. Both use a separately compiled +JavaScript database worker and the pinned sqlite3 3.6.0 WASM asset. They cover +memory migrations/catalogs, FK enforcement, generated records and relations, +rollback/savepoints/transaction lifetime, bounded cursors, watch snapshots, exact +BigInt/Decimal/blob/calendar transport, unsupported interruption, responsive UI +events during a long query, bounded startup failures and exclusive OPFS ownership. +Committed rows survive closing/reopening and an actual page reload while a second +transaction is still open. Reload recovery discards that transaction, passes +integrity_check, and applies the next schema version without losing prior rows. + +Browser acceptance reproduced two numeric differences: integral doubles can be +mistaken for unsafe integer parameters, and epoch-microsecond arithmetic loses +precision for remote DateTime values. Explicit SqlReal binding and component-based +instant decoding preserve their intent and precision in both compilation modes. +Floating keyset cursor tokens preserve their value and stable tie breaker. +Checks include BC microseconds and DateTime's upper boundary. Two additional real +native database checks verify SqlReal floating storage on SQLite and PostgreSQL. +The native UTC instant AOT fixture is revalidated after sharing SQLite functions +with the web worker. Captured browser results are in +`research/validation/browser.json`; setup and limitations are in +`docs/sqlite-web.md`. These correctness checks do not measure throughput or certify +Safari, Firefox, Flutter embedding, IndexedDB or a shared multi-tab service. +Statement interruption remains explicitly unsupported by this web driver. +After the floating-cursor correction, all 54 native query/floating-parameter +checks pass again, as do both complete browser runs and static analysis. + +The native full suite passes in one invocation with PostgreSQL enabled. macOS runs +test files serially because separate Dart CLI startups can concurrently rewrite +and codesign the shared SQLite native-asset cache before application code starts. +Explicit concurrent database operations inside tests remain enabled. Browser +acceptance commands run separately from the native suite. + +Twenty-four unconstrained-relation checks cover real SQLite/PostgreSQL catalogs, +missing/nullable targets, nonunique cardinality, per-parent pagination, self and +inverse navigation, correlated predicates, streaming, transactions and watch +dependencies. The declaration adds no FK, index, uniqueness promise or cascade. +Adding a real FK over dangling data fails and rolls back; repairing rows permits +the same reviewed migration to proceed. Removing it preserves rows and permits +unmatched references again. + +Seven generator checks distinguish query navigation from physical constraints +and reject invalid selectors, types, codecs, names and referential actions. +Five additional invalid generated API calls fail static analysis. Existing FK +fixtures remain byte-for-byte deterministic. Browser JS and Dart WASM acceptance +also covers generated query-only relations with single and composite REAL keys. +This reproduced a missing floating binding tag in relationship batches; grouping +now retains comparable storage values and binding restores SQL floating intent. +See `docs/relations.md` for declaration and integrity semantics. +The complete native suite passes all 703 checks in one invocation after these +changes, with PostgreSQL enabled. Both complete browser runs and static analysis +also pass. The design acceptance map records the remaining product gates. + +Seventeen CHECK checks cover generated named/unnamed constraints, SQL NULL, +native write failures, import and baseline without replacing rows, duplicate +constraints, drift, casts/precedence, missing/type-changed columns and SQLite +Unicode identifiers. Add/change/remove migrations validate old data and roll back +history on failure; explicit renames preserve incoming references, indexes, +views and SQLite triggers. Unclaimed checks still block SQLite rebuilds. +PostgreSQL unvalidated, NO INHERIT and NOT ENFORCED constraints stay unmanaged; +verification plans but does not execute volatile row expressions. + +The generator additionally rejects dynamic/empty CHECK SQL, empty names and +duplicate names. CLI inspection exposes names and expressions. Older snapshots +without checks preserve their JSON/checksum representation. CHECK SQL is trusted, +uses physical names and may have backend overrides; PostgreSQL comparison has +planning cost, and checked SQLite renames may copy a table twice. These boundaries +are documented in `docs/checks.md`. + +The complete native suite passes 721 checks in one invocation, with PostgreSQL +enabled, after updating an old integer-test assertion for the newly recognized +CHECK catalog category. Static analysis reports no issues. Real Chrome JS and +Dart WASM runs each pass 15 scenarios, including generated CHECK enforcement, +atomic constraint migration failure/retry and persistent upgrades. The browser +report records both runs; these are correctness checks, not performance results. + +Seventeen client-default checks cover real SQLite/PostgreSQL creation, typed +domain IDs, nullable values, DateTime constructors, optional static-method +arguments, generic factories and const function references. Explicit values/null +and database DEFAULT bypass the corresponding factory. Prepared inserts and +batches call each omitted factory once and retain values through compilation, +execution and conflict clauses. Updates do not apply insert defaults; database +rollback does not undo Dart side effects. Explicit DEFAULT on a client-defaulted +identity retains each backend's native sequence behavior. + +Generation and moved-output analysis do not execute application factories. +Generator negatives reject incompatible/async scalar results, erased domain +types, required arguments, private/nonconstant references and repeated annotations. +Four invalid generated create calls fail static analysis. Snapshots and imports +contain no client factories; physical defaults and migration checksums remain +separate. Tables without client factories take a direct insertion path, while +other tables cache their default-bearing columns. See `docs/defaults.md`. + +The complete native suite passes 740 checks in one invocation with PostgreSQL +enabled, and static analysis reports no issues. Both real Chrome compilation +modes pass 16 scenarios, adding client omission versus explicit null and prepared +batch behavior. The browser schema snapshot itself is unchanged by adding a +client factory. No throughput measurement is claimed by these correctness runs. + +Nineteen computed-column checks cover SQLite and PostgreSQL stored/virtual CRUD, +nullable results, batch/conflict returning, generated relationship keys, quoted SQL, +catalog/import roundtrips and drift. Existing rows survive add/drop, expression and +result-type changes, stored-to-ordinary materialization, renamed CHECK expressions, +indexes, incoming references and views. Invalid recomputation rolls back rows, +schema and migration history. Decimal computation retains precision casts through +import and generation. Ten invalid declaration cases and seven invalid generated +API uses fail source/type checks; low-level copy/backfill maps reject computed +targets. CLI inspection returns computation SQL and storage mode. + +The implementation uses `ReadField` for computed results and excludes those +fields from generated creation/patch inputs. Native PostgreSQL mode/dependency +limits remain explicit; automatic diff requires reviewed replacement plans for +mode conversions it cannot emit natively. Renamed computed SQLite tables may +require two copies. See `docs/computed.md` for these costs and migration limits. +The complete native suite passes 761 checks in one invocation with PostgreSQL +enabled, and static analysis reports no issues. Real Chrome JS and Dart WASM each +pass 17 scenarios, including computed updates and migration recomputation alongside +the existing OPFS recovery/upgrade scenarios. The captured browser report records +both runs; these do not establish throughput or native Flutter behavior. + +Eighteen many-to-many checks exercise the generated `example/teams` schema against +SQLite and PostgreSQL. Memberships carry an enum role and UTC joining time, with +two foreign keys and a composite primary key. Forward/reverse pagination returns +only requested associations; root pagination and empty results remain independent. +Nested team rosters deduplicate shared keys, parameter limits trigger bounded +chunks, and correlated predicates/counts remain in one statement. Tests assert +actual statement counts and fetched-row volume alongside payloads and endpoint +identity. Transactions roll back partial endpoint/association writes; composite-key +conflicts preserve joining times, unlinking preserves endpoints, cascades remove +only affected memberships, and watches track committed payload/endpoint changes. + +Six invalid generated API uses fail analysis, covering composite keys, enum +payloads, result shapes and nonexistent implicit graph writes. The new example is +part of deterministic generation checks and runs directly, reporting two SQL +statements returning one root and two association rows. The targeted native +invocation passes 54 checks (many-to-many, generator and types), and static analysis +reports no issues. This stage adds examples and acceptance coverage over the +existing runtime; the last full-suite run remains the 761-check invocation above. +Real Chrome JS and Dart WASM each pass 18 scenarios. Both exercise the same teams +schema, checking association payloads, per-parent limits, two-statement reads, +transaction updates and endpoint cascades. The browser report retains both runs. + +Twenty-seven new plan/observation checks pass against real SQLite and PostgreSQL. +Inspection uses existing statement compilers, without acquiring a connection, +decoding fabricated rows or invoking result mappers. It exposes root and one-key +child SQL templates, projection/presence/key slots, joins, read dependencies, +per-parent pagination and composite-key chunk capacity. Template count is distinct +from data-dependent execution count. Bound values are omitted; literal SQL text +is retained. Raw dependencies stay explicitly opaque. + +Optional acquisition and decoding hooks propagate into sessions, transactions +and savepoints. Tests cover real pool/lease waiting, timeout/cancellation and +late-lease draining, SQL versus mapper failures, nested row grouping, cursor +batches and returning writes. Observer exceptions preserve commits and original +errors. Hooks omitted means no observation events or measurement Stopwatches; +inspection is only constructed when requested. These phase measurements exclude +some client work and do not isolate server CPU or provide trace correlation IDs. +See `docs/observability.md` for the exact scope and runnable example. + +Static analysis passes. The updated teams example executes two SQL statements, +returns one root/two child rows and reports one acquisition/two decode batches. +The full native invocation passes 807 checks with PostgreSQL enabled. Real Chrome +JS and Dart WASM each pass 18 scenarios, including non-executing plan inspection, +one acquisition and child/root decode observations for generated memberships, +alongside OPFS persistence, reload recovery and upgrades. The captured browser +report contains both runs. This establishes behavior, not throughput or allocation +cost; native Flutter and the remaining acceptance gates are still open. + +The runtime cost harness completes four workloads on SQLite WAL, local PostgreSQL +and a delayed TCP relay, retaining 200 raw/ORM samples for both paired latency and +eight-client throughput. Probes verify exact SQL/parameters, result shapes and row +volumes; PostgreSQL protocol bytes and SQLite logical JSON byte counts have distinct +labels. Independent echo calibration measured 26.410 ms p50 for the nominal 20 ms +delay. This is a local controlled-latency experiment, not a remote-server deployment. + +Separate processes measure live isolate-group heap/RSS and selected main-isolate +allocation traces. The SDK's current `accumulatedSize` implementation reports live +size, so the harness does not misrepresent it as total allocation. Timing processes +have hooks, VM service and profiler disabled. Captured tool hashes, sample counts, +SQL/parameter equality and result volumes were independently read back. Analysis +reports no issues; no runtime source changed in this stage, so the 807-test and +JS/WASM correctness runs above remain the runtime baseline. + +The results identify intermediate List construction and PostgreSQL protocol +round trips as concrete optimization candidates. There is no new performance +percentage guarantee or claim of superiority over another ORM; detailed numbers, +scope and reproduction commands are in `docs/performance.md`. + +Selection decoding now binds field readers once and passes their results directly +to each typed mapper. Dynamic field maps also avoid an intermediate value list. +Related rows are expanded into fixed-length buffers with one copy, retaining +separate storage from driver rows. Public API, SQL, result shapes and mapping order +remain the existing contract. Six new backend checks cover all two-to-six-field +arities, repeated columns, deferred/ordered evaluation, streaming, first-error +propagation and dynamic field snapshots/nulls. The focused selection, relation, +observation, inspection and UNION invocation passes 93 checks. The full native +invocation passes 813 checks with PostgreSQL enabled, and analysis reports no +issues. Real Chrome JS and Dart WASM each pass 18 scenarios; the teams scenario +additionally checks a six-field mixed-type projection and deferred/ordered mapping. +The browser report retains both runs. The separate `runtime-selection.json` cost +capture preserves the original baseline, retaining 200 timing/concurrency samples +per lane and 64 acquisition samples. All twelve scenario/case combinations retain +identical SQL, parameters, row volumes and logical/protocol bytes. Three SQLite +relationship reads reduce selected List/backing-List traces from 8,244 to 5,220; +local PostgreSQL reduces them from 17,564 to 14,541. Normal latency and throughput +do not establish a speed improvement: some timings increased alongside raw-driver +controls, and relay calibration changed between runs. The report records those +limits rather than attributing timing changes to decoding. + +PostgreSQL protocol lifecycle review confirms separate awaited prepare/dispose +work and pending portal cleanup in the pinned driver's Statement implementation. +Caching statements while retaining the current bind path cannot just omit +disposal; cancellation, portal release and connection reuse must stay valid. +The current adapter deliberately retains its verified cleanup behavior. The +measured round-trip cost and scope are documented in `docs/performance.md`. + +Temporal precision now spans declarations, generated/manual columns, immutable +snapshots, SQL assignments and value/expression operations. Default and explicit +six-digit declarations retain the prior canonical snapshot and migration checksum. +SQLite uses managed coercion/functions and CHECKs; PostgreSQL uses native type +modifiers. Runtime rounding uses day-local microseconds to preserve extended dates +on JS without overflowing a total timestamp count. Tests cover all seven precisions +around the PostgreSQL epoch, BC dates, extended timestamp/instant limits, nullable +values, defaults/computed columns, CTE/grouping/streams, batch/upsert and rounded +relation keys. Import roundtrips preserve metadata and avoid duplicate wrappers. +Reviewed narrowing and rename can detect merged unique keys, rolling back schema, +data and history before a successful retry. + +The new 21-check suite and the 17-check catalog import suite pass together. The +full native invocation then passes 834 checks with PostgreSQL enabled. Static +analysis passes. Real JS and WASM each pass 19 browser scenarios, including the +new precision worker scenario and existing persistence/reload upgrades. The +captured browser report includes both runs; no native Flutter claim is added. + +## Editor protocol capture + +`tool/benchmark_editor.dart` launches the installed Dart 3.13.3 Analysis Server in +isolated consumer packages against runtime `7eaab3b`. The checked report is +`research/benchmarks/editor.json`; it retains harness hashes, every warm sample, +startup/readiness costs, diagnostic ranges and applied rename edits. Independent +readback checks all hashes, nearest-rank quantiles, current error ranges and rename +edit counts. Final consumer analysis passes at all three scales. Root static +analysis also passes; runtime sources are unchanged from the 834-native-check and +19-per-browser-scenario capture above. + +At 1000 models, warm table-entry completion is 11.572 / 17.094 ms p50 / p95; +query-field completion is 0.780 / 0.850 ms. Initial project readiness is 3.405 s, +and the query-field probe has a separate 128.488 ms opening/diagnostic wait. These +are stdio protocol observations with twenty warm samples per probe, not complete +GUI latency or a declaration-frontend comparison. + +Record field rename returns null. Primary-constructor and table fields update +their declaration and typed reference; typedef rename updates the alias and its +two references. Rename probes run in a separate clean server: same-session class +field rename after diagnostic recovery stalled during harness development and +is not proven reliable here. A valid Dart arithmetic index selector passes CLI +analysis but the generator rejects it with a source offset, demonstrating the +boundary between Dart and ORM-specific rules. This closes the measured LSP slice, +not the full three-authoring-frontend experiment or whole-product goal. + +## Declaration-form comparison + +`tool/compare_authoring.dart` now executes the design's User/Post/Profile/Follow +experiment using actual Record, primary-constructor and typed table declarations. +Experimental adapters resolve each input, preserve field metadata and map it to a +common Record schema before using the unchanged production generator. The base, +field-type edit and physical-name-preserving field rename each produce identical +canonical source, generated client and physical snapshot across the three forms. +Four composite FKs, compound keys/uniqueness, identity/default/nullability, index +order and CHECK contents are verified independently from the report. + +All forms compile the same generated create/patch and nested relation consumer. +The six stale-consumer checks fail after type edits/renames and pass after their +references/types are repaired. Fourteen negative cases locate Dart, generator or +adapter errors in original source; constructor logic and custom table codecs are +rejected instead of silently lost. Class/table schema rename updates the field and +typed constraint reference through real LSP edits. Record field rename is manual. + +The captured model declarations occupy 487 / 585 / 1,574 bytes and 24 / 24 / 43 +formatted lines respectively. These are fixture observations, not a universal +Record superiority claim. Stage timings have fixed-order JIT/cache bias and are +not a frontend speed comparison. The [report and interpretation](authoring.md) +preserve the distinction between schema-only adapters and nominal runtime rows. +Root static analysis passes. Runtime sources remain unchanged; this work does not +claim additional database or native Flutter validation. + +## Native Flutter APK upgrade acceptance + +The Android application and host runner are now captured in +`research/validation/flutter.json`. Flutter 3.47.4/Dart 3.13.3 loads SQLite 3.53.4 +on an isolated API 35 arm64 emulator. Version code 1 writes two records; version +code 2 is installed with `adb install -r`, adds the new field/relationship, retains +the original physical column and verifies existing values down to microseconds. +The release application checks generated writes/projections, transaction rollback, +commit-driven watch delivery, native cancellation and subsequent worker reuse. +An independently restarted release process verifies three persisted rows and the +comment, unchanged migration checksums, no migration replay and read-only reopen. + +The three phases pass 4/17/11 assertions. A two-million-row recursive sum completes +while main-isolate timer and Flutter animation callbacks advance (15 and 7 in the +final capture). These are progress observations, not an FPS or throughput claim. +Source/APK/screenshot hashes and version/history/row continuity were independently +read back. The stable screenshot was visually inspected. Initial oversized log +chunks were truncated by Android; bounded chunks and a fresh complete run resolved +the report transport issue. ORM runtime source is unchanged. Root analysis and +the bundled migration chain check pass; the prior 834-test native run and 19+19 +browser scenarios remain separate runtime evidence. + +This closes the Android native Flutter gate, not physical-device, Apple-platform +or power-loss acceptance. Reproduction and exact scope are in [Flutter](flutter.md). + +## Final acceptance review + +The final native run passes 835 tests in 7 minutes 44 seconds on Dart 3.13.3, +SQLite 3.53.4 and PostgreSQL 18.4. The exact log, command and changed-source hashes +are saved in [native validation](../research/validation/native.json). Subsequent +documented usage and analysis passed without modifying the tested runtime. + +- Advanced queries are documented with an executable cookbook: filters, joined + ordering, relation counts, grouped CTEs, windows, subqueries and versioned + nullable cursors pass nine checks on each real backend. +- Catalog review now preserves PostgreSQL policy roles/command/mode/expressions + and enabled/forced RLS flags. Import/baseline leave them unchanged. Actual + triggers on both backends continue to execute after import and verification. +- Type, backend and migration behavior are reconciled against design §§5 and + 10–12 in [capabilities](capabilities.md). Calendar SQL arithmetic, timezone-rule + conversion and additional native extensions remain outside the implemented + typed API; the design does not specify that function family. +- Native Flutter has direct Android debug → release upgrade and restart evidence. + Browser JS/WASM have separate worker/OPFS evidence. Neither capture certifies + every platform or every native server test on that platform. +- Generation, editor and runtime cost reports retain their actual sample scopes + and historical source revisions. The same-session editor rename timeout and + checked server-restart recovery are retained as an explicit limitation. +- The documented onboarding, migration assets, examples and static analysis pass; + [acceptance](acceptance.md#final-lifecycle-audit) maps the complete usage lifecycle. + +To-one projections join by default when declared keys prove uniqueness; otherwise +they batch and check cardinality. Collections use explicit parameter-aware batches. +`verifyColumns` checks column names/types/nullability, integer widths, decimal precision/scale, SQLite collations and computed expressions/modes. `verifySchema` additionally compares defaults, +keys, row CHECK constraints and simple indexes; its `unmanaged` objects require separate review. +Ordinary migration batches are atomic. Explicit backfills use durable per-step +checkpoints and short data transactions on both databases. General recoverable +autocommit SQL remains PostgreSQL-only; SQLite rejects `CheckedSql`. These are +documented backend boundaries, not silently substituted behavior. + +The full suite includes 52 shared SQLite/PostgreSQL query checks, 20 generated +client/migration integration checks, 29 source generation checks, five codec +regressions, 19 migration evolution/catalog checks, 13 recovery checks, three CLI +workflows, 37 streaming/execution checks, 28 relation strategy checks and three +negative compilation checks covering 29 invalid API uses, plus 18 domain-codec +integration checks, 41 subscription checks, eight asset-builder checks and one +build_runner process workflow, plus 32 real-database set-query checks and 24 acquisition checks, plus 41 transaction-control checks, 29 retry checks, 19 application-version checks, 56 backfill checks, 18 catalog-import checks, two import CLI workflows, 20 integer-width checks, 29 exact-decimal checks, 19 decimal-precision checks, 23 decimal-division checks and 23 decimal-average checks, plus 24 local temporal checks, 20 UTC instant checks, 24 named SQL checks, two explicit floating-parameter checks, 24 unconstrained-relation checks, 17 CHECK-constraint checks and 17 client-default checks. + +Nineteen computed-column checks and additional generation/type checks are included +in the current aggregate above. + +## Environment + +Use the standalone SDK if the Flutter launcher tries to update its cache: +`/Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dart`. + +On 2026-09-15 the installed Flutter cache reports 3.47.4 stable with Dart 3.13.3. +Xcode reports 27.0 (27A266a), but `xcodebuild -license check` exits 69 and explicitly +reports that its license has not been accepted. Apple-platform Flutter verification +needs the user's own license review/acceptance and any remaining Xcode initialization. +No license was accepted on the user's behalf; other development can continue. + +The installed Android SDK and API 35 arm64 image were used to create a separate +temporary AVD without modifying the user's existing AVD. Both APK builds and the +three application phases succeeded. Flutter's build provisioned its NDK +28.2.13676358 through the existing SDK setup; no interactive license acceptance +was performed. Android acceptance does not depend on resolving the Xcode state. diff --git a/docs/queries.md b/docs/queries.md new file mode 100644 index 00000000..04ac6285 --- /dev/null +++ b/docs/queries.md @@ -0,0 +1,228 @@ +# Queries + +`select` chooses SQL expressions and decodes their values. A scalar expression +returns its Dart type; `(u.id, u.email).map(...)` constructs a named Record or DTO +after reading the selected columns. `(u.id, u.email).row` returns a positional +`(int, String)` Record with no custom mapping. SQL records support 2–6 fields. + +`query.map(...)` maps the already selected Dart result. It does not create SQL +columns, and its properties are not usable in SQL filters or ordering. + +Run the [cookbook](../example/queries.dart) with `dart run example/queries.dart`. +It checks nine combined query/schema scenarios on an in-memory SQLite database. +Set `ORM_EXAMPLE_POSTGRES` to a disposable local PostgreSQL URL to run the same +checks there. That example explicitly disables TLS for local development, creates +a uniquely named schema and removes only that schema in `finally`. + +## Filters, results and ordering + +Build a query with normal Dart control flow. Builder calls return new query +values; terminal calls execute them: + +```dart +var query = db.users.where((u) => u.email.like('%@example.com')); +if (minimumScore != null) { + query = query.where((u) => u.score.gte(minimumScore)); +} +final emails = await query.orderBy((u) => [u.id.asc()]) + .take(20).select((u) => u.email).get(); // List +``` + +Use `.eq`, `.and` and `.or` to construct SQL predicates. `.isNull()` and +`.isNotNull()` test SQL NULL; `.eq(null)` is available only on a nullable field. +An empty `isIn([])` is false. Other NULL-containing membership expressions retain +SQL's three-valued logic, not Dart collection semantics. Values are bound as +parameters. `like` treats `%` and `_` as SQL wildcards. + +`get()` returns a list, `first()` returns the first value or null, and `single()` +requires exactly one result. `count()` and `exists()` issue dedicated SQL. Add +explicit ordering when the first row or page must be deterministic. Use +[keyset cursors](#keyset-pagination) for changing large datasets; +`skip`/`take` provide offset/limit pagination. + +## Keyset pagination + +Choose distinct root-table columns with a declared unique tie breaker. Nullable +columns require explicit NULL ordering: + +```dart +final first = await db.users + .orderBy((u) => [u.nickname.asc(nulls: .last), u.id.asc()]) + .take(20).get(); +if (first.isNotEmpty) { + final last = first.last; + final token = db.users.cursorToken((u) => [ + u.nickname.cursor(last.nickname, nulls: .last), u.id.cursor(last.id), + ]); + final next = await db.users.seekToken(token, + orderBy: (u) => [u.nickname.asc(nulls: .last), u.id.asc()], + ).take(20).get(); +} +``` + +`seekAfter` accepts the same typed cursor terms directly. `cursorToken` transports +their values losslessly and `seekToken` validates the format version, table, +ordering and codecs. Keep the original filters when constructing subsequent +pages. A cursor is pagination input, not an authorization token or a frozen +snapshot. Joins, grouping, DISTINCT, CTE/set results, offset and non-column +ordering do not have the required table-key proof and are rejected. + +## Join and order by related fields + +Use a typed alias when a related field participates in root ordering or a flat +projection. This example uses the [generated example schema](../example/schema.dart): + +```dart +final author = usersTable.alias(); +final rows = await db.posts + .join(author, on: (p, a) => p.authorId.equals(a.id)) + .orderBy((p) => [author.fields.email.asc(), p.id.asc()]) + .select((p) => (p.title, author.fields.email).row) + .get(); // List<(String, String)> +``` + +For `leftJoin`, wrap a possibly absent selection with +`author.optional(author.fields.email)` and compose it with `.map(...)`; an +unguarded non-null field is rejected before SQL. `author.isPresent` can test +the joined row's presence independently of its nullable values. Aliases cannot +be reused across unrelated query scopes or referenced before their join. + +To order by a collection count, use +`db.users.orderBy((u) => [u.posts.count().desc(), u.id.asc()])`. This compiles a +correlated count without loading posts. Normal nested results still use +[relationship selections](relations.md) and their explicit loading strategies. + +## Grouping, subqueries, CTEs and windows + +```dart +final totals = db.posts.groupBy((p) => [p.authorId]) + .having((p) => p.id.count().gt(1)) + .select((p) => (p.authorId, p.id.count()).row) + .asCte('author_totals'); +final active = await totals.query + .orderBy((c) => [c.ref((p) => p.authorId).asc()]).get(); + +final ranks = await db.posts.orderBy((p) => [p.id.asc()]) + .select((p) => (p.id, rowNumber( + partitionBy: [p.authorId], orderBy: [p.createdAt.desc(), p.id.desc()], + )).row).get(); +``` + +Aggregates in `where`, ungrouped selected columns and invalid window placement +are rejected before execution. Window functions preserve row count; group +aggregates combine rows. A CTE exports its selected SQL expressions through +`ref`, including when the Dart result is mapped to a DTO. An arbitrary DTO +property or unselected expression is not an exported SQL column. + +Use `field.isInQuery(selectedQuery)`, `selectedQuery.existsExpression()` and +`selectedQuery.scalar()` for nested SQL. +A scalar subquery must have a provable single-row bound, such as an ungrouped +aggregate or `take(1)`; a missing row produces null. Inner predicates may refer +to the outer query's fields through an explicitly captured expression. Queries +from different database/transaction objects cannot be combined. + +For SQL beyond these typed operations, use parameterized raw expressions or +[checked named SQL](named-sql.md). Raw fragments are trusted SQL supplied by the +application; do not interpolate user input into them. + +## UNION and UNION ALL + +Combine scalar expressions or SQL records with matching types: + +```dart +final names = db.users.select((u) => u.email) + .union(db.posts.select((p) => p.title)); +final List result = await names.get(); + +final rows = db.users.select((u) => (u.id, u.email).row) + .unionAll(db.posts.select((p) => (p.id, p.title).row)) + .orderBy((row) => [ + row.ref((u) => u.id).asc(), + row.ref((u) => u.email).asc(), + ]); + +final List<({int id, String label})> cards = await rows.take(20) + .map((row) => (id: row.$1, label: row.$2)) + .get(); +``` + +`union` removes duplicate **stored SQL rows**; `unionAll` keeps them. Neither +promises operand order. Use a final `orderBy` when order matters. SQL equality, +collations and NULL rules determine duplicates, independently of Dart `==`. +These semantics follow [PostgreSQL set operations](https://www.postgresql.org/docs/current/queries-union.html) +and [SQLite compound SELECT](https://www.sqlite.org/lang_select.html#compound_select_statements). + +Different Dart result types are rejected by the analyzer. Before execution, the +ORM additionally requires matching positional columns, codec identities and +nullability. Custom codecs must reuse the same instance. Different mappings to +the same Dart type are not interchangeable. Nullable wrappers around a shared +codec remain compatible; nullable and required columns must be explicitly +aligned, for example with `alias.nullable(alias.fields.id)` in outer joins. + +Each operand must belong to the same `Database` object or transaction session. +Combining a root query with a transaction query is rejected. Values remain bound +parameters; PostgreSQL standalone `value(...)` expressions carry explicit SQL +types so parameters in projections do not default to text. + +### Map after the set operation + +Arbitrary Dart mappers and loaded relationships are not SQL set columns: + +```dart +// Rejected: these mappers have different behavior despite both returning String. +final upper = db.users.select((u) => u.email.map((v) => v.toUpperCase())); +final lower = db.posts.select((p) => p.title.map((v) => v.toLowerCase())); +// upper.union(lower) throws QUERY.UNION_SELECTION. +``` + +Whole generated entities are Record/DTO mappings, so explicitly select their SQL +fields with `.row` before combining them. Map the result afterward. For a SQL +transformation, use an expression such as `u.email.upper()` before `union`. +Putting a Dart-mapped query in a CTE does not bypass this rule. The ORM never runs +a Dart mapper while planning SQL or guesses its behavior from its return type. + +## Scope and composition + +Operand filters, grouping, ordering and limits apply to that operand. Filters, +ordering and limits after the set operation apply to the combined rows. Each +operand is a derived table, which preserves its local clauses on both backends. +The complete union executes as one SQL statement. + +`UnionFields.ref(...)` uses an expression exported by the **left** operand. An +unselected column, incompatible codec or unsafe nullable reference fails before +SQL execution. DTO property names are not exported SQL columns. + +```dart +final recent = db.users.orderBy((u) => [u.id.desc()]).take(10) + .select((u) => u.email) + .unionAll(db.posts.orderBy((p) => [p.id.desc()]).take(10) + .select((p) => p.title)); + +final filtered = recent.where((row) => row.ref((u) => u.email).like('a%')); +final cte = filtered.asCte('recent_names'); +final selected = cte.query.select((c) => c.ref((row) => row.ref((u) => u.email))); +await for (final name in selected.stream(batchSize: 64)) { + print(name); +} +``` + +Nested sets preserve association: `a.unionAll(b).union(c)` differs from +`a.unionAll(b.union(c))`. Each nesting adds an exported-expression scope, so +references follow that nesting. CTEs can wrap sets, supply operands and be joined +through typed aliases. Scalar sets also support `isInQuery` and, with `take(1)`, +`scalar()`. `count`, `exists`, aggregation, `stream` and `watch` reuse normal query +execution. Subscriptions collect table dependencies from both operands, including +nested CTEs and SQL subqueries; existing raw-SQL notification rules still apply. + +Set results do not have declared primary/unique keys. Mutations and keyset cursor +pagination are therefore rejected; offset/limit pagination and database cursor +streaming are supported. `INTERSECT` and `EXCEPT` do not yet have typed methods. + +## Aggregate numeric results + +PostgreSQL returns `SUM(BIGINT)` and integer `AVG` as NUMERIC. Integer results are +parsed exactly, with overflow or fractional values rejected rather than rounded +through a double. On `int`/`double` expressions, `average()` returns an approximate +`double?`. On `Decimal` expressions, `average(scale: ..., rounding: ...)` returns +an exact `Decimal?` rounded once to the requested scale, including when the total +would overflow. See [decimal arithmetic and averages](decimals.md). diff --git a/docs/relations.md b/docs/relations.md new file mode 100644 index 00000000..b9e96155 --- /dev/null +++ b/docs/relations.md @@ -0,0 +1,247 @@ +# Relationship queries + +Generated relation getters describe a query. They do not read data until a +containing query executes. Select the exact related shape you need: + +```dart +final cards = await db.posts.select((p) => ( + p.title, + p.author.select((a) => (a.id, a.email) + .map((id, email) => (id: id, email: email))).required(), +).map((title, author) => (title: title, author: author))).get(); +``` + +## Navigation without foreign keys + +Use `relatesTo()` for a read-only navigation edge when the database does not +enforce a foreign key. It uses the same checked key selectors and generated +query API as `references()`: + +```dart +typedef Account = ({int tenant, int id, String? label}); +typedef Entry = ({@Id() int id, int? tenant, int? owner}); +final accounts = entity(); +final entries = entity(); +final accountKey = accounts.primaryKey((a) => (a.tenant, a.id)); +final ownerAccount = entries.key((e) => (e.tenant, e.owner)) + .relatesTo(accounts.key((a) => (a.tenant, a.id)), inverse: 'entries'); +``` + +After generation, `e.ownerAccount.one()` returns an optional Account and +`a.entries.many()` returns a list of Entry records. Normal projections, filters, +correlated predicates, per-parent pagination, transactions, streams and watches +apply. Generated getter documentation identifies the unconstrained edge. + +Matching rows may be missing, and lookup keys may be nonunique. `relatesTo()` +does not infer a unique constraint or an index. Only an independently declared +primary/unique key permits automatic to-one JOIN loading; otherwise `one()` +uses a batch and checks cardinality. `required()` still reports missing matches. +SQL equality does not match a composite key containing NULL. The key selectors +must retain matching Dart and storage types, column order and arity. + +The edge itself exposes query operations. Create or change stored key values +through the normal table write API, using a transaction when needed. There is no +implicit connect, disconnect, cascade or existence validation, and `relatesTo()` +has no `onDelete` option. Deleting a target can leave stored references dangling. +`watch()` tracks the tables read by a selection, including joined or batched +targets, without inventing foreign-key write effects. + +Adding, renaming or removing a query-only edge does not change the physical +schema snapshot or emit migration DDL. Replacing an existing `references()` with +`relatesTo()` does change the snapshot: removing that database constraint must +go through a reviewed migration. Switching back can fail on dangling data; repair +the data before retrying. Catalog import cannot discover unconstrained navigation +rules, so declare them explicitly in the imported schema. This API covers +same-database key equality, including self relations; it does not implement +cross-database queries or arbitrary relationship predicate declarations. + +## Many-to-many with business fields + +An explicit association table gives each membership its own role and joining time. +The [teams example](../example/teams/schema.dart) uses two foreign keys and a +composite primary key, with no artificial membership ID: + +```dart +enum MembershipRole { owner, member } +typedef Membership = ({ + int teamId, + int userId, + @Default.sql("'member'") MembershipRole role, + DateTime joinedAt, +}); +final memberships = entity(); +final membershipKey = memberships.primaryKey((m) => (m.teamId, m.userId)); +final team = memberships.key((m) => m.teamId) + .references(teams.key((t) => t.id), inverse: 'memberships', onDelete: .cascade); +final user = memberships.key((m) => m.userId) + .references(users.key((u) => u.id), inverse: 'memberships', onDelete: .cascade); +``` + +`teams` and `users` are separately declared entities. The resulting navigation is +`user.memberships → membership.team`, or `team.memberships → membership.user`. +Select the relationship's payload together with the opposite endpoint: + +```dart +final cards = await db.users.select((u) => ( + u.name, + u.memberships.orderBy((m) => [m.joinedAt.desc(), m.teamId.desc()]).take(2) + .select((m) => (m.team.select((t) => t.name).required(), m.role) + .map((team, role) => (team: team, role: role))).many(), +).map((name, teams) => (name: name, teams: teams))).get(); +``` + +This returns a typed list of user names and membership cards. Each membership's +role belongs to that association; two users sharing a team can have different +roles. An empty relationship returns `[]`. Use a deterministic tie breaker with +per-parent pagination. Root pagination is independent of the membership limits. + +Create and update memberships through their ordinary generated table API: + +```dart +await db.transaction((tx) async { + await tx.memberships.create(teamId: 10, userId: 1, + role: .set(MembershipRole.owner), joinedAt: DateTime.now()); + await tx.memberships.byId(teamId: 20, userId: 1) + .patch(role: .set(MembershipRole.member)); +}); +await db.memberships.byId(teamId: 10, userId: 1).delete().execute(); +``` + +The composite key rejects duplicate memberships. Both endpoints must already +exist, or be created earlier in the same transaction. Unlinking deletes only the +association. Deleting an endpoint follows the declared cascade, removing its +memberships while retaining opposite endpoints. Composite-key `onConflictUpdate` +can change a role while leaving the original joining time unchanged. There are +no hidden object-graph saves or implicit writes through a navigation getter. + +The example declares one index beginning with `user_id` for reverse traversal; +its primary key already begins with `team_id`. Additional ordering indexes depend +on measured query patterns, not an index added automatically for every relation. + +Real SQLite/PostgreSQL checks establish these statement counts for the fixture, +within the available parameter capacity: + +| Selection | SQL statements | Rows returned by each statement | +| --- | --- | --- | +| Four users, latest two memberships each, joined team names | 2 | 4, 5 | +| Three teams, second member per team, joined user names | 2 | 3, 2 | +| Four users, all memberships/teams, each team's complete roster | 3 | 4, 6, 6 | +| Membership counts, every-member predicate and team-existence predicate | 1 | 4 | +| Empty root result with a membership selection | 1 | 0 | + +Parameter chunking adds statements: with a deliberately limited five-parameter +driver, a member-role filter and per-user limit execute one root plus two child +statements. Roster lookups deduplicate shared team keys. These checks establish +returned volume and statement counts, not throughput, physical database page reads +or optimizer cost. Snapshot-consistency rules below still apply to multi-statement +loads. `watch()` observes committed association and selected endpoint changes. + +Run the self-contained SQLite example: + +```sh +dart run bin/orm.dart generate example/teams/schema.dart +dart run example/teams/main.dart +``` + +It prints the selected records, SQL statement count and returned row counts. The +same generated schema is exercised against PostgreSQL by `many_to_many_test.dart`. + +## Single relationships + +`one()` returns an optional related result. `required()` checks that a related +row exists and reports `RELATION.MISSING` if it does not. Presence is checked +independently of the selected values: a present row whose selected nullable +column is NULL can still satisfy `required()`. + +The default `ToOneStrategy.automatic` uses a LEFT JOIN when the relationship +keys cover a declared primary key, unique key or simple unique index on the +target. That proves at most one match, so joining does not multiply root rows +or change root pagination. Nested to-one projections use the same statement. + +You can select the strategy explicitly: + +```dart +p.author.one(strategy: .join); +p.author.required(strategy: .batch); +``` + +`.join` rejects keys that do not prove target uniqueness with +`RELATION.JOIN_KEY`. Without that proof, `.automatic` uses batch loading and +checks cardinality. More than one visible result produces +`RELATION.CARDINALITY`. The uniqueness proof uses the schema declaration, so +the actual database must enforce those declared constraints; use schema +verification when adopting an existing database. + +Relation filters stay inside the JOIN condition and do not remove root rows. +`one()` represents a filtered-out target as null; `required()` reports it during +decoding. Selecting an all-null record still distinguishes a present record +from an absent relationship using a separate marker. That marker avoids user +column names, including SQLite's case-insensitive identifier collisions. + +A proven unique match has at most one row. `take(0)` and `skip(1)` therefore +produce an absent joined relation. Ordering does not change that unique match. +To deliberately select one row from a collection, use explicit ordering and +`take(1).one(strategy: .batch)`. + +Each generated getter produces a fresh table occurrence. Use fresh getter calls +when selecting differently filtered views of the same relationship: + +```dart +final query = db.posts.select((p) => ( + p.author.where((a) => a.score.gt(10)).one(), + p.author.where((a) => a.score.lte(10)).one(), +).map((high, low) => (high: high, low: low))); +``` + +Reusing the identical edge with identical predicates deduplicates its JOIN. +Reusing one edge with conflicting predicates produces `RELATION.ALIAS`; use +fresh getters or select a batch strategy for the separate view. + +## Collections and composite keys + +`many()` uses batch loading and always returns a typed list. Empty relationships +are empty lists, and an empty root result performs no child queries. + +```dart +final users = await db.users.orderBy((u) => [u.id.asc()]).take(20) + .select((u) => (u.id, u.posts + .orderBy((p) => [p.createdAt.desc(), p.id.desc()]) + .take(3).select((p) => p.title).many()) + .map((id, titles) => (id: id, titles: titles))) + .get(); +``` + +There is one root statement plus statements per relation and parameter chunk, +not per parent. Duplicate parent keys share a lookup. A composite key with any +NULL component does not match through SQL equality and is excluded from child +lookup batches. Composite matches preserve tuple identity, so equal IDs in +different tenants do not mix. + +Chunk capacity includes all bound parameters: key width, filters, projections, +nested joins and per-parent window bounds. Large composite batches use row-value +IN rather than deep OR trees. SQLite uses a VALUES subquery; PostgreSQL uses a +row-constructor list so column types determine parameter types. See the +[SQLite row-value rules](https://www.sqlite.org/rowvalue.html) and +[PostgreSQL VALUES type rules](https://www.postgresql.org/docs/current/queries-values.html). + +Collection `take/skip` applies per parent using SQL window partitioning. It +requires explicit ordering and window-function capability. The ORM does not +fetch every child and discard excess rows in Dart. A collection's selected +to-one relationships can join into that same child statement. Collections below +a joined parent load in subsequent batches using the joined parent keys. + +`any/none/every/count` compile to correlated SQL and do not materialize related +objects. `every` is true for an empty collection; a predicate returning SQL +UNKNOWN counts as unsatisfied. Use `any` as well when an empty relationship +should be excluded. + +`compile()` exposes the root SQL, including selected to-one JOINs. It does not +pretend to contain the later collection statements. `inspect()` also describes +conditional child SQL templates, output/key slots, joins and batch capacity; +see [plans and observations](observability.md). `onQuery` reports every +executed statement and fetched row count. Streaming uses these same strategies +per root batch; see [execution](execution.md). + +Multiple statements can observe different snapshots under PostgreSQL Read +Committed. Use an explicit Repeatable Read transaction when root and collection +queries must share a snapshot. A JOIN-only selection is one statement. diff --git a/docs/sqlite-web.md b/docs/sqlite-web.md new file mode 100644 index 00000000..72d6cc4a --- /dev/null +++ b/docs/sqlite-web.md @@ -0,0 +1,216 @@ +# SQLite in browsers and Flutter Web + +`package:orm/sqlite.dart` selects the native isolate or browser worker internally. +Both execute through sqlite3's shared database/statement interfaces. Generated +models, selections, relationships, transactions, migrations and subscriptions use +one API. + +```dart +import 'package:orm/sqlite.dart'; +import 'schema.orm.dart'; + +final db = await sqlite(const SqliteOptions.persistent('app')); +// Apply the application's reviewed Dart migration history before queries. +final user = await db.users.create(email: 'seven@example.com'); +await db.close(); +``` + +Use `SqliteOptions.memory()` for an isolated in-memory database. For the same +persistent configuration on native platforms, provide `nativePath`: + +```dart +final db = await sqlite(SqliteOptions.persistent( + 'app', + nativePath: databaseFilePath, +)); +``` + +The application owns its native directory choice; `nativePath` is unused on Web. +`journal` and `busyTimeout` configure native connections. Browser persistence uses +DELETE journaling, FULL synchronous mode and exclusive OPFS ownership; it does not +apply native WAL/busy-wait settings. +`SqliteOptions.file(path)` and `readOnly(path)` remain native file operations. +Browser persistence uses the explicit name, never a guessed basename of a native +path. Unsupported storage fails before opening a worker. + +## Flutter Web + +The ORM package declares its worker and SQLite WASM as Web-only Flutter assets. +A Flutter consumer needs no worker source, manual downloads, asset declarations or +URL configuration. `flutter run -d chrome`, `flutter build web` and +`flutter build web --wasm` use the same database configuration. The default worker +is compiled once by the package maintainer. Both application compilation modes +use that JavaScript worker with the same SQLite WASM engine. + +See the runnable [Flutter example](../example/flutter/README.md). This packaging +uses Flutter's platform asset filters, available since Flutter 3.41. The ORM stays +a pure Dart package without an SDK Flutter dependency. + +Default resource resolution follows the document's HTML base, so a Flutter app +built with `--base-href /app/` also works when opened directly at `/app/notes/42`. +It does not resolve resource paths against the current route. + +## Plain Dart and custom deployment + +Copy the installed package's matching resources into your static files: + +```sh +dart run orm web-assets # default output: web/orm/ +dart run orm web-assets public/orm # custom output directory +``` + +Plain Dart defaults to `orm/` under the HTML base. The command verifies the +packaged hashes before copying and retains older deployment assets. It needs no +worker compilation or WASM download. Serve JavaScript with a JavaScript MIME type +and WASM with `application/wasm`. + +Advanced deployment can set `SqliteWebOptions` on `memory()` or `persistent()`: + +```dart +final db = await sqlite(SqliteOptions.persistent( + 'app', + web: SqliteWebOptions(assetBase: Uri.parse('static/database/')), +)); +``` + +`assetBase` is the directory of the versioned files and must end with `/`. +Individual `worker` and `wasm` URI overrides resolve against the HTML base. +`worker` must be same-origin HTTP(S). WASM may use an explicitly configured CDN +with appropriate CORS and CSP. Flutter's custom `assetBase` is not automatically +read from private engine configuration: keep database resources same-origin or +provide these explicit overrides. CDN/CSP deployment has not been certified. + +The client checks worker protocol/build identity before requesting database open. +The browser verifies the SQLite module's subresource integrity before it is +instantiated. A stale worker or wrong module fails explicitly. A custom compatible +SQLite build must supply its own SHA-256 `wasmIntegrity` value. Stock resources are +content-addressed; deploy them together with the app and retain resources needed +by older open pages. Offline caching remains the application's deployment policy. + +For maintainers, `dart run tool/build_sqlite_web.dart` rebuilds the stock worker +and pins the official sqlite3 3.6.0 module. `--check` verifies source and artifact +fingerprints without rebuilding. The custom-worker entry remains +`package:orm/sqlite_web_worker.dart`; its Dart `main()` calls +`runSqliteWebWorker()`. Applications using the stock worker do not need it. + +## Storage and connection ownership + +An OPFS database lives under the origin-private `dart-orm/` directory. The +underlying VFS persists `/database` and its rollback journal, and uses memory for +temporary files. The driver requests DELETE journaling and FULL synchronous mode, +and enables and verifies foreign keys before returning a connection. A memory +database requests MEMORY journaling. Requested modes are checked against SQLite. + +OPFS requires a supporting browser and a secure context (localhost works for +development). There is no implicit fallback to non-persistent memory or IndexedDB. +The underlying sync access handles exclusively own the database. Opening another +worker/tab for the same name fails while the first owns it. Await `db.close()` to +release it before reopening. A single driver queues whole leases, so separate +queries cannot enter someone else's transaction. This implementation does not +coordinate a shared database service across tabs. + +`openTimeout` defaults to 15 seconds. Failed startup terminates the failed worker; +normal close first closes statements, the database and OPFS handles, then terminates +the worker. Schema migrations run through the same owned connection. The browser +acceptance test reloads a page with a transaction still open, verifies recovery of +committed data and rollback of that transaction, then applies a versioned upgrade. + +OPFS data remains subject to the browser's quota, eviction and user-clearing rules. +The sqlite3 3.6.0 WASM API is explicitly marked experimental, including the future +stability of VFS storage formats. This adapter's Chromium acceptance is not a +cross-browser production certification. The implementation follows the package's +[dedicated-worker OPFS API](https://pub.dev/documentation/sqlite3/latest/wasm/SimpleOpfsFileSystem-class.html). + +## Numeric and temporal boundaries + +JavaScript represents int and double with the same underlying number type and +cannot distinguish every 64-bit integer. See [Dart number semantics](https://dart.dev/resources/language/number-representation). +The worker protocol rejects untagged integer parameters outside +`-9007199254740991..9007199254740991`. Use BigInt for wider integers. SQLite int64 +results beyond the safe range cross the worker boundary as exact BigInt values, +not rounded doubles. Generated int fields must remain in the safe range; decoding +an incompatible value fails. This bridge applies the same rule to JS and WASM +clients so their storage contract is consistent. + +`Codecs.real` preserves floating-point intent automatically on web builds, including +integral doubles such as `1e20`. For raw SQL or custom encoders, `SqlReal(1e20)` is +an explicit floating parameter; SQLite binds it with the native double API. +SqlReal is also accepted by native SQLite/PostgreSQL drivers. Raw PostgreSQL SQL +still needs suitable SQL casts/context to establish parameter/result types. +Finite floating values retain their value through versioned keyset cursor tokens, +including a unique tie breaker when several rows have the same floating value. + +Blobs use structured-clone typed arrays. BigInt uses tagged decimal text. Decimal +parameters and SQLite arithmetic use the same exact implementation and registered +functions as native SQLite. LocalDate, LocalTime and LocalDateTime remain explicit +calendar values. UTC DateTime encoding uses date/time components, and browser +decoding avoids a lossy total-microseconds JS number. Acceptance includes BC dates +and microseconds near DateTime's upper bound. Calling SDK arithmetic that loses +precision before passing a value to the ORM cannot be repaired by the driver. + +## Capabilities and observable cost + +Queries run in the worker, leaving the browser event loop responsive. Streaming +uses a real prepared cursor and fetches bounded batches through worker messages. +Closing or cancelling a stream releases its statement and lease. Ordinary get() +still materializes its complete result; use streaming/pagination for large reads. +Messages incur structured-clone and decoding costs; no zero-copy or throughput +claim is made. + +Statement interruption is unavailable with this synchronous WASM API, so +`capabilities.cancellation` is false. Statement deadlines/cancellation and +transaction deadline/retry options that require interruption fail before execution. +This does not prevent ordinary transactions, savepoints, acquisition limits or +early cursor release. Do not treat worker termination as proof of a SQL rollback +or as a replacement for confirmed commit outcomes. + +`watch()` merges known writes after commit and emits fresh snapshots. It does not +observe another origin, tab or independent database connection. Named SQL queries +retain their explicit `reads:` requirement. Same-origin persistence, browser +storage permission and database ownership are separate from reactive invalidation. + +## Reproducible acceptance + +```sh +dart run tool/test_browser.dart +dart run tool/test_browser.dart --wasm +dart run tool/test_flutter_web.dart /absolute/path/to/flutter +``` + +The runner verifies packaged resource fingerprints, copies the matching assets, +checks generated fixture freshness, compiles the client, starts a local asset +server, and launches Chrome +with a disposable profile. Set `CHROME_EXECUTABLE` to override its path. It writes +`report.js.json` or `report.wasm.json` under `.dart_tool/browser/` and fails if the +browser reports any failed check. Reports contain the actual browser, Dart SDK, +compilation mode and engine checksum. The captured results are in +`research/validation/browser.json`. + +The subsequent [API audit capture](../research/validation/api-audit.md) passes +20 checks in each compilation mode, adding mixed-session query rejection, +independent optional-join guards and rejected-write state checks. Its logs are +separate from the original browser capture. + +Run these separately from the native full suite: concurrent Dart CLI invocations +can race while rewriting/codesigning the shared macOS native-asset cache. The +acceptance checks cover memory migration/catalog checks, FK and CHECK enforcement, +atomic constraint migration rollback, client defaults with explicit null and +prepared batch values, stored/virtual computed fields and expression migrations, +many-to-many payloads and pagination with measured query/row counts, generated +relations, transactions/savepoints/lifetime, cursors, subscriptions, numeric and +calendar codecs, unsupported interruption, event-loop responsiveness, startup +failures, exclusive OPFS ownership, reopening, page reload recovery and upgrades. +The plain Dart runs do not establish Flutter embedding. Neither runner measures +throughput or establishes Safari, Firefox, IndexedDB or multi-tab service support. + +The unified-entry acceptance adds actual Flutter release builds in JS and WASM, +with and without cross-origin isolation headers. The app consumes the package's +unchanged asset declarations and default URI resolution from a nested route. +It also checks stale worker rejection and WASM integrity failure. See +[the refactor validation](../research/validation/sqlite-refactor.md). + +Hot reload retains application state. Flutter Web hot restart, which can abandon +live JS resources without closing them, is not certified by these release tests; +close the database or refresh the page when restarting a live OPFS session. +Multi-tab sharing, automatic storage fallback and cross-tab watch propagation +remain outside this driver. A second owner fails rather than bypassing OPFS locks. diff --git a/docs/types.md b/docs/types.md new file mode 100644 index 00000000..e07966ff --- /dev/null +++ b/docs/types.md @@ -0,0 +1,462 @@ +# Domain types and storage + +Record fields keep their Dart types throughout generated creation, patches, +predicates, projections, relationships and cursor values. A codec describes the +storage boundary: `encode` produces a driver value, and `decode` validates and +constructs the application value. + +## Custom IDs and values + +Declare a public constant codec beside its type: + +```dart +import 'package:orm/schema.dart'; + +extension type const UserId(int value) { + static const codec = Codec.integer(_decode, _encode); + static UserId _decode(Object? value) => UserId(value as int); + static int _encode(UserId value) => value.value; +} + +typedef User = ({ + @Id.generated() @UseCodec(UserId.codec) UserId id, + String name, +}); +final users = entity(); +``` + +After generation, `db.users.byId(UserId(1))` accepts the domain ID; +`db.users.byId(1)` is a static error. Use the same domain ID codec on foreign-key +fields. Required integer primary keys can retain database-generated identities. + +`Codec.text(decode, encode)` and `Codec.integer(decode, encode)` constrain +the encoder's return type. The general `Codec(storage, decode, encode)` accepts +the storage tags in the table below. It is the application's responsibility to +validate the representation and keep encoding deterministic. + +`@UseCodec` accepts a **public const variable or public static const field**. +The generator checks the reference's resolved Dart type and reads the constant +storage tag. It never calls application encoders, decoders or model constructors. +Private references and inline codec constructors are rejected. A nullable field +can use a non-nullable codec; the generated client adds `.nullable()` so SQL +`NULL` bypasses the domain decoder. A nullable codec cannot back a required field. + +Imported types, extension types, public record aliases and nested generic types +retain qualified names in generated code. Two different libraries may both +declare `Email`. Output can be generated into a different directory. + +See the complete [domain fixture](../test/support/codecs/schema.dart) and its +[types](../test/support/codecs/types.dart). `Codec.map` remains available when +constructing table definitions directly at runtime. + +## Enums + +```dart +enum Membership { + @EnumValue('pending-payment') pending, + active, + @EnumValue('closed') cancelled, +} + +typedef Account = ({ + @Id() int id, + Membership membership, + Membership? previousMembership, +}); +``` + +Enums use text labels. Without `@EnumValue`, the label is the constant's Dart +name. Explicit labels allow a Dart rename without changing stored data. Ordinals +are never stored. Duplicate labels fail generation; an unknown stored label fails +decoding with `CODEC.ENUM`. The runtime equivalent is `Codecs.enumeration` with +an explicit enum-to-label map. + +This is portable text storage, with no native enum or automatically generated +`CHECK` constraint. A database writer can therefore insert an unknown label; +the decoder detects it when read. Adding or renaming stored labels requires a +reviewed data migration and compatibility with any still-running application +versions. + +## Structured JSON + +Use a codec for a structured value, including a record: + +```dart +import 'dart:convert'; +import 'package:orm/schema.dart'; + +typedef Location = ({String city, int zone}); +const locationCodec = Codec('json', decodeLocation, encodeLocation); + +Location decodeLocation(Object? raw) { + final json = Codecs.json.decode(raw) as Map; + return (city: json['city'] as String, zone: json['zone'] as int); +} +String encodeLocation(Location value) => + jsonEncode({'city': value.city, 'zone': value.zone}); + +typedef Place = ({ + @Id() int id, + @UseCodec(locationCodec) Location? location, +}); +``` + +Use `Codecs.json.decode(raw)` inside a custom JSON decoder. SQLite supplies JSON +text; PostgreSQL supplies parsed data wrapped in `SqlJson`. This bridge handles +JSON strings without decoding them twice. A JSON document containing `null` is +passed to the domain decoder; it does not bypass validation as SQL `NULL` does. + +For an arbitrary document with explicit presence: + +```dart +typedef Event = ({ + @Id() int id, + @UseCodec(Codecs.jsonDocument) SqlJson? payload, +}); + +// In generated create/patch arguments: +// null -> SQL NULL +// SqlJson(null) -> JSON null +// SqlJson('text') -> JSON string +// SqlJson({'a': 1}) -> JSON object +``` + +Read the document through `.value`. `field.isNull()` tests SQL nullness. For +ordinary JSON values, `Codecs.json` returns the parsed Dart value, including +`null`; that value alone cannot distinguish JSON null from SQL null. `jsonEncode` +determines which payloads are serializable. Use a domain codec to validate their +shape and reconstruct application types. + +Raw PostgreSQL results also use `SqlJson` for non-SQL-null `json`/`jsonb` columns. +Decode these with `Codecs.json` when consuming portable raw results. JSON values +as relational keys have not been validated and are outside the current verified +key support; use scalar IDs for relationships. + +## Physical storage and migrations + +| Storage tag | Built-in Dart value | SQLite | PostgreSQL | +| --- | --- | --- | --- | +| `integer` | `int` | `INTEGER` | `BIGINT` | +| `bigint` | `BigInt` | `TEXT` | `NUMERIC` | +| `decimal` | `Decimal` | collated `TEXT` | `NUMERIC` | +| `real` | `double` | `REAL` | `DOUBLE PRECISION` | +| `text` | `String`, enums | `TEXT` | `TEXT` | +| `boolean` | `bool` | `INTEGER` | `BOOLEAN` | +| `instant` | UTC `DateTime` | collated UTC text | `TIMESTAMPTZ` | +| `date` | `LocalDate` | collated `TEXT` | `DATE` | +| `time` | `LocalTime` | collated `TEXT` | `TIME WITHOUT TIME ZONE` | +| `local_datetime` | `LocalDateTime` | collated `TEXT` | `TIMESTAMP WITHOUT TIME ZONE` | +| `blob` | `Uint8List` | `BLOB` | `BYTEA` | +| `json` | JSON value or custom codec | JSON text | `JSONB` | + +Snapshots record physical storage, nullability, defaults and constraints. They +do not embed application functions. Changing a codec's validation or enum labels +without changing storage does not create a DDL diff; write an explicit data +migration when existing stored values need conversion. Changing storage uses the +normal reviewed migration diff/conversion workflow. + +Storage semantics still belong to each database. In particular, SQLite `bigint` +text retains exact digits but does not provide numeric text ordering/arithmetic. +Native enum types remain a backend extension. Browser numeric limits and tested +transport are described in [SQLite web](sqlite-web.md). Use `Decimal` for exact +decimal data. + +## UTC instants + +Declare an instant as `DateTime`. Generated writes accept UTC or local DateTime +objects and preserve their actual instant; reads return UTC. `Codecs.dateTime` +is a constant codec with the `instant` storage tag. Its text representation has +six fractional digits, a `+00` offset and an explicit BC suffix when applicable. + +SQLite uses `TEXT COLLATE orm_instant_v1`; comparisons operate on instants rather +than lexicographic text. This preserves microsecond order, equivalent offsets, +BC and extended years, uniqueness, relationship keys, window aggregates and +stable cursors. The same collation backs column indexes. PostgreSQL uses native +TIMESTAMPTZ and binary decoding independent of session DateStyle and timezone. + +Timestamp text may have a numeric UTC offset or `Z`. Zone-less database text is +defined as UTC, including SQLite's CURRENT_TIMESTAMP default; it is never parsed +using the process timezone. Invalid dates, leap seconds, excessive fractional +precision and invalid offsets fail decoding. IANA timezone names are not parsed +by this codec. Applications must explicitly resolve civil times and timezone rules +before constructing the DateTime they store. SQLite's default itself has second +resolution; precise application values retain their microseconds. + +Supported instants run from 4714-11-24 00:00:00 BC through 275760-09-13 00:00:00 +UTC. The upper bound follows [Dart DateTime's range](https://api.dart.dev/dart-core/DateTime-class.html), +and the lower bound follows PostgreSQL. The PostgreSQL decoder checks bounds +before adding epoch offsets, so larger native values cannot wrap through int64. +Raw finite values beyond DateTime's range and infinities remain text; typed +instant decoding rejects them. These range and precision guarantees are verified +on native Dart, not JavaScript or browser storage. + +Native drivers advertise `Capabilities.temporal`, covering the local types below +and UTC instants. Borrowed PostgreSQL pools must use `postgresTypeRegistry()` and +`PostgresDriver.borrow(pool, temporal: true)`. Without that capability, typed +temporal queries fail before execution. This replaces the earlier preview's +`localTemporal` flag; no compatibility alias is provided. + +### Upgrading historical timestamp storage + +Previously generated `timestamp` snapshots retain their original storage meaning: +SQLite TEXT with BINARY ordering, or PostgreSQL TIMESTAMPTZ. Their serialized +snapshots, SQL and migration checksums remain unchanged. New generation emits +`instant`, which produces a visible, reviewed type change. Do not recreate an +applied migration from the current application schema. + +```dart +final upgrade = Migration.diff( + '0002_instants', + dialect: .sqlite, + from: previous.snapshot!, + to: SchemaSnapshot(currentSchema), + previous: previous.checksum, + using: { + 'events': {'created_at': 'orm_instant_v1(created_at)'}, + }, +); +``` + +For a PostgreSQL history use `dialect: .postgres` and the reviewed conversion +`using: {'events': {'created_at': 'created_at'}}`. Only that history's expression is needed. + +The SQLite converter parses and canonicalizes each value with UTC semantics; +the rebuild installs the new collation. Invalid timestamps or newly equivalent +unique keys abort the migration and roll back its changes. Audit zone-less legacy +text before conversion: if an external writer stored local civil time there, +provide a reviewed conversion using its actual timezone. PostgreSQL already stores +instants, so the shown conversion preserves values; existing infinities or values +beyond DateTime's range still require separate data review. Old cursor tokens +carry the old storage tag and cannot be reused under the new ordering contract. + +The [captured legacy migration](../test/support/instants/m0001_legacy.dart) is tested +against its checksum from commit `00853b4`, including upgrade and rollback on real +databases. Plain unmanaged SQLite TEXT still imports as String; the managed +instant collation lets the importer infer DateTime without sampling data. + +## Local calendar values + +```dart +typedef Appointment = ({ + @Id.generated() int id, + LocalDate day, + @Default.sql("'12:30'") LocalTime time, + LocalDateTime? starts, +}); +final appointments = entity(); + +// After generation: +await db.appointments.create( + day: LocalDate(2024, 2, 29), + time: Change.set(LocalTime(12, 30)), + starts: LocalDateTime.parse('2024-02-29 12:30:00.000001'), +); +``` + +These values have no timezone or implied UTC instant. `DateTime`, strings and +other local temporal types cannot be substituted in generated predicates or +writes. `LocalDateTime` combines a `LocalDate` and `LocalTime`. Its `add(Duration)` +uses calendar days of exactly 24 hours; it does not perform timezone or daylight +saving conversion. `LocalDate.addDays` and `daysUntil` operate on calendar dates. + +| Value | Supported finite range | +| --- | --- | +| LocalDate | 4714-11-24 BC through 5874897-12-31 | +| LocalTime | 00:00:00 through 24:00:00, at microsecond resolution | +| LocalDateTime | 4714-11-24 00:00:00 BC through 294276-12-31 23:59:59.999999 | + +Constructors use astronomical year numbers: year 0 is 1 BC, and year -1 is 2 BC. +Text parsing accepts those year numbers or PostgreSQL's trailing ` BC` notation. +Serialization uses era notation; times always include six fractional digits. +Invalid calendar dates, offsets, leap seconds and excess fractional precision are +rejected. `24:00` is a distinct time-of-day endpoint; combining it with a date +normalizes to the next date at `00:00`. Addition beyond the finite range fails. +The exact endpoints come from PostgreSQL's [timestamp constants](https://github.com/postgres/postgres/blob/REL_18_STABLE/src/include/datatype/timestamp.h). +The Gregorian ordinal conversion uses March-based 400-year cycles, as described +in [civil date algorithms](https://howardhinnant.github.io/date_algorithms.html). + +`Codecs.date`, `Codecs.time` and `Codecs.localDateTime` accept their corresponding +value type or valid text, never a `DateTime`. An owned PostgreSQL pool uses native +binary decoders, so it does not truncate these ranges to Dart's `DateTime` range. +Binary reads remain independent of session DateStyle and timezone. Raw date and +timestamp infinity values remain strings; typed calendar decoding rejects them. +For a borrowed PostgreSQL pool, configure it before opening connections: + +```dart +import 'package:orm/postgres.dart'; +import 'package:postgres/postgres.dart' as pg; + +final pool = pg.Pool.withEndpoints(endpoints, + settings: pg.PoolSettings(typeRegistry: postgresTypeRegistry()), +); +final db = Database(PostgresDriver.borrow(pool, temporal: true)); +``` + +The default borrowed-pool capability is false; temporal queries fail before +execution until explicitly enabled. The registry's text fallback accepts ISO +DateStyle only. The capability covers these three scalar types and UTC instants; arrays, ranges, +intervals, time with timezone and timezone conversions are still outside it. + +SQLite stores text with `orm_date_v1`, `orm_time_v1` or `orm_local_datetime_v1` +collations. They compare parsed calendar values, including equivalent text forms, +BC dates and extended years. Predicates, min/max, grouping, unique keys, foreign +keys, relations and keyset cursors preserve that ordering, and column indexes +use the same collation. External SQLite connections need matching registrations +to use those tables and indexes. No calendar-validation CHECK is emitted: +invalid external text sorts after valid values and fails typed decoding. + +Snapshots preserve distinct storage tags. Catalog import recognizes native +PostgreSQL scalar types and the three managed SQLite collations. Plain SQLite +TEXT does not imply a calendar type. Changing text to a calendar type requires a +reviewed migration; newly equivalent unique keys can make conversion fail and +roll back. Historical backfills persist canonical text keys for resumable paging. + +SQL calendar arithmetic and timezone-rule conversion have no typed API in this +version; they are separate [extension boundaries](capabilities.md#values-and-physical-storage). +Use local types when the domain value actually has no timezone; use DateTime for +resolved instants. + +## Temporal precision + +```dart +typedef Event = ({ + @Id.generated() int id, + @TemporalPrecision(3) LocalTime clock, + @TemporalPrecision(3) LocalDateTime appointment, + @TemporalPrecision(0) DateTime occurredAt, +}); +final events = entity(); +``` + +`@TemporalPrecision` accepts 0 through 6 fractional second digits on `LocalTime`, +`LocalDateTime` and `DateTime`, including nullable fields and domain codecs with +the corresponding storage tag. Manual `Column` declarations use +`temporalPrecision: 3`. Date-only and unrelated storage types are rejected. +Omitting precision retains microseconds. Explicit six and the default have the +same canonical snapshot and DDL, preserving existing migration checksums. + +| Dart value | PostgreSQL column | SQLite column | +| --- | --- | --- | +| `LocalTime` | `TIME(p) WITHOUT TIME ZONE` | collated TEXT with precision CHECK | +| `LocalDateTime` | `TIMESTAMP(p) WITHOUT TIME ZONE` | collated TEXT with precision CHECK | +| `DateTime` | `TIMESTAMPTZ(p)` | UTC text with precision CHECK | + +The precision range follows PostgreSQL's [date/time type contract](https://www.postgresql.org/docs/18/datatype-datetime.html). +ORM inserts, updates, batches and upserts coerce assigned expressions in SQL. +SQLite defaults, computed expressions and migration copies use the same registered +function. Raw PostgreSQL writes undergo native column coercion; raw SQLite writes +with excess nonzero digits fail the CHECK instead of silently changing the input. +External SQLite connections need these ORM functions and collations registered. +Default/six-digit columns retain their previous validation behavior. + +Rounding matches PostgreSQL's [timestamp implementation](https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/utils/adt/timestamp.c) +and [time implementation](https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/utils/adt/date.c): +nearest representable value, with exact halves away from midnight for `LocalTime` +and away from 2000-01-01 for timestamps. Instant rounding uses the UTC timeline. +For example, at three digits, `2000-01-01 00:00:00.0005` becomes `.001`, while +`1999-12-31 23:59:59.9995` becomes `.999`. A time can reach `24:00`; a timestamp +can move into the next day. Values outside the supported finite range are rejected. + +Value methods and SQL expressions expose the same explicit operation: + +```dart +final rounded = LocalTime.parse('12:00:00.1235').withPrecision(3); +final selected = await db.events + .select((e) => e.appointment.withPrecision(0)) + .get(); +``` + +`withPrecision` also applies to local timestamps and resolved DateTime instants; +the DateTime operation returns UTC. +SQL results preserve nullable types. The operation composes with projections, +CTEs, grouping and streams. Column precision does not change the value codec or +implicitly round predicate parameters. Use the returned stored key, or explicitly +round a lookup value, when precision affects a primary/foreign key. + +Catalog inspection, CLI inspection and import retain precision. Recognized SQLite +precision checks and coercion wrappers are interpreted as column metadata; other +checks remain visible. Import drafts defaults and computed SQL without adding a +second SQLite wrapper. Changing precision is a type change and requires reviewed +conversion expressions for the selected database. Rounding can merge unique keys; such a +failure rolls back the data, schema and migration history together. Renames retain +the precision metadata. + +`temporal_precision_test` covers native SQLite/PostgreSQL values, generated writes, +all seven precisions around the PostgreSQL epoch, defaults/computed values, +relations, batch/upsert rollback, import, catalog drift and reviewed migration +rollback. The browser precision scenario additionally checks BC dates, extended +timestamps and the maximum DateTime boundary through the worker. These checks do +not establish native Flutter behavior or add timezone conversion support. + +## Signed integer column widths + +```dart +typedef Counter = ({ + @Id.generated() @IntegerBits(32) int id, + @IntegerBits(16) int small, + @IntegerBits(32) int? optional, + int total, +}); +final counters = entity(); +``` + +`@IntegerBits` accepts 16, 32 or 64 on fields with integer storage, including +integer-backed domain codecs. Omitting it means 64. For manual tables, pass +`integerBits: 16` to `Column`. Explicit 64 and the default have the same serialized +schema and produce no migration difference. + +Width belongs to the column metadata. It does not replace the `int` value codec +or constrain an aggregate to its source column's range. PostgreSQL SUM over +SMALLINT/INTEGER produces BIGINT; the generated API decodes wider results normally. +Other arithmetic and overflow follow the database's native rules. See PostgreSQL's +[numeric types](https://www.postgresql.org/docs/current/datatype-numeric.html) and +[aggregate return types](https://www.postgresql.org/docs/current/functions-aggregate.html). + +| Width | PostgreSQL | SQLite column constraint | +|---|---|---| +| 16 | SMALLINT | integer storage value between -32768 and 32767 | +| 32 | INTEGER | integer storage value between -2147483648 and 2147483647 | +| 64/default | BIGINT | normal INTEGER storage | + +SQLite still uses its native variable-sized integer representation, not a forced +two- or four-byte layout. For 16/32, DDL adds a CHECK which permits NULL when the +column is nullable and otherwise requires an integer within the signed range. +Default 64 retains ordinary SQLite INTEGER affinity. ORM writes use Dart `int`; +SQLite's affinity and raw SQL rules are documented in +[its datatype reference](https://www.sqlite.org/datatype3.html). + +Catalog inspection recognizes the emitted SQLite range checks while ignoring +quoted defaults and comments. It does not claim to prove equivalence of arbitrary +handwritten CHECK expressions; those remain unmanaged. Column checks and full +schema verification both compare the inferred width. Catalog import emits +`@IntegerBits` for PostgreSQL SMALLINT/INTEGER and recognized SQLite range checks. + +Width changes are type changes in migration history. Supply reviewed conversion +expressions for the selected database, even for widening. PostgreSQL alters the native +type; SQLite rebuilds the table with the new constraint. Data outside a narrowed +range fails and rolls back the migration/history together. Physical column renames +retain the width. Changing an identity column's width preserves generation and, on PostgreSQL, +the associated sequence type. Backfills use their saved width metadata when +verifying the historical schema. + +Native JIT and AOT checks include integer values beyond JavaScript's exact-number +range. Browser int values use the safe-number range; verified wider transport +uses BigInt. See [SQLite web](sqlite-web.md) for the boundaries and browser checks. + +## Verification + +The generated domain fixture runs against native SQLite and PostgreSQL. Checks +cover writes, parameters, patches, invalid stored values, JSON scalar/null +semantics, joined/batched relations, batch/upsert, cursor transport, streaming and +catalog verification. Generator tests reject mismatched codecs and preserve +nullable aliases. Negative compilation tests use the actual generated APIs. +`test/support/codecs/native.dart` also compiles and runs as a native macOS AOT +program. These checks do not establish browser support. + +## Exact decimals + +Use `Decimal` for finite base-ten values, including money. Generated fields use +`Codecs.decimal` without an annotation. See [exact decimals](decimals.md) for +construction, arithmetic, numeric keys, SQLite storage requirements and current +limits. `BigInt` storage does not provide these fractional or SQLite numeric +ordering semantics. diff --git a/docs/watch.md b/docs/watch.md new file mode 100644 index 00000000..1c379de5 --- /dev/null +++ b/docs/watch.md @@ -0,0 +1,151 @@ +# Query subscriptions + +`watch()` emits an initial query result, then re-runs the query after relevant +committed writes: + +```dart +final subscription = db.users + .orderBy((u) => [u.id.asc()]) + .select((u) => u.email) + .watch() + .listen( + (emails) => print(emails), // List + onError: (Object error) => print(error), + ); + +await db.users.create(email: 'seven@example.com'); +await subscription.cancel(); +``` + +Each stream has one subscriber and starts work only when listened to. Create a +separate `watch()` for another subscriber. Each subscription executes its own +query; there is no global result cache, object identity map or polling timer. +Each refresh uses normal `query.get()` semantics, including relationship batches. +Bound snapshot size with filters and `take`; use `query.stream()` for incremental +row consumption through a database cursor. + +## Commit behavior + +Typed insert, update, delete, upsert and batch operations record affected tables. +Zero-row writes and `ON CONFLICT DO NOTHING` with no inserted rows do not refresh +queries. Raw operations can explicitly declare effects as described below. + +```dart +await db.transaction((tx) async { + await tx.users.create(email: 'a@example.com'); + await tx.users.create(email: 'b@example.com'); +}); // One combined invalidation after COMMIT succeeds. +``` + +A rolled-back transaction produces no invalidation. Released savepoints merge +their changes into the enclosing transaction; rolled-back savepoints discard +their changes. A batch's chunks follow the same transaction boundary. SQL and +constraint failures do not publish successful-write notifications. + +If COMMIT's acknowledgement is lost, its outcome may be unknown. The transaction +still throws `TRANSACTION.COMMIT`, and subscriptions conservatively re-read its +affected tables. A refreshed result is not evidence that retrying the mutation is +safe. The PostgreSQL test executes a real commit before injecting this fault. + +Subscriptions observe invalidation and current data, not an audit log of every +intermediate state. Several commits may coalesce while a read is running or a +subscription is paused. Results are not compared for equality automatically; +an affected-table write may emit an unchanged projection. Consumers can apply +`distinct` with their own equality function if required. + +## Which queries refresh + +The compiler tracks physical sources used by root queries, explicit and automatic +JOINs, subqueries, CTEs, relation predicates and nested batch-loaded relations. +An unrelated table write does not execute another read. + +Generated clients register their schema metadata. Declared foreign-key delete +effects then propagate through `CASCADE` chains; `SET NULL` invalidates the child +without treating that child as deleted. Ordinary updates do not trigger a +delete cascade. For hand-authored tables, register the full relevant schema: + +```dart +db.registerSchema([usersSchema, postsSchema]); +``` + +Read dependencies are captured before the first read starts. An invalidation +arriving during a read marks that result stale; the subscription re-reads before +emitting it. Only one read runs per subscription at a time. Normal query and +relation loading semantics still apply; subscriptions do not add a stronger +cross-statement transaction isolation guarantee. + +## Raw SQL, triggers and external writers + +Table names hidden inside raw SQL cannot be discovered from a typed expression. +Add those dependencies explicitly: + +```dart +final postCount = sql( + ['(SELECT COUNT(*) FROM posts)'], [], Codecs.integer, +); +final counts = db.users.select((_) => postCount).watch(reads: [postsSchema]); +``` + +Raw commands can declare tables they affect: + +```dart +await db.execute( + SqlCommand('DELETE FROM posts'), + changedTables: [postsSchema], +); +``` + +`changedTables` requests an invalidation after a successful command even when its +reported row count is zero. It follows the current transaction/savepoint boundary +and conservatively includes declared delete effects. It is explicit metadata; +the ORM does not parse arbitrary SQL to infer writes. + +For triggers, procedures or already-committed external work, notify explicitly: + +```dart +db.invalidate([postsSchema]); +``` + +Inside a transaction, `tx.invalidate(...)` waits for that transaction's commit. +Supply all tables affected by unmodeled triggers or procedures. Independent +SQLite connections, PostgreSQL pools and other processes do not automatically +notify each other. Integrate an external notification, polling or CDC mechanism +and call `invalidate` after observing the external commit. Separate database +views using the **same driver instance** share notifications. + +## Pause, cancellation and errors + +- Pausing a subscription prevents additional reads. Writes set one dirty flag; + resuming performs a fresh read. A read already in progress may finish, but its + result is discarded if the subscriber is paused, avoiding a growing result queue. +- Cancelling the subscription stops future reads and interrupts active SQL when + the driver supports cancellation. Await cancellation to wait for the active read + and its connection cleanup. A driver without cancellation must finish its read. +- `ExecutionOptions.timeout` limits each SQL statement, including relation + batches. It is not a lifetime limit for the subscription. +- An optional cancellation token cancels the subscription with + `OPERATION.CANCELLED` and closes it. Explicit subscription cancellation is silent. +- Query/decoding failures emit errors. A later invalidation can retry; errors do + not create a polling or automatic retry loop. Invalid subscription/query + definitions detected before the first read are terminal. +- `Database.close()` stops its driver's subscriptions and waits for active reads. + A paused listener cannot prevent database shutdown; it observes stream completion + when resumed or cancelled. + +Watch a root database query. Transaction and leased-session queries reject +subscription with `WATCH.SESSION`: those views expire when their callback returns. +Also avoid awaiting an outside subscription from code holding the only available +connection; its next read must wait for that lease to be released. + +## Verification + +The suite covers both native SQLite and PostgreSQL, including real independent +SQLite workers and PostgreSQL connections, joined/CTE/relation dependencies, +savepoint rollback, multi-level cascades, pause, cancellation and database close. +Controlled completed-read and commit-acknowledgement faults exercise race handling +against real database state. Negative compilation checks verify subscription +result types. The native execution example also verifies subscriptions in macOS +AOT. Real Chrome JavaScript and Dart WASM runs verify worker subscriptions; +the [native Flutter application](flutter.md) also verifies initial delivery, +committed relation writes, rollback without notification and typed patch refresh +inside an Android AOT release application. diff --git a/example/flutter/.gitignore b/example/flutter/.gitignore new file mode 100644 index 00000000..79f7ecab --- /dev/null +++ b/example/flutter/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/example/flutter/.metadata b/example/flutter/.metadata new file mode 100644 index 00000000..c143d627 --- /dev/null +++ b/example/flutter/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "9584c6713b324636289d067944a46fd6b49df14b" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 9584c6713b324636289d067944a46fd6b49df14b + base_revision: 9584c6713b324636289d067944a46fd6b49df14b + - platform: android + create_revision: 9584c6713b324636289d067944a46fd6b49df14b + base_revision: 9584c6713b324636289d067944a46fd6b49df14b + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/flutter/README.md b/example/flutter/README.md new file mode 100644 index 00000000..ede746bf --- /dev/null +++ b/example/flutter/README.md @@ -0,0 +1,80 @@ +# Flutter acceptance + +For Web, use the same package and SQLite entry point: + +```sh +cd example/flutter +flutter pub get +flutter run -d chrome +flutter build web --wasm +``` + +No worker compilation, WASM download or application asset declaration is needed. +The Web example applies the fixed Dart migration history, persists a note and its +comment in one transaction, loads typed relationships and checks that animation +advances during worker SQL. Refreshing the page retains its OPFS database. +The platform-specific acceptance files select different checks and host reporting; +they do not implement database drivers. + +The automated release runner is `dart run tool/test_flutter_web.dart /path/to/flutter` +from the repository root. For native directory choices and current platform limits, +see [SQLite setup](../../docs/sqlite-web.md). + +## Android acceptance + +This Android application exercises the public ORM APIs in an installed Flutter +app. The native bridge only provides the application's files directory and launch +phase; SQLite executes through `package:orm/sqlite.dart` in its worker isolate. + +The legacy schema stores two notes. The current schema preserves the physical +`body` column while exposing `text`, adds a defaulted `done` field and a related +comments table. Both migrations are fixed Dart libraries with recorded fingerprints, imported +through a static registry and compiled into the APK. No migration assets are loaded. The application +checks generated CRUD, transaction rollback, relation projections, watch delivery, +cancellation, read-only reopening and process persistence. + +Use a dedicated arm64 Android emulator. The runner rejects physical-device serials +and an already-installed `dev.orm.orm_flutter`; it never clears application data. +To repeat, explicitly uninstall that test app on your dedicated emulator or use a +fresh emulator. No production database or physical device is involved. + +From the repository root, after choosing an installed Flutter SDK: + +```sh +cd example/flutter +flutter pub get +flutter build apk --debug --target-platform android-arm64 \ + --dart-define=ORM_SCHEMA_VERSION=1 +mkdir -p ../../.dart_tool/flutter +cp build/app/outputs/flutter-apk/app-debug.apk ../../.dart_tool/flutter/legacy.apk +flutter build apk --release --target-platform android-arm64 --build-number=2 \ + --dart-define=ORM_SCHEMA_VERSION=2 +cp build/app/outputs/flutter-apk/app-release.apk ../../.dart_tool/flutter/current.apk +cd ../.. +dart run tool/test_flutter.dart /path/to/android/sdk/platform-tools/adb \ + emulator-5580 .dart_tool/flutter/legacy.apk .dart_tool/flutter/current.apk +``` + +The example uses Flutter's debug signing key for both APKs so `adb install -r` +can preserve the database. These APKs are test artifacts, not store releases. +The runner force-stops between phases, verifies distinct process IDs and matching +database paths, compares rows and migration checksums after restart, and saves a +machine-readable report at `research/validation/flutter.json`. Logs, individual +reports and APK copies stay in `.dart_tool/flutter/`; the final screenshot is +saved as `research/validation/flutter-android.png`. + +The application prints chunked base64 JSON so the host can capture release reports +without `run-as`. SQL work runs while a Flutter animation and Dart timer advance; +their progress verifies worker separation, not a frame-rate performance target. +The acceptance scope is the tested Android emulator. iOS, macOS Flutter, physical +devices and power-loss recovery require their own runs. + +To edit the schema, run the generator from the repository root after `pub get`: + +```sh +dart run bin/orm.dart generate example/flutter/lib/legacy/schema.dart +dart run bin/orm.dart generate example/flutter/lib/schema.dart +``` + +Do not regenerate applied migration history. Add a reviewed migration for future +schema changes. diff --git a/example/flutter/analysis_options.yaml b/example/flutter/analysis_options.yaml new file mode 100644 index 00000000..5620c6d0 --- /dev/null +++ b/example/flutter/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - web/** diff --git a/example/flutter/android/.gitignore b/example/flutter/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/example/flutter/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/flutter/android/app/build.gradle.kts b/example/flutter/android/app/build.gradle.kts new file mode 100644 index 00000000..4d903648 --- /dev/null +++ b/example/flutter/android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "dev.orm.orm_flutter" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "dev.orm.orm_flutter" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/example/flutter/android/app/src/debug/AndroidManifest.xml b/example/flutter/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/example/flutter/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/flutter/android/app/src/main/AndroidManifest.xml b/example/flutter/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1494326a --- /dev/null +++ b/example/flutter/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/example/flutter/android/app/src/main/kotlin/dev/orm/orm_flutter/MainActivity.kt b/example/flutter/android/app/src/main/kotlin/dev/orm/orm_flutter/MainActivity.kt new file mode 100644 index 00000000..d26197d5 --- /dev/null +++ b/example/flutter/android/app/src/main/kotlin/dev/orm/orm_flutter/MainActivity.kt @@ -0,0 +1,24 @@ +package dev.orm.orm_flutter + +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "orm_acceptance/config") + .setMethodCallHandler { call, result -> + if (call.method == "read") { + result.success(mapOf( + "filesPath" to filesDir.absolutePath, + "phase" to (intent.getStringExtra("orm_phase") ?: "upgrade"), + "api" to android.os.Build.VERSION.SDK_INT, + "pid" to android.os.Process.myPid() + )) + } else { + result.notImplemented() + } + } + } +} diff --git a/example/flutter/android/app/src/main/res/drawable-v21/launch_background.xml b/example/flutter/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/example/flutter/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/flutter/android/app/src/main/res/drawable/launch_background.xml b/example/flutter/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/example/flutter/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/example/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/example/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/example/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/example/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/example/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/flutter/android/app/src/main/res/values-night/styles.xml b/example/flutter/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/example/flutter/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/flutter/android/app/src/main/res/values/styles.xml b/example/flutter/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/example/flutter/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/flutter/android/app/src/profile/AndroidManifest.xml b/example/flutter/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/example/flutter/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/flutter/android/build.gradle.kts b/example/flutter/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/example/flutter/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/flutter/android/gradle.properties b/example/flutter/android/gradle.properties new file mode 100644 index 00000000..e96108cf --- /dev/null +++ b/example/flutter/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/example/flutter/android/gradle/wrapper/gradle-wrapper.properties b/example/flutter/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..a20f2c46 --- /dev/null +++ b/example/flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/example/flutter/android/settings.gradle.kts b/example/flutter/android/settings.gradle.kts new file mode 100644 index 00000000..b28021a9 --- /dev/null +++ b/example/flutter/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/example/flutter/lib/acceptance.dart b/example/flutter/lib/acceptance.dart new file mode 100644 index 00000000..f0327f1d --- /dev/null +++ b/example/flutter/lib/acceptance.dart @@ -0,0 +1,290 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'legacy/seed.dart'; +import 'migrations/migrations.g.dart'; +import 'schema.orm.dart'; + +typedef NoteCard = ({int id, String text, bool done, List comments}); + +/// Runs inside the installed application, using its own persistent database. +Future> runAcceptance({ + required void Function(List) onRows, + required void Function(String) onCheck, + required int Function() frameCount, +}) async { + const version = int.fromEnvironment('ORM_SCHEMA_VERSION', defaultValue: 2); + final config = (await const MethodChannel('orm_acceptance/config') + .invokeMapMethod('read'))!; + final phase = version == 1 ? 'legacy' : config['phase']! as String; + final path = '${config['filesPath']}/acceptance.sqlite'; + final checks = []; + final report = { + 'phase': phase, + 'schemaVersion': version, + 'release': kReleaseMode, + 'dart': Platform.version, + 'os': Platform.operatingSystemVersion, + 'androidApi': config['api'], + 'pid': config['pid'], + 'path': path, + 'checks': checks, + }; + void check(bool condition, String name) { + if (!condition) throw StateError(name); + checks.add(name); + onCheck(name); + } + + Database? database; + StreamSubscription>? subscription; + try { + check( + await File(path).exists() == (phase != 'legacy'), + phase == 'legacy' ? 'Fresh database' : 'Existing database retained', + ); + final migrations = migrationHistory.checked.take(version).toList(); + final db = database = await sqlite(SqliteOptions.file(path)); + report['sqlite'] = (await db.execute(SqlCommand('SELECT sqlite_version()'))) + .rows + .single + .single; + if (phase == 'legacy') { + await seedLegacy(db, migrations.single); + final rows = (await db.execute( + SqlCommand('SELECT id, body, created_at FROM notes ORDER BY id'), + )).rows; + check( + rows.length == 2, + 'Version 1 typed transaction persisted two notes', + ); + report['legacyRows'] = rows; + } else { + check(phase == 'upgrade' || phase == 'reopen', 'Known execution phase'); + final before = await Migrator(db.sql).history(); + check( + before.map((m) => m.id).join(',') == + (phase == 'upgrade' + ? '0001_initial' + : '0001_initial,0002_comments'), + 'Expected previous migration history', + ); + final applied = await Migrator(db.sql).apply(migrations); + check( + applied.join(',') == (phase == 'upgrade' ? '0002_comments' : ''), + phase == 'upgrade' + ? 'Only version 2 migration applied' + : 'No migration replay', + ); + final schema = await verifySchema(db.sql, migrations.last.snapshot!); + check(schema.matches, 'Live schema matches bundled snapshot'); + report['unmanaged'] = schema.unmanaged.map((o) => o.name).toList(); + final original = await db.notes.byId(1).single(); + check( + original.text == 'Written by version 1' && + original.createdAt == DateTime.utc(2026, 9, 15, 1, 2, 3, 123, 456), + 'Original value and timestamp microseconds preserved', + ); + check( + original.done == (phase == 'reopen'), + 'New field default / persisted update', + ); + final query = db.notes + .orderBy((n) => [n.id.asc()]) + .select( + (n) => + ( + n.id, + n.text, + n.done, + n.comments + .orderBy((c) => [c.id.asc()]) + .select((c) => c.text) + .many(), + ).map( + (id, text, done, comments) => + (id: id, text: text, done: done, comments: comments), + ), + ); + final snapshots = >[]; + Object? watchError; + subscription = query.watch().listen((rows) { + snapshots.add(rows); + onRows(rows); + }, onError: (Object error) => watchError = error); + Future waitFor(bool Function(List) predicate) async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (snapshots.isEmpty || !predicate(snapshots.last)) { + if (watchError != null) throw watchError!; + if (DateTime.now().isAfter(deadline)) { + throw StateError('Watch timed out'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + await waitFor((rows) => rows.length == (phase == 'upgrade' ? 2 : 3)); + check(true, 'Initial watch snapshot delivered to Flutter'); + if (phase == 'upgrade') { + await db.transaction((tx) async { + final note = await tx.notes.create( + text: 'Written by version 2', + createdAt: DateTime.utc(2026, 9, 15, 2), + ); + await tx.comments.create( + noteId: note.id, + text: 'Related in one transaction', + ); + }); + await waitFor( + (rows) => rows.length == 3 && rows.last.comments.length == 1, + ); + check(true, 'Committed relation write refreshed watch'); + final count = snapshots.length; + final rollback = StateError('Expected rollback'); + try { + await db.transaction((tx) async { + await tx.notes.create( + text: 'Must roll back', + createdAt: DateTime.utc(2026), + ); + throw rollback; + }); + } catch (error) { + if (!identical(error, rollback)) rethrow; + } + await Future.delayed(const Duration(milliseconds: 200)); + check( + snapshots.length == count && (await query.get()).length == 3, + 'Rollback neither persisted nor notified', + ); + await db.notes.byId(1).patch(done: .set(true)); + await waitFor((rows) => rows.first.done); + check(true, 'Typed patch refreshed watch'); + var ticks = 0; + final timer = Timer.periodic( + const Duration(milliseconds: 16), + (_) => ticks++, + ); + final framesBefore = frameCount(); + final watch = Stopwatch()..start(); + try { + final result = await db.execute( + SqlCommand( + 'WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<2000000) SELECT sum(x) FROM n', + ), + options: const ExecutionOptions(timeout: Duration(seconds: 30)), + ); + check( + result.rows.single.single == 2000001000000, + 'Background SQLite computation result', + ); + } finally { + timer.cancel(); + watch.stop(); + } + report['background'] = { + 'elapsedUs': watch.elapsedMicroseconds, + 'timerTicks': ticks, + 'flutterFrames': frameCount() - framesBefore, + }; + check( + ticks >= 3 && frameCount() - framesBefore >= 3, + 'Dart timer and Flutter frames advanced during SQLite work', + ); + final token = CancellationToken(); + final cancel = Timer(const Duration(milliseconds: 50), token.cancel); + String? cancellation; + try { + await db.execute( + SqlCommand( + 'WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<100000000) SELECT sum(x) FROM n', + ), + options: ExecutionOptions( + cancellation: token, + timeout: const Duration(seconds: 30), + ), + ); + } on OrmException catch (error) { + cancellation = error.code; + } finally { + cancel.cancel(); + } + check( + cancellation == 'OPERATION.CANCELLED', + 'Native SQLite cancellation', + ); + check( + (await query.get()).length == 3, + 'Worker usable after cancellation', + ); + } else { + check( + snapshots.last.last.comments.single == 'Related in one transaction', + 'Relation data survived process restart', + ); + } + report['watchSnapshots'] = snapshots.length; + report['rows'] = [ + for (final n in await query.get()) + {'id': n.id, 'text': n.text, 'done': n.done, 'comments': n.comments}, + ]; + await subscription.cancel(); + subscription = null; + } + final versionState = await Migrator(db.sql).requireVersion(migrations); + check( + versionState.id == migrations.last.id, + 'Application schema compatibility', + ); + report['history'] = [ + for (final m in await Migrator(db.sql).history()) + {'id': m.id, 'checksum': m.checksum}, + ]; + await db.close(); + database = null; + final reader = await sqlite(SqliteOptions.readOnly(path)); + try { + check( + (await reader.execute(SqlCommand('SELECT count(*) FROM notes'))) + .rows + .single + .single == + (phase == 'legacy' ? 2 : 3), + 'Closed database reopened read-only', + ); + } finally { + await reader.close(); + } + report['status'] = 'passed'; + } catch (error, stack) { + report['status'] = 'failed'; + report['error'] = '$error'; + report['stack'] = '$stack'; + } finally { + await subscription?.cancel(); + await database?.close(); + } + final json = jsonEncode(report); + await File('${config['filesPath']}/$phase.json') + .writeAsString(json, flush: true); + final encoded = base64Encode(utf8.encode(json)); + // The Android Flutter log sink truncated 1,800-character chunks in release. + const size = 700; + final count = (encoded.length / size).ceil(); + for (var i = 0; i < count; i++) { + final end = (i + 1) * size; + // Machine-readable transport also works in release builds without run-as. + // ignore: avoid_print + print( + 'ORM_ACCEPTANCE $phase ${i + 1}/$count ${encoded.substring(i * size, end > encoded.length ? encoded.length : end)}', + ); + } + return report; +} diff --git a/example/flutter/lib/acceptance_web.dart b/example/flutter/lib/acceptance_web.dart new file mode 100644 index 00000000..2798587f --- /dev/null +++ b/example/flutter/lib/acceptance_web.dart @@ -0,0 +1,95 @@ +import 'package:flutter/foundation.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'migrations/migrations.g.dart'; +import 'schema.orm.dart'; + +typedef NoteCard = ({int id, String text, bool done, List comments}); + +Future> runAcceptance({ + required void Function(List) onRows, + required void Function(String) onCheck, + required int Function() frameCount, +}) async { + final report = { + 'phase': 'web', + 'release': kReleaseMode, + 'sqlite': 'opening', + }; + Database? database; + void check(bool condition, String name) { + if (!condition) throw StateError(name); + onCheck(name); + } + + try { + final db = database = await sqlite( + const SqliteOptions.persistent('flutter-example'), + ); + report['sqlite'] = (await db.execute(SqlCommand('SELECT sqlite_version()'))) + .rows + .single + .single; + await Migrator(db.sql).apply(migrationHistory.checked); + check( + (await verifySchema( + db.sql, + migrationHistory.checked.last.snapshot!, + )).matches, + 'Bundled Dart migrations match the browser database', + ); + if (await db.notes.count() == 0) { + await db.transaction((tx) async { + final note = await tx.notes.create( + text: 'Stored in the browser', + createdAt: DateTime.now().toUtc(), + ); + await tx.comments.create( + noteId: note.id, + text: 'Written in the same transaction', + ); + }); + } + final rows = await db.notes + .orderBy((n) => [n.id.asc()]) + .select( + (n) => + ( + n.id, + n.text, + n.done, + n.comments + .orderBy((c) => [c.id.asc()]) + .select((c) => c.text) + .many(), + ).map( + (id, text, done, comments) => + (id: id, text: text, done: done, comments: comments), + ), + ) + .get(); + onRows(rows); + check( + rows.isNotEmpty && rows.first.comments.isNotEmpty, + 'Typed records and relationships loaded into Flutter', + ); + final frames = frameCount(); + await db.execute( + SqlCommand( + 'WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<1000000) SELECT SUM(x) FROM n', + ), + ); + check( + frameCount() > frames, + 'Flutter animation advanced during worker SQL', + ); + report['status'] = 'passed'; + } catch (error) { + report['status'] = 'failed'; + report['error'] = error.toString(); + } finally { + await database?.close(); + } + return report; +} diff --git a/example/flutter/lib/legacy/schema.dart b/example/flutter/lib/legacy/schema.dart new file mode 100644 index 00000000..ba0d8ba5 --- /dev/null +++ b/example/flutter/lib/legacy/schema.dart @@ -0,0 +1,4 @@ +import 'package:orm/schema.dart'; + +typedef Note = ({@Id.generated() int id, String body, DateTime createdAt}); +final notes = entity(table: 'notes'); diff --git a/example/flutter/lib/legacy/schema.orm.dart b/example/flutter/lib/legacy/schema.orm.dart new file mode 100644 index 00000000..6d7ad8bc --- /dev/null +++ b/example/flutter/lib/legacy/schema.orm.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Note; + +final _notesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _notesBody = Column( + "body", + Codecs.text, + nullable: false, + generated: false, +); +final _notesCreatedAt = Column( + "created_at", + Codecs.dateTime, + nullable: false, + generated: false, +); +final notesSchema = TableSchema( + "notes", + columns: [_notesId, _notesBody, _notesCreatedAt], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class NotesFields extends Fields { + NotesFields(super.table); + late final id = column(_notesId); + late final body = column(_notesBody); + late final createdAt = column(_notesCreatedAt); +} + +final notesTable = Table( + notesSchema, + NotesFields.new, + (row) => ( + row.id, + row.body, + row.createdAt, + ).map((id, body, createdAt) => (id: id, body: body, createdAt: createdAt)), +); + +final class NotesTableSet extends TableSet { + NotesTableSet(QueryContext db) : super(db, notesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required String body, + required DateTime createdAt, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.body.set(body), + row.createdAt.set(createdAt), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension NotesUpdates on Query { + Future patch({ + Change body = const Change.keep(), + Change createdAt = const Change.keep(), + }) => update( + (row) => [...row.body.change(body), ...row.createdAt.change(createdAt)], + ).execute(); +} + +final appSchema = List.unmodifiable([notesSchema]); + +extension AppTables on QueryContext { + NotesTableSet get notes => NotesTableSet(this); +} diff --git a/example/flutter/lib/legacy/schema.snapshot.dart b/example/flutter/lib/legacy/schema.snapshot.dart new file mode 100644 index 00000000..62aecacc --- /dev/null +++ b/example/flutter/lib/legacy/schema.snapshot.dart @@ -0,0 +1,14 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "notes", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("body", Codecs.text), + Column("created_at", Codecs.dateTime), + ], + primaryKey: ["id"], + ), +]); diff --git a/example/flutter/lib/legacy/seed.dart b/example/flutter/lib/legacy/seed.dart new file mode 100644 index 00000000..efa04e57 --- /dev/null +++ b/example/flutter/lib/legacy/seed.dart @@ -0,0 +1,18 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.orm.dart'; + +Future seedLegacy(Database db, Migration initial) async { + await Migrator(db.sql).apply([initial]); + await db.transaction((tx) async { + await tx.notes.create( + body: 'Written by version 1', + createdAt: DateTime.utc(2026, 9, 15, 1, 2, 3, 123, 456), + ); + await tx.notes.create( + body: 'Keep this across the upgrade', + createdAt: DateTime.utc(2026, 9, 15), + ); + }); +} diff --git a/example/flutter/lib/main.dart b/example/flutter/lib/main.dart new file mode 100644 index 00000000..5d32bd5c --- /dev/null +++ b/example/flutter/lib/main.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +// The acceptance harness has platform-specific reporting and lifecycle checks. +// Both implementations use package:orm/sqlite.dart for database access. +import 'acceptance.dart' if (dart.library.js_interop) 'acceptance_web.dart'; + +void main() => runApp(const MaterialApp(home: AcceptanceApp())); + +class AcceptanceApp extends StatefulWidget { + const AcceptanceApp({super.key}); + + @override + State createState() => _AcceptanceAppState(); +} + +class _AcceptanceAppState extends State + with SingleTickerProviderStateMixin { + late final AnimationController animation; + int frames = 0; + final checks = []; + List rows = []; + Map? report; + + @override + void initState() { + super.initState(); + animation = + AnimationController(vsync: this, duration: const Duration(seconds: 2)) + ..addListener(() => frames++) + ..repeat(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + final result = await runAcceptance( + onRows: (value) => setState(() => rows = value), + onCheck: (value) => setState(() => checks.add(value)), + frameCount: () => frames, + ); + animation.stop(); + setState(() => report = result); + }); + } + + @override + void dispose() { + animation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final status = report?['status']; + return Scaffold( + backgroundColor: const Color(0xFFF5F7F3), + appBar: AppBar(title: const Text('Dart ORM · Flutter acceptance')), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + Text( + status == 'passed' + ? 'All checks passed' + : status == 'failed' + ? 'Check failed' + : 'Running checks', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 12), + if (report == null) const LinearProgressIndicator(), + if (report != null) + Text( + '${report!['phase']} · SQLite ${report!['sqlite']} · ' + '${report!['release'] == true ? 'release' : 'debug'}', + ), + if (report?['error'] != null) + Text( + '${report!['error']}', + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 20), + for (final row in rows) + Card( + child: ListTile( + leading: Icon( + row.done ? Icons.check_circle : Icons.circle_outlined, + color: const Color(0xFF53715A), + ), + title: Text(row.text), + subtitle: row.comments.isEmpty + ? null + : Text(row.comments.join('\n')), + ), + ), + const SizedBox(height: 20), + Text( + '${checks.length} verified checks', + style: Theme.of(context).textTheme.titleMedium, + ), + for (final check in checks) + Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Text('✓ $check'), + ), + ], + ), + ); + } +} diff --git a/example/flutter/lib/migrations/m0001_initial.dart b/example/flutter/lib/migrations/m0001_initial.dart new file mode 100644 index 00000000..850f3080 --- /dev/null +++ b/example/flutter/lib/migrations/m0001_initial.dart @@ -0,0 +1,28 @@ +// Review before applying. Applied migrations must remain unchanged. +import 'package:orm/migrate.dart'; + +const migrationChecksum = + "eb8478c032ecf4fd28752a34ee1b74b503ae8708c0f6ef851856c63ac02c2252"; +final migration = Migration.steps( + "0001_initial", + [ + ExecuteSql( + "CREATE TABLE \"notes\" (\"id\" INTEGER PRIMARY KEY NOT NULL, \"body\" TEXT NOT NULL, \"created_at\" TEXT COLLATE \"orm_instant_v1\" NOT NULL)", + ), + ], + dialect: SqlDialect.sqlite, + + snapshot: _schema, +); + +final _schema = SchemaSnapshot([ + TableSchema( + "notes", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("body", Codecs.text), + Column("created_at", Codecs.dateTime), + ], + primaryKey: ["id"], + ), +]); diff --git a/example/flutter/lib/migrations/m0002_comments.dart b/example/flutter/lib/migrations/m0002_comments.dart new file mode 100644 index 00000000..fb5aa83a --- /dev/null +++ b/example/flutter/lib/migrations/m0002_comments.dart @@ -0,0 +1,45 @@ +// Review before applying. Applied migrations must remain unchanged. +import 'package:orm/migrate.dart'; + +const migrationChecksum = + "2967a6b4178d2061acad6451e497c7709f82d674a190caa938a765a32accec26"; +final migration = Migration.steps( + "0002_comments", + [ + ExecuteSql( + "CREATE TABLE \"comments\" (\"id\" INTEGER PRIMARY KEY NOT NULL, \"note_id\" INTEGER NOT NULL, \"text\" TEXT NOT NULL, FOREIGN KEY (\"note_id\") REFERENCES \"notes\" (\"id\") ON DELETE CASCADE)", + ), + ExecuteSql( + "ALTER TABLE \"notes\" ADD COLUMN \"done\" INTEGER NOT NULL DEFAULT (false)", + ), + ], + dialect: SqlDialect.sqlite, + previous: "eb8478c032ecf4fd28752a34ee1b74b503ae8708c0f6ef851856c63ac02c2252", + snapshot: _schema, +); + +final _schema = SchemaSnapshot([ + TableSchema( + "notes", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("body", Codecs.text), + Column("done", Codecs.boolean, defaultSql: "false"), + Column("created_at", Codecs.dateTime), + ], + primaryKey: ["id"], + ), + TableSchema( + "comments", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("note_id", Codecs.integer), + Column("text", Codecs.text), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["note_id"], "notes", ["id"], onDelete: "CASCADE"), + ], + ), +]); diff --git a/example/flutter/lib/migrations/migrations.g.dart b/example/flutter/lib/migrations/migrations.g.dart new file mode 100644 index 00000000..a83de529 --- /dev/null +++ b/example/flutter/lib/migrations/migrations.g.dart @@ -0,0 +1,11 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +import 'package:orm/migrate.dart'; + +import 'm0001_initial.dart' as m0; +import 'm0002_comments.dart' as m1; + +const migrationDialect = SqlDialect.sqlite; +final migrationHistory = MigrationHistory([ + (m0.migration, m0.migrationChecksum), + (m1.migration, m1.migrationChecksum), +], dialect: migrationDialect); diff --git a/example/flutter/lib/schema.dart b/example/flutter/lib/schema.dart new file mode 100644 index 00000000..f76261ba --- /dev/null +++ b/example/flutter/lib/schema.dart @@ -0,0 +1,19 @@ +import 'package:orm/schema.dart'; + +typedef Note = ({ + @Id.generated() int id, + @ColumnName('body') String text, + @Default.sql('false') bool done, + DateTime createdAt, +}); +typedef Comment = ({@Id.generated() int id, int noteId, String text}); + +final notes = entity(table: 'notes'); +final comments = entity(table: 'comments'); +final note = comments + .key((c) => c.noteId) + .references( + notes.key((n) => n.id), + inverse: 'comments', + onDelete: .cascade, + ); diff --git a/example/flutter/lib/schema.orm.dart b/example/flutter/lib/schema.orm.dart new file mode 100644 index 00000000..6efbed11 --- /dev/null +++ b/example/flutter/lib/schema.orm.dart @@ -0,0 +1,172 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Note, Comment; + +final _notesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _notesText = Column( + "body", + Codecs.text, + nullable: false, + generated: false, +); +final _notesDone = Column( + "done", + Codecs.boolean, + nullable: false, + generated: false, + defaultSql: "false", +); +final _notesCreatedAt = Column( + "created_at", + Codecs.dateTime, + nullable: false, + generated: false, +); +final notesSchema = TableSchema( + "notes", + columns: [_notesId, _notesText, _notesDone, _notesCreatedAt], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class NotesFields extends Fields { + NotesFields(super.table); + late final id = column(_notesId); + late final text = column(_notesText); + late final done = column(_notesDone); + late final createdAt = column(_notesCreatedAt); + Relation get comments => + Relation(commentsTable, parent: [id], child: (row) => [row.noteId]); +} + +final notesTable = Table( + notesSchema, + NotesFields.new, + (row) => (row.id, row.text, row.done, row.createdAt).map( + (id, text, done, createdAt) => + (id: id, text: text, done: done, createdAt: createdAt), + ), +); + +final class NotesTableSet extends TableSet { + NotesTableSet(QueryContext db) : super(db, notesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required String text, + Change done = const Change.keep(), + required DateTime createdAt, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.text.set(text), + ...row.done.change(done), + row.createdAt.set(createdAt), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension NotesUpdates on Query { + Future patch({ + Change text = const Change.keep(), + Change done = const Change.keep(), + Change createdAt = const Change.keep(), + }) => update( + (row) => [ + ...row.text.change(text), + ...row.done.change(done), + ...row.createdAt.change(createdAt), + ], + ).execute(); +} + +final _commentsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _commentsNoteId = Column( + "note_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _commentsText = Column( + "text", + Codecs.text, + nullable: false, + generated: false, +); +final commentsSchema = TableSchema( + "comments", + columns: [_commentsId, _commentsNoteId, _commentsText], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["note_id"], "notes", ["id"], onDelete: "CASCADE"), + ], +); + +final class CommentsFields extends Fields { + CommentsFields(super.table); + late final id = column(_commentsId); + late final noteId = column(_commentsNoteId); + late final text = column(_commentsText); + Relation get note => + Relation(notesTable, parent: [noteId], child: (row) => [row.id]); +} + +final commentsTable = Table( + commentsSchema, + CommentsFields.new, + (row) => ( + row.id, + row.noteId, + row.text, + ).map((id, noteId, text) => (id: id, noteId: noteId, text: text)), +); + +final class CommentsTableSet extends TableSet { + CommentsTableSet(QueryContext db) : super(db, commentsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required int noteId, + required String text, + }) => createRow( + (row) => [...row.id.change(id), row.noteId.set(noteId), row.text.set(text)], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension CommentsUpdates on Query { + Future patch({ + Change noteId = const Change.keep(), + Change text = const Change.keep(), + }) => + update((row) => [...row.noteId.change(noteId), ...row.text.change(text)]) + .execute(); +} + +final appSchema = List.unmodifiable([notesSchema, commentsSchema]); + +extension AppTables on QueryContext { + NotesTableSet get notes => NotesTableSet(this); + CommentsTableSet get comments => CommentsTableSet(this); +} diff --git a/example/flutter/lib/schema.snapshot.dart b/example/flutter/lib/schema.snapshot.dart new file mode 100644 index 00000000..564b03d8 --- /dev/null +++ b/example/flutter/lib/schema.snapshot.dart @@ -0,0 +1,28 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "notes", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("body", Codecs.text), + Column("done", Codecs.boolean, defaultSql: "false"), + Column("created_at", Codecs.dateTime), + ], + primaryKey: ["id"], + ), + TableSchema( + "comments", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("note_id", Codecs.integer), + Column("text", Codecs.text), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["note_id"], "notes", ["id"], onDelete: "CASCADE"), + ], + ), +]); diff --git a/example/flutter/pubspec.lock b/example/flutter/pubspec.lock new file mode 100644 index 00000000..876620cd --- /dev/null +++ b/example/flutter/pubspec.lock @@ -0,0 +1,382 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b + url: "https://pub.dev" + source: hosted + version: "108.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46 + url: "https://pub.dev" + source: hosted + version: "14.4.0" + 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: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + build: + dependency: transitive + description: + name: build + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" + url: "https://pub.dev" + source: hosted + version: "4.0.11" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + url: "https://pub.dev" + source: hosted + version: "2.0.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: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" + url: "https://pub.dev" + source: hosted + version: "3.1.13" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mysql_client_plus: + dependency: transitive + description: + name: mysql_client_plus + sha256: a9760c010854ed24fe46bad968871ac0e1ab491a5e1991dc40c823ce16eede82 + url: "https://pub.dev" + source: hosted + version: "0.1.3" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "9d233b6f2d9c52e1a2b5fbe70451d2c10ac674d3bb419d0ec8de14989d437c26" + url: "https://pub.dev" + source: hosted + version: "0.19.4" + orm: + dependency: "direct main" + description: + path: "../.." + relative: true + source: path + version: "0.1.0-dev.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + postgres: + dependency: transitive + description: + name: postgres + sha256: "123de5cbadc56a7e8d9fa485c780b6b56940b4081f4c74f3a5578682757c299b" + url: "https://pub.dev" + source: hosted + version: "3.5.12" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: fa3fd961e83b2dca8890a7b1014ab1226831d2b5908067c0ab7f385ffb655caf + url: "https://pub.dev" + source: hosted + version: "3.6.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + 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" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.0 <4.0.0" diff --git a/example/flutter/pubspec.yaml b/example/flutter/pubspec.yaml new file mode 100644 index 00000000..2d4494da --- /dev/null +++ b/example/flutter/pubspec.yaml @@ -0,0 +1,19 @@ +name: orm_flutter +description: Native and web Flutter acceptance application for Dart ORM. +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: '>=3.13.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + orm: + path: ../.. + +dev_dependencies: + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/example/flutter/web/index.html b/example/flutter/web/index.html new file mode 100644 index 00000000..00efa071 --- /dev/null +++ b/example/flutter/web/index.html @@ -0,0 +1,12 @@ + + + + + + + Dart ORM · Flutter + + + + + diff --git a/example/main.dart b/example/main.dart new file mode 100644 index 00000000..cbd913e3 --- /dev/null +++ b/example/main.dart @@ -0,0 +1,38 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.orm.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await Migrator( + db.sql, + ).apply([Migration.create('0001_initial', appSchema, dialect: db.dialect)]); + final User user = await db.transaction((tx) async { + final User user = await tx.users.create(email: 'seven@example.com'); + await tx.posts.create( + authorId: user.id, + title: 'Hello Dart', + createdAt: DateTime.now(), + ); + return user; + }); + await db.users.byId(user.id).patch(nickname: .set('Seven')); + final cards = await db.users + .select( + (u) => ( + u.email, + u.posts + .orderBy((p) => [p.createdAt.desc(), p.id.desc()]) + .take(3) + .select((p) => p.title) + .many(), + ).map((email, titles) => (email: email, titles: titles)), + ) + .get(); + print(cards); + } finally { + await db.close(); + } +} diff --git a/example/migrate.dart b/example/migrate.dart new file mode 100644 index 00000000..aab57a09 --- /dev/null +++ b/example/migrate.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +import 'package:orm/migrate_cli.dart'; +import 'package:orm/sqlite.dart'; + +import 'migrations/migrations.g.dart'; +import 'schema.snapshot.dart'; + +// This application owns a SQLite history. Only its file path varies by environment. +Future main(List args) => runMigrationCli( + args, + directory: 'example/migrations', + history: migrationHistory, + schema: schema, + connect: ({required readOnly}) async { + final path = Platform.environment['ORM_SQLITE_PATH'] ?? 'app.sqlite'; + return (await sqlite( + readOnly ? SqliteOptions.readOnly(path) : SqliteOptions.file(path), + )).sql; + }, +); diff --git a/example/migrations/m0001_initial.dart b/example/migrations/m0001_initial.dart new file mode 100644 index 00000000..d7d277c0 --- /dev/null +++ b/example/migrations/m0001_initial.dart @@ -0,0 +1,59 @@ +// Review before applying. Applied migrations must remain unchanged. +import 'package:orm/migrate.dart'; + +const migrationChecksum = + "096e374467b15d9025274fd9599e4f38f3ea76aa0c29e95d9d3d383119973509"; +final migration = Migration.steps( + "0001_initial", + [ + ExecuteSql( + "CREATE TABLE \"users\" (\"id\" INTEGER PRIMARY KEY NOT NULL, \"email\" TEXT NOT NULL, \"nickname\" TEXT, \"score\" INTEGER NOT NULL DEFAULT (0), UNIQUE (\"email\"))", + ), + ExecuteSql( + "CREATE TABLE \"posts\" (\"id\" INTEGER PRIMARY KEY NOT NULL, \"author_id\" INTEGER NOT NULL, \"title\" TEXT NOT NULL, \"created_at\" TEXT COLLATE \"orm_instant_v1\" NOT NULL, FOREIGN KEY (\"author_id\") REFERENCES \"users\" (\"id\") ON DELETE CASCADE)", + ), + ExecuteSql( + "CREATE INDEX \"author_timeline\" ON \"posts\" (\"author_id\", \"created_at\", \"id\")", + ), + ], + dialect: SqlDialect.sqlite, + + snapshot: _schema, +); + +final _schema = SchemaSnapshot([ + TableSchema( + "users", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("email", Codecs.text), + Column("nickname", Codecs.text.nullable(), nullable: true), + Column("score", Codecs.integer, defaultSql: "0"), + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + ), + TableSchema( + "posts", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("author_id", Codecs.integer), + Column("title", Codecs.text), + Column("created_at", Codecs.dateTime), + ], + primaryKey: ["id"], + + indexes: [ + IndexSchema("author_timeline", [ + "author_id", + "created_at", + "id", + ], unique: false), + ], + foreignKeys: [ + ForeignKey(["author_id"], "users", ["id"], onDelete: "CASCADE"), + ], + ), +]); diff --git a/example/migrations/migrations.g.dart b/example/migrations/migrations.g.dart new file mode 100644 index 00000000..a8ba37d9 --- /dev/null +++ b/example/migrations/migrations.g.dart @@ -0,0 +1,9 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +import 'package:orm/migrate.dart'; + +import 'm0001_initial.dart' as m0; + +const migrationDialect = SqlDialect.sqlite; +final migrationHistory = MigrationHistory([ + (m0.migration, m0.migrationChecksum), +], dialect: migrationDialect); diff --git a/example/queries.dart b/example/queries.dart new file mode 100644 index 00000000..d6fd4fb2 --- /dev/null +++ b/example/queries.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.orm.dart'; + +/// Runs the query cookbook on an empty, disposable database. +Future> queryCookbook( + Database db, { + int? minimumScore, +}) async { + final checks = []; + void check(bool condition, String name) { + if (!condition) throw StateError(name); + checks.add(name); + } + + await Migrator( + db.sql, + ).apply([Migration.create('0001_example', appSchema, dialect: db.dialect)]); + final (z, a) = await db.transaction((tx) async { + final z = await tx.users.create( + email: 'z@example.com', + nickname: 'Z', + score: .set(2), + ); + final a = await tx.users.create(email: 'a@example.com'); + await tx.posts.create( + authorId: z.id, + title: 'Z1', + createdAt: DateTime.utc(2026, 1, 1), + ); + await tx.posts.create( + authorId: z.id, + title: 'Z2', + createdAt: DateTime.utc(2026, 1, 2), + ); + await tx.posts.create( + authorId: a.id, + title: 'A1', + createdAt: DateTime.utc(2026, 1, 1), + ); + return (z, a); + }); + + var query = db.users.where((u) => u.email.like('%@example.com')); + if (minimumScore != null) { + query = query.where((u) => u.score.gte(minimumScore)); + } + final emails = await query + .orderBy((u) => [u.id.asc()]) + .take(20) + .select((u) => u.email) + .get(); + final expectedEmails = [ + for (final user in [z, a]) + if (minimumScore == null || user.score >= minimumScore) user.email, + ]; + check( + jsonEncode(emails) == jsonEncode(expectedEmails), + 'Dynamic typed filter and scalar selection', + ); + + final author = usersTable.alias(); + final joined = await db.posts + .join(author, on: (p, a) => p.authorId.equals(a.id)) + .orderBy((p) => [author.fields.email.asc(), p.id.asc()]) + .select((p) => (p.title, author.fields.email).row) + .get(); + final expectedJoined = [('A1', a.email), ('Z1', z.email), ('Z2', z.email)]; + check( + joined.length == expectedJoined.length && + joined.indexed.every((e) => e.$2 == expectedJoined[e.$1]), + 'Joined field orders root rows', + ); + final relationOrder = await db.users + .orderBy((u) => [u.posts.count().desc(), u.id.asc()]) + .select((u) => u.email) + .get(); + check( + jsonEncode(relationOrder) == jsonEncode([z.email, a.email]), + 'Correlated relation count orders root rows', + ); + + final totals = db.posts + .groupBy((p) => [p.authorId]) + .having((p) => p.id.count().gt(1)) + .select((p) => (p.authorId, p.id.count()).row) + .asCte('author_totals'); + final active = await totals.query + .orderBy((c) => [c.ref((p) => p.authorId).asc()]) + .get(); + check( + active.length == 1 && active.single == (z.id, 2), + 'Grouping, HAVING and CTE exported columns', + ); + final ranks = await db.posts + .orderBy((p) => [p.id.asc()]) + .select( + (p) => ( + p.id, + rowNumber( + partitionBy: [p.authorId], + orderBy: [p.createdAt.desc(), p.id.desc()], + ), + ).row, + ) + .get(); + check( + jsonEncode(ranks.map((r) => r.$2).toList()) == '[2,1,1]', + 'Window ordering is independent of result ordering', + ); + final selectedAuthors = db.posts.select((p) => p.authorId); + check( + await db.users.where((u) => u.id.isInQuery(selectedAuthors)).count() == 2, + 'Typed IN subquery', + ); + final counts = await db.users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => db.posts + .where((p) => p.authorId.equals(u.id)) + .select((p) => p.id.count()) + .scalar(), + ) + .get(); + check(jsonEncode(counts) == '[2,1]', 'Correlated scalar aggregate'); + + final first = await db.users + .orderBy((u) => [u.nickname.asc(nulls: .last), u.id.asc()]) + .take(1) + .get(); + final last = first.single; + final token = db.users.cursorToken( + (u) => [ + u.nickname.cursor(last.nickname, nulls: .last), + u.id.cursor(last.id), + ], + ); + final next = await db.users + .seekToken( + token, + orderBy: (u) => [u.nickname.asc(nulls: .last), u.id.asc()], + ) + .take(1) + .get(); + check( + first.single.id == z.id && next.single.id == a.id, + 'Versioned cursor preserves NULL order and unique tie breaker', + ); + check( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, + 'Generated schema matches the live database', + ); + return {'backend': db.dialect.name, 'checks': checks, 'status': 'passed'}; +} + +Future main() async { + final url = Platform.environment['ORM_EXAMPLE_POSTGRES']; + final schema = 'orm_example_${pid}_${DateTime.now().microsecondsSinceEpoch}'; + final Database db = url == null + ? await sqlite(const SqliteOptions.memory()) + : postgres( + PostgresOptions(url: Uri.parse(url), tls: .disable, schema: schema), + ); + var created = false; + try { + if (url != null) { + await db.execute(SqlCommand('CREATE SCHEMA "$schema"')); + created = true; + } + print(jsonEncode(await queryCookbook(db, minimumScore: 1))); + } finally { + try { + if (created) { + await db.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + } + } finally { + await db.close(); + } + } +} diff --git a/example/schema.dart b/example/schema.dart new file mode 100644 index 00000000..048c208a --- /dev/null +++ b/example/schema.dart @@ -0,0 +1,22 @@ +import 'package:orm/schema.dart'; + +final class User({ + @Id.generated() required final int id, + @Unique() required final String email, + required final String? nickname, + @Default.sql('0') required final int score, +}); + +final class Post({ + @Id.generated() required final int id, + required final int authorId, + required final String title, + required final DateTime createdAt, +}); + +final users = entity(); +final posts = entity(); +final author = posts + .key((p) => p.authorId) + .references(users.key((u) => u.id), inverse: 'posts', onDelete: .cascade); +final authorTimeline = posts.index((p) => (p.authorId, p.createdAt, p.id)); diff --git a/example/schema.orm.dart b/example/schema.orm.dart new file mode 100644 index 00000000..c5b7f17d --- /dev/null +++ b/example/schema.orm.dart @@ -0,0 +1,195 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show User, Post; + +final _usersId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _usersEmail = Column( + "email", + Codecs.text, + nullable: false, + generated: false, +); +final _usersNickname = Column( + "nickname", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final _usersScore = Column( + "score", + Codecs.integer, + nullable: false, + generated: false, + defaultSql: "0", +); +final usersSchema = TableSchema( + "users", + columns: [_usersId, _usersEmail, _usersNickname, _usersScore], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + indexes: [], + foreignKeys: [], +); + +final class UsersFields extends Fields { + UsersFields(super.table); + late final id = column(_usersId); + late final email = column(_usersEmail); + late final nickname = column(_usersNickname); + late final score = column(_usersScore); + Relation get posts => + Relation(postsTable, parent: [id], child: (row) => [row.authorId]); +} + +final usersTable = Table( + usersSchema, + UsersFields.new, + (row) => (row.id, row.email, row.nickname, row.score).map( + (v0, v1, v2, v3) => models.User(id: v0, email: v1, nickname: v2, score: v3), + ), +); + +final class UsersTableSet extends TableSet { + UsersTableSet(QueryContext db) : super(db, usersTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required String email, + String? nickname, + Change score = const Change.keep(), + }) => createRow( + (row) => [ + ...row.id.change(id), + row.email.set(email), + row.nickname.set(nickname), + ...row.score.change(score), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension UsersUpdates on Query { + Future patch({ + Change email = const Change.keep(), + Change nickname = const Change.keep(), + Change score = const Change.keep(), + }) => update( + (row) => [ + ...row.email.change(email), + ...row.nickname.change(nickname), + ...row.score.change(score), + ], + ).execute(); +} + +final _postsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _postsAuthorId = Column( + "author_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _postsTitle = Column( + "title", + Codecs.text, + nullable: false, + generated: false, +); +final _postsCreatedAt = Column( + "created_at", + Codecs.dateTime, + nullable: false, + generated: false, +); +final postsSchema = TableSchema( + "posts", + columns: [_postsId, _postsAuthorId, _postsTitle, _postsCreatedAt], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [ + IndexSchema("author_timeline", [ + "author_id", + "created_at", + "id", + ], unique: false), + ], + foreignKeys: [ + ForeignKey(["author_id"], "users", ["id"], onDelete: "CASCADE"), + ], +); + +final class PostsFields extends Fields { + PostsFields(super.table); + late final id = column(_postsId); + late final authorId = column(_postsAuthorId); + late final title = column(_postsTitle); + late final createdAt = column(_postsCreatedAt); + Relation get author => + Relation(usersTable, parent: [authorId], child: (row) => [row.id]); +} + +final postsTable = Table( + postsSchema, + PostsFields.new, + (row) => (row.id, row.authorId, row.title, row.createdAt).map( + (v0, v1, v2, v3) => + models.Post(id: v0, authorId: v1, title: v2, createdAt: v3), + ), +); + +final class PostsTableSet extends TableSet { + PostsTableSet(QueryContext db) : super(db, postsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required int authorId, + required String title, + required DateTime createdAt, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.authorId.set(authorId), + row.title.set(title), + row.createdAt.set(createdAt), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension PostsUpdates on Query { + Future patch({ + Change authorId = const Change.keep(), + Change title = const Change.keep(), + Change createdAt = const Change.keep(), + }) => update( + (row) => [ + ...row.authorId.change(authorId), + ...row.title.change(title), + ...row.createdAt.change(createdAt), + ], + ).execute(); +} + +final appSchema = List.unmodifiable([usersSchema, postsSchema]); + +extension AppTables on QueryContext { + UsersTableSet get users => UsersTableSet(this); + PostsTableSet get posts => PostsTableSet(this); +} diff --git a/example/schema.snapshot.dart b/example/schema.snapshot.dart new file mode 100644 index 00000000..ee213dae --- /dev/null +++ b/example/schema.snapshot.dart @@ -0,0 +1,39 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "users", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("email", Codecs.text), + Column("nickname", Codecs.text.nullable(), nullable: true), + Column("score", Codecs.integer, defaultSql: "0"), + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + ), + TableSchema( + "posts", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("author_id", Codecs.integer), + Column("title", Codecs.text), + Column("created_at", Codecs.dateTime), + ], + primaryKey: ["id"], + + indexes: [ + IndexSchema("author_timeline", [ + "author_id", + "created_at", + "id", + ], unique: false), + ], + foreignKeys: [ + ForeignKey(["author_id"], "users", ["id"], onDelete: "CASCADE"), + ], + ), +]); diff --git a/example/teams/main.dart b/example/teams/main.dart new file mode 100644 index 00000000..f1cbca33 --- /dev/null +++ b/example/teams/main.dart @@ -0,0 +1,70 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.dart'; +import 'schema.orm.dart'; + +Future main() async { + final events = []; + final acquisitions = []; + final decodes = []; + final db = await sqlite( + const SqliteOptions.memory(), + onQuery: events.add, + onAcquire: acquisitions.add, + onDecode: decodes.add, + ); + try { + await Migrator( + db.sql, + ).apply([Migration.create('0001_teams', appSchema, dialect: db.dialect)]); + await db.transaction((tx) async { + await tx.users.create(id: 1, name: 'Ada'); + await tx.teams.create(id: 10, name: 'Core'); + await tx.teams.create(id: 20, name: 'Docs'); + await tx.memberships.create( + teamId: 10, + userId: 1, + role: .set(MembershipRole.owner), + joinedAt: DateTime.utc(2026, 1, 1), + ); + await tx.memberships.create( + teamId: 20, + userId: 1, + joinedAt: DateTime.utc(2026, 1, 2), + ); + }); + events.clear(); + acquisitions.clear(); + decodes.clear(); + final query = db.users.select( + (u) => ( + u.name, + u.memberships + .orderBy((m) => [m.joinedAt.desc(), m.teamId.desc()]) + .take(2) + .select( + (m) => ( + m.team.select((t) => t.name).required(), + m.role, + ).map((team, role) => (team: team, role: role)), + ) + .many(), + ).map((name, teams) => (name: name, teams: teams)), + ); + final plan = query.inspect(); + print( + '${plan.sqlTemplateCount} SQL templates; ${plan.loads.single.maxKeysPerBatch} parent keys per batch', + ); + final cards = await query.get(); + print(cards); + print( + '${events.length} SQL statements; ${events.map((e) => e.rowCount).toList()} rows', + ); + print( + '${acquisitions.length} acquisition; ${decodes.length} decode batches', + ); + } finally { + await db.close(); + } +} diff --git a/example/teams/schema.dart b/example/teams/schema.dart new file mode 100644 index 00000000..f728fa12 --- /dev/null +++ b/example/teams/schema.dart @@ -0,0 +1,35 @@ +import 'package:orm/schema.dart'; + +typedef User = ({@Id() int id, String name}); +typedef Team = ({@Id() int id, String name}); + +enum MembershipRole { owner, member } + +typedef Membership = ({ + int teamId, + int userId, + @Default.sql("'member'") MembershipRole role, + DateTime joinedAt, +}); + +final users = entity(); +final teams = entity(); +final memberships = entity(); +final membershipKey = memberships.primaryKey((m) => (m.teamId, m.userId)); +final team = memberships + .key((m) => m.teamId) + .references( + teams.key((t) => t.id), + inverse: 'memberships', + onDelete: .cascade, + ); +final user = memberships + .key((m) => m.userId) + .references( + users.key((u) => u.id), + inverse: 'memberships', + onDelete: .cascade, + ); +final userMemberships = memberships.index( + (m) => (m.userId, m.joinedAt, m.teamId), +); diff --git a/example/teams/schema.orm.dart b/example/teams/schema.orm.dart new file mode 100644 index 00000000..7ce78dc5 --- /dev/null +++ b/example/teams/schema.orm.dart @@ -0,0 +1,236 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show User, Team, Membership; + +final _usersId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _usersName = Column( + "name", + Codecs.text, + nullable: false, + generated: false, +); +final usersSchema = TableSchema( + "users", + columns: [_usersId, _usersName], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class UsersFields extends Fields { + UsersFields(super.table); + late final id = column(_usersId); + late final name = column(_usersName); + Relation get memberships => + Relation(membershipsTable, parent: [id], child: (row) => [row.userId]); +} + +final usersTable = Table( + usersSchema, + UsersFields.new, + (row) => (row.id, row.name).map((id, name) => (id: id, name: name)), +); + +final class UsersTableSet extends TableSet { + UsersTableSet(QueryContext db) : super(db, usersTable) { + db.registerSchema(appSchema); + } + Future create({required int id, required String name}) => + createRow((row) => [row.id.set(id), row.name.set(name)]); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension UsersUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change name = const Change.keep(), + }) => + update((row) => [...row.id.change(id), ...row.name.change(name)]) + .execute(); +} + +final _teamsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _teamsName = Column( + "name", + Codecs.text, + nullable: false, + generated: false, +); +final teamsSchema = TableSchema( + "teams", + columns: [_teamsId, _teamsName], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class TeamsFields extends Fields { + TeamsFields(super.table); + late final id = column(_teamsId); + late final name = column(_teamsName); + Relation get memberships => + Relation(membershipsTable, parent: [id], child: (row) => [row.teamId]); +} + +final teamsTable = Table( + teamsSchema, + TeamsFields.new, + (row) => (row.id, row.name).map((id, name) => (id: id, name: name)), +); + +final class TeamsTableSet extends TableSet { + TeamsTableSet(QueryContext db) : super(db, teamsTable) { + db.registerSchema(appSchema); + } + Future create({required int id, required String name}) => + createRow((row) => [row.id.set(id), row.name.set(name)]); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension TeamsUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change name = const Change.keep(), + }) => + update((row) => [...row.id.change(id), ...row.name.change(name)]) + .execute(); +} + +final _membershipsTeamId = Column( + "team_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _membershipsUserId = Column( + "user_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _membershipsRole = Column( + "role", + Codecs.enumeration({ + models.MembershipRole.owner: "owner", + models.MembershipRole.member: "member", + }), + nullable: false, + generated: false, + defaultSql: "'member'", +); +final _membershipsJoinedAt = Column( + "joined_at", + Codecs.dateTime, + nullable: false, + generated: false, +); +final membershipsSchema = TableSchema( + "memberships", + columns: [ + _membershipsTeamId, + _membershipsUserId, + _membershipsRole, + _membershipsJoinedAt, + ], + primaryKey: ["team_id", "user_id"], + uniqueKeys: [], + indexes: [ + IndexSchema("user_memberships", [ + "user_id", + "joined_at", + "team_id", + ], unique: false), + ], + foreignKeys: [ + ForeignKey(["team_id"], "teams", ["id"], onDelete: "CASCADE"), + ForeignKey(["user_id"], "users", ["id"], onDelete: "CASCADE"), + ], +); + +final class MembershipsFields extends Fields { + MembershipsFields(super.table); + late final teamId = column(_membershipsTeamId); + late final userId = column(_membershipsUserId); + late final role = column(_membershipsRole); + late final joinedAt = column(_membershipsJoinedAt); + Relation get team => + Relation(teamsTable, parent: [teamId], child: (row) => [row.id]); + Relation get user => + Relation(usersTable, parent: [userId], child: (row) => [row.id]); +} + +final membershipsTable = Table( + membershipsSchema, + MembershipsFields.new, + (row) => (row.teamId, row.userId, row.role, row.joinedAt).map( + (teamId, userId, role, joinedAt) => + (teamId: teamId, userId: userId, role: role, joinedAt: joinedAt), + ), +); + +final class MembershipsTableSet + extends TableSet { + MembershipsTableSet(QueryContext db) : super(db, membershipsTable) { + db.registerSchema(appSchema); + } + Future create({ + required int teamId, + required int userId, + Change role = const Change.keep(), + required DateTime joinedAt, + }) => createRow( + (row) => [ + row.teamId.set(teamId), + row.userId.set(userId), + ...row.role.change(role), + row.joinedAt.set(joinedAt), + ], + ); + Query byId({ + required int teamId, + required int userId, + }) => where((row) => row.teamId.eq(teamId).and(row.userId.eq(userId))); +} + +extension MembershipsUpdates on Query { + Future patch({ + Change teamId = const Change.keep(), + Change userId = const Change.keep(), + Change role = const Change.keep(), + Change joinedAt = const Change.keep(), + }) => update( + (row) => [ + ...row.teamId.change(teamId), + ...row.userId.change(userId), + ...row.role.change(role), + ...row.joinedAt.change(joinedAt), + ], + ).execute(); +} + +final appSchema = List.unmodifiable([ + usersSchema, + teamsSchema, + membershipsSchema, +]); + +extension AppTables on QueryContext { + UsersTableSet get users => UsersTableSet(this); + TeamsTableSet get teams => TeamsTableSet(this); + MembershipsTableSet get memberships => MembershipsTableSet(this); +} diff --git a/example/teams/schema.snapshot.dart b/example/teams/schema.snapshot.dart new file mode 100644 index 00000000..ff99ed00 --- /dev/null +++ b/example/teams/schema.snapshot.dart @@ -0,0 +1,37 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "users", + columns: [Column("id", Codecs.integer), Column("name", Codecs.text)], + primaryKey: ["id"], + ), + TableSchema( + "teams", + columns: [Column("id", Codecs.integer), Column("name", Codecs.text)], + primaryKey: ["id"], + ), + TableSchema( + "memberships", + columns: [ + Column("team_id", Codecs.integer), + Column("user_id", Codecs.integer), + Column("role", Codecs.text, defaultSql: "'member'"), + Column("joined_at", Codecs.dateTime), + ], + primaryKey: ["team_id", "user_id"], + + indexes: [ + IndexSchema("user_memberships", [ + "user_id", + "joined_at", + "team_id", + ], unique: false), + ], + foreignKeys: [ + ForeignKey(["team_id"], "teams", ["id"], onDelete: "CASCADE"), + ForeignKey(["user_id"], "users", ["id"], onDelete: "CASCADE"), + ], + ), +]); diff --git a/graphs/alipay.jpg b/graphs/alipay.jpg deleted file mode 100644 index 733b88b1..00000000 Binary files a/graphs/alipay.jpg and /dev/null differ diff --git a/graphs/wechat_pay.jpg b/graphs/wechat_pay.jpg deleted file mode 100644 index d13ca857..00000000 Binary files a/graphs/wechat_pay.jpg and /dev/null differ diff --git a/lib/builder.dart b/lib/builder.dart new file mode 100644 index 00000000..e0e727b9 --- /dev/null +++ b/lib/builder.dart @@ -0,0 +1,4 @@ +/// Optional build_runner integration. Runtime clients import orm.dart instead. +library; + +export 'src/generate.dart' show ormBuilder, ormQueryBuilder; diff --git a/lib/cli.dart b/lib/cli.dart new file mode 100644 index 00000000..6460687f --- /dev/null +++ b/lib/cli.dart @@ -0,0 +1,251 @@ +/// Project commands with ordinary typed Dart configuration and static history. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:dart_style/dart_style.dart'; +import 'package:path/path.dart' as p; + +import 'drivers/mysql.dart'; +import 'drivers/postgres.dart'; +import 'drivers/sqlite.dart'; +import 'generate.dart'; +import 'migrate.dart'; +import 'migrate_cli.dart'; +import 'runtime.dart'; +import 'src/sqlite/assets_io.dart'; + +export 'migrate.dart' show MigrationHistory, SchemaRenames, SchemaSnapshot; +export 'migrate_cli.dart' show MigrationConnection; +export 'runtime.dart' show SqlDatabase; + +part 'src/cli/commands.dart'; +part 'src/cli/init.dart'; + +final class OrmConfig { + final String schema; + final String? output; + final String migrations; + final MigrationHistory history; + final SchemaSnapshot snapshot; + final MigrationConnection? connect; + final SchemaRenames renames; + final Map> using; + const OrmConfig({ + this.schema = 'lib/schema.dart', + this.output, + this.migrations = 'migrations', + required this.history, + required this.snapshot, + this.connect, + this.renames = const SchemaRenames(), + this.using = const {}, + }); +} + +const _help = { + '': '''Usage: dart run orm [--json] [--config orm.config.dart] + init --database sqlite|postgres|mysql|mariadb + generate [schema.dart] [output.orm.dart] + migrate create|check|plan|apply|status|verify|baseline|record|inspect + migration registry [--dialect engine] + db inspect|import + queries generate|check + web-assets [directory] + +Run help for details. Project commands use orm.config.dart. +Exit codes: 0 success, 1 execution failure, 2 drift/import issues, 64 usage error. +--json emits machine reports; schema and migration files always remain Dart.''', + 'init': '''Usage: dart run orm init --database sqlite|postgres|mysql|mariadb [--json] +Creates lib/schema.dart, its generated client/snapshot, a static migration +registry and orm.config.dart in an existing Dart project. Never replaces files, +connects to a database or applies DDL. Server URLs are read from DATABASE_URL +only when a connection command runs.''', + 'generate': + '''Usage: dart run orm generate [schema.dart] [output.orm.dart] [--json] +Without a path, uses OrmConfig.schema or lib/schema.dart. Generates the client +and standalone physical snapshot without connecting to a database. +Use an explicit schema path to recreate a deleted snapshot before loading config.''', + 'migrate': '''Usage: dart run orm migrate [--json] + create [--allow-destructive] Generate schema, then save a reviewed diff + check Validate fixed Dart migration history + plan Inspect pending steps without applying + apply [--max-backfill-batches n] Apply pending migrations + status Inspect applied history and checkpoints + verify Compare the catalog with the bundled snapshot + baseline Verify and register an existing schema + record Record an unpublished migration edit + inspect
Inspect physical table metadata +Configuration, fixed engine, connections, renames and conversions belong in +orm.config.dart. create/check/record do not connect. No command except explicit +apply/baseline changes database state. Review generated migrations before apply.''', + 'migration': '''Usage: dart run orm migration registry [--dialect sqlite|postgres|mysql|mariadb] +Rebuild static imports without updating reviewed migration fingerprints.''', + 'db': '''Usage: dart run orm db inspect --table name + dart run orm db import --output schema.dart [--table name] +Database: --sqlite file | --postgres-env NAME | --mysql-env NAME | --mariadb-env NAME +Server options: --tls verifyFull|require|disable; PostgreSQL: --database-schema name +Inspection and catalog import do not apply DDL or replace output files.''', + 'queries': '''Usage: dart run orm queries generate [output.queries.dart] + dart run orm queries check --source queries.dart [--output file] +Database options match help db. Generation is offline; check validates queries +against the selected database.''', + 'web-assets': '''Usage: dart run orm web-assets [directory] [--json] +Copies verified SQLite worker/WASM assets to web/orm by default. +Flutter Web bundles these resources automatically.''', +}; + +/// Called by the package executable or a project's explicit Dart entrypoint. +/// Loading a config is normal Dart execution, never reflection or JSON loading. +Future runOrmCli(List arguments, {OrmConfig? config}) async { + final json = arguments.contains('--json'); + final output = _CliOutput(json); + try { + final args = List.of(arguments); + if (args.where((a) => a == '--json').length > 1) { + throw const FormatException('Repeated option --json.'); + } + args.remove('--json'); + String? configPath; + final configIndex = args.indexOf('--config'); + if (configIndex >= 0) { + if (config != null || + args.where((a) => a == '--config').length > 1 || + configIndex + 1 >= args.length || + args[configIndex + 1].startsWith('--')) { + throw const FormatException( + 'Use --config once.', + ); + } + configPath = args[configIndex + 1]; + args.removeRange(configIndex, configIndex + 2); + } + if (args.isEmpty || + args.first == 'help' || + args.contains('--help') || + args.length == 1 && args.first == 'migrate') { + final topic = args.isEmpty || args.first == '--help' + ? '' + : args.first == 'help' + ? (args.length > 1 ? args[1] : '') + : args.first; + final help = _help[topic]; + if (help == null) throw FormatException('Unknown command: $topic.'); + if (json) { + output.report({'help': help}); + } else { + stdout.writeln(help); + } + return; + } + if (args.first == 'init') { + if (config != null || configPath != null) { + throw const FormatException( + 'Run init from the package CLI without --config.', + ); + } + await _initializeProject(args.skip(1).toList(), output); + return; + } + final projectCommand = + args.first == 'migrate' || args.first == 'generate' && args.length == 1; + if (config == null && projectCommand) { + final path = configPath ?? 'orm.config.dart'; + if (await File(path).exists()) { + final child = await Process.start(Platform.resolvedExecutable, [ + 'run', + path, + ...args, + if (json) '--json', + ], mode: ProcessStartMode.inheritStdio); + exitCode = await child.exitCode; + return; + } + if (configPath != null || args.first == 'migrate') { + throw FormatException( + 'Missing $path. Run dart run orm init --database .', + ); + } + args.add('lib/schema.dart'); + } else if (configPath != null) { + throw const FormatException( + '--config applies to generate without a path and migrate commands.', + ); + } + if (config != null && args.first == 'generate' && args.length == 1) { + await writeGeneratedSchema(config.schema, output: config.output); + output.report({ + 'generated': + config.output ?? p.setExtension(config.schema, '.orm.dart'), + }); + return; + } + if (config != null && args.first == 'migrate') { + var snapshot = config.snapshot; + if (args.length > 1 && args[1] == 'create') { + if (!(args.length == 3 || + args.length == 4 && args.last == '--allow-destructive') || + args[2].startsWith('--')) { + throw const FormatException( + 'Use migrate create [--allow-destructive].', + ); + } + // Generate before diffing so an edited model cannot silently use the + // snapshot compiled into this invocation's configuration. + final generated = await generateSchema( + config.schema, + outputPath: config.output, + ); + final client = + config.output ?? p.setExtension(config.schema, '.orm.dart'); + await File(client).parent.create(recursive: true); + await File(client).writeAsString(generated.dart); + final snapshotPath = client.endsWith('.orm.dart') + ? '${client.substring(0, client.length - '.orm.dart'.length)}.snapshot.dart' + : p.setExtension(client, '.snapshot.dart'); + await File(snapshotPath).writeAsString(generated.snapshotDart); + snapshot = generated.snapshot; + } + await runMigrationCli( + args.skip(1).toList(), + history: config.history, + directory: config.migrations, + schema: snapshot, + connect: config.connect, + renames: config.renames, + using: config.using, + json: json, + ); + return; + } + await _runExplicitCli(args, json: json); + } on FormatException catch (error) { + output.error(error.message, 64); + } on ArgumentError catch (_) { + output.error('Invalid command option or configuration. Use --help.', 64); + } catch (error) { + output.error(error.toString(), 1); + } +} + +final class _CliOutput(final bool json) { + void report(Map value) { + if (json) { + stdout.writeln(const JsonEncoder.withIndent(' ').convert(value)); + } else { + for (final entry in value.entries) { + stdout.writeln( + '${entry.key}: ${entry.value is String ? entry.value : jsonEncode(entry.value)}', + ); + } + } + } + + void error(String message, int code) { + stderr.writeln( + json ? jsonEncode({'error': message, 'exitCode': code}) : message, + ); + exitCode = code; + } +} diff --git a/lib/driver.dart b/lib/driver.dart new file mode 100644 index 00000000..84848dd1 --- /dev/null +++ b/lib/driver.dart @@ -0,0 +1,201 @@ +/// Raw SQL driver contracts. Independent of query builders and ORM sessions. +library; + +import 'values.dart'; +export 'values.dart'; + +enum SqlDialect { sqlite, postgres, mysql, mariadb } + +/// SQL text and separately bound values. Values are never interpolated into SQL. +final class SqlCommand { + final String sql; + final List parameters; + SqlCommand(this.sql, [List parameters = const []]) + : parameters = List.unmodifiable(parameters); +} + +sealed class Backend { + const Backend(); +} + +final class Postgres extends Backend { + const Postgres(); +} + +final class Sqlite extends Backend { + const Sqlite(); +} + +final class Mysql extends Backend { + const Mysql(); +} + +final class Mariadb extends Backend { + const Mariadb(); +} + +final class Capabilities { + final SqlDialect dialect; + final int maxParameters; + final bool returning; + final bool windowFunctions; + final bool streaming; + final bool cancellation; + + /// Honors statement deadlines, potentially by discarding the connection. + /// A timeout does not prove that a submitted write was rolled back. + final bool statementTimeout; + final bool exactDecimal; + final bool temporal; + const Capabilities({ + required this.dialect, + required this.maxParameters, + this.returning = true, + this.windowFunctions = true, + this.streaming = false, + this.cancellation = false, + bool? statementTimeout, + this.exactDecimal = false, + this.temporal = false, + }) : statementTimeout = statementTimeout ?? cancellation; +} + +final class SqlResult { + final List> rows; + final List columns; + final int affectedRows; + + /// Generated identity returned directly by the server, when available. + final int? lastInsertId; + const SqlResult( + this.rows, { + this.columns = const [], + this.affectedRows = 0, + this.lastInsertId, + }); +} + +abstract interface class SqlConnection { + /// Actual adapter state after the last completed request; null if unavailable. + bool? get transactionActive; + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }); + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }); + + /// Permanently discard a connection whose protocol or transaction is uncertain. + Future invalidate(); +} + +abstract interface class Driver { + Capabilities get capabilities; + Future run(Future Function(SqlConnection) action); + Future close(); +} + +/// A cancellation request. Await the operation itself to observe its actual +/// database outcome; completion can win a race with cancellation. +final class CancellationToken { + bool _cancelled = false; + final Set _listeners = {}; + bool get isCancelled => _cancelled; + void cancel() { + if (_cancelled) return; + _cancelled = true; + for (final listener in _listeners.toList()) { + _notify(listener); + } + _listeners.clear(); + } + + /// Adapter hook; returns a function that removes the listener. + /// Listener errors are isolated so every pending operation receives cancellation. + void Function() listen(void Function() listener) { + if (_cancelled) { + _notify(listener); + return () {}; + } + _listeners.add(listener); + return () => _listeners.remove(listener); + } + + void _notify(void Function() listener) { + try { + listener(); + } catch (_) {} + } +} + +final class ExecutionOptions { + final CancellationToken? cancellation; + + /// Time allowed to acquire a lease. Independent of statement [timeout]. + final Duration? acquireTimeout; + + /// Limit for each database statement/fetch, not time spent consuming rows. + final Duration? timeout; + const ExecutionOptions({ + this.cancellation, + this.timeout, + this.acquireTimeout, + }); + AcquisitionOptions get acquisition => + acquireTimeout == null && cancellation == null + ? const AcquisitionOptions() + : AcquisitionOptions(timeout: acquireTimeout, cancellation: cancellation); + void check() { + if (acquireTimeout != null && acquireTimeout! <= Duration.zero) { + throw ArgumentError.value(acquireTimeout, 'acquireTimeout'); + } + if (timeout != null && timeout! <= Duration.zero) { + throw ArgumentError.value(timeout, 'timeout'); + } + if (cancellation?.isCancelled ?? false) { + throw const OrmException( + 'OPERATION.CANCELLED', + 'Cancelled before starting a database operation.', + ); + } + } +} + +/// Limits waiting for a connection, including establishment and initialization. +/// Cancellation after acquisition does not cancel the session callback. +final class AcquisitionOptions { + final Duration? timeout; + final CancellationToken? cancellation; + const AcquisitionOptions({this.timeout, this.cancellation}); + void check() { + if (timeout != null && timeout! <= Duration.zero) { + throw ArgumentError.value(timeout, 'acquireTimeout'); + } + if (cancellation?.isCancelled ?? false) { + throw const OrmException( + 'OPERATION.CANCELLED', + 'Connection acquisition cancelled.', + ); + } + } +} + +abstract interface class SqlCursor { + Future fetch( + int count, { + ExecutionOptions options = const ExecutionOptions(), + }); + Future close(); +} + +/// Adapter classification. Retrying also requires a confirmed rollback and an +/// explicitly repeatable application callback; these flags alone are not enough. +abstract interface class SqlFailure implements Exception { + bool get retryTransaction; + + /// Retry the same COMMIT only while the adapter confirms the transaction is active. + bool get retryCommit; + bool get commitRejected; +} diff --git a/lib/drivers/mariadb.dart b/lib/drivers/mariadb.dart new file mode 100644 index 00000000..bb8771fb --- /dev/null +++ b/lib/drivers/mariadb.dart @@ -0,0 +1,5 @@ +/// MariaDB connections without ORM or generated model dependencies. +library; + +export '../driver.dart'; +export 'mysql.dart' show MariadbDriver, MariadbOptions, MysqlTls, MysqlFailure; diff --git a/lib/drivers/mysql.dart b/lib/drivers/mysql.dart new file mode 100644 index 00000000..1af68625 --- /dev/null +++ b/lib/drivers/mysql.dart @@ -0,0 +1,283 @@ +/// MySQL and MariaDB connections without ORM or generated model dependencies. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:mysql_client_plus/exception.dart' as mysql; +import 'package:mysql_client_plus/mysql_client_plus.dart' as mysql; +import 'package:mysql_client_plus/mysql_protocol.dart' as protocol; + +import '../driver.dart'; +export '../driver.dart'; + +part '../src/mysql/connection.dart'; +part '../src/mysql/values.dart'; + +enum MysqlTls { + /// TLS with certificate and hostname verification. + verifyFull, + + /// TLS without certificate verification. Use only on a trusted network. + require, + + /// Unencrypted transport. The client requires TLS for SHA-2 authentication. + disable, +} + +class MysqlOptions { + final Uri url; + final MysqlTls tls; + final Duration connectTimeout; + final Duration queryTimeout; + final SecurityContext? securityContext; + + const MysqlOptions({ + required this.url, + this.tls = MysqlTls.verifyFull, + this.connectTimeout = const Duration(seconds: 10), + this.queryTimeout = const Duration(seconds: 30), + this.securityContext, + }); +} + +final class MariadbOptions extends MysqlOptions { + const MariadbOptions({ + required super.url, + super.tls, + super.connectTimeout, + super.queryTimeout, + super.securityContext, + }); +} + +/// A server-reported error. Numeric codes are preserved; SQLSTATE is not +/// exposed by mysql_client_plus and is not guessed from the error message. +final class MysqlFailure implements SqlFailure { + final mysql.MySQLServerException cause; + const MysqlFailure(this.cause); + + int get code => cause.errorCode; + @override + bool get retryTransaction => code == 1213 || code == 1205; + @override + bool get retryCommit => false; + @override + bool get commitRejected => code == 1213; + @override + String toString() => 'MysqlFailure($code): ${cause.message}'; +} + +/// One physical connection. Whole leases are queued, including transactions. +/// Create additional drivers explicitly when independent concurrent leases are +/// required. Closing drains all leases accepted before close was called. +final class MysqlDriver extends _MysqlDriver { + MysqlDriver._( + mysql.MySQLConnection connection, + MysqlOptions options, + String serverVersion, + ) : super(connection, options, serverVersion, SqlDialect.mysql); + + static Future open(MysqlOptions options) async { + final opened = await _openMysql(options, SqlDialect.mysql); + return MysqlDriver._(opened.connection, options, opened.version); + } +} + +final class MariadbDriver extends _MysqlDriver { + MariadbDriver._( + mysql.MySQLConnection connection, + MariadbOptions options, + String serverVersion, + ) : super(connection, options, serverVersion, SqlDialect.mariadb); + + static Future open(MariadbOptions options) async { + final opened = await _openMysql(options, SqlDialect.mariadb); + return MariadbDriver._(opened.connection, options, opened.version); + } +} + +abstract class _MysqlDriver implements Driver { + final mysql.MySQLConnection _connection; + final MysqlOptions _options; + final SqlDialect _dialect; + final String serverVersion; + Future _tail = Future.value(); + Future? _closing; + bool _discarded = false; + + _MysqlDriver( + this._connection, + this._options, + this.serverVersion, + this._dialect, + ); + + @override + Capabilities get capabilities => Capabilities( + dialect: _dialect, + maxParameters: 65535, + returning: false, + streaming: false, + cancellation: false, + statementTimeout: true, + exactDecimal: true, + temporal: true, + ); + + @override + Future run(Future Function(SqlConnection) action) { + if (_closing != null) { + return Future.error( + const OrmException( + 'DRIVER.CLOSED', + 'MySQL driver is closing or closed.', + ), + ); + } + final result = _tail.then((_) async { + if (_discarded || !_connection.connected) { + throw const OrmException( + 'DRIVER.CLOSED', + 'MySQL connection has been discarded. Open a new driver.', + ); + } + final lease = _MysqlConnection(_connection, _options.queryTimeout); + try { + final result = await action(lease); + await lease._release(); + return result; + } catch (error, stack) { + try { + await lease._release(); + } catch (_) { + await lease.invalidate(); + } + Error.throwWithStackTrace(error, stack); + } finally { + if (lease._invalid) _discarded = true; + } + }); + _tail = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + @override + Future close() => _closing ??= _tail.then((_) async { + if (!_discarded && _connection.connected) { + try { + await _connection.close().timeout(_options.queryTimeout); + } catch (_) { + _connection.getSocket().destroy(); + rethrow; + } + } else { + _connection.getSocket().destroy(); + } + }); +} + +Future<({mysql.MySQLConnection connection, String version})> _openMysql( + MysqlOptions options, + SqlDialect dialect, +) async { + final url = options.url; + final schemes = dialect == SqlDialect.mysql + ? const {'mysql'} + : const {'mariadb', 'mysql'}; + if (!schemes.contains(url.scheme) || + url.host.isEmpty || + url.pathSegments.length != 1 || + url.pathSegments.single.isEmpty || + url.hasPort && (url.port < 1 || url.port > 65535) || + url.hasQuery || + url.hasFragment || + url.userInfo.isEmpty || + options.connectTimeout <= Duration.zero || + options.queryTimeout <= Duration.zero) { + throw ArgumentError( + 'Provide a ${dialect.name} URL with user and database, positive timeouts, ' + 'and typed options instead of URL query parameters.', + ); + } + final colon = url.userInfo.indexOf(':'); + final username = Uri.decodeComponent( + colon < 0 ? url.userInfo : url.userInfo.substring(0, colon), + ); + final password = colon < 0 + ? '' + : Uri.decodeComponent(url.userInfo.substring(colon + 1)); + if (username.isEmpty) throw ArgumentError('MySQL user must not be empty.'); + mysql.MySQLConnection? connection; + var expired = false; + try { + return await (() async { + final opened = await mysql.MySQLConnection.createConnection( + host: url.host, + port: url.hasPort ? url.port : 3306, + userName: username, + password: password, + databaseName: url.pathSegments.single, + secure: options.tls != MysqlTls.disable, + securityContext: options.securityContext, + // The upstream client's default accepts invalid certificates. + onBadCertificate: (_) => options.tls == MysqlTls.require, + ); + connection = opened; + if (expired) { + opened.getSocket().destroy(); + throw const OrmException('CONNECTION.TIMEOUT', 'Connection timed out.'); + } + await opened.connect( + timeoutMs: options.connectTimeout.inMilliseconds + .clamp(1, 2147483647) + .toInt(), + ); + final result = await opened.execute('SELECT VERSION()'); + final version = result.rows.single.colAt(0) as String; + final maria = version.toLowerCase().contains('mariadb'); + if (maria != (dialect == SqlDialect.mariadb)) { + throw OrmException( + 'DRIVER.ENGINE', + 'Expected ${dialect.name}, but the server reports $version.', + ); + } + final match = RegExp(r'^(\d+)\.(\d+)').firstMatch(version); + final major = match == null ? 0 : int.parse(match[1]!); + final minor = match == null ? 0 : int.parse(match[2]!); + if (maria + ? major < 10 || major == 10 && minor < 6 + : major < 8 || major == 8 && minor < 4) { + throw const OrmException( + 'DRIVER.VERSION', + 'MySQL 8.4+ or MariaDB 10.6+ is required.', + ); + } + // Do not silently coerce invalid dates, truncate values, or convert + // instants through the deployment host's time zone. + await opened.execute("SET SESSION time_zone = '+00:00'"); + await opened.execute( + "SET SESSION sql_mode = CONCAT_WS(',', NULLIF(@@SESSION.sql_mode, ''), " + "'ANSI_QUOTES', 'NO_BACKSLASH_ESCAPES', 'STRICT_ALL_TABLES', " + "'NO_ZERO_DATE', 'NO_ZERO_IN_DATE', 'ERROR_FOR_DIVISION_BY_ZERO', " + "'NO_ENGINE_SUBSTITUTION')", + ); + await opened.execute('SET SESSION autocommit = 1'); + return (connection: opened, version: version); + })().timeout(options.connectTimeout); + } on TimeoutException catch (error) { + expired = true; + connection?.getSocket().destroy(); + throw OrmException( + 'CONNECTION.TIMEOUT', + 'MySQL connection establishment timed out.', + cause: error, + ); + } catch (_) { + expired = true; + connection?.getSocket().destroy(); + rethrow; + } +} diff --git a/lib/drivers/postgres.dart b/lib/drivers/postgres.dart new file mode 100644 index 00000000..83aade44 --- /dev/null +++ b/lib/drivers/postgres.dart @@ -0,0 +1,454 @@ +/// PostgreSQL connection pooling with backend-specific configuration. +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:postgres/postgres.dart' as pg; + +import '../driver.dart'; +export '../driver.dart'; + +part '../src/postgres/temporal.dart'; + +enum PostgresTls { verifyFull, require, disable } + +final class PostgresOptions { + final Uri url; + final PostgresTls tls; + final int maxConnections; + final Duration connectTimeout; + + /// Driver pool queue limit; separate from opening a new connection. + final Duration poolTimeout; + final Duration queryTimeout; + final String applicationName; + final String? schema; + const PostgresOptions({ + required this.url, + this.tls = PostgresTls.verifyFull, + this.maxConnections = 8, + this.connectTimeout = const Duration(seconds: 10), + this.poolTimeout = const Duration(seconds: 30), + this.queryTimeout = const Duration(seconds: 30), + this.applicationName = 'dart-orm', + this.schema, + }); +} + +/// A server-reported error, preserving SQLSTATE and the original exception. +final class PostgresFailure implements SqlFailure { + final pg.ServerException cause; + const PostgresFailure(this.cause); + String? get code => cause.code; + @override + bool get retryTransaction => code == '40001' || code == '40P01'; + @override + bool get retryCommit => false; + @override + bool get commitRejected => code?.startsWith('23') == true || retryTransaction; + @override + String toString() => cause.toString(); +} + +final class PostgresDriver implements Driver { + final pg.Pool _pool; + final bool _ownsPool; + final bool _temporal; + final Future Function()? _cancelConnection; + final Expando _backendIds = Expando(); + final Duration? _queryTimeout; + final Duration? _connectTimeout; + bool _closed = false; + + PostgresDriver(PostgresOptions options) + : _pool = _createPool(options), + _ownsPool = true, + _temporal = true, + _queryTimeout = options.queryTimeout, + _connectTimeout = options.connectTimeout, + _cancelConnection = (() => _openControl(options)); + PostgresDriver.borrow( + pg.Pool pool, { + Future Function()? cancellationConnection, + this._queryTimeout, + // Enable only when the pool uses postgresTypeRegistry(). + this._temporal = false, + }) : _pool = pool, + _ownsPool = false, + _connectTimeout = null, + _cancelConnection = cancellationConnection; + + static Future _openControl(PostgresOptions options) { + final url = options.url, colon = options.url.userInfo.indexOf(':'); + return pg.Connection.open( + pg.Endpoint( + host: url.host, + port: url.hasPort ? url.port : 5432, + database: url.pathSegments.single, + username: url.userInfo.isEmpty + ? null + : Uri.decodeComponent( + colon < 0 ? url.userInfo : url.userInfo.substring(0, colon), + ), + password: colon < 0 + ? null + : Uri.decodeComponent(url.userInfo.substring(colon + 1)), + ), + settings: pg.ConnectionSettings( + connectTimeout: options.connectTimeout, + queryTimeout: const Duration(seconds: 5), + sslMode: switch (options.tls) { + PostgresTls.verifyFull => pg.SslMode.verifyFull, + PostgresTls.require => pg.SslMode.require, + PostgresTls.disable => pg.SslMode.disable, + }, + applicationName: '${options.applicationName}/cancel', + ), + ); + } + + static pg.Pool _createPool(PostgresOptions options) { + final url = options.url; + if (!{'postgres', 'postgresql'}.contains(url.scheme) || + url.host.isEmpty || + url.pathSegments.length != 1 || + options.maxConnections < 1) { + throw ArgumentError('Provide a PostgreSQL URL and a positive pool size.'); + } + if (options.queryTimeout <= Duration.zero || + options.poolTimeout <= Duration.zero || + options.connectTimeout <= Duration.zero) { + throw ArgumentError('PostgreSQL timeouts must be positive.'); + } + if (url.hasQuery || url.hasFragment) { + throw ArgumentError( + 'Use typed PostgresOptions instead of URL query options.', + ); + } + final colon = url.userInfo.indexOf(':'); + final user = colon < 0 ? url.userInfo : url.userInfo.substring(0, colon); + final password = colon < 0 + ? null + : Uri.decodeComponent(url.userInfo.substring(colon + 1)); + return pg.Pool.withEndpoints( + [ + pg.Endpoint( + host: url.host, + port: url.hasPort ? url.port : 5432, + database: url.pathSegments.single, + username: user.isEmpty ? null : Uri.decodeComponent(user), + password: password, + ), + ], + settings: pg.PoolSettings( + maxConnectionCount: options.maxConnections, + connectTimeout: options.poolTimeout, + queryTimeout: null, + applicationName: options.applicationName, + timeZone: 'UTC', + typeRegistry: postgresTypeRegistry(), + onOpen: options.schema == null + ? null + : (connection) async { + final schema = '"${options.schema!.replaceAll('"', '""')}"'; + await connection.execute( + pg.Sql( + r"SELECT pg_catalog.set_config('search_path', $1, false)", + types: [pg.Type.text], + ), + parameters: [schema], + ); + }, + sslMode: switch (options.tls) { + PostgresTls.verifyFull => pg.SslMode.verifyFull, + PostgresTls.require => pg.SslMode.require, + PostgresTls.disable => pg.SslMode.disable, + }, + ), + ); + } + + @override + Capabilities get capabilities => Capabilities( + dialect: SqlDialect.postgres, + maxParameters: 65535, + streaming: true, + cancellation: _cancelConnection != null, + exactDecimal: true, + temporal: _temporal, + ); + @override + Future run(Future Function(SqlConnection) action) async { + if (_closed) { + throw const OrmException('DRIVER.CLOSED', 'PostgreSQL driver is closed.'); + } + var acquired = false; + try { + return await _pool.withConnection( + (connection) { + acquired = true; + return action( + _PostgresConnection( + connection, + _cancelConnection, + _backendIds, + _queryTimeout, + ), + ); + }, + settings: _connectTimeout == null + ? null + : pg.ConnectionSettings(connectTimeout: _connectTimeout), + ); + } on TimeoutException catch (error) { + if (acquired) rethrow; + throw OrmException( + 'CONNECTION.TIMEOUT', + 'PostgreSQL connection acquisition timed out.', + cause: error, + ); + } + } + + @override + Future close() async { + if (_closed) return; + _closed = true; + if (_ownsPool) await _pool.close(); + } +} + +final class _PostgresConnection implements SqlConnection { + // package:postgres does not expose ReadyForQuery transaction status publicly. + @override + bool? get transactionActive => null; + final pg.Connection connection; + final Future Function()? _control; + final Expando _backendIds; + final Duration? _queryTimeout; + bool _busy = false; + int _cursorId = 0; + _PostgresConnection( + this.connection, + this._control, + this._backendIds, + this._queryTimeout, + ); + + // ResultStream is buffered only for this bounded statement. Its subscription + // has no hidden timeout task that could cancel a later statement. We own and + // await cancellation below, including connection-control cleanup. + Future _query(SqlCommand command) async { + final statement = await connection.prepare( + pg.Sql( + command.sql, + types: List.filled(command.parameters.length, pg.Type.unspecified), + ), + ); + try { + final rows = []; + final subscription = statement + .bind([ + for (final p in command.parameters) p is SqlReal ? p.value : p, + ]) + .listen(rows.add); + try { + await subscription.asFuture(); + final schema = await subscription.schema; + final jsonColumns = { + for (final (index, column) in schema.columns.indexed) + if (column.typeOid == pg.Type.json.oid || + column.typeOid == pg.Type.jsonb.oid) + index, + }; + // The driver parses JSON strings and JSON null into ordinary Dart values. + // Retain their distinction from SQL text and SQL NULL without reserializing. + final List> values = jsonColumns.isEmpty + ? rows + : [ + for (final row in rows) + [ + for (var i = 0; i < row.length; i++) + jsonColumns.contains(i) && !row.isSqlNull(i) + ? SqlJson(row[i]) + : row[i], + ], + ]; + return SqlResult( + values, + columns: [for (final c in schema.columns) c.columnName ?? ''], + affectedRows: await subscription.affectedRows, + ); + } finally { + await subscription.cancel(); + } + } finally { + await statement.dispose(); + } + } + + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + options.check(); + if (_busy) { + throw const OrmException( + 'SESSION.BUSY', + 'Await the active statement before using this connection again.', + ); + } + final timeout = options.timeout ?? _queryTimeout; + if (timeout != null && timeout <= Duration.zero) { + throw ArgumentError.value(timeout, 'timeout'); + } + if ((options.cancellation != null || timeout != null) && _control == null) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'A borrowed PostgreSQL pool needs a cancellation connection factory.', + ); + } + _busy = true; + Future? cancelling; + var completed = false, timedOut = false, statementStarted = false; + Timer? deadline; + void Function()? unsubscribe; + try { + if (options.cancellation != null || timeout != null) { + var backendId = _backendIds[connection.info]; + void cancel() { + if (completed || cancelling != null) return; + final pid = backendId; + cancelling = () async { + // PID discovery is part of the same deadline. Until it completes, + // discard this connection; the application SQL has not been sent. + if (pid == null) { + await connection.close(force: true); + return; + } + try { + final control = await _control!(); + try { + if (!completed) { + final result = await control.execute( + pg.Sql( + r'SELECT pg_cancel_backend($1)', + types: [pg.Type.integer], + ), + parameters: [pid], + ); + if (result.single.single != true && !completed) { + await connection.close(force: true); + } + } + } finally { + await control.close(force: true); + } + } catch (_) { + await connection.close(force: true); + rethrow; + } + }(); + // Observe failures immediately; the operation awaits cleanup below. + unawaited(cancelling!.catchError((Object _) {})); + } + + unsubscribe = options.cancellation?.listen(cancel); + if (timeout != null) { + deadline = Timer(timeout, () { + timedOut = true; + cancel(); + }); + } + options.check(); + backendId ??= _backendIds[connection.info] = + (await _query(SqlCommand('SELECT pg_backend_pid()'))) + .rows + .single + .single + as int; + options.check(); + if (timedOut) { + throw const OrmException( + 'OPERATION.TIMEOUT', + 'PostgreSQL PID discovery exceeded the statement deadline.', + ); + } + } + statementStarted = true; + return await _query(command); + } catch (error) { + if ((timedOut || options.cancellation?.isCancelled == true) && + (!statementStarted || + error is pg.ServerException && error.code == '57014')) { + throw OrmException( + timedOut ? 'OPERATION.TIMEOUT' : 'OPERATION.CANCELLED', + statementStarted ? 'PostgreSQL stopped the statement.' : 'PostgreSQL PID discovery was interrupted before the statement.', + cause: error, + ); + } + if (error is pg.ServerException) throw PostgresFailure(error); + rethrow; + } finally { + completed = true; + deadline?.cancel(); + unsubscribe?.call(); + try { + await cancelling; + } finally { + _busy = false; + } + } + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + final name = 'orm_cursor_${_cursorId++}'; + await execute( + SqlCommand( + 'DECLARE "$name" NO SCROLL CURSOR FOR ${command.sql}', + command.parameters, + ), + options: options, + ); + return _PostgresCursor(this, name); + } + + @override + Future invalidate() => connection.close(force: true); +} + +final class _PostgresCursor( + final _PostgresConnection connection, + final String name, +) implements SqlCursor { + bool _closed = false; + @override + Future fetch( + int count, { + ExecutionOptions options = const ExecutionOptions(), + }) { + if (_closed) throw const OrmException('CURSOR.CLOSED', 'Cursor has ended.'); + if (count < 1) throw ArgumentError.value(count, 'count'); + return connection.execute( + SqlCommand('FETCH FORWARD $count FROM "$name"'), + options: options, + ); + } + + @override + Future close() async { + if (_closed) return; + _closed = true; + try { + await connection.execute(SqlCommand('CLOSE "$name"')); + } on PostgresFailure catch (e) { + if (e.code != '25P02' && e.code != '34000') rethrow; + } + } +} diff --git a/lib/drivers/sqlite.dart b/lib/drivers/sqlite.dart new file mode 100644 index 00000000..3024bce6 --- /dev/null +++ b/lib/drivers/sqlite.dart @@ -0,0 +1,52 @@ +/// SQLite with background execution on native platforms and the web. +library; + +import '../driver.dart'; +import '../src/sqlite/options.dart'; +import '../src/sqlite/unsupported.dart' + if (dart.library.io) '../src/sqlite/native.dart' + if (dart.library.js_interop) '../src/sqlite/web.dart'; + +export '../driver.dart'; +export '../src/sqlite/failure.dart'; +export '../src/sqlite/options.dart'; + +final class SqliteDriver implements Driver { + final SqlConnection _connection; + final Future Function() _shutdown; + @override + final Capabilities capabilities; + Future _tail = Future.value(); + Future? _closing; + bool _closed = false; + SqliteDriver._(this._connection, this.capabilities, this._shutdown); + + static Future open(SqliteOptions options) async { + final opened = await connectSqlite(options); + if (opened.version < 3035000) { + await opened.close(); + throw const OrmException( + 'CAPABILITY.VERSION', + 'SQLite 3.35 or newer is required.', + ); + } + return SqliteDriver._(opened.connection, opened.capabilities, opened.close); + } + + @override + Future run(Future Function(SqlConnection) action) { + if (_closed) { + throw const OrmException('DRIVER.CLOSED', 'SQLite driver is closed.'); + } + // Queue the whole lease: no caller can enter another caller's transaction. + final result = _tail.then((_) => action(_connection)); + _tail = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + @override + Future close() { + _closed = true; + return _closing ??= _tail.then((_) => _shutdown()); + } +} diff --git a/lib/generate.dart b/lib/generate.dart new file mode 100644 index 00000000..2be108b0 --- /dev/null +++ b/lib/generate.dart @@ -0,0 +1,21 @@ +/// Development-time source generation and migration file operations. +/// Application code imports a generated client and a database entrypoint. +library; + +export 'src/generate.dart' + show + GenerationException, + GeneratedSchema, + generateSchema, + writeGeneratedSchema, + GeneratedQueries, + generateQueries, + writeGeneratedQueries, + checkGeneratedQueries, + checkSqlQueries, + ImportedSchema, + SchemaImportIssue, + importSchema, + writeMigrationRegistry, + writeMigration, + recordMigration; diff --git a/lib/mariadb.dart b/lib/mariadb.dart new file mode 100644 index 00000000..cc146938 --- /dev/null +++ b/lib/mariadb.dart @@ -0,0 +1,19 @@ +/// Typed MariaDB access with its own capabilities and migration target. +library; + +import 'drivers/mariadb.dart'; +import 'orm.dart'; +export 'drivers/mariadb.dart'; +export 'orm.dart'; + +Future> mariadb( + MariadbOptions options, { + void Function(QueryEvent)? onQuery, + void Function(AcquisitionEvent)? onAcquire, + void Function(DecodeEvent)? onDecode, +}) async => Database( + await MariadbDriver.open(options), + onQuery: onQuery, + onAcquire: onAcquire, + onDecode: onDecode, +); diff --git a/lib/migrate.dart b/lib/migrate.dart new file mode 100644 index 00000000..d2c05c94 --- /dev/null +++ b/lib/migrate.dart @@ -0,0 +1,59 @@ +/// Reviewable schema DDL with checksummed transactional and recoverable migrations. +library; + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +import 'runtime.dart'; +import 'schema_model.dart'; +export 'schema_model.dart' + show + SqlDialect, + TableSchema, + Column, + Codec, + Codecs, + ComputedColumn, + ComputedStorage, + CheckSchema, + ForeignKey, + IndexSchema, + OrmException; + +part 'src/migrate/schema.dart'; +part 'src/migrate/history.dart'; +part 'src/migrate/snapshot.dart'; +part 'src/migrate/source.dart'; +part 'src/migrate/catalog.dart'; +part 'src/migrate/step.dart'; +part 'src/migrate/diff.dart'; +part 'src/migrate/recovery.dart'; +part 'src/migrate/backfill.dart'; +part 'src/migrate/sqlite_checks.dart'; +part 'src/migrate/checks.dart'; +part 'src/migrate/computed.dart'; +part 'src/migrate/mysql_schema.dart'; +part 'src/migrate/mysql_catalog.dart'; +part 'src/migrate/mysql_diff.dart'; +part 'src/migrate/mysql_recovery.dart'; + +String _quote(String identifier) => '"${identifier.replaceAll('"', '""')}"'; + +Object? _canonical(Object? value) => switch (value) { + Map map => { + for (final key in map.keys.toList()..sort()) key: _canonical(map[key]), + }, + List list => list.map(_canonical).toList(), + _ => value, +}; +String _hash(Object? value) => + sha256.convert(utf8.encode(jsonEncode(_canonical(value)))).toString(); + +Object? _freezeJson(Object? value) => switch (value) { + Map map => Map.unmodifiable({ + for (final entry in map.entries) entry.key: _freezeJson(entry.value), + }), + List list => List.unmodifiable(list.map(_freezeJson)), + _ => value, +}; diff --git a/lib/migrate_cli.dart b/lib/migrate_cli.dart new file mode 100644 index 00000000..2d854631 --- /dev/null +++ b/lib/migrate_cli.dart @@ -0,0 +1,295 @@ +/// Project-owned migration command entrypoint with statically imported history. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'generate.dart'; +import 'migrate.dart'; +import 'runtime.dart'; + +typedef MigrationConnection = FutureOr> Function({ + required bool readOnly, +}); + +/// Runs a project's statically compiled migration history. Offline commands never +/// call [connect]. The connection factory must honor read-only storage requests. +Future runMigrationCli( + List arguments, { + required MigrationHistory history, + required String directory, + SchemaSnapshot? schema, + MigrationConnection? connect, + SchemaRenames renames = const SchemaRenames(), + Map> using = const {}, + bool json = true, +}) async { + void report(Map value) { + if (json) { + stdout.writeln(const JsonEncoder.withIndent(' ').convert(value)); + } else { + for (final entry in value.entries) { + stdout.writeln( + '${entry.key}: ${entry.value is String ? entry.value : jsonEncode(entry.value)}', + ); + } + } + } + + void fail(String message, int code) { + stderr.writeln( + json ? jsonEncode({'error': message, 'exitCode': code}) : message, + ); + exitCode = code; + } + + try { + if (arguments.isEmpty || arguments.singleOrNull == '--help') { + stdout.writeln('''Migration commands: + create [--allow-destructive] + record Record a reviewed, unpublished edit + check + plan + apply [--max-backfill-batches ] + status + verify + baseline + inspect
+Connection, target schema, renames and conversions belong in the Dart entrypoint. +Rebuild static imports with: dart run orm migration registry '''); + return; + } + final command = arguments.first; + final rest = arguments.skip(1).toList(); + final expected = + command == 'create' || command == 'record' || command == 'inspect' + ? 1 + : 0; + var destructive = false; + int? batches; + if (command == 'create' && rest.lastOrNull == '--allow-destructive') { + destructive = true; + rest.removeLast(); + } + if (command == 'apply' && + rest.length == 2 && + rest.first == '--max-backfill-batches') { + batches = int.tryParse(rest.last); + if (batches == null || batches < 1) { + throw const FormatException('Use a positive batch count.'); + } + rest.clear(); + } + if (!{ + 'create', + 'record', + 'check', + 'plan', + 'apply', + 'status', + 'verify', + 'baseline', + 'inspect', + }.contains(command) || + rest.length != expected || + rest.any((value) => value.startsWith('--'))) { + throw const FormatException('Invalid migration command. Use --help.'); + } + if (command == 'record') { + final checksum = await recordMigration( + rest.single, + directory: directory, + history: history, + ); + report({'recorded': rest.single, 'checksum': checksum}); + return; + } + final migrations = history.checked; + if (command == 'check') { + report({ + 'valid': true, + 'dialect': history.dialect.name, + 'migrations': migrations.map((m) => m.id).toList(), + }); + return; + } + if (command == 'create') { + if (schema == null) { + throw const FormatException( + 'Provide the target schema in the Dart entrypoint.', + ); + } + final previous = migrations.lastOrNull; + if (previous != null && previous.snapshot == null) { + throw const FormatException( + 'The previous migration needs a historical schema.', + ); + } + final change = Migration.diff( + rest.single, + dialect: history.dialect, + from: previous?.snapshot ?? SchemaSnapshot([]), + to: schema, + previous: previous?.checksum, + renames: renames, + using: using, + allowDestructive: destructive, + ); + if (change.steps.isEmpty) { + report({'created': null, 'reason': 'Schema is unchanged.'}); + } else { + final path = await writeMigration( + change, + directory: directory, + history: history, + ); + report({'created': path, 'checksum': change.checksum}); + } + return; + } + if (command == 'verify' && schema == null) { + throw const FormatException( + 'Provide the expected schema in the Dart entrypoint.', + ); + } + if (command == 'baseline' && + (migrations.isEmpty || migrations.last.snapshot == null)) { + throw const FormatException( + 'Baseline requires a final historical schema.', + ); + } + if (connect == null) { + throw const FormatException('Provide a database connection factory.'); + } + final db = await connect( + readOnly: !{'apply', 'baseline'}.contains(command), + ); + try { + if (db.dialect != history.dialect) { + throw OrmException( + 'MIGRATION.TARGET', + 'History targets ${history.dialect.name}, connection is ${db.dialect.name}.', + ); + } + final migrator = Migrator(db); + switch (command) { + case 'plan': + final pending = await migrator.plan(migrations); + report({ + 'atomic': !pending.any( + (m) => m.steps.any( + (s) => s is CheckedSql || s is CheckedTableSql || s is Backfill, + ), + ), + 'pending': [ + for (final m in pending) + { + 'id': m.id, + 'checksum': m.checksum, + 'steps': m.steps.map((s) => s.toJson()).toList(), + }, + ], + 'progress': (await migrator.progress()) + .map((p) => p.toJson()) + .toList(), + }); + case 'apply': + final applied = await migrator.apply( + migrations, + maxBackfillBatches: batches, + ); + report({ + 'applied': applied, + if (batches != null) + 'complete': (await migrator.plan(migrations)).isEmpty, + }); + case 'status': + report({ + 'applied': [ + for (final m in await migrator.history()) + {'id': m.id, 'checksum': m.checksum}, + ], + 'progress': (await migrator.progress()) + .map((p) => p.toJson()) + .toList(), + }); + case 'baseline' || 'verify': + final result = command == 'baseline' + ? await migrator.baseline( + migrations, + expected: migrations.last.snapshot!, + ) + : await verifySchema(db, schema!); + report({ + 'matches': result.matches, + 'differences': result.differences, + 'unmanaged': [ + for (final o in result.unmanaged) + {'kind': o.kind, 'name': o.name, 'definition': o.definition}, + ], + }); + if (!result.matches) exitCode = 2; + case 'inspect': + final table = await inspectTable(db, rest.single); + report({ + 'table': table.name, + 'columns': [ + for (final c in table.columns) + { + 'name': c.name, + 'storageType': c.storageType, + 'nullable': c.nullable, + 'default': c.defaultSql, + 'generated': c.generated, + if (c.computed case final computed?) + 'computed': { + 'expression': computed.expression(db.dialect), + 'storage': computed.storage.name, + }, + if (c.integerBits != null) 'integerBits': c.integerBits, + if (c.decimalPrecision != null) + 'decimalPrecision': c.decimalPrecision, + if (c.decimalPrecision != null) + 'decimalScale': c.decimalScale ?? 0, + if (c.temporalPrecision != null) + 'temporalPrecision': c.temporalPrecision, + if (c.collation != null) 'collation': c.collation, + }, + ], + 'primaryKey': table.primaryKey, + 'uniqueKeys': table.uniqueKeys, + 'foreignKeys': [ + for (final k in table.foreignKeys) + { + 'columns': k.columns, + 'target': k.target, + 'targetColumns': k.targetColumns, + 'onDelete': k.onDelete, + }, + ], + 'indexes': [ + for (final i in table.indexes) + {'name': i.name, 'columns': i.columns, 'unique': i.unique}, + ], + 'checks': [ + for (final check in table.checks) + {'name': check.name, 'expression': check.expression}, + ], + 'unmanaged': [ + for (final o in table.unmanaged) + {'kind': o.kind, 'name': o.name, 'definition': o.definition}, + ], + }); + } + } finally { + await db.close(); + } + } on FormatException catch (error) { + fail(error.message, 64); + } on ArgumentError catch (_) { + fail('Invalid migration argument. Use --help.', 64); + } catch (error) { + fail(error.toString(), 1); + } +} diff --git a/lib/mysql.dart b/lib/mysql.dart new file mode 100644 index 00000000..cb6d0ebf --- /dev/null +++ b/lib/mysql.dart @@ -0,0 +1,19 @@ +/// Typed MySQL access. Use drivers/mysql.dart for the independent raw driver. +library; + +import 'drivers/mysql.dart'; +import 'orm.dart'; +export 'drivers/mysql.dart'; +export 'orm.dart'; + +Future> mysql( + MysqlOptions options, { + void Function(QueryEvent)? onQuery, + void Function(AcquisitionEvent)? onAcquire, + void Function(DecodeEvent)? onDecode, +}) async => Database( + await MysqlDriver.open(options), + onQuery: onQuery, + onAcquire: onAcquire, + onDecode: onDecode, +); diff --git a/lib/orm.dart b/lib/orm.dart new file mode 100644 index 00000000..4164fb71 --- /dev/null +++ b/lib/orm.dart @@ -0,0 +1,16 @@ +/// Typed relational queries and explicit database sessions. +library; + +import 'dart:async'; + +import 'runtime.dart'; +import 'sql.dart'; +export 'sql.dart'; +export 'values.dart'; +export 'driver.dart'; +export 'runtime.dart'; +export 'schema_model.dart'; + +part 'src/database.dart'; +part 'src/observation.dart'; +part 'src/watch.dart'; diff --git a/lib/postgres.dart b/lib/postgres.dart new file mode 100644 index 00000000..79764da8 --- /dev/null +++ b/lib/postgres.dart @@ -0,0 +1,19 @@ +/// PostgreSQL ORM convenience entrypoint. Raw SQL drivers are in drivers/postgres.dart. +library; + +import 'orm.dart'; +import 'drivers/postgres.dart'; +export 'orm.dart'; +export 'drivers/postgres.dart'; + +Database postgres( + PostgresOptions options, { + void Function(QueryEvent)? onQuery, + void Function(AcquisitionEvent)? onAcquire, + void Function(DecodeEvent)? onDecode, +}) => Database( + PostgresDriver(options), + onQuery: onQuery, + onAcquire: onAcquire, + onDecode: onDecode, +); diff --git a/lib/runtime.dart b/lib/runtime.dart new file mode 100644 index 00000000..9fb2b17a --- /dev/null +++ b/lib/runtime.dart @@ -0,0 +1,48 @@ +/// Raw SQL sessions, transactions and cursor lifetimes without a query builder. +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'driver.dart'; +export 'driver.dart'; + +part 'src/runtime/database.dart'; +part 'src/runtime/session_connection.dart'; +part 'src/runtime/mysql_transaction.dart'; +part 'src/runtime/options.dart'; +part 'src/runtime/stream.dart'; +part 'src/execution.dart'; +part 'src/transaction.dart'; + +enum QueryOperation { execute, cursorOpen, cursorFetch, cursorClose } + +final class QueryEvent { + final QueryOperation operation; + final String sql; + final int parameterCount; + final Duration elapsed; + final int? rowCount; + final Object? error; + const QueryEvent({ + this.operation = QueryOperation.execute, + required this.sql, + required this.parameterCount, + required this.elapsed, + this.rowCount, + this.error, + }); +} + +/// Time until a driver lease is granted or acquisition fails. Includes native +/// pool wait/connection setup; it does not separate those driver internals. +final class AcquisitionEvent { + final Duration elapsed; + final bool reusedConnection; + final Object? error; + const AcquisitionEvent({ + required this.elapsed, + required this.reusedConnection, + this.error, + }); +} diff --git a/lib/schema.dart b/lib/schema.dart new file mode 100644 index 00000000..00f4d0a0 --- /dev/null +++ b/lib/schema.dart @@ -0,0 +1,144 @@ +/// Compile-time schema declarations. Key selectors are analyzed as source and +/// never called with synthetic row values. +library; + +import 'schema_model.dart' show Codec, ComputedStorage; +export 'schema_model.dart'; + +/// A public const codec reference. Generation reads its type and storage tag; +/// it never executes the application's encode/decode functions. +final class UseCodec { + final Codec codec; + const UseCodec(this.codec); +} + +/// Stable database text for an enum constant, independent of its Dart name. +final class EnumValue { + final String value; + const EnumValue(this.value); +} + +final class Id { + final bool generated; + const Id() : generated = false; + const Id.generated() : generated = true; +} + +final class Unique { + const Unique(); +} + +final class ColumnName { + final String name; + const ColumnName(this.name); +} + +final class Default { + final String expression; + const Default.sql(this.expression); +} + +/// Generates an omitted insert value in Dart. This never declares SQL DEFAULT. +/// The factory is referenced during generation, not invoked. +final class ClientDefault { + final T Function() factory; + const ClientDefault(this.factory); +} + +/// SQL computed by the database; omitted from generated writes. +final class Computed { + final String expression; + final String? sqlite, postgres, mysql, mariadb; + final ComputedStorage storage; + const Computed.sql( + this.expression, { + this.sqlite, + this.postgres, + this.mysql, + this.mariadb, + this.storage = ComputedStorage.stored, + }); +} + +/// Signed integer column storage. SQL expression results retain the int codec. +final class IntegerBits { + final int value; + const IntegerBits(this.value); +} + +/// Decimal column precision and scale, matching PostgreSQL NUMERIC(p, s). +final class DecimalDigits { + final int precision; + final int scale; + const DecimalDigits(this.precision, [this.scale = 0]); +} + +/// Fractional second digits (0..6) for time, local timestamp or UTC instant columns. +final class TemporalPrecision { + final int digits; + const TemporalPrecision(this.digits); +} + +enum ReferentialAction { restrict, cascade, setNull, setDefault, noAction } + +/// A fixed SQL file with declared result and parameter Record types. +/// Run query generation, then check the manifest against each target database. +SqlDeclaration sqlQuery({ + String? sqlite, + String? postgres, + String? mysql, + String? mariadb, +}) => SqlDeclaration(sqlite, postgres, mysql, mariadb); + +final class SqlDeclaration { + final String? sqlite, postgres, mysql, mariadb; + const SqlDeclaration(this.sqlite, this.postgres, this.mysql, this.mariadb); +} + +Entity entity({String? table}) => Entity(table); + +final class Entity { + final String? table; + const Entity(this.table); + EntityKey key(K Function(M) selector) => EntityKey(this, selector); + SchemaConstraint primaryKey(K Function(M) selector) => + const SchemaConstraint(); + SchemaConstraint unique(K Function(M) selector) => + const SchemaConstraint(); + + /// Trusted SQL using physical column names. Omitted name uses the declaration + /// name; explicit name: null preserves an unnamed imported constraint. + SchemaConstraint check( + String expression, { + String? name, + String? sqlite, + String? postgres, + String? mysql, + String? mariadb, + }) => const SchemaConstraint(); + SchemaConstraint index( + K Function(M) selector, { + String? name, + bool unique = false, + }) => const SchemaConstraint(); +} + +final class EntityKey { + final Entity entity; + final K Function(M) selector; + const EntityKey(this.entity, this.selector); + SchemaConstraint references( + EntityKey target, { + String? inverse, + ReferentialAction onDelete = ReferentialAction.restrict, + }) => const SchemaConstraint(); + + /// Read-only query navigation without a database foreign key. Matching rows + /// may be absent or duplicated; this declaration creates no write effects. + SchemaConstraint relatesTo(EntityKey target, {String? inverse}) => + const SchemaConstraint(); +} + +final class SchemaConstraint { + const SchemaConstraint(); +} diff --git a/lib/schema_model.dart b/lib/schema_model.dart new file mode 100644 index 00000000..da512345 --- /dev/null +++ b/lib/schema_model.dart @@ -0,0 +1,180 @@ +/// Physical table metadata shared by SQL compilation and schema migrations. +library; + +import 'driver.dart' show SqlDialect; +import 'values.dart'; +export 'driver.dart' show SqlDialect; +export 'values.dart'; + +final class Column { + final String name; + final Codec codec; + final bool nullable; + final bool generated; + final String? defaultSql; + final ComputedColumn? computed; + + /// Called once for an omitted value when constructing an insert. Prepared + /// mutations retain that value; compilation and updates never call this. + final T Function()? clientDefault; + + /// Signed integer storage width (16, 32 or 64). The default is 64. + /// This describes the column, not the result width of SQL arithmetic. + final int? integerBits; + final int? decimalPrecision; + final int? decimalScale; + final int? temporalPrecision; + const Column( + this.name, + this.codec, { + this.nullable = false, + this.generated = false, + this.defaultSql, + this.computed, + this.clientDefault, + this.integerBits, + this.decimalPrecision, + this.decimalScale, + this.temporalPrecision, + }); +} + +enum ComputedStorage { stored, virtual } + +/// Database-computed SQL using physical column names. +final class ComputedColumn { + final String sqlite, postgres; + final String? mysql, mariadb; + final ComputedStorage storage; + const ComputedColumn( + String expression, { + this.storage = ComputedStorage.stored, + }) : sqlite = expression, + postgres = expression, + mysql = expression, + mariadb = expression; + const ComputedColumn.forDialects({ + required this.sqlite, + required this.postgres, + this.mysql, + this.mariadb, + this.storage = ComputedStorage.stored, + }); + String expression(SqlDialect dialect) => switch (dialect) { + SqlDialect.sqlite => sqlite, + SqlDialect.postgres => postgres, + SqlDialect.mysql => + mysql ?? + (throw const OrmException( + 'SCHEMA.DIALECT', + 'Declare the MySQL expression explicitly.', + )), + SqlDialect.mariadb => + mariadb ?? + (throw const OrmException( + 'SCHEMA.DIALECT', + 'Declare the MariaDB expression explicitly.', + )), + }; +} + +final class ForeignKey { + final List columns; + final String target; + final List targetColumns; + final String onDelete; + const ForeignKey( + this.columns, + this.target, + this.targetColumns, { + this.onDelete = 'RESTRICT', + }); +} + +/// A row CHECK expression. A null name leaves naming to the database. +final class CheckSchema { + final String? name; + final String sqlite; + final String postgres; + final String? mysql, mariadb; + const CheckSchema(this.name, String expression) + : sqlite = expression, + postgres = expression, + mysql = expression, + mariadb = expression; + const CheckSchema.forDialects( + this.name, { + required this.sqlite, + required this.postgres, + this.mysql, + this.mariadb, + }); + String expression(SqlDialect dialect) => switch (dialect) { + SqlDialect.sqlite => sqlite, + SqlDialect.postgres => postgres, + SqlDialect.mysql => + mysql ?? + (throw const OrmException( + 'SCHEMA.DIALECT', + 'Declare the MySQL expression explicitly.', + )), + SqlDialect.mariadb => + mariadb ?? + (throw const OrmException( + 'SCHEMA.DIALECT', + 'Declare the MariaDB expression explicitly.', + )), + }; +} + +final class IndexSchema { + final String name; + final List columns; + final bool unique; + const IndexSchema(this.name, this.columns, {this.unique = false}); +} + +final class TableSchema { + final String name; + final List> columns; + final List primaryKey; + final List> uniqueKeys; + final List foreignKeys; + final List indexes; + final List checks; + final List> clientDefaults; + TableSchema( + this.name, { + required List> columns, + List primaryKey = const [], + List> uniqueKeys = const [], + List foreignKeys = const [], + List indexes = const [], + List checks = const [], + }) : columns = List.unmodifiable(columns), + clientDefaults = List.unmodifiable( + columns.where((c) => c.clientDefault != null), + ), + primaryKey = List.unmodifiable(primaryKey), + uniqueKeys = List.unmodifiable( + uniqueKeys.map(List.unmodifiable), + ), + foreignKeys = List.unmodifiable([ + for (final key in foreignKeys) + ForeignKey( + List.unmodifiable(key.columns), + key.target, + List.unmodifiable(key.targetColumns), + onDelete: key.onDelete, + ), + ]), + checks = List.unmodifiable(checks), + indexes = List.unmodifiable([ + for (final index in indexes) + IndexSchema( + index.name, + List.unmodifiable(index.columns), + unique: index.unique, + ), + ]); +} diff --git a/lib/sql.dart b/lib/sql.dart new file mode 100644 index 00000000..bd8e6bc5 --- /dev/null +++ b/lib/sql.dart @@ -0,0 +1,32 @@ +/// Typed SQL construction and result shaping, independent of connection runtimes. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'driver.dart'; +import 'schema_model.dart'; +export 'driver.dart'; +export 'schema_model.dart'; + +part 'src/sql_context.dart'; +part 'src/temporal_sql.dart'; +part 'src/decimal_sql.dart'; +part 'src/decimal_division.dart'; +part 'src/decimal_average.dart'; +part 'src/sql.dart'; +part 'src/named_sql.dart'; +part 'src/selection.dart'; +part 'src/table.dart'; +part 'src/query.dart'; +part 'src/plan.dart'; +part 'src/mutation.dart'; +part 'src/relation.dart'; +part 'src/batch.dart'; +part 'src/advanced.dart'; +part 'src/cte.dart'; +part 'src/cursor.dart'; +part 'src/union.dart'; +part 'src/query_reads.dart'; +part 'src/query_stream.dart'; diff --git a/lib/sqlite.dart b/lib/sqlite.dart new file mode 100644 index 00000000..b1ee7037 --- /dev/null +++ b/lib/sqlite.dart @@ -0,0 +1,19 @@ +/// SQLite ORM convenience entrypoint. Raw SQL drivers are in drivers/sqlite.dart. +library; + +import 'orm.dart'; +import 'drivers/sqlite.dart'; +export 'orm.dart'; +export 'drivers/sqlite.dart'; + +Future> sqlite( + SqliteOptions options, { + void Function(QueryEvent)? onQuery, + void Function(AcquisitionEvent)? onAcquire, + void Function(DecodeEvent)? onDecode, +}) async => Database( + await SqliteDriver.open(options), + onQuery: onQuery, + onAcquire: onAcquire, + onDecode: onDecode, +); diff --git a/lib/sqlite_web_worker.dart b/lib/sqlite_web_worker.dart new file mode 100644 index 00000000..7d235977 --- /dev/null +++ b/lib/sqlite_web_worker.dart @@ -0,0 +1,172 @@ +/// Compile a worker entry point whose main calls [runSqliteWebWorker]. +library; + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:sqlite3/wasm.dart' as sqlite; +import 'package:web/web.dart' as web; + +import 'driver.dart'; +import 'src/sqlite/execution.dart'; +import 'src/sqlite/web_build.dart'; +import 'src/sqlite/web_wire.dart'; + +void runSqliteWebWorker() { + final self = globalContext as web.DedicatedWorkerGlobalScope; + final server = _WorkerDatabase(); + Future tail = Future.value(); + self.onmessage = ((web.MessageEvent event) { + tail = tail.then((_) async { + final message = event.data! as JSArray; + final id = message[0]; + try { + final result = await server.handle( + (message[1]! as JSString).toDart, + message[2], + ); + self.postMessage([id, true.toJS, server.active?.toJS, result].toJS); + } catch (error) { + final JSArray details; + if (error is sqlite.SqliteException) { + details = [ + 'sqlite'.toJS, + error.message.toJS, + error.resultCode.toJS, + error.extendedResultCode.toJS, + ].toJS; + } else { + details = [ + (error is OrmException ? error.code : 'DRIVER.SQLITE').toJS, + error.toString().toJS, + ].toJS; + } + self.postMessage([id, false.toJS, server.active?.toJS, details].toJS); + } + }); + }).toJS; +} + +final class _WorkerDatabase { + sqlite.WasmSqlite3? engine; + sqlite.CommonDatabase? db; + sqlite.SimpleOpfsFileSystem? opfs; + sqlite.InMemoryFileSystem? memory; + SqliteExecutor? executor; + bool? get active => db == null ? null : !db!.autocommit; + + Future handle(String operation, JSAny? payload) async { + if (operation == 'hello') { + return [sqliteWebProtocol.toJS, sqliteWorkerBuild.toJS].toJS; + } + if (operation == 'open') { + if (engine != null) { + throw const OrmException('DRIVER.OPEN', 'Worker is already open.'); + } + final options = payload! as JSArray; + final wasm = (options[0]! as JSString).toDart; + final storage = (options[1]! as JSString).toDart; + try { + final web.Response response; + try { + response = await (globalContext as web.DedicatedWorkerGlobalScope) + .fetch( + wasm.toJS, + web.RequestInit(integrity: (options[3]! as JSString).toDart), + ) + .toDart; + if (!response.ok) throw StateError('HTTP ${response.status}'); + } catch (e) { + throw OrmException( + 'DRIVER.ASSET', + 'SQLite WASM fetch or integrity check failed.', + cause: e, + ); + } + final bytes = (await response.arrayBuffer().toDart).toDart; + final sqlite3 = engine = await sqlite.WasmSqlite3.load( + Uint8List.view(bytes), + ); + if (storage == 'opfs') { + opfs = await sqlite.SimpleOpfsFileSystem.loadFromStorage( + 'dart-orm/${(options[2]! as JSString).toDart}', + ); + sqlite3.registerVirtualFileSystem(opfs!, makeDefault: true); + } else if (storage == 'memory') { + memory = sqlite.InMemoryFileSystem(); + sqlite3.registerVirtualFileSystem(memory!, makeDefault: true); + } else { + throw const OrmException( + 'DRIVER.STORAGE', + 'Unknown browser storage.', + ); + } + final opened = db = sqlite3.open( + storage == 'memory' ? ':memory:' : '/database', + ); + executor = SqliteExecutor(opened); + // Sync OPFS handles own the database exclusively; a busy wait cannot + // coordinate another owner. Keep durable rollback-journal storage. + final maxParameters = configureSqlite( + opened, + journal: storage == 'memory' ? 'memory' : 'delete', + busyTimeout: Duration.zero, + fullSync: true, + ); + return [sqlite3.version.versionNumber.toJS, maxParameters.toJS].toJS; + } catch (_) { + close(); + rethrow; + } + } + final opened = db; + if (opened == null) { + throw const OrmException('DRIVER.CLOSED', 'SQLite worker is not open.'); + } + switch (operation) { + case 'execute': + return sqliteWebResult( + executor!.execute(sqliteWebDartCommand(payload)), + ); + case 'cursor': + return executor!.openCursor(sqliteWebDartCommand(payload)).toJS; + case 'fetch': + final parts = payload! as JSArray; + return sqliteWebResult( + executor!.fetch( + (parts[0]! as JSNumber).toDartInt, + (parts[1]! as JSNumber).toDartInt, + ), + ); + case 'release': + executor!.release((payload! as JSNumber).toDartInt); + return null; + case 'close': + close(); + return null; + default: + throw const OrmException( + 'DRIVER.PROTOCOL', + 'Unknown SQLite worker command.', + ); + } + } + + void close() { + if (executor != null) { + executor!.close(); + } else { + db?.close(); + } + executor = null; + db = null; + if (opfs case final fs?) { + engine?.unregisterVirtualFileSystem(fs); + fs.close(); + } + if (memory case final fs?) engine?.unregisterVirtualFileSystem(fs); + opfs = null; + memory = null; + } +} diff --git a/lib/src/advanced.dart b/lib/src/advanced.dart new file mode 100644 index 00000000..14fee654 --- /dev/null +++ b/lib/src/advanced.dart @@ -0,0 +1,359 @@ +part of '../sql.dart'; + +/// Explicit aliases support self joins and any number of joins without adding +/// a growing number of generic join-result types. +final class TableAlias { + final Table definition; + final F fields; + final _CteDefinition? _cte; + late final String _marker = _presenceName(); + TableAlias._(this.definition, [this._cte]) + : fields = definition.createFields(TableRef(definition.schema)); + String _presenceName() { + var name = '_orm_present'; + while (definition.schema.columns.any((c) => c.name.toLowerCase() == name)) { + name += '_'; + } + return name; + } + + Selection optional(Selection selection) => + _OptionalJoin(this, selection); + Selection get row => optional(definition.selectRow(fields)); + Expr nullable(Expr expression) => + Expr._(expression._node, expression.codec.nullable()); + Expr get isPresent => + Expr._(_Presence(this), Codecs.integer.nullable()).isNotNull(); +} + +final class _Join( + final TableAlias alias, + final Expr on, + final bool left, +); + +final class _Presence(final TableAlias alias) extends _Node { + @override + String writeSql(_Writer w) { + if (!w.leftJoins.contains(alias.fields.table)) { + throw const OrmException( + 'QUERY.OUTER_JOIN', + 'Optional projections require this alias to be left joined.', + ); + } + return '${w.quote(w.aliases[alias.fields.table]!)}.${w.quote(alias._marker)}'; + } +} + +final class _OptionalJoin( + final TableAlias alias, + final Selection selection, +) extends Selection { + @override + _Decoder _bind(_SelectionPlan plan) { + final marker = plan.column( + Expr._(_Presence(alias), Codecs.integer.nullable()), + ); + final decode = plan.optional(alias.fields.table, selection); + return (row) => row[marker] == null ? null : decode(row); + } +} + +final class _Subquery( + final Query query, { + final bool exists = false, +}) extends _Node { + @override + String writeSql(_Writer w) { + final (plan, _) = query._plan(); + if (plan.relations.isNotEmpty) { + throw const OrmException( + 'QUERY.SUBQUERY', + 'SQL subqueries cannot include batch-loaded relations.', + ); + } + if (w.dialect != query.database.dialect) { + throw const OrmException( + 'QUERY.DIALECT', + 'Subqueries must use the enclosing SQL dialect.', + ); + } + return '${exists ? 'EXISTS ' : ''}(${query._write(w, plan)})'; + } +} + +enum WindowFrame { rowsToCurrent, rowsAll, rangeToCurrent } + +Expr rowNumber({ + List> partitionBy = const [], + List orderBy = const [], +}) => Expr._( + _WindowNode( + _Function('ROW_NUMBER', const []), + List.unmodifiable(partitionBy), + List.unmodifiable(orderBy), + null, + ), + Codecs.integer, +); +Expr rank({ + List> partitionBy = const [], + required List orderBy, +}) => Expr._( + _WindowNode( + _Function('RANK', const []), + List.unmodifiable(partitionBy), + List.unmodifiable(orderBy), + null, + ), + Codecs.integer, +); + +final class _WindowNode( + final _Node function, + final List> partition, + final List order, + final WindowFrame? frame, +) extends _Node { + @override + String writeSql(_Writer w) { + if (function case final _DecimalAverage average) { + return average.writeAverage(w, window: this); + } + final clauses = []; + if (partition.isNotEmpty) { + clauses.add( + 'PARTITION BY ${partition.map((e) => e._node.write(w)).join(', ')}', + ); + } + if (order.isNotEmpty) { + clauses.add('ORDER BY ${order.map((o) => o._write(w)).join(', ')}'); + } + if (frame != null) { + clauses.add(switch (frame!) { + WindowFrame.rowsToCurrent => + 'ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + WindowFrame.rowsAll => + 'ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING', + WindowFrame.rangeToCurrent => + 'RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + }); + } + return '${function.writeSql(w)} OVER (${clauses.join(' ')})'; + } +} + +bool _aggregate(_Node node) => switch (node) { + _WindowNode() || _Subquery() || _RelationSubquery() => false, + _DecimalAverage() => true, + _Function(:final name) + when {'COUNT', 'SUM', 'MIN', 'MAX', 'AVG'}.contains(name) => + true, + _ => _children(node).any(_aggregate), +}; +bool _window(_Node node) => node is _WindowNode || _children(node).any(_window); +List<_Node> _children(_Node node) => switch (node) { + _TemporalNode(:final child) || _TemporalCast(:final child) => [child], + _DecimalAverage(:final child) || _AverageInput(:final child) => [child], + _DecimalRatio(:final numerator, :final divisor) => [numerator, ?divisor], + _DecimalNode(:final child) => [child], + _DecimalCast(:final child) => [child], + _DecimalArithmetic(:final left, :final right) => [left, right], + _Binary(:final left, :final right) => [left, right], + _Unary(:final child) => [child], + _Function(:final arguments) => arguments, + _In(:final expression, :final values) => [expression, ...values], + _Raw(:final values) => values, + _WindowNode(:final function, :final partition, :final order) => [ + function, + ...partition.map((e) => e._node), + ...order.map((e) => e.expression._node), + ], + _RelationSubquery(:final relation) => [ + for (final p in relation._parent) p._node, + ], + _ => [], +}; + +void _validateGrouping(_QueryState state, _SelectionPlan plan) { + final optional = { + for (final join in state.joins) + if (join.left) join.alias.fields.table, + }; + bool unguarded(MapEntry, List>> entry) { + final absent = optional.difference(entry.key); + return entry.value.any((e) => _outerNullable(e._node, absent)); + } + + if (plan.required.any((e) => _outerNullable(e._node, optional)) || + plan.guarded.entries.any(unguarded)) { + throw const OrmException( + 'QUERY.NULLABILITY', + 'Use alias.optional(selection) or alias.nullable(expression) for outer-joined fields.', + ); + } + final predicates = [ + if (state.predicate case final p?) p._node, + for (final j in state.joins) j.on._node, + ]; + if (predicates.any((p) => _aggregate(p) || _window(p))) { + throw const OrmException( + 'QUERY.AGGREGATE', + 'WHERE and JOIN predicates cannot contain aggregate or window functions.', + ); + } + if (state.group.any((e) => _aggregate(e._node) || _window(e._node)) || + (state.having != null && _window(state.having!._node))) { + throw const OrmException( + 'QUERY.AGGREGATE', + 'Invalid aggregate/window placement.', + ); + } + final checked = [ + for (final e in plan.columns) e._node, + for (final o in state.order) o.expression._node, + if (state.having case final h?) h._node, + ]; + if (state.group.isEmpty && !checked.any(_aggregate)) { + if (state.having != null) { + throw const OrmException( + 'QUERY.AGGREGATE', + 'HAVING requires grouping or aggregation.', + ); + } + return; + } + bool grouped(_Node node) { + if (state.group.any((g) => _sameSqlNode(g._node, node))) return true; + if (node is _DecimalAverage) return true; + if (node is _Function && + {'COUNT', 'SUM', 'MIN', 'MAX', 'AVG'}.contains(node.name)) { + return true; + } + if (node is _ColumnNode || node is _Presence) return false; + if (node is _WindowNode) { + return [ + ..._children(node.function), + ...node.partition.map((e) => e._node), + ...node.order.map((e) => e.expression._node), + ].every(grouped); + } + return _children(node).every(grouped); + } + + if (!checked.every(grouped)) { + throw const OrmException( + 'QUERY.GROUPING', + 'Select grouped expressions or aggregates; ungrouped columns are ambiguous.', + ); + } +} + +bool _outerNullable(_Node node, Set optional) => switch (node) { + _ColumnNode(:final table) => optional.contains(table), + _WindowNode() || _Raw() => false, + _Function(name: 'COUNT') => false, + _Unary(op: 'IS NULL' || 'IS NOT NULL') => false, + _ => _children(node).any((child) => _outerNullable(child, optional)), +}; + +bool _sameSqlNode(_Node a, _Node b) { + if (identical(a, b)) return true; + if (a is _WindowNode && b is _WindowNode) { + return a.frame == b.frame && + _sameSqlNode(a.function, b.function) && + a.partition.length == b.partition.length && + a.partition.indexed.every( + (e) => _sameSqlNode(e.$2._node, b.partition[e.$1]._node), + ) && + a.order.length == b.order.length && + a.order.indexed.every((e) { + final other = b.order[e.$1]; + return e.$2.descending == other.descending && + e.$2.nulls == other.nulls && + _sameSqlNode(e.$2.expression._node, other.expression._node); + }); + } + return switch ((a, b)) { + ( + _TemporalNode(child: final ac, kind: final ak), + _TemporalNode(child: final bc, kind: final bk), + ) => + ak == bk && _sameSqlNode(ac, bc), + ( + _DecimalAverage(child: final ac, scale: final ax, rounding: final ar), + _DecimalAverage(child: final bc, scale: final bx, rounding: final br), + ) => + ax == bx && ar == br && _sameSqlNode(ac, bc), + ( + _DecimalRatio( + numerator: final an, + divisor: final ad, + scale: final ax, + rounding: final ar, + ), + _DecimalRatio( + numerator: final bn, + divisor: final bd, + scale: final bx, + rounding: final br, + ), + ) => + ax == bx && + ar == br && + _sameSqlNode(an, bn) && + (ad == null ? bd == null : bd != null && _sameSqlNode(ad, bd)), + ( + _DecimalCast(child: final ac, precision: final ap, scale: final asc), + _DecimalCast(child: final bc, precision: final bp, scale: final bs), + ) => + ap == bp && asc == bs && _sameSqlNode(ac, bc), + ( + _TemporalCast(child: final ac, kind: final ak, digits: final ap), + _TemporalCast(child: final bc, kind: final bk, digits: final bp), + ) => + ak == bk && ap == bp && _sameSqlNode(ac, bc), + (_DecimalNode(child: final ac), _DecimalNode(child: final bc)) => + _sameSqlNode(ac, bc), + ( + _DecimalArithmetic(left: final al, op: final ao, right: final ar), + _DecimalArithmetic(left: final bl, op: final bo, right: final br), + ) => + ao == bo && _sameSqlNode(al, bl) && _sameSqlNode(ar, br), + ( + _ColumnNode(table: final at, name: final an), + _ColumnNode(table: final bt, name: final bn), + ) => + at == bt && an == bn, + ( + _Parameter(value: final av, sqlType: final at), + _Parameter(value: final bv, sqlType: final bt), + ) => + av == bv && at == bt, + ( + _Function( + name: final an, + arguments: final aa, + distinct: final ad, + decimal: final ax, + ), + _Function( + name: final bn, + arguments: final ba, + distinct: final bd, + decimal: final bx, + ), + ) => + an == bn && + ax == bx && + ad == bd && + aa.length == ba.length && + aa.indexed.every((e) => _sameSqlNode(e.$2, ba[e.$1])), + ( + _Binary(left: final al, op: final ao, right: final ar), + _Binary(left: final bl, op: final bo, right: final br), + ) => + ao == bo && _sameSqlNode(al, bl) && _sameSqlNode(ar, br), + _ => false, + }; +} diff --git a/lib/src/batch.dart b/lib/src/batch.dart new file mode 100644 index 00000000..e97a5dc1 --- /dev/null +++ b/lib/src/batch.dart @@ -0,0 +1,145 @@ +part of '../sql.dart'; + +/// Inserts consecutive rows of the same shape together, splitting at the +/// driver's parameter limit. All chunks share one transaction by default. +final class BatchInsert { + final QueryContext database; + final F _fields; + final _QueryState _state; + final List> _rows; + BatchInsert._( + this.database, + this._fields, + this._state, + List> rows, + ) : _rows = List.unmodifiable(rows); + + List _compile([_SelectionPlan? selection]) { + final commands = []; + var chunk = >[]; + List? shape; + var parameters = 0; + final extra = _Writer( + database.dialect, + {_state.source: 't0'}, + database: database, + exactDecimal: database.capabilities.exactDecimal, + temporal: database.capabilities.temporal, + ); + if (selection != null) { + for (final column in selection.columns) { + column._node.write(extra); + } + } + final limit = database.capabilities.maxParameters - extra.parameters.length; + void flush() { + if (chunk.isEmpty) return; + commands.add( + Mutation._( + database, + _fields, + _state, + _MutationKind.insert, + chunk.first, + rows: chunk, + )._compile(selection), + ); + chunk = []; + parameters = 0; + } + + for (final row in _rows) { + final actual = row.where((a) => a._value != null).toList(); + final columns = [for (final a in actual) a.field.definition.name]; + final writer = _Writer( + database.dialect, + {_state.source: 't0'}, + database: database, + exactDecimal: database.capabilities.exactDecimal, + temporal: database.capabilities.temporal, + ); + for (final a in actual) { + a._value!.write(writer); + } + final count = writer.parameters.length; + if (count > limit) { + throw const OrmException( + 'QUERY.PARAMETERS', + 'A single insert row exceeds the parameter limit.', + ); + } + final sameShape = + shape != null && + shape.length == columns.length && + columns.indexed.every((entry) => shape![entry.$1] == entry.$2); + if (!sameShape || parameters + count > limit || actual.isEmpty) flush(); + shape = columns; + chunk.add(row); + parameters += count; + if (actual.isEmpty) flush(); + } + flush(); + return List.unmodifiable(commands); + } + + List compile() => _compile(); + Future _run({ + _SelectionPlan? selection, + ExecutionOptions options = const ExecutionOptions(), + }) async { + options.check(); + final commands = _compile(selection); + if (commands.isEmpty) return const SqlResult([]); + Future execute(QueryContext db) async { + var count = 0; + final rows = >[]; + try { + for (final command in commands) { + final result = await db.executeCommand( + command, + options: options, + changedTables: [_state.source.schema], + affectedOnly: true, + cascade: false, + ); + count += result.affectedRows; + rows.addAll(result.rows); + } + } catch (_) { + // Cancellation can happen between statements, after earlier chunks + // succeeded. Catching it must not allow a partial batch to commit. + if (db.inTransaction) db.markFailed(); + rethrow; + } + return SqlResult(rows, affectedRows: count); + } + + return database.inTransaction + ? execute(database) + : database.atomic(execute, acquire: options.acquisition); + } + + Future execute({ + ExecutionOptions options = const ExecutionOptions(), + }) async => (await _run(options: options)).affectedRows; + BatchReturning returning(Selection Function(F) selection) => + BatchReturning._(this, selection(_fields)); +} + +final class BatchReturning { + final BatchInsert _batch; + final Selection _selection; + BatchReturning._(this._batch, this._selection); + Future> get({ + ExecutionOptions options = const ExecutionOptions(), + }) async { + final plan = _SelectionPlan(); + final decode = _selection._bind(plan); + final result = await _batch._run(selection: plan, options: options); + return _batch.database.observeDecode( + null, + result.rows.length, + () => [for (final row in result.rows) decode(row)], + ); + } +} diff --git a/lib/src/cli/commands.dart b/lib/src/cli/commands.dart new file mode 100644 index 00000000..18a71f1a --- /dev/null +++ b/lib/src/cli/commands.dart @@ -0,0 +1,341 @@ +part of '../../cli.dart'; + +Future _runExplicitCli( + List arguments, { + bool json = false, +}) async { + void report(Map value) => _CliOutput(json).report(value); + try { + if (arguments.first == 'web-assets') { + if (arguments.length > 2 || + arguments.skip(1).any((a) => a.startsWith('--'))) { + throw const FormatException( + 'web-assets expects at most one directory.', + ); + } + final directory = Directory( + arguments.length == 2 ? arguments[1] : 'web/orm', + ); + await copySqliteWebAssets(directory); + _CliOutput(json).report({'copied': directory.path}); + return; + } + if (arguments.first == 'generate') { + if (arguments.length < 2 || + arguments.length > 3 || + arguments.skip(1).any((a) => a.startsWith('--'))) { + throw const FormatException( + 'generate expects a schema and optional output path.', + ); + } + await writeGeneratedSchema( + arguments[1], + output: arguments.length == 3 ? arguments[2] : null, + ); + _CliOutput(json).report({ + 'generated': arguments.length == 3 + ? arguments[2] + : p.setExtension(arguments[1], '.orm.dart'), + }); + return; + } + if (arguments.length < 2) { + throw const FormatException('Expected a subcommand.'); + } + final command = '${arguments[0]} ${arguments[1]}'; + if (command == 'migration registry') { + if (arguments.length < 3 || + arguments[2].startsWith('--') || + arguments.length != 3 && + !(arguments.length == 5 && + arguments[3] == '--dialect' && + { + 'sqlite', + 'postgres', + 'mysql', + 'mariadb', + }.contains(arguments[4]))) { + throw const FormatException('migration registry expects a directory.'); + } + report({ + 'registry': await writeMigrationRegistry( + arguments[2], + dialect: arguments.length == 5 + ? SqlDialect.values.byName(arguments[4]) + : null, + ), + }); + return; + } + if (command == 'queries generate') { + if (arguments.length < 3 || + arguments.length > 4 || + arguments.skip(2).any((a) => a.startsWith('--'))) { + throw const FormatException( + 'queries generate expects a source and optional output path.', + ); + } + await writeGeneratedQueries( + arguments[2], + output: arguments.length == 4 ? arguments[3] : null, + ); + _CliOutput(json).report({ + 'generated': arguments.length == 4 + ? arguments[3] + : p.setExtension(arguments[2], '.queries.dart'), + }); + return; + } + final common = { + 'sqlite', + 'postgres-env', + 'mysql-env', + 'mariadb-env', + 'tls', + 'database-schema', + }; + final flags = switch (command) { + 'db inspect' => {...common, 'table'}, + 'db import' => {...common, 'output', 'table'}, + 'queries check' => {...common, 'source', 'output'}, + _ => throw FormatException('Unknown command: $command'), + }; + final (positionals, options) = _parse(arguments.skip(2).toList(), flags); + if (positionals.isNotEmpty) { + throw const FormatException('Unexpected positional arguments.'); + } + final queries = command == 'queries check' + ? await checkGeneratedQueries( + _required(options, 'source'), + output: options['output'], + ) + : null; + final table = command == 'db inspect' ? _required(options, 'table') : null; + final importOutput = command == 'db import' + ? _required(options, 'output') + : ''; + final importTables = options['table'] == null ? null : [options['table']!]; + if (command == 'db import') { + if (p.extension(importOutput) != '.dart') { + throw const FormatException( + 'Import output must be a .dart source file.', + ); + } + for (final path in [ + importOutput, + p.setExtension(importOutput, '.import.json'), + ]) { + if (await FileSystemEntity.type(path, followLinks: false) != + FileSystemEntityType.notFound) { + throw FormatException( + 'Import never replaces an existing output: $path', + ); + } + } + } + _databaseDialect(options); + final db = await _open(options, readOnly: true); + try { + switch (command) { + case 'queries check': + report({'queries': await checkSqlQueries(db, queries!)}); + case 'db inspect': + final info = await inspectTable(db, table!); + report({ + 'table': info.name, + 'columns': [ + for (final c in info.columns) + { + 'name': c.name, + 'storageType': c.storageType, + 'nullable': c.nullable, + 'default': c.defaultSql, + 'generated': c.generated, + if (c.computed case final computed?) + 'computed': { + 'expression': computed.expression(db.dialect), + 'storage': computed.storage.name, + }, + if (c.integerBits != null) 'integerBits': c.integerBits, + if (c.decimalPrecision != null) + 'decimalPrecision': c.decimalPrecision, + if (c.decimalPrecision != null) + 'decimalScale': c.decimalScale ?? 0, + if (c.temporalPrecision != null) + 'temporalPrecision': c.temporalPrecision, + if (c.collation != null) 'collation': c.collation, + }, + ], + 'primaryKey': info.primaryKey, + 'uniqueKeys': info.uniqueKeys, + 'foreignKeys': [ + for (final k in info.foreignKeys) + { + 'columns': k.columns, + 'target': k.target, + 'targetColumns': k.targetColumns, + 'onDelete': k.onDelete, + }, + ], + 'indexes': [ + for (final i in info.indexes) + {'name': i.name, 'columns': i.columns, 'unique': i.unique}, + ], + 'checks': [ + for (final check in info.checks) + {'name': check.name, 'expression': check.expression}, + ], + 'unmanaged': _objects(info.unmanaged), + }); + case 'db import': + final result = await importSchema(db, tables: importTables); + final output = File(importOutput); + final importReport = File( + p.setExtension(importOutput, '.import.json'), + ); + await output.parent.create(recursive: true); + await output.create(exclusive: true); + var reportCreated = false; + try { + await importReport.create(exclusive: true); + reportCreated = true; + await output.writeAsString(result.dart, flush: true); + await importReport.writeAsString( + '${const JsonEncoder.withIndent(' ').convert(result.toJson())}\n', + flush: true, + ); + } catch (_) { + await output.delete(); + if (reportCreated) await importReport.delete(); + rethrow; + } + report({ + 'source': output.path, + 'report': importReport.path, + ...result.toJson(), + }); + if (result.hasBlockingIssues) exitCode = 2; + } + } finally { + await db.close(); + } + } on FormatException catch (error) { + _CliOutput(json).error(error.message, 64); + } on ArgumentError catch (_) { + _CliOutput(json) + .error('Invalid command option or configuration. Use --help.', 64); + } catch (error) { + _CliOutput(json).error(error.toString(), 1); + } +} + +(List, Map) _parse( + List args, + Set allowed, +) { + final positionals = [], options = {}; + for (var i = 0; i < args.length; i++) { + final argument = args[i]; + if (!argument.startsWith('--')) { + positionals.add(argument); + continue; + } + final key = argument.substring(2); + if (!allowed.contains(key) || options.containsKey(key)) { + throw FormatException('Unknown or repeated option --$key.'); + } + if (++i == args.length || args[i].startsWith('--')) { + throw FormatException('Missing value for --$key.'); + } + options[key] = args[i]; + } + return (positionals, options); +} + +String _required(Map options, String key) => + options[key] ?? (throw FormatException('Missing --$key.')); +SqlDialect _databaseDialect(Map options) { + final selected = [ + 'sqlite', + 'postgres-env', + 'mysql-env', + 'mariadb-env', + ].where(options.containsKey).toList(); + if (selected.length != 1) { + throw const FormatException( + 'Choose exactly one of --sqlite, --postgres-env, --mysql-env and --mariadb-env.', + ); + } + if (options.containsKey('sqlite')) { + if (options.containsKey('tls') || options.containsKey('database-schema')) { + throw const FormatException('Server options cannot configure SQLite.'); + } + if (options['sqlite'] == ':memory:') { + throw const FormatException( + 'CLI commands need a persistent SQLite file.', + ); + } + return SqlDialect.sqlite; + } + final dialect = SqlDialect.values.byName( + selected.single.replaceFirst('-env', ''), + ); + if (dialect != SqlDialect.postgres && + options.containsKey('database-schema')) { + throw const FormatException( + '--database-schema configures PostgreSQL only.', + ); + } + return dialect; +} + +Future> _open( + Map options, { + required bool readOnly, +}) async { + final dialect = _databaseDialect(options); + if (dialect == SqlDialect.sqlite) { + final file = _required(options, 'sqlite'); + return SqlDatabase( + await SqliteDriver.open( + readOnly ? SqliteOptions.readOnly(file) : SqliteOptions.file(file), + ), + ); + } + final name = _required(options, '${dialect.name}-env'); + final value = Platform.environment[name]; + if (value == null || value.isEmpty) { + throw FormatException('Environment variable $name is empty or missing.'); + } + Uri url; + try { + url = Uri.parse(value); + } on FormatException { + throw const FormatException( + 'Database environment value is not a valid URL.', + ); + } + if (dialect == SqlDialect.mysql || dialect == SqlDialect.mariadb) { + final tls = MysqlTls.values.byName(options['tls'] ?? 'verifyFull'); + return SqlDatabase( + dialect == SqlDialect.mysql + ? await MysqlDriver.open(MysqlOptions(url: url, tls: tls)) + : await MariadbDriver.open(MariadbOptions(url: url, tls: tls)), + ); + } + return SqlDatabase( + PostgresDriver( + PostgresOptions( + url: url, + tls: PostgresTls.values.byName(options['tls'] ?? 'verifyFull'), + schema: options['database-schema'], + maxConnections: 1, + ), + ), + ); +} + +List _objects(List objects) => [ + for (final o in objects) + {'kind': o.kind, 'name': o.name, 'definition': o.definition}, +]; diff --git a/lib/src/cli/init.dart b/lib/src/cli/init.dart new file mode 100644 index 00000000..f5d32c69 --- /dev/null +++ b/lib/src/cli/init.dart @@ -0,0 +1,139 @@ +part of '../../cli.dart'; + +Future _initializeProject(List args, _CliOutput output) async { + final (positionals, options) = _parse(args, {'database'}); + if (positionals.isNotEmpty) { + throw const FormatException('init accepts no positional arguments.'); + } + final engine = _required(options, 'database'); + if (!{'sqlite', 'postgres', 'mysql', 'mariadb'}.contains(engine)) { + throw const FormatException( + 'Choose --database sqlite, postgres, mysql or mariadb.', + ); + } + if (!await File('pubspec.yaml').exists()) { + throw const FormatException( + 'Run init in a Dart project containing pubspec.yaml and the orm dependency.', + ); + } + const source = 'lib/schema.dart'; + const client = 'lib/schema.orm.dart'; + const snapshot = 'lib/schema.snapshot.dart'; + const registry = 'migrations/migrations.g.dart'; + const config = 'orm.config.dart'; + const paths = [source, client, snapshot, registry, config]; + for (final path in paths) { + if (await FileSystemEntity.type(path, followLinks: false) != + FileSystemEntityType.notFound) { + throw FormatException('init never replaces an existing file: $path'); + } + } + final migrations = Directory('migrations'); + if (await migrations.exists() && + !await migrations.list(followLinks: false).isEmpty) { + throw const FormatException( + 'init requires an empty migrations directory; existing history needs explicit configuration.', + ); + } + final saved = []; + Future save(String path, String content) async { + final file = File(path); + await file.parent.create(recursive: true); + await file.create(exclusive: true); + saved.add(file); + await file.writeAsString(content, flush: true); + } + + try { + await save(source, _initialSchema); + final generated = await generateSchema(source, outputPath: client); + await save(client, generated.dart); + await save(snapshot, generated.snapshotDart); + final dialect = SqlDialect.values.byName(engine); + final formatter = DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ); + await save( + registry, + formatter.format(migrationHistorySource([], dialect: dialect)), + ); + await save(config, formatter.format(_initialConfig(engine))); + } catch (_) { + for (final file in saved.reversed) { + await file.delete(); + } + rethrow; + } + output.report({ + 'database': engine, + 'created': paths, + 'next': [ + 'dart run orm migrate create 0001_initial', + 'dart run orm migrate apply', + ], + }); +} + +const _initialSchema = '''import 'package:orm/schema.dart'; + +final class Task({ + @Id.generated() required final int id, + required final String title, + @Default.sql('false') required final bool done, +}); + +final tasks = entity(); +'''; + +String _initialConfig(String engine) { + final connection = switch (engine) { + 'sqlite' => + '''return SqlDatabase(await SqliteDriver.open( + readOnly + ? const SqliteOptions.readOnly('app.sqlite') + : const SqliteOptions.file('app.sqlite'), + ));''', + 'postgres' => + '''return SqlDatabase(PostgresDriver(PostgresOptions( + url: databaseUrl(), + maxConnections: 1, + )));''', + 'mysql' => + '''return SqlDatabase(await MysqlDriver.open(MysqlOptions( + url: databaseUrl(), + )));''', + 'mariadb' => + '''return SqlDatabase(await MariadbDriver.open(MariadbOptions( + url: databaseUrl(), + )));''', + _ => throw ArgumentError.value(engine), + }; + return '''${engine == 'sqlite' ? '' : "import 'dart:io';"} +import 'package:orm/cli.dart'; +import 'package:orm/drivers/$engine.dart'; +import 'lib/schema.snapshot.dart' as target; +import 'migrations/migrations.g.dart'; + +Future main(List args) => runOrmCli(args, config: OrmConfig( + schema: 'lib/schema.dart', + migrations: 'migrations', + history: migrationHistory, + snapshot: target.schema, + connect: ({required bool readOnly}) async { + $connection + }, +)); +${engine == 'sqlite' ? '' : ''' +Uri databaseUrl() { + final value = Platform.environment['DATABASE_URL']; + if (value == null || value.isEmpty) { + throw const FormatException('DATABASE_URL is empty or missing.'); + } + try { + return Uri.parse(value); + } on FormatException { + throw const FormatException('DATABASE_URL is not a valid database URL.'); + } +} +'''}'''; +} diff --git a/lib/src/codec.dart b/lib/src/codec.dart new file mode 100644 index 00000000..1c5e3f33 --- /dev/null +++ b/lib/src/codec.dart @@ -0,0 +1,189 @@ +part of '../values.dart'; + +/// A non-SQL-null JSON document. Its value may itself be JSON null. +/// Drivers use this envelope for parsed JSON, including JSON string scalars. +final class SqlJson { + final Object? value; + const SqlJson(this.value); +} + +/// Explicit floating-point SQL input. JavaScript cannot distinguish an +/// integral double from int by runtime type; this preserves the SQL intention. +final class SqlReal { + final double value; + const SqlReal(this.value); +} + +/// Storage and Dart values meet only at this boundary. +final class Codec { + final String sqlType; + // Nullable wrappers share a comparison identity, while remaining distinct + // from a codec whose own decoder handles SQL NULL. + final Object? _identity; + + /// Whether two codecs have the same storage and decoding identity. + /// Nullable wrappers preserve identity; independently mapped codecs do not. + bool sameStorageAs(Codec other) => + (_identity ?? this) == (other._identity ?? other); + final T Function(Object? value) _decode; + final Object? Function(T value) _encode; + + const Codec(this.sqlType, this._decode, this._encode) : _identity = null; + Codec._nullable(this.sqlType, this._decode, this._encode, this._identity); + const Codec.text(this._decode, String Function(T value) encode) + : _identity = null, + sqlType = 'text', + _encode = encode; + const Codec.integer(this._decode, int Function(T value) encode) + : _identity = null, + sqlType = 'integer', + _encode = encode; + T decode(Object? value) => _decode(value); + Object? encode(T value) => _encode(value); + bool get acceptsNull => null is T; + + Codec nullable() => Codec._nullable( + sqlType, + (value) => value == null ? null : decode(value), + (value) => value == null ? null : encode(value), + _identity ?? (this, #nullable), + ); + + Codec map(R Function(T) from, T Function(R) to) => Codec( + sqlType, + (value) => from(decode(value)), + (value) => encode(to(value)), + ); +} + +abstract final class Codecs { + static const date = Codec('date', _decodeDate, _encodeDate); + static LocalDate _decodeDate(Object? value) => switch (value) { + LocalDate() => value, + String() => LocalDate.parse(value), + _ => throw const FormatException('Expected a local date or date text.'), + }; + static String _encodeDate(LocalDate value) => value.toString(); + static const time = Codec('time', _decodeTime, _encodeTime); + static LocalTime _decodeTime(Object? value) => switch (value) { + LocalTime() => value, + String() => LocalTime.parse(value), + _ => throw const FormatException('Expected a local time or time text.'), + }; + static String _encodeTime(LocalTime value) => value.toString(); + static const localDateTime = Codec( + 'local_datetime', + _decodeLocalDateTime, + _encodeLocalDateTime, + ); + static LocalDateTime _decodeLocalDateTime(Object? value) => switch (value) { + LocalDateTime() => value, + String() => LocalDateTime.parse(value), + _ => throw const FormatException( + 'Expected a local timestamp or timestamp text.', + ), + }; + static String _encodeLocalDateTime(LocalDateTime value) => value.toString(); + static const decimal = Codec( + 'decimal', + _decodeDecimal, + _encodeDecimal, + ); + static Decimal _decodeDecimal(Object? value) => switch (value) { + String() => Decimal.parse(value), + int() => Decimal.fromBigInt(BigInt.from(value)), + BigInt() => Decimal.fromBigInt(value), + _ => throw const FormatException( + 'Exact decimal storage requires text or an integer.', + ), + }; + static String _encodeDecimal(Decimal value) => value.toString(); + + /// Enum storage labels are explicit and independent of declaration ordinals. + static Codec enumeration(Map labels) { + final encode = Map.unmodifiable(labels); + final decode = {for (final entry in encode.entries) entry.value: entry.key}; + if (encode.isEmpty || decode.length != encode.length) { + throw ArgumentError('Enum mappings must contain unique storage labels.'); + } + return Codec( + 'text', + (value) { + if (decode[value] case final result?) return result; + throw const OrmException('CODEC.ENUM', 'Unknown stored enum value.'); + }, + (value) { + if (encode[value] case final result?) return result; + throw const OrmException( + 'CODEC.ENUM', + 'Enum value has no storage label.', + ); + }, + ); + } + + // PostgreSQL SUM(BIGINT) returns NUMERIC text. Parse exactly and reject + // overflow instead of rounding through double. + static final integer = Codec( + 'integer', + (v) => v is int ? v : int.parse(v as String), + (v) => v, + ); + static final bigint = Codec( + 'bigint', + (v) => v is BigInt ? v : BigInt.parse(v.toString()), + (v) => v.toString(), + ); + static final real = Codec( + 'real', + (v) => v is String ? double.parse(v) : (v as num).toDouble(), + (v) => + const bool.fromEnvironment('dart.library.js_interop') ? SqlReal(v) : v, + ); + static final text = Codec('text', (v) => v as String, (v) => v); + static final boolean = Codec( + 'boolean', + (v) => switch (v) { + true || 1 => true, + false || 0 => false, + _ => throw FormatException('Invalid SQL boolean: $v'), + }, + (v) => v, + ); + static const dateTime = Codec( + 'instant', + _decodeInstant, + _encodeInstant, + ); + static final bytes = Codec( + 'blob', + (v) => v as Uint8List, + (v) => v, + ); + static const json = Codec('json', _decodeJson, jsonEncode); + static const jsonDocument = Codec( + 'json', + _jsonDocument, + _encodeDocument, + ); + static Object? _decodeJson(Object? value) => switch (value) { + SqlJson() => value.value, + String() => jsonDecode(value), + _ => value, + }; + static SqlJson _jsonDocument(Object? value) => switch (value) { + null => throw const FormatException('SQL NULL is not a JSON document.'), + SqlJson() => value, + _ => SqlJson(_decodeJson(value)), + }; + static String _encodeDocument(SqlJson value) => jsonEncode(value.value); +} + +final class OrmException implements Exception { + final String code; + final String message; + final Object? cause; + const OrmException(this.code, this.message, {this.cause}); + @override + String toString() => 'OrmException($code): $message'; +} diff --git a/lib/src/cte.dart b/lib/src/cte.dart new file mode 100644 index 00000000..9757c93f --- /dev/null +++ b/lib/src/cte.dart @@ -0,0 +1,124 @@ +part of '../sql.dart'; + +abstract interface class _CteDefinition { + String get name; + String _writeDefinition(_Writer writer); +} + +/// A CTE exports SQL expression identities. Dart mapper properties are not +/// mistaken for SQL columns. ref() accepts only expressions the source selected. +final class Cte implements _CteDefinition { + final Query _source; + @override + final String name; + final _SelectionPlan _plan; + final _Decoder _decode; + late final Table> _table = _makeTable(); + + factory Cte._(Query source, String name) { + if (name.isEmpty) throw ArgumentError('CTE name cannot be empty.'); + final (plan, decode) = source._plan( + deduplicate: _sqlRowShape(source._selection) == null, + ); + if (plan.relations.isNotEmpty) { + throw const OrmException( + 'QUERY.CTE', + 'A CTE contains SQL columns, not batch-loaded relations.', + ); + } + return Cte._planned(source, name, plan, decode); + } + Cte._planned(this._source, this.name, this._plan, this._decode); + + Table> _makeTable() { + final optional = { + for (final join in [..._source._state.joins, ..._plan.joins]) + if (join.left) join.alias.fields.table, + }; + final nullable = [ + for (final column in _plan.columns) + column.codec.acceptsNull || _outerNullable(column._node, optional), + ]; + final schema = TableSchema( + name, + columns: [ + for (var i = 0; i < _plan.columns.length; i++) + Column( + 'c$i', + nullable[i] + ? _plan.columns[i].codec.nullable() + : _plan.columns[i].codec, + nullable: nullable[i], + ), + ], + ); + return Table( + schema, + (table) => CteFields._(table, _source._fields, _plan, nullable), + (fields) => _ReboundSelection(_decode, [ + for (var i = 0; i < _plan.columns.length; i++) + Expr._(_ColumnNode(fields.table, 'c$i'), schema.columns[i].codec), + ], source: _source._selection), + ); + } + + Query> get query { + final fields = _table.createFields(TableRef(_table.schema)); + return Query._( + _source.database, + fields, + _QueryState(fields.table, ctes: [this]), + _table.selectRow(fields), + ); + } + + TableAlias> alias() => TableAlias._(_table, this); + + @override + String _writeDefinition(_Writer writer) => + '${writer.quote(name)} ' + '(${[for (var i = 0; i < _plan.columns.length; i++) writer.quote('c$i')].join(', ')}) ' + 'AS (${_source._write(writer, _plan)})'; +} + +final class CteFields extends Fields { + final F _original; + final _SelectionPlan _plan; + final List _nullable; + CteFields._(super.table, this._original, this._plan, this._nullable); + + Expr ref(Expr Function(F) expression) { + final original = expression(_original); + final index = _plan.columns.indexWhere( + (e) => _sameSqlNode(e._node, original._node), + ); + if (index < 0) { + throw const OrmException( + 'QUERY.CTE_COLUMN', + 'The CTE did not export this SQL expression.', + ); + } + if (_nullable[index] && !original.codec.acceptsNull) { + throw const OrmException( + 'QUERY.NULLABILITY', + 'This CTE column is nullable; reference it with a nullable expression.', + ); + } + return Expr._(_ColumnNode(table, 'c$index'), original.codec); + } +} + +final class _ReboundSelection( + final _Decoder decode, + final List> columns, { + final Selection? source, +}) extends Selection { + @override + _Decoder _bind(_SelectionPlan plan) { + for (final column in columns) { + plan.require(column); + } + final indices = [for (final column in columns) plan.column(column)]; + return (row) => decode([for (final index in indices) row[index]]); + } +} diff --git a/lib/src/cursor.dart b/lib/src/cursor.dart new file mode 100644 index 00000000..dd2375d0 --- /dev/null +++ b/lib/src/cursor.dart @@ -0,0 +1,215 @@ +part of '../sql.dart'; + +enum NullOrder { first, last } + +final class CursorTerm { + final OrderTerm order; + final Object? _value; + const CursorTerm._(this.order, this._value); +} + +extension KeysetQuery on Query { + Query seekAfter(List Function(F) cursor) { + final terms = List.unmodifiable(cursor(_fields)); + _validateCursor(terms.map((t) => t.order).toList()); + _Node? predicate; + _Node? prefix; + for (final term in terms) { + final node = term.order.expression._node; + final last = term._value; + final isNull = _Unary('IS NULL', node, postfix: true); + _Node comparison; + if (last == null) { + comparison = term.order.nulls == NullOrder.first + ? _Unary('IS NOT NULL', node, postfix: true) + : _Raw(const ['FALSE'], const []); + } else { + comparison = _Binary( + node, + term.order.descending ? '<' : '>', + _Parameter(last, storageType: term.order.expression.codec.sqlType), + ); + if (term.order.nulls == NullOrder.last) { + comparison = _Binary(comparison, 'OR', isNull); + } + } + final branch = prefix == null + ? comparison + : _Binary(prefix, 'AND', comparison); + predicate = predicate == null ? branch : _Binary(predicate, 'OR', branch); + final equal = last == null + ? isNull + : _Binary( + node, + '=', + _Parameter( + last, + storageType: term.order.expression.codec.sqlType, + ), + ); + prefix = prefix == null ? equal : _Binary(prefix, 'AND', equal); + } + final next = Expr._(predicate!, Codecs.boolean.nullable()); + return _copy( + _state.copy( + predicate: _state.predicate?.and(next) ?? next, + order: [for (final term in terms) term.order], + ), + ); + } + + /// Versioned, lossless cursor transport. This is pagination input, not an + /// authorization token. The next query still applies its own WHERE policies. + String cursorToken(List Function(F) cursor) { + final terms = cursor(_fields); + _validateCursor(terms.map((t) => t.order).toList()); + final payload = { + 'version': 1, + 'table': _state.source.schema.name, + 'order': _cursorShape(terms.map((t) => t.order).toList()), + 'values': [for (final term in terms) _encodeCursorValue(term._value)], + }; + return base64Url.encode(utf8.encode(jsonEncode(payload))); + } + + Query seekToken( + String token, { + required List Function(F) orderBy, + }) { + final order = orderBy(_fields); + _validateCursor(order); + try { + final payload = jsonDecode( + utf8.decode(base64Url.decode(token)), + ) as Map; + if (payload['version'] != 1 || + payload['table'] != _state.source.schema.name || + jsonEncode(payload['order']) != jsonEncode(_cursorShape(order))) { + throw const FormatException( + 'Cursor belongs to another schema or sort order.', + ); + } + final values = payload['values'] as List; + if (values.length != order.length) { + throw const FormatException('Cursor arity differs.'); + } + final terms = []; + for (var i = 0; i < order.length; i++) { + final codec = order[i].expression.codec; + final decoded = codec.decode(_decodeCursorValue(values[i])); + terms.add(CursorTerm._(order[i], codec.encode(decoded))); + } + return seekAfter((_) => terms); + } catch (e) { + throw OrmException( + 'QUERY.CURSOR', + 'Invalid cursor for this query.', + cause: e, + ); + } + } + + void _validateCursor(List order) { + if (order.isEmpty || + _state.joins.isNotEmpty || + _state.group.isNotEmpty || + _state.distinct || + _state.offset != null || + _state.union != null || + _state.ctes.isNotEmpty) { + throw const OrmException( + 'QUERY.CURSOR', + 'Keyset pagination requires an ungrouped table query without joins, DISTINCT or offset.', + ); + } + final names = {}; + for (final term in order) { + final node = _unwrapStorage(term.expression._node); + if (node is! _ColumnNode || + node.table != _state.source || + !names.add(node.name)) { + throw const OrmException( + 'QUERY.CURSOR', + 'Order by distinct columns belonging to this table.', + ); + } + if (term.expression.codec.acceptsNull && term.nulls == null) { + throw const OrmException( + 'QUERY.CURSOR', + 'Nullable cursor columns require explicit NULLS FIRST or LAST.', + ); + } + } + final schema = _state.source.schema; + final keys = [ + schema.primaryKey, + ...schema.uniqueKeys, + for (final index in schema.indexes) + if (index.unique) index.columns, + ]; + if (!keys.any( + (key) => + key.isNotEmpty && + key.every( + (name) => + names.contains(name) && + !schema.columns.firstWhere((c) => c.name == name).nullable, + ), + )) { + throw const OrmException( + 'QUERY.CURSOR', + 'Include a non-null primary or unique key as the stable tie breaker.', + ); + } + } + + List _cursorShape(List order) => [ + for (final o in order) + [ + (_unwrapStorage(o.expression._node) as _ColumnNode).name, + o.expression.codec.sqlType, + o.expression.codec.acceptsNull, + o.descending, + o.nulls?.name, + ], + ]; +} + +Object? _encodeCursorValue(Object? value) => switch (value) { + null || String() || bool() => value, + SqlReal(:final value) when value.isFinite => ['double', value.toString()], + int v => ['int', v.toString()], + double v when v.isFinite => ['double', v.toString()], + DateTime v => ['time', v.toUtc().toIso8601String()], + Uint8List v => ['bytes', base64Encode(v)], + _ => throw const OrmException( + 'QUERY.CURSOR', + 'Codec storage value is not supported in cursors.', + ), +}; +Object? _decodeCursorValue(Object? value) => switch (value) { + null || String() || bool() => value, + ['int', String v] => _cursorInt(v), + ['double', String v] => _cursorDouble(v), + ['time', String v] => DateTime.parse(v).toUtc(), + ['bytes', String v] => base64Decode(v), + _ => throw const FormatException('Invalid cursor value.'), +}; + +int _cursorInt(String value) { + final parsed = int.parse(value); + if (parsed.toString() != value) { + throw const FormatException( + 'Cursor integer cannot be represented exactly.', + ); + } + return parsed; +} + +double _cursorDouble(String value) { + final parsed = double.parse(value); + if (!parsed.isFinite) { + throw const FormatException('Cursor numbers must be finite.'); + } + return parsed; +} diff --git a/lib/src/database.dart b/lib/src/database.dart new file mode 100644 index 00000000..7d72d341 --- /dev/null +++ b/lib/src/database.dart @@ -0,0 +1,197 @@ +part of '../orm.dart'; + +/// Typed ORM access over a raw SQL runtime. The runtime owns connections, +/// transactions and cursor lifetimes; this layer adds tables and invalidation. +class Database extends QueryContext { + final SqlDatabase sql; + final void Function(DecodeEvent)? onDecode; + late final _ChangeHub _changes = _changeHubs[driver] ??= _ChangeHub(); + final Map _pendingChanges = {}; + bool _invalidationQueued = false; + + Database( + Driver driver, { + void Function(QueryEvent)? onQuery, + void Function(AcquisitionEvent)? onAcquire, + void Function(DecodeEvent)? onDecode, + }) : this.fromSql( + SqlDatabase(driver, onQuery: onQuery, onAcquire: onAcquire), + onDecode: onDecode, + ); + + Database.fromSql(this.sql, {this.onDecode}) { + if (!sql.inSession) { + final removeChanges = sql.addChangeListener( + (tables) => _changes.publish({for (final table in tables) table: true}), + ); + sql.addCloseListener(() async { + removeChanges(); + await _changes.stop(); + }); + } + } + + Driver get driver => sql.driver; + @override + Capabilities get capabilities => sql.capabilities; + @override + SqlDialect get dialect => sql.dialect; + @override + bool get inTransaction => sql.inTransaction; + @override + bool get inSession => sql.inSession; + void Function(QueryEvent)? get onQuery => sql.onQuery; + void Function(AcquisitionEvent)? get onAcquire => sql.onAcquire; + + @override + TableSet table(Table definition) => + TableSet(this, definition); + + /// Register declared FK effects for query invalidation. + @override + void registerSchema(Iterable tables) => + _changes.register(tables); + + /// Explicit notification for raw SQL, triggers or externally committed work. + /// A transaction defers notification until commit; rollback discards it. + void invalidate(Iterable tables) { + checkActive(); + _recordChanges(tables, cascade: true); + } + + void _recordChanges(Iterable tables, {required bool cascade}) { + final changes = {}; + for (final table in tables) { + _changes.registerTable(table); + changes[table.name] = cascade; + } + if (!inTransaction) { + _changes.publish(changes); + return; + } + _mergeChanges(_pendingChanges, changes); + if (!_invalidationQueued) { + _invalidationQueued = true; + sql.deferInvalidation(() => _changes.publish(_pendingChanges)); + } + } + + void checkActive() => sql.checkActive(); + @override + void markFailed() => sql.markFailed(); + + @override + Future run( + Future Function(SqlConnection) action, { + AcquisitionOptions acquire = const AcquisitionOptions(), + OrmException? acquisitionTimeoutError, + }) => sql.run( + action, + acquire: acquire, + acquisitionTimeoutError: acquisitionTimeoutError, + ); + + @override + Future executeOn( + SqlConnection connection, + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => sql.executeOn(connection, command, options: options); + + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + Iterable changedTables = const [], + }) => executeCommand(command, options: options, changedTables: changedTables); + + @override + Future executeCommand( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + Iterable changedTables = const [], + bool affectedOnly = false, + bool cascade = true, + }) { + options.check(); + if (options.cancellation != null && !capabilities.cancellation) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This driver cannot cancel a running statement.', + ); + } + if (options.timeout != null && !capabilities.statementTimeout) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This driver does not support statement timeouts.', + ); + } + final tables = List.unmodifiable(changedTables); + if (tables.isEmpty) return sql.execute(command, options: options); + return run((connection) async { + final result = await executeOn(connection, command, options: options); + if (!affectedOnly || result.affectedRows > 0) { + _recordChanges(tables, cascade: cascade); + } + return result; + }, acquire: options.acquisition); + } + + Future session( + Future Function(Database session) action, { + AcquisitionOptions acquire = const AcquisitionOptions(), + }) => sql.session( + (session) => action(Database.fromSql(session, onDecode: onDecode)), + acquire: acquire, + ); + + Future transaction( + Future Function(Database tx) action, { + TransactionOptions? options, + AcquisitionOptions acquire = const AcquisitionOptions(), + Duration? timeout, + CancellationToken? cancellation, + TransactionRetry? retry, + }) => sql.transaction( + (tx) => action(Database.fromSql(tx, onDecode: onDecode)), + options: options, + acquire: acquire, + timeout: timeout, + cancellation: cancellation, + retry: retry, + ); + + Future savepoint(Future Function(Database tx) action) => + sql.savepoint((tx) => action(Database.fromSql(tx, onDecode: onDecode))); + + @override + Stream streamRows( + SqlCommand command, { + required int batchSize, + required ExecutionOptions options, + required Future> Function( + SqlConnection, + List>, + ExecutionOptions, + ) + decode, + }) => sql.streamRows( + command, + batchSize: batchSize, + options: options, + decode: decode, + ); + + Future discard() => sql.discard(); + Future close() => sql.close(); + + @override + Future atomic( + Future Function(QueryContext) action, { + AcquisitionOptions acquire = const AcquisitionOptions(), + }) => transaction(action, acquire: acquire); + + @override + T observeDecode(String? sql, int inputRows, T Function() action) => + _observeDecode(sql, inputRows, action); +} diff --git a/lib/src/decimal.dart b/lib/src/decimal.dart new file mode 100644 index 00000000..fb175956 --- /dev/null +++ b/lib/src/decimal.dart @@ -0,0 +1,264 @@ +part of '../values.dart'; + +/// Rounding is explicit; [exact] rejects a non-zero discarded remainder. +enum DecimalRounding { + exact, + towardZero, + floor, + ceiling, + halfAwayFromZero, + halfEven, +} + +/// A finite base-ten value, independent of binary floating point. +/// +/// Equal values have identical normalized coefficients and scales. The range +/// matches finite unconstrained PostgreSQL NUMERIC: at most 131072 integer +/// digits and 16383 fractional digits. This does not retain display precision. +final class Decimal implements Comparable { + static const maxIntegerDigits = 131072; + static const maxFractionDigits = 16383; + static final zero = Decimal._(BigInt.zero, 0); + final BigInt coefficient; + final int scale; + const Decimal._(this.coefficient, this.scale); + + factory Decimal.fromBigInt(BigInt coefficient, {int scale = 0}) { + if (coefficient == BigInt.zero) return zero; + return Decimal._digits( + coefficient.abs().toString(), + coefficient.isNegative, + scale, + ); + } + + factory Decimal.parse(String source) { + // Bound parsing work before allocating a potentially enormous BigInt. + if (source.length > maxIntegerDigits + maxFractionDigits + 32) { + throw const FormatException('Decimal input is too long.'); + } + final match = _syntax.firstMatch(source); + if (match == null || + match.end != source.length || + (match[2]!.isEmpty && (match[3] ?? '').isEmpty)) { + throw const FormatException('Expected a finite decimal string.'); + } + final exponent = int.tryParse(match[4] ?? '0'); + if (exponent == null || + exponent < -maxIntegerDigits - maxFractionDigits || + exponent > maxIntegerDigits + maxFractionDigits) { + throw const FormatException('Decimal exponent is out of range.'); + } + final fraction = match[3] ?? ''; + return Decimal._digits( + '${match[2]}$fraction', + match[1] == '-', + fraction.length - exponent, + ); + } + + static final _syntax = RegExp( + r'^([+-]?)([0-9]*)(?:\.([0-9]*))?(?:[eE]([+-]?[0-9]+))?$', + ); + + static Decimal? tryParse(String source) { + try { + return Decimal.parse(source); + } on FormatException { + return null; + } on RangeError { + return null; + } + } + + factory Decimal._digits(String digits, bool negative, int scale) { + var start = 0, end = digits.length; + while (start < end && digits.codeUnitAt(start) == 48) { + start++; + } + if (start == end) return zero; + if (scale < -maxIntegerDigits || + scale > maxFractionDigits + digits.length) { + throw RangeError('Decimal scale is out of range.'); + } + while (digits.codeUnitAt(end - 1) == 48) { + end--; + scale--; + } + if (scale > maxFractionDigits || end - start - scale > maxIntegerDigits) { + throw RangeError('Decimal exceeds the supported finite NUMERIC range.'); + } + final coefficient = BigInt.parse( + '${negative ? '-' : ''}${digits.substring(start, end)}', + ); + return Decimal._(coefficient, scale); + } + + static BigInt _power(int n) => BigInt.from(10).pow(n); + + Decimal operator -() => Decimal._(-coefficient, scale); + Decimal operator +(Decimal other) { + final common = math.max(scale, other.scale); + return Decimal.fromBigInt( + coefficient * _power(common - scale) + + other.coefficient * _power(common - other.scale), + scale: common, + ); + } + + Decimal operator -(Decimal other) => this + -other; + Decimal operator *(Decimal other) => Decimal.fromBigInt( + coefficient * other.coefficient, + scale: scale + other.scale, + ); + + /// Divides to [scale] fractional digits (negative scales round integer digits). + /// The default rejects a result requiring rounding, including repeating ratios. + Decimal divide( + Decimal other, { + required int scale, + DecimalRounding rounding = DecimalRounding.exact, + }) { + validateScale(scale); + if (other.coefficient == BigInt.zero) { + throw UnsupportedError('Division by zero'); + } + final shift = other.scale - this.scale + scale; + return _quotient( + coefficient * (shift > 0 ? _power(shift) : BigInt.one), + other.coefficient * (shift < 0 ? _power(-shift) : BigInt.one), + scale, + rounding, + ); + } + + /// Converts an integer fraction to a finite decimal at an explicit scale. + /// Only the rounded result must fit the finite Decimal range. + factory Decimal.fromFraction( + BigInt numerator, + BigInt denominator, { + required int scale, + DecimalRounding rounding = DecimalRounding.exact, + }) { + validateScale(scale); + if (denominator == BigInt.zero) throw UnsupportedError('Division by zero'); + return _quotient( + numerator * (scale > 0 ? _power(scale) : BigInt.one), + denominator * (scale < 0 ? _power(-scale) : BigInt.one), + scale, + rounding, + ); + } + + Decimal rounded( + int scale, { + DecimalRounding rounding = DecimalRounding.exact, + }) { + validateScale(scale); + if (scale >= this.scale) return this; + return _quotient(coefficient, _power(this.scale - scale), scale, rounding); + } + + static void validateScale(int scale) { + if (scale < -maxIntegerDigits || scale > maxFractionDigits) { + throw RangeError.range( + scale, + -maxIntegerDigits, + maxFractionDigits, + 'scale', + ); + } + } + + /// Whether the value already fits a NUMERIC column without rounding. + bool fits(int precision, int scale) { + validateDigits(precision, scale); + return coefficient == BigInt.zero || + this.scale <= scale && + coefficient.abs().toString().length - this.scale <= + precision - scale; + } + + /// PostgreSQL-style column coercion: round ties away from zero, then check + /// precision. Expression result codecs do not inherit column constraints. + Decimal constrained(int precision, int scale) { + validateDigits(precision, scale); + final result = rounded(scale, rounding: DecimalRounding.halfAwayFromZero); + if (!result.fits(precision, scale)) { + throw RangeError('Decimal does not fit NUMERIC($precision, $scale).'); + } + return result; + } + + static void validateDigits(int precision, int scale) { + if (precision < 1 || precision > 1000 || scale < -1000 || scale > 1000) { + throw ArgumentError( + 'Decimal precision must be 1..1000 and scale -1000..1000.', + ); + } + } + + static Decimal _quotient( + BigInt a, + BigInt b, + int scale, + DecimalRounding rounding, + ) { + var q = a ~/ b; + final remainder = a.remainder(b); + if (remainder != BigInt.zero) { + final sign = a.sign * b.sign; + final half = (remainder.abs() * BigInt.two).compareTo(b.abs()); + final increment = switch (rounding) { + DecimalRounding.exact => throw const FormatException( + 'Decimal result requires rounding.', + ), + DecimalRounding.towardZero => false, + DecimalRounding.floor => sign < 0, + DecimalRounding.ceiling => sign > 0, + DecimalRounding.halfAwayFromZero => half >= 0, + DecimalRounding.halfEven => half > 0 || (half == 0 && q.isOdd), + }; + if (increment) q += BigInt.from(sign); + } + return Decimal.fromBigInt(q, scale: scale); + } + + @override + int compareTo(Decimal other) { + if (coefficient.sign != other.coefficient.sign) { + return coefficient.sign.compareTo(other.coefficient.sign); + } + if (coefficient == BigInt.zero) return 0; + final size = coefficient.abs().toString().length - scale; + final otherSize = other.coefficient.abs().toString().length - other.scale; + if (size != otherSize) return size.compareTo(otherSize) * coefficient.sign; + final common = math.max(scale, other.scale); + return (coefficient * _power(common - scale)).compareTo( + other.coefficient * _power(common - other.scale), + ); + } + + bool operator <(Decimal other) => compareTo(other) < 0; + bool operator <=(Decimal other) => compareTo(other) <= 0; + bool operator >(Decimal other) => compareTo(other) > 0; + bool operator >=(Decimal other) => compareTo(other) >= 0; + @override + bool operator ==(Object other) => + other is Decimal && + coefficient == other.coefficient && + scale == other.scale; + @override + int get hashCode => Object.hash(coefficient, scale); + @override + String toString() { + final digits = coefficient.abs().toString(), + sign = coefficient.isNegative ? '-' : ''; + if (scale <= 0) return '$sign$digits${'0' * -scale}'; + if (scale >= digits.length) { + return '${sign}0.${'0' * (scale - digits.length)}$digits'; + } + final split = digits.length - scale; + return '$sign${digits.substring(0, split)}.${digits.substring(split)}'; + } +} diff --git a/lib/src/decimal_average.dart b/lib/src/decimal_average.dart new file mode 100644 index 00000000..e0c4940e --- /dev/null +++ b/lib/src/decimal_average.dart @@ -0,0 +1,123 @@ +part of '../sql.dart'; + +final class _DecimalAverage( + final _Node child, + final int scale, + final DecimalRounding rounding, +) extends _Node { + @override + String writeSql(_Writer w) => writeAverage(w); + + String writeAverage(_Writer w, {_WindowNode? window}) { + if (w.mysql) { + throw const OrmException( + 'CAPABILITY.DECIMAL_ROUNDING', + 'Exact decimal averages are not supported on MySQL/MariaDB.', + ); + } + if (w.averageInputs == null) { + throw const OrmException( + 'QUERY.AGGREGATE', + 'An average requires a SELECT query; use a scalar subquery in assignments.', + ); + } + if (w.dialect == SqlDialect.sqlite) { + final function = _Function('orm_decimal_avg_v1', [ + child, + _Parameter(scale), + _Parameter(rounding.index), + ]); + return (window == null + ? function + : _WindowNode( + function, + window.partition, + window.order, + window.frame, + )) + .writeSql(w); + } + final input = _AverageInput(child); + final partition = [ + for (final e in window?.partition ?? >[]) + Expr._(_AverageInput(e._node), e.codec), + ]; + final order = [ + for (final o in window?.order ?? []) + OrderTerm._( + Expr._(_AverageInput(o.expression._node), o.expression.codec), + o.descending, + o.nulls, + ), + ]; + String aggregate(String name, _Node argument) { + final function = _Function(name, [argument]); + return (window == null + ? function + : _WindowNode(function, partition, order, window.frame)) + .write(w); + } + + // B exceeds every possible COUNT (signed int64). Each SUM stays finite: + // high has 20 fewer integer digits; low is bounded by COUNT*B. Native SUM + // preserves its exact accumulator without holding an array of input rows. + const base = "NUMERIC '1e20'"; + final high = aggregate('SUM', _Raw(['div(', ', $base)'], [input])); + final low = aggregate('SUM', _Raw(['mod(', ', $base)'], [input])); + final count = aggregate('COUNT', input); + final prefix = + ''' +WITH orm_mean_args(h, l, n) AS MATERIALIZED ( + VALUES ($high, $low, CAST($count AS NUMERIC)) +), orm_mean_high AS MATERIALIZED ( + SELECT *, div(h, NULLIF(n, 0)) AS qh, mod(h, NULLIF(n, 0)) AS rh + FROM orm_mean_args +), orm_mean_tail AS MATERIALIZED ( + SELECT *, rh * $base + l AS t FROM orm_mean_high +), orm_mean_parts AS MATERIALIZED ( + SELECT n, qh * $base + div(t, NULLIF(n, 0)) AS q, + mod(t, NULLIF(n, 0)) AS r FROM orm_mean_tail +), orm_mean_normal AS MATERIALIZED ( + SELECT n, + CASE WHEN q > 0 AND r < 0 THEN q - 1 WHEN q < 0 AND r > 0 THEN q + 1 ELSE q END AS q, + CASE WHEN q > 0 AND r < 0 THEN r + n WHEN q < 0 AND r > 0 THEN r - n ELSE r END AS r + FROM orm_mean_parts +), orm_decimal_remainder AS MATERIALIZED ( + SELECT abs(q) AS q, abs(r) AS r, n AS b, n = 0 AS nil, + CASE WHEN q = 0 THEN sign(r) ELSE sign(q) END AS sgn + FROM orm_mean_normal +)'''; + return _DecimalRoundSql(scale, rounding).write(prefix); + } +} + +// Shared by the high/low/count aggregates (and by a window's frame expressions). +// Projection staging handles windows; ordinary aggregation uses a row lateral. +final class _AverageInput(final _Node child) extends _Node { + @override + String writeSql(_Writer w) => w.averageInputs!.capture(this); +} + +final class _AverageInputs(final _Writer writer, final String anchor) { + final List joins = []; + final Map<_AverageInput, String> _references = {}; + String capture(_AverageInput input) => _references.putIfAbsent(input, () { + final name = writer.quote( + '_orm_avg_${writer.aliases.length}_${joins.length}', + ); + final saved = writer.project; + writer.project = null; + late String value; + try { + value = input.child.write(writer); + } finally { + writer.project = saved; + } + // The zero term keeps even a volatile, otherwise uncorrelated input tied + // to each source row. OFFSET prevents flattening/repeated evaluation. + joins.add( + ' CROSS JOIN LATERAL (SELECT ($value) + CAST($anchor IS NULL AS INT) * NUMERIC \'0\' AS v OFFSET 0) AS $name', + ); + return '$name.v'; + }); +} diff --git a/lib/src/decimal_division.dart b/lib/src/decimal_division.dart new file mode 100644 index 00000000..316be215 --- /dev/null +++ b/lib/src/decimal_division.dart @@ -0,0 +1,133 @@ +part of '../sql.dart'; + +/// A null divisor denotes rounding one value. Operands are evaluated once. +final class _DecimalRatio( + final _Node numerator, + final _Node? divisor, + final int scale, + final DecimalRounding rounding, +) extends _Node { + @override + String writeSql(_Writer w) { + if (w.mysql) { + throw const OrmException( + 'CAPABILITY.DECIMAL_ROUNDING', + 'Exact SQL division and rounding are not implemented for MySQL/MariaDB; use an explicit DECIMAL expression.', + ); + } + final a = numerator.write(w), b = divisor?.write(w); + if (w.dialect == SqlDialect.sqlite) { + return b == null + ? 'orm_decimal_round_v1($a, $scale, ${rounding.index})' + : 'orm_decimal_div_v1($a, $b, $scale, ${rounding.index})'; + } + // MATERIALIZED keeps volatile expressions and correlated operands from + // being reevaluated by the quotient, remainder and rounding calculations. + final prefix = + ''' +WITH orm_decimal_args(a, b) AS MATERIALIZED ( + VALUES (CAST($a AS NUMERIC), CAST(${b ?? '1'} AS NUMERIC)) +), orm_decimal_parts AS MATERIALIZED ( + SELECT abs(a) AS a, abs(b) AS b, sign(a) * sign(b) AS sgn, + a IS NULL OR b IS NULL AS nil, + div(abs(a), NULLIF(abs(b), 0)) AS q + FROM orm_decimal_args +), orm_decimal_remainder AS MATERIALIZED ( + SELECT *, a - q * b AS r FROM orm_decimal_parts +)'''; + return _DecimalRoundSql(scale, rounding).write(prefix); + } +} + +// The input CTE exposes a nonnegative integer quotient q, remainder r < b, +// positive denominator b, result sign sgn and empty/null marker nil. +final class _DecimalRoundSql(final int scale, final DecimalRounding rounding) { + String write(String prefix) => + scale >= 0 ? _positive(prefix) : _negative(prefix); + + String _positive(String prefix) { + final factor = "NUMERIC '1e$scale'"; + final unit = "NUMERIC '1e-$scale'"; + final text = scale == 0 + ? 'q::text' + : "q::text || '.' || lpad(f::text, $scale, '0')"; + final increment = _increment( + nonzero: 'r <> 0', + above: 'r > b - r', + tie: 'r = b - r', + odd: 'mod(${scale == 0 ? 'q' : 'f'}, 2) <> 0', + ); + // Usually r*factor fits NUMERIC. For a huge remainder, split b=d*factor+e + // instead. Then c=div(r,d) overestimates div(r*factor,b) by at most one: + // the fallback implies d >= 10^(131072-2*scale) > factor^2. All products + // below stay within the original denominator's magnitude or factor^2. + return '''($prefix, +orm_decimal_scaled AS MATERIALIZED ( + SELECT *, length(trunc(r)::text) + $scale <= ${Decimal.maxIntegerDigits} AS simple + FROM orm_decimal_remainder +), orm_decimal_split AS MATERIALIZED ( + SELECT *, CASE WHEN simple THEN r * $factor ELSE r END AS n, + CASE WHEN simple THEN b ELSE div(b, $factor) END AS d, + CASE WHEN simple THEN 0 ELSE mod(b, $factor) END AS e + FROM orm_decimal_scaled +), orm_decimal_estimate AS MATERIALIZED ( + SELECT *, div(n, NULLIF(d, 0)) AS c FROM orm_decimal_split +), orm_decimal_correction AS MATERIALIZED ( + SELECT *, CASE WHEN simple THEN n - c * d + ELSE (n - c * d) * $factor - c * e END AS tail + FROM orm_decimal_estimate +), orm_decimal_final AS ( + SELECT q, b, sgn, nil, + CASE WHEN tail < 0 THEN c - 1 ELSE c END AS f, + CASE WHEN tail < 0 THEN tail + b ELSE tail END AS r + FROM orm_decimal_correction +) +SELECT CASE WHEN nil THEN NULL WHEN b = 0 THEN 1 / b + ${_inexact('r <> 0')} + ELSE sgn * (CAST($text AS NUMERIC) + CASE WHEN $increment THEN $unit ELSE 0 END) +END FROM orm_decimal_final)'''; + } + + String _negative(String prefix) { + final places = -scale, half = "NUMERIC '5e${-scale - 1}'"; + final increment = _increment( + nonzero: 'lost <> 0 OR r <> 0', + above: 'lost > $half OR (lost = $half AND r <> 0)', + tie: 'lost = $half AND r = 0', + odd: 'mod(kept, 2) <> 0', + ); + // Construct integer digits as text so even the outermost supported scale + // (-131072) can return zero without first constructing an overflowing unit. + return '''($prefix, +orm_decimal_integer AS MATERIALIZED ( + SELECT *, CAST(COALESCE(NULLIF(left(q::text, greatest(length(q::text) - $places, 0)), ''), '0') AS NUMERIC) AS kept + FROM orm_decimal_remainder +), orm_decimal_truncated AS MATERIALIZED ( + SELECT *, CAST(kept::text || repeat('0', $places) AS NUMERIC) AS truncated + FROM orm_decimal_integer +), orm_decimal_final AS ( + SELECT *, q - truncated AS lost FROM orm_decimal_truncated +) +SELECT CASE WHEN nil THEN NULL WHEN b = 0 THEN 1 / b + ${_inexact('lost <> 0 OR r <> 0')} + ELSE sgn * CAST((kept + CASE WHEN $increment THEN 1 ELSE 0 END)::text || repeat('0', $places) AS NUMERIC) +END FROM orm_decimal_final)'''; + } + + String _inexact(String nonzero) => rounding == DecimalRounding.exact + ? "WHEN $nonzero THEN CAST(q::text || '_decimal_rounding_required' AS NUMERIC)" + : ''; + + String _increment({ + required String nonzero, + required String above, + required String tie, + required String odd, + }) => switch (rounding) { + DecimalRounding.exact || DecimalRounding.towardZero => 'FALSE', + DecimalRounding.floor => 'sgn < 0 AND ($nonzero)', + DecimalRounding.ceiling => 'sgn > 0 AND ($nonzero)', + DecimalRounding.halfAwayFromZero => '($above) OR ($tie)', + DecimalRounding.halfEven => '($above) OR (($tie) AND ($odd))', + }; +} diff --git a/lib/src/decimal_sql.dart b/lib/src/decimal_sql.dart new file mode 100644 index 00000000..aded9a5e --- /dev/null +++ b/lib/src/decimal_sql.dart @@ -0,0 +1,162 @@ +part of '../sql.dart'; + +// Attach collation to every decimal expression, including computed projections, +// CTE references and UNION outputs. Native PostgreSQL NUMERIC needs no wrapper. +final class _DecimalNode(final _Node child) extends _Node { + @override + String writeSql(_Writer w) { + if (!w.exactDecimal) { + throw const OrmException( + 'CAPABILITY.DECIMAL', + 'This driver does not provide exact decimal SQL.', + ); + } + final sql = child.write(w); + return w.dialect == SqlDialect.sqlite + ? '($sql COLLATE "orm_decimal_v1")' + : sql; + } +} + +final class _DecimalCast( + final _Node child, + final int precision, + final int scale, { + final bool columnAssignment = false, +}) extends _Node { + @override + String writeSql(_Writer w) { + Decimal.validateDigits(precision, scale); + if (!w.exactDecimal) { + throw const OrmException( + 'CAPABILITY.DECIMAL', + 'This driver does not provide exact decimal SQL.', + ); + } + final expression = child.write(w); + if (w.mysql) { + if (precision > 65 || scale < 0 || scale > 30 || scale > precision) { + throw const OrmException( + 'CAPABILITY.DECIMAL', + 'MySQL/MariaDB decimals require precision 1–65 and scale 0–min(precision, 30).', + ); + } + if (columnAssignment) return expression; + throw const OrmException( + 'CAPABILITY.DECIMAL_PRECISION', + 'MySQL/MariaDB decimal casts may clamp overflow; use explicit native SQL when its precision is acceptable.', + ); + } + return w.dialect == SqlDialect.postgres + ? 'CAST($expression AS NUMERIC($precision,$scale))' + : 'orm_decimal_cast_v1($expression, $precision, $scale)'; + } +} + +final class _DecimalArithmetic( + final _Node left, + final String op, + final _Node right, +) extends _Node { + @override + String writeSql(_Writer w) { + if (w.mysql) { + throw const OrmException( + 'CAPABILITY.DECIMAL_PRECISION', + 'MySQL/MariaDB arithmetic may silently truncate decimal digits; use explicit native SQL when its precision is acceptable.', + ); + } + final a = left.write(w), b = right.write(w); + if (w.dialect != SqlDialect.sqlite) return '($a $op $b)'; + final name = switch (op) { + '+' => 'add', + '-' => 'sub', + '*' => 'mul', + _ => throw StateError('Invalid decimal operation'), + }; + return 'orm_decimal_${name}_v1($a, $b)'; + } +} + +extension DecimalExpression on Expr { + /// Averages non-null inputs, rounding once at the requested result scale. + /// Empty/all-null input returns null; the default rejects lost digits. + Expr average({ + required int scale, + DecimalRounding rounding = DecimalRounding.exact, + }) { + Decimal.validateScale(scale); + if (_window(_node)) { + throw const OrmException( + 'QUERY.WINDOW', + 'Project a window result through a CTE before averaging it.', + ); + } + return Expr._( + _DecimalAverage(_node, scale, rounding), + Codecs.decimal.nullable(), + ); + } + + /// Rounds in SQL; [DecimalRounding.exact] rejects any lost nonzero digits. + Expr rounded( + int scale, { + DecimalRounding rounding = DecimalRounding.exact, + }) { + Decimal.validateScale(scale); + return Expr._(_DecimalRatio(_node, null, scale, rounding), codec); + } + + /// Divides in SQL to an explicit scale without a floating-point intermediate. + Expr divide( + Decimal divisor, { + required int scale, + DecimalRounding rounding = DecimalRounding.exact, + }) { + Decimal.validateScale(scale); + return Expr._( + _DecimalRatio( + _node, + value(divisor, Codecs.decimal)._node, + scale, + rounding, + ), + codec, + ); + } + + /// Divides two SQL expressions. A null operand produces null. + Expr divideExpression( + Expr divisor, { + required int scale, + DecimalRounding rounding = DecimalRounding.exact, + }) { + Decimal.validateScale(scale); + return Expr._( + _DecimalRatio(_node, divisor._node, scale, rounding), + Codecs.decimal.nullable(), + ); + } + + Expr constrained(int precision, int scale) { + Decimal.validateDigits(precision, scale); + return Expr._(_DecimalCast(_node, precision, scale), codec); + } + + Expr plus(Decimal n) => _arithmetic('+', value(n, Codecs.decimal)); + Expr minus(Decimal n) => _arithmetic('-', value(n, Codecs.decimal)); + Expr times(Decimal n) => _arithmetic('*', value(n, Codecs.decimal)); + Expr plusExpression(Expr other) => _binary('+', other); + Expr minusExpression(Expr other) => _binary('-', other); + Expr timesExpression(Expr other) => _binary('*', other); + Expr _arithmetic(String op, Expr other) => + Expr._(_DecimalArithmetic(_node, op, other._node), codec); + Expr _binary(String op, Expr other) => Expr._( + _DecimalArithmetic(_node, op, other._node), + Codecs.decimal.nullable(), + ); + Expr sum({bool distinct = false}) => Expr._( + _Function('SUM', [_node], decimal: true, distinct: distinct), + Codecs.decimal.nullable(), + ); +} diff --git a/lib/src/execution.dart b/lib/src/execution.dart new file mode 100644 index 00000000..80bbb031 --- /dev/null +++ b/lib/src/execution.dart @@ -0,0 +1,81 @@ +part of '../runtime.dart'; + +/// The driver may not expose a cancellable queue. Abandon the request, never +/// enter its SQL callback, and release a late lease normally. The separately +/// tracked drain future keeps SqlDatabase.close() aware of outstanding resources. +final class _ConnectionWait { + final Completer _result = Completer(); + Timer? _timer; + final Stopwatch? _clock; + final Duration? _timeout; + final OrmException _timeoutError; + void Function()? _unsubscribe; + Future Function(SqlConnection)? _action; + late final Future drained; + Future get result => _result.future; + + _ConnectionWait( + Driver driver, + Future Function(SqlConnection) action, + AcquisitionOptions options, { + OrmException? timeoutError, + }) : _action = action, + _timeoutError = + timeoutError ?? + const OrmException( + 'CONNECTION.TIMEOUT', + 'Connection acquisition timed out.', + ), + _timeout = options.timeout, + _clock = options.timeout == null ? null : (Stopwatch()..start()) { + _unsubscribe = options.cancellation?.listen( + () => _abandon( + const OrmException( + 'OPERATION.CANCELLED', + 'Connection acquisition cancelled.', + ), + ), + ); + if (options.timeout case final timeout?) { + _timer = Timer(timeout, () => _abandon(_timeoutError)); + } + drained = + Future.sync( + () => driver.run((connection) async { + final action = _action; + if (action == null) return null; + // Timers can run late while the isolate drains queued microtasks. + if (_clock != null && _clock.elapsed >= _timeout!) { + _abandon(_timeoutError); + return null; + } + _action = null; + _dispose(); + return await action(connection); + }), + ).then( + (value) { + _dispose(); + if (!_result.isCompleted) _result.complete(value as R); + }, + onError: (Object error, StackTrace stack) { + _action = null; + _dispose(); + if (!_result.isCompleted) _result.completeError(error, stack); + }, + ); + } + + void _abandon(OrmException error) { + if (_action == null) return; + _action = null; + _dispose(); + _result.completeError(error); + } + + void _dispose() { + _clock?.stop(); + _timer?.cancel(); + _unsubscribe?.call(); + } +} diff --git a/lib/src/generate.dart b/lib/src/generate.dart new file mode 100644 index 00000000..9f8cba32 --- /dev/null +++ b/lib/src/generate.dart @@ -0,0 +1,123 @@ +/// Source analysis and deterministic client generation. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/analysis/session.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart' show Keyword; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/dart/element/type_system.dart'; +import 'package:build/build.dart' as builder; +import 'package:dart_style/dart_style.dart'; +import 'package:path/path.dart' as p; + +import '../migrate.dart'; +import '../orm.dart'; + +part 'generate/model.dart'; +part 'generate/reader.dart'; +part 'generate/types.dart'; +part 'generate/emitter.dart'; +part 'generate/build.dart'; +part 'generate/import.dart'; +part 'generate/queries.dart'; +part 'generate/migrations.dart'; + +final class GenerationException implements Exception { + final String message; + const GenerationException(this.message); + @override + String toString() => 'GenerationException: $message'; +} + +final class GeneratedSchema { + final String dart; + final SchemaSnapshot snapshot; + const GeneratedSchema(this.dart, this.snapshot); + String get snapshotDart => _formatMigration(schemaSource(snapshot)); +} + +Future generateSchema( + String sourcePath, { + String? outputPath, +}) async { + final source = p.normalize(p.absolute(sourcePath)); + final output = p.normalize( + p.absolute(outputPath ?? p.setExtension(source, '.orm.dart')), + ); + if (p.extension(output) != '.dart' || + source == output || + source == _schemaSnapshotPath(output)) { + throw const GenerationException( + 'Client and snapshot outputs must be separate Dart files from the source.', + ); + } + final contexts = AnalysisContextCollection(includedPaths: [source]); + try { + final resolved = await contexts + .contextFor(source) + .currentSession + .getResolvedUnit(source); + if (resolved is! ResolvedUnitResult) { + throw GenerationException('Cannot analyze $source.'); + } + final errors = resolved.diagnostics.where( + (e) => e.severity.name.toLowerCase() == 'error', + ); + if (errors.isNotEmpty) { + throw GenerationException(errors.map((e) => e.toString()).join('\n')); + } + final import = p + .relative(source, from: p.dirname(output)) + .replaceAll(r'\', '/'); + return _generate( + resolved.unit, + resolved.libraryElement, + import, + (uri) => uri.scheme == 'file' + ? p + .relative(uri.toFilePath(), from: p.dirname(output)) + .replaceAll(r'\', '/') + : uri.toString(), + ); + } finally { + await contexts.dispose(); + } +} + +GeneratedSchema _generate( + CompilationUnit unit, + LibraryElement library, + String sourceImport, + String Function(Uri) importUri, +) { + final names = _DartNames(library.uri, importUri); + final schema = _SchemaReader(unit, library.typeSystem, names).read(); + return GeneratedSchema( + DartFormatter(languageVersion: library.languageVersion.effective) + .format(_emit(schema, sourceImport, names)), + SchemaSnapshot([for (final table in schema) table.snapshot()]), + ); +} + +/// Writes generated output only after source analysis and schema validation. +Future writeGeneratedSchema(String source, {String? output}) async { + output ??= p.setExtension(source, '.orm.dart'); + final result = await generateSchema(source, outputPath: output); + final file = File(output); + await file.parent.create(recursive: true); + final snapshot = result.snapshotDart; + await file.writeAsString(result.dart); + await File(_schemaSnapshotPath(output)).writeAsString(snapshot); +} + +String _schemaSnapshotPath(String output) => output.endsWith('.orm.dart') + ? '${output.substring(0, output.length - '.orm.dart'.length)}.snapshot.dart' + : p.setExtension(output, '.snapshot.dart'); diff --git a/lib/src/generate/build.dart b/lib/src/generate/build.dart new file mode 100644 index 00000000..0727f607 --- /dev/null +++ b/lib/src/generate/build.dart @@ -0,0 +1,130 @@ +part of '../generate.dart'; + +/// Factory used by build_runner's build.yaml registration. +builder.Builder ormBuilder(builder.BuilderOptions options) { + if (options.config.isNotEmpty) { + throw ArgumentError( + 'ORM has no builder-specific options. Select schema roots with generate_for.', + ); + } + return const _OrmBuilder(); +} + +/// Fixed SQL files are read as build assets so their edits invalidate output. +builder.Builder ormQueryBuilder(builder.BuilderOptions options) { + if (options.config.isNotEmpty) { + throw ArgumentError('Select named SQL libraries with generate_for.'); + } + return const _OrmQueryBuilder(); +} + +final class _OrmQueryBuilder implements builder.Builder { + const _OrmQueryBuilder(); + @override + Map> get buildExtensions => const { + '.dart': ['.queries.dart'], + }; + @override + Future build(builder.BuildStep step) async { + final input = step.inputId; + if (!await step.resolver.isLibrary(input)) { + throw const GenerationException( + 'Select a query library, not a part file.', + ); + } + final output = input.changeExtension('.queries.dart'); + final (unit, library) = await const _OrmBuilder()._resolveSchema(step); + final result = await _generateQueries( + unit, + library, + p.url.relative(input.path, from: p.url.dirname(output.path)), + (uri) { + if (uri.scheme != 'asset') return uri.toString(); + final asset = builder.AssetId.resolve(uri); + if (asset.package != input.package) { + throw GenerationException('Put public domain types under lib/: $uri'); + } + return p.url.relative(asset.path, from: p.url.dirname(output.path)); + }, + (path) => step.readAsString( + builder.AssetId.resolve(Uri.parse(path), from: input), + ), + ); + await step.writeAsString(output, result.dart); + } +} + +final class _OrmBuilder implements builder.Builder { + const _OrmBuilder(); + @override + Map> get buildExtensions => const { + '.dart': ['.orm.dart', '.snapshot.dart'], + }; + + @override + Future build(builder.BuildStep step) async { + final input = step.inputId; + if (!await step.resolver.isLibrary(input)) { + throw const GenerationException( + 'Select a schema library, not a part file.', + ); + } + final output = input.changeExtension('.orm.dart'); + final (unit, library) = await _resolveSchema(step); + final result = _generate( + unit, + library, + p.url.relative(input.path, from: p.url.dirname(output.path)), + (uri) { + if (uri.scheme != 'asset') return uri.toString(); + final asset = builder.AssetId.resolve(uri); + if (asset.package != input.package) { + throw GenerationException( + 'Cannot import $uri from another package; put public domain types under lib/.', + ); + } + return p.url.relative(asset.path, from: p.url.dirname(output.path)); + }, + ); + // Both outputs are fully constructed before touching the build writer. + final snapshot = result.snapshotDart; + await step.writeAsString(output, result.dart); + await step.writeAsString(input.changeExtension('.snapshot.dart'), snapshot); + } + + Future<(CompilationUnit, LibraryElement)> _resolveSchema( + builder.BuildStep step, + ) async { + for (var attempt = 0; ; attempt++) { + final library = await step.resolver.libraryFor(step.inputId); + final node = await step.resolver.astNodeFor( + library.firstFragment, + resolve: true, + ); + if (node is! CompilationUnit) { + throw GenerationException('Cannot resolve schema ${step.inputId}.'); + } + final resolvedLibrary = node.declaredFragment!.element; + try { + // Resolver's syntax check omits semantic errors. Check those through + // the public session only after resolver-owned AST resolution. Other + // concurrent build steps may replace that session; retry resolution. + final diagnostics = await resolvedLibrary.session.getErrors( + resolvedLibrary.firstFragment.source.fullName, + ); + if (diagnostics is! ErrorsResult) { + throw GenerationException('Cannot validate schema ${step.inputId}.'); + } + final errors = diagnostics.diagnostics.where( + (e) => e.severity.name.toLowerCase() == 'error', + ); + if (errors.isNotEmpty) { + throw GenerationException(errors.map((e) => e.toString()).join('\n')); + } + return (node, resolvedLibrary); + } on InconsistentAnalysisException { + if (attempt >= 2) rethrow; + } + } + } +} diff --git a/lib/src/generate/emitter.dart b/lib/src/generate/emitter.dart new file mode 100644 index 00000000..bf6d4985 --- /dev/null +++ b/lib/src/generate/emitter.dart @@ -0,0 +1,151 @@ +part of '../generate.dart'; + +String _emit(List<_Entity> schema, String import, _DartNames names) { + final b = StringBuffer('// GENERATED CODE - DO NOT MODIFY BY HAND.\n\n') + ..writeln("import 'package:orm/sql.dart';") + ..writeln("import ${_literal(import)} as models;") + ..writeln( + "export ${_literal(import)} show ${schema.map((e) => e.row).toSet().join(', ')};", + ); + if (names.typedData) { + b.writeln("import 'dart:typed_data';"); + } + for (final (uri, prefix) in names.imports) { + b.writeln('import ${_literal(uri)} as $prefix;'); + } + b.writeln(''); + for (final entity in schema) { + for (final f in entity.fields) { + b.writeln( + 'final ${_columnSymbol(entity, f)} = Column<${f.type}>(${_literal(f.column)}, ${f.codec}, ' + 'nullable: ${f.nullable}, generated: ${f.generated}${f.defaultSql == null ? '' : ', defaultSql: ${_literal(f.defaultSql!)}'}${f.clientDefault == null ? '' : ', clientDefault: ${f.clientDefault}'}${f.computed == null ? '' : ', computed: ${_computedLiteral(f.computed!)}'}${f.integerBits == null || f.integerBits == 64 ? '' : ', integerBits: ${f.integerBits}'}${f.temporalPrecision == null || f.temporalPrecision == 6 ? '' : ', temporalPrecision: ${f.temporalPrecision}'}${f.decimalPrecision == null ? '' : ', decimalPrecision: ${f.decimalPrecision}'}${f.decimalScale == null || f.decimalScale == 0 ? '' : ', decimalScale: ${f.decimalScale}'});', + ); + } + b.writeln( + 'final ${entity.name}Schema = TableSchema(${_literal(entity.table)}, ' + 'columns: [${entity.fields.map((f) => _columnSymbol(entity, f)).join(', ')}], ' + 'primaryKey: ${_strings(entity.columns(entity.primaryKey))}, ' + 'uniqueKeys: [${entity.uniqueKeys.map((k) => _strings(entity.columns(k))).join(', ')}], ' + 'indexes: [${entity.indexes.map((i) => 'IndexSchema(${_literal(i.name)}, ${_strings(entity.columns(i.keys))}, unique: ${i.unique})').join(', ')}], ' + '${entity.checks.isEmpty ? '' : 'checks: [${entity.checks.map((c) => 'CheckSchema.forDialects(${c.name == null ? 'null' : _literal(c.name!)}, sqlite: ${_literal(c.sqlite)}, postgres: ${_literal(c.postgres)}, mysql: ${c.mysql == null ? 'null' : _literal(c.mysql!)}, mariadb: ${c.mariadb == null ? 'null' : _literal(c.mariadb!)})').join(', ')}], '}' + 'foreignKeys: [${entity.edges.where((e) => e.isForeignKey).map((e) => 'ForeignKey(${_strings(entity.columns(e.parentKeys))}, ${_literal(e.target.table)}, ${_strings(e.target.columns(e.childKeys))}, onDelete: ${_literal(e.onDelete!)})').join(', ')}]);', + ); + b.writeln( + 'final class ${entity.fieldsType} extends Fields {\n ${entity.fieldsType}(super.table);', + ); + for (final f in entity.fields) { + b.writeln( + 'late final ${f.name} = ${f.computed == null ? 'column' : 'readColumn'}(${_columnSymbol(entity, f)});', + ); + } + for (final edge in entity.edges) { + if (edge.onDelete == null) { + b.writeln( + '/// Read-only navigation; no database foreign key or write effects.', + ); + } + b.writeln( + 'Relation<${edge.target.rowType}, ${edge.target.fieldsType}> get ${edge.name} => ' + 'Relation(${edge.target.name}Table, parent: [${edge.parentKeys.join(', ')}], ' + 'child: (row) => [${edge.childKeys.map((k) => 'row.$k').join(', ')}]);', + ); + } + b.writeln('}'); + final selection = _modelSelection(entity, 'row'); + b.writeln( + 'final ${entity.name}Table = Table<${entity.rowType}, ${entity.fieldsType}>(' + '${entity.name}Schema, ${entity.fieldsType}.new, (row) => $selection);', + ); + b.writeln( + 'final class ${entity.setType} extends TableSet<${entity.rowType}, ${entity.fieldsType}> {' + '${entity.setType}(QueryContext db) : super(db, ${entity.name}Table) { db.registerSchema(appSchema); }', + ); + final parameters = []; + final assignments = []; + for (final f in entity.fields) { + if (f.computed != null) continue; + if (f.generated || f.defaultSql != null || f.clientDefault != null) { + parameters.add('Change<${f.type}> ${f.name} = const Change.keep()'); + assignments.add('...row.${f.name}.change(${f.name})'); + } else if (f.nullable) { + parameters.add('${f.type} ${f.name}'); + assignments.add('row.${f.name}.set(${f.name})'); + } else { + parameters.add('required ${f.type} ${f.name}'); + assignments.add('row.${f.name}.set(${f.name})'); + } + } + b.writeln( + 'Future<${entity.rowType}> create({${parameters.join(', ')}}) => createRow((row) => [${assignments.join(', ')}]);', + ); + if (entity.primaryKey.isNotEmpty) { + final positional = entity.primaryKey.length == 1; + final params = entity.primaryKey + .map( + (k) => '${positional ? '' : 'required '}${entity.field(k).type} $k', + ) + .join(', '); + b.writeln( + 'Query<${entity.rowType}, ${entity.fieldsType}> byId(${positional ? params : '{$params}'}) => ' + 'where((row) => ${entity.primaryKey.map((k) => 'row.$k.eq($k)').join('.and(')}${')' * (entity.primaryKey.length - 1)});', + ); + } + b.writeln('}'); + if (entity.fields.any((f) => !f.generated && f.computed == null)) { + b.writeln( + 'extension ${entity.symbol}Updates on Query<${entity.rowType}, ${entity.fieldsType}> {' + 'Future patch({${entity.fields.where((f) => !f.generated && f.computed == null).map((f) => 'Change<${f.type}> ${f.name} = const Change.keep()').join(', ')}}) => ' + 'update((row) => [${entity.fields.where((f) => !f.generated && f.computed == null).map((f) => '...row.${f.name}.change(${f.name})').join(', ')}]).execute(); }', + ); + } + } + b.writeln( + 'final appSchema = List.unmodifiable([${schema.map((e) => '${e.name}Schema').join(', ')}]);', + ); + b.writeln('extension AppTables on QueryContext {'); + for (final e in schema) { + b.writeln('${e.setType} get ${e.name} => ${e.setType}(this);'); + } + b.writeln('}'); + return b.toString(); +} + +String _modelSelection(_Entity entity, String row) { + final named = entity.constructorNamedFields; + if (named == null) return _recordSelection(entity.fields, row); + String construct(String Function(_Field) value) => + '${entity.rowType}(${entity.fields.map((f) => '${named.contains(f.name) ? '${f.name}: ' : ''}${value(f)}').join(', ')})'; + final fields = entity.fields; + if (fields.length == 1) { + return '$row.${fields.single.name}.map((value) => ${construct((_) => 'value')})'; + } + if (fields.length <= 6) { + return '(${fields.map((f) => '$row.${f.name}').join(', ')})' + '.map((${List.generate(fields.length, (i) => 'v$i').join(', ')}) => ' + '${construct((f) => 'v${fields.indexOf(f)}')})'; + } + final left = fields.take(5).toList(), right = fields.skip(5).toList(); + final leftNames = left.map((f) => f.name).toSet(); + return '(${_recordSelection(left, row)}, ${_recordSelection(right, row)})' + '.map((left, right) => ' + '${construct((f) => '${leftNames.contains(f.name) ? 'left' : 'right'}.${f.name}')})'; +} + +String _recordSelection(List<_Field> fields, String row) { + if (fields.length == 1) { + final f = fields.single; + return '$row.${f.name}.map((v) => (${f.name}: v,))'; + } + if (fields.length <= 6) { + return '(${fields.map((f) => '$row.${f.name}').join(', ')})' + '.map((${fields.map((f) => f.name).join(', ')}) => ' + '(${fields.map((f) => '${f.name}: ${f.name}').join(', ')}))'; + } + // Nested typed composition avoids a combinatorial number of arity helpers. + final left = fields.take(5).toList(), right = fields.skip(5).toList(); + return '(${_recordSelection(left, row)}, ${_recordSelection(right, row)})' + '.map((left, right) => (${[...left.map((f) => '${f.name}: left.${f.name}'), ...right.map((f) => '${f.name}: right.${f.name}')].join(', ')}))'; +} + +String _computedLiteral(ComputedColumn value) => + 'ComputedColumn.forDialects(sqlite: ${_literal(value.sqlite)}, postgres: ${_literal(value.postgres)}, mysql: ${value.mysql == null ? 'null' : _literal(value.mysql!)}, mariadb: ${value.mariadb == null ? 'null' : _literal(value.mariadb!)}, storage: ComputedStorage.${value.storage.name})'; diff --git a/lib/src/generate/import.dart b/lib/src/generate/import.dart new file mode 100644 index 00000000..e8dd4276 --- /dev/null +++ b/lib/src/generate/import.dart @@ -0,0 +1,729 @@ +part of '../generate.dart'; + +/// A catalog fact that needs manual handling. Blocking issues prevent a faithful +/// declaration of the affected table or relationship; other issues retain objects +/// which must stay in reviewed, database-specific migrations. +final class SchemaImportIssue { + final String code; + final String object; + final String detail; + final bool blocking; + const SchemaImportIssue( + this.code, + this.object, + this.detail, { + this.blocking = true, + }); + Map toJson() => { + 'code': code, + 'object': object, + 'detail': detail, + 'blocking': blocking, + }; +} + +/// Editable declarations, physical-to-Dart names, and a separate review report. +/// This is not a migration or permission to replace the existing schema. +final class ImportedSchema { + final SqlDialect dialect; + final String schema; + final String dart; + final Map entities; + final Map> fields; + final List issues; + ImportedSchema._( + this.dialect, + this.schema, + this.dart, + Map entities, + Map> fields, + List issues, + ) : entities = Map.unmodifiable(entities), + fields = Map.unmodifiable( + fields.map((k, v) => MapEntry(k, Map.unmodifiable(v))), + ), + issues = List.unmodifiable(issues); + bool get hasBlockingIssues => issues.any((i) => i.blocking); + Map toJson() => { + 'format': 1, + 'dialect': dialect.name, + 'schema': schema, + 'entities': entities, + 'fields': fields, + 'issues': [for (final issue in issues) issue.toJson()], + }; +} + +/// Reads the selected database's physical catalog in one consistent +/// catalog snapshot. No user rows are read, inferred, or changed. Omit [tables] +/// to discover user tables; pass exact physical names to restrict the import. +/// Unsupported columns exclude their whole table and produce blocking issues. +Future importSchema( + SqlDatabase db, { + List? tables, +}) { + final requested = tables == null ? null : List.unmodifiable(tables); + if (requested != null && + (requested.isEmpty || requested.toSet().length != requested.length)) { + throw ArgumentError.value(tables, 'tables', 'Choose distinct table names.'); + } + if (db.inTransaction) { + throw const OrmException( + 'IMPORT.SESSION', + 'Import needs its own catalog snapshot.', + ); + } + return db.transaction( + (tx) => _importCatalog(tx, requested), + options: switch (db.dialect) { + SqlDialect.sqlite => const SqliteTransaction(), + SqlDialect.postgres => const PostgresTransaction( + isolation: .repeatableRead, + readOnly: true, + ), + SqlDialect.mysql => const MysqlTransaction( + isolation: .repeatableRead, + readOnly: true, + ), + SqlDialect.mariadb => const MariadbTransaction( + isolation: .repeatableRead, + readOnly: true, + ), + }, + ); +} + +Future _importCatalog( + SqlDatabase db, + List? requested, +) async { + final issues = []; + final schema = db.dialect == SqlDialect.sqlite + ? 'main' + : (await db.execute( + SqlCommand( + _mysqlDialect(db.dialect) + ? 'SELECT DATABASE()' + : 'SELECT current_schema()', + ), + )).rows.single.single + as String?; + if (schema == null) { + throw const OrmException( + 'IMPORT.SCHEMA', + 'Select an existing database or schema in the connection options.', + ); + } + final sqliteKinds = {}; + if (db.dialect == SqlDialect.sqlite) { + final list = await db.execute(SqlCommand('PRAGMA main.table_list')); + if (!list.columns.contains('type')) { + throw const OrmException( + 'IMPORT.CAPABILITY', + 'SQLite catalog import requires table_list (SQLite 3.37 or later).', + ); + } + for (final row in list.rows) { + sqliteKinds[row[1] as String] = row[2] as String; + } + } + final inventory = await db.execute( + SqlCommand( + db.dialect == SqlDialect.sqlite + ? "SELECT name, type, sql FROM main.sqlite_schema WHERE type IN ('table', 'view') AND substr(name, 1, 7) <> 'sqlite_' ORDER BY name" + : _mysqlDialect(db.dialect) + ? "SELECT TABLE_NAME, TABLE_TYPE, '' FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME" + : '''SELECT c.relname, c.relkind::text, + CASE WHEN c.relkind IN ('v', 'm') THEN pg_get_viewdef(c.oid) ELSE '' END + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p', 'v', 'm', 'f') ORDER BY c.relname''', + ), + ); + final inventoryRows = _mysqlDialect(db.dialect) + ? [ + for (final row in inventory.rows) + [ + for (final value in row) + value is List ? utf8.decode(value) : value, + ], + ] + : inventory.rows; + final objects = {for (final row in inventoryRows) row[0] as String: row}; + final selected = + requested?.toList() ?? + objects.keys + .where( + (n) => !{'_orm_migrations', '_orm_migration_steps'}.contains(n), + ) + .toList(); + selected.sort(); + final infos = {}; + final generated = >{}; + final reported = {}; + for (final name in selected) { + final object = objects[name]; + if (object == null) { + issues.add( + SchemaImportIssue( + 'IMPORT.MISSING', + name, + 'No table with this exact name exists in the selected schema.', + ), + ); + continue; + } + final kind = sqliteKinds[name] ?? object[1] as String; + if (!{'r', 'table', 'BASE TABLE'}.contains(kind)) { + issues.add( + SchemaImportIssue( + 'IMPORT.OBJECT', + name, + '$kind: ${object[2] ?? ''}', + blocking: + requested != null || + !{'v', 'm', 'view', 'VIEW', 'shadow'}.contains(kind), + ), + ); + continue; + } + if (db.dialect == SqlDialect.sqlite) { + final shadow = await db.execute( + SqlCommand( + "SELECT name FROM sqlite_temp_schema WHERE name = ?1 COLLATE NOCASE AND type IN ('table', 'view')", + [name], + ), + ); + if (shadow.rows.isNotEmpty) { + issues.add( + SchemaImportIssue( + 'IMPORT.SCOPE', + name, + 'A temporary object shadows this table.', + ), + ); + continue; + } + } else if (db.dialect == SqlDialect.postgres) { + final shape = await db.execute( + SqlCommand( + r'''SELECT pg_table_is_visible(c.oid), + c.relispartition OR EXISTS(SELECT 1 FROM pg_inherits WHERE inhrelid = c.oid OR inhparent = c.oid) + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() AND c.relname = $1''', + [name], + ), + ); + if (shape.rows.single[0] != true || shape.rows.single[1] == true) { + issues.add( + SchemaImportIssue( + 'IMPORT.SCOPE', + name, + 'Shadowed, partitioned, or inherited tables need explicit declarations.', + ), + ); + continue; + } + } + final before = issues.where((i) => i.blocking).length; + final info = await inspectTable(db, name); + for (final extra in info.unmanaged) { + // SQLite inspection conservatively returns all views. Report each once; + // views in the selected inventory already have their own object entry. + if (extra.kind == 'view' && selected.contains(extra.name)) { + continue; + } + final object = extra.kind == 'view' + ? 'view/${extra.name}' + : '$name/${extra.kind}/${extra.name}'; + if (!reported.add(object)) { + continue; + } + issues.add( + SchemaImportIssue( + 'IMPORT.UNMANAGED', + object, + extra.definition, + blocking: + _mysqlDialect(db.dialect) && + {'column', 'table options'}.contains(extra.kind), + ), + ); + } + final identities = {}; + if (info.columns.isEmpty) { + issues.add( + SchemaImportIssue( + 'IMPORT.EMPTY', + name, + 'A model requires at least one column.', + ), + ); + } + Map> modes = {}; + if (db.dialect == SqlDialect.postgres) { + final result = await db.execute( + SqlCommand( + r'''SELECT a.attname, a.attidentity::text, a.attgenerated::text + FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() AND c.relname = $1 AND a.attnum > 0 AND NOT a.attisdropped''', + [name], + ), + ); + modes = {for (final row in result.rows) row[0] as String: row}; + } + for (final column in info.columns) { + final path = '$name.${column.name}'; + if (_importType(column, db.dialect) == null) { + issues.add( + SchemaImportIssue( + 'IMPORT.TYPE', + path, + 'No exact declaration mapping for ${column.storageType}. No data values were sampled.', + ), + ); + } + if (_mysqlDialect(db.dialect) && + column.storageType.startsWith('DATETIME')) { + issues.add( + SchemaImportIssue( + 'IMPORT.TEMPORAL_SEMANTICS', + path, + 'DATETIME does not identify an application timezone. Imported as LocalDateTime; explicitly choose a UTC instant codec if that is the application contract.', + blocking: false, + ), + ); + } + if (_mysqlDialect(db.dialect) && column.storageType == 'TINYINT(1)') { + issues.add( + SchemaImportIssue( + 'IMPORT.BOOLEAN_SEMANTICS', + path, + 'TINYINT(1) is MySQL boolean storage but does not enforce a 0/1 value domain. Review this bool declaration against the application contract; no rows were sampled.', + blocking: false, + ), + ); + } + if (info.primaryKey.contains(column.name) && column.nullable) { + issues.add( + SchemaImportIssue( + 'IMPORT.NULLABLE_KEY', + path, + 'Nullable primary keys cannot provide a typed row identity.', + ), + ); + } + if (column.generated && column.computed == null) { + if (_mysqlDialect(db.dialect) && + {'SMALLINT', 'INT', 'BIGINT'}.contains(column.storageType) && + !column.nullable && + _same(info.primaryKey, [column.name])) { + identities.add(column.name); + } else if (db.dialect == SqlDialect.postgres && + modes[column.name]![1] == 'd' && + modes[column.name]![2] == '' && + {'SMALLINT', 'INTEGER', 'BIGINT'}.contains(column.storageType) && + !column.nullable && + _same(info.primaryKey, [column.name])) { + identities.add(column.name); + } else { + issues.add( + SchemaImportIssue( + 'IMPORT.GENERATED', + path, + 'Generated expressions and ALWAYS/non-primary identities need explicit write semantics.', + ), + ); + } + } + } + if (info.columns.isNotEmpty && + info.columns.every((c) => c.computed != null)) { + issues.add( + SchemaImportIssue( + 'IMPORT.COMPUTED_TABLE', + name, + 'Portable declarations need at least one ordinary column.', + ), + ); + } + if (db.dialect == SqlDialect.sqlite && + info.primaryKey.length == 1 && + info.columns.any( + (c) => + c.name == info.primaryKey.single && + c.storageType == 'INTEGER' && + !c.nullable, + )) { + final indexes = await db.execute( + SqlCommand('PRAGMA main.index_list(${_importQuote(name)})'), + ); + if (!indexes.rows.any((r) => r[3] == 'pk')) { + identities.add(info.primaryKey.single); + } + } + if (issues.where((i) => i.blocking).length != before) continue; + infos[name] = info; + generated[name] = identities; + } + return _importDeclarations(infos, generated, db.dialect, schema, issues); +} + +// Match physical types exactly. In particular, NUMERIC does not prove BigInt, +// and SQLite TEXT does not prove an application DateTime, enum, or JSON codec. +(String, String?)? _importType(ColumnInfo column, SqlDialect dialect) { + if (_mysqlDialect(dialect)) return _importMysqlType(column); + return switch (( + dialect, + column.temporalPrecision == null + ? column.storageType + : column.storageType.replaceFirst(RegExp(r'\([0-6]\)'), ''), + )) { + (SqlDialect.sqlite, 'TEXT') + when column.collation?.toLowerCase() == 'orm_decimal_v1' => + ('Decimal', null), + (SqlDialect.postgres, 'NUMERIC') => ('Decimal', null), + (SqlDialect.postgres, _) when column.decimalPrecision != null => ( + 'Decimal', + null, + ), + (SqlDialect.sqlite, 'INTEGER') || + (SqlDialect.postgres, 'SMALLINT' || 'INTEGER' || 'BIGINT') => ('int', null), + (SqlDialect.sqlite, 'TEXT') + when column.collation?.toLowerCase() == 'orm_date_v1' => + ('LocalDate', null), + (SqlDialect.sqlite, 'TEXT') + when column.collation?.toLowerCase() == 'orm_time_v1' => + ('LocalTime', null), + (SqlDialect.sqlite, 'TEXT') + when column.collation?.toLowerCase() == 'orm_local_datetime_v1' => + ('LocalDateTime', null), + (SqlDialect.sqlite, 'TEXT') + when column.collation?.toLowerCase() == 'orm_instant_v1' => + ('DateTime', null), + (_, 'TEXT') => ('String', null), + (SqlDialect.sqlite, 'REAL') || + (SqlDialect.postgres, 'DOUBLE PRECISION') => ('double', null), + (SqlDialect.sqlite, 'BLOB') || + (SqlDialect.postgres, 'BYTEA') => ('Uint8List', null), + (SqlDialect.postgres, 'BOOLEAN') => ('bool', null), + (SqlDialect.postgres, 'TIMESTAMPTZ') => ('DateTime', null), + (SqlDialect.postgres, 'DATE') => ('LocalDate', null), + (SqlDialect.postgres, 'TIME WITHOUT TIME ZONE') => ('LocalTime', null), + (SqlDialect.postgres, 'TIMESTAMP WITHOUT TIME ZONE') => ( + 'LocalDateTime', + null, + ), + (SqlDialect.postgres, 'JSONB') => ('SqlJson', 'Codecs.jsonDocument'), + _ => null, + }; +} + +(String, String?)? _importMysqlType(ColumnInfo column) { + final type = column.storageType.toUpperCase(); + // Exact declared storage only: unsigned widths, binary text and arbitrary + // lengths must not be silently rewritten into the ORM's default types. + if (type.contains('UNSIGNED') || type.contains('ZEROFILL')) return null; + if (RegExp(r'^DECIMAL\([0-9]+,[0-9]+\)$').hasMatch(type) && + column.decimalPrecision != null) { + return ('Decimal', null); + } + final temporal = column.temporalPrecision == null + ? type + : type.replaceFirst(RegExp(r'\([0-6]\)'), ''); + return switch (temporal) { + 'SMALLINT' || 'INT' || 'BIGINT' => ('int', null), + 'TINYINT(1)' => ('bool', null), + 'VARCHAR(255)' => ('String', null), + 'DOUBLE' => ('double', null), + 'LONGBLOB' => ('Uint8List', null), + 'DATE' => ('LocalDate', null), + 'TIME' => ('LocalTime', null), + 'DATETIME' => ('LocalDateTime', null), + 'JSON' => ('SqlJson', 'Codecs.jsonDocument'), + _ => null, + }; +} + +String _importQuote(String name) => '"${name.replaceAll('"', '""')}"'; +String _importCap(String name) => name[0].toUpperCase() + name.substring(1); + +final class _ImportNames { + final used = { + ...Keyword.keywords.keys, + ..._databaseMembers, + 'column', + 'readColumn', + 'appSchema', + 'models', + 'row', + 'left', + 'right', + 'v', + 'String', + 'int', + 'double', + 'bool', + 'DateTime', + 'BigInt', + 'Uint8List', + 'SqlJson', + 'Decimal', + 'LocalDate', + 'LocalTime', + 'LocalDateTime', + 'DecimalDigits', + 'entity', + 'Id', + 'Unique', + 'ColumnName', + 'Default', + 'UseCodec', + 'Codecs', + }; + String take( + String physical, { + String fallback = 'field', + bool entity = false, + }) { + final words = physical + .replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (m) => '${m[1]}_${m[2]}', + ) + .split(RegExp('[^a-zA-Z0-9]+')) + .where((w) => w.isNotEmpty) + .toList(); + var base = words.isEmpty + ? fallback + : words.first.toLowerCase() + + words.skip(1).map((w) => _importCap(w.toLowerCase())).join(); + if (!RegExp('^[a-zA-Z]').hasMatch(base)) { + base = '$fallback${_importCap(base)}'; + } + var value = base, n = 2; + Set symbols(String v) => { + v, + if (entity) ...[ + '${_importCap(v)}Row', + '${v}Schema', + '${v}Table', + '${_importCap(v)}Fields', + '${_importCap(v)}TableSet', + '${_importCap(v)}Updates', + ], + }; + while (symbols(value).any(used.contains)) { + value = '$base${n++}'; + } + used.addAll(symbols(value)); + return value; + } +} + +ImportedSchema _importDeclarations( + Map infos, + Map> generated, + SqlDialect dialect, + String schema, + List issues, +) { + final names = _ImportNames(); + final entities = { + for (final name in infos.keys) + name: names.take(name, fallback: 'table', entity: true), + }; + final fieldNames = {for (final name in infos.keys) name: _ImportNames()}; + final columnSymbols = {}; + String field(String table, String column) { + var result = fieldNames[table]!.take(column); + while (!columnSymbols.add('_${entities[table]}${_importCap(result)}')) { + result = fieldNames[table]!.take(result); + } + return result; + } + + final fields = { + for (final info in infos.values) + info.name: { + for (final c in info.columns) c.name: field(info.name, c.name), + }, + }; + final b = StringBuffer( + '// Imported catalog draft. Review the import report before baselining.\n\n', + )..writeln("import 'package:orm/schema.dart';"); + if (infos.values.any( + (t) => t.columns.any((c) => _importType(c, dialect)?.$1 == 'Uint8List'), + )) { + b.writeln("import 'dart:typed_data';"); + } + for (final info in infos.values) { + final entity = entities[info.name]!; + b.writeln('\nfinal class ${_importCap(entity)}Row({'); + for (final c in info.columns) { + final type = _importType(c, dialect)!; + b.writeln('@ColumnName(${_literal(c.name)})'); + if (c.integerBits != null && c.integerBits != 64) { + b.writeln('@IntegerBits(${c.integerBits})'); + } + if (c.temporalPrecision != null && c.temporalPrecision != 6) { + b.writeln('@TemporalPrecision(${c.temporalPrecision})'); + } + if (c.decimalPrecision != null) { + b.writeln( + '@DecimalDigits(${c.decimalPrecision}, ${c.decimalScale ?? 0})', + ); + } + if (generated[info.name]!.contains(c.name)) b.writeln('@Id.generated()'); + if (c.declarationDefaultSql != null) { + b.writeln('@Default.sql(${_literal(c.declarationDefaultSql!)})'); + } + if (c.computed != null) { + final computed = c.declarationComputed!; + b.writeln( + '@Computed.sql(${_literal(computed.expression(dialect))}, storage: ComputedStorage.${computed.storage.name})', + ); + issues.add( + SchemaImportIssue( + 'IMPORT.COMPUTED_SQL', + '${info.name}.${c.name}', + 'Computed SQL was read from ${dialect.name}; review expression portability before using another backend.', + blocking: false, + ), + ); + } + if (type.$2 != null) b.writeln('@UseCodec(${type.$2})'); + b.writeln( + 'required final ${type.$1}${c.nullable ? '?' : ''} ${fields[info.name]![c.name]},', + ); + } + b.writeln( + '});\nfinal $entity = entity<${_importCap(entity)}Row>(table: ${_literal(info.name)});', + ); + } + String selector(String table, List columns) { + final values = columns.map((c) => 'row.${fields[table]![c]}').toList(); + return '(row) => ${values.length == 1 ? values.single : '(${values.join(', ')})'}'; + } + + for (final info in infos.values) { + final entity = entities[info.name]!; + void constraint(String kind, List columns, {String extra = ''}) { + final symbol = names.take('$entity${_importCap(kind)}'); + b.writeln( + 'final $symbol = $entity.$kind(${selector(info.name, columns)}$extra);', + ); + } + + if (info.primaryKey.isNotEmpty && generated[info.name]!.isEmpty) { + constraint('primaryKey', info.primaryKey); + } + final unique = info.uniqueKeys.toList() + ..sort((a, b) => jsonEncode(a).compareTo(jsonEncode(b))); + for (final key in unique) { + constraint('unique', key); + } + final indexes = info.indexes.toList() + ..sort((a, b) => a.name.compareTo(b.name)); + for (final index in indexes) { + constraint( + 'index', + index.columns, + extra: ', name: ${_literal(index.name)}, unique: ${index.unique}', + ); + } + final checks = info.checks.toList() + ..sort( + (a, b) => + jsonEncode([a.name, a.expression]) + .compareTo(jsonEncode([b.name, b.expression])), + ); + for (final check in checks) { + final symbol = names.take(check.name ?? '${entity}Check'); + b.writeln( + 'final $symbol = $entity.check(${_literal(check.expression)}, ' + 'name: ${check.name == null ? 'null' : _literal(check.name!)});', + ); + } + if (checks.isNotEmpty) { + issues.add( + SchemaImportIssue( + 'IMPORT.CHECK_SQL', + info.name, + 'CHECK expressions use ${dialect.name} SQL. Review other-dialect overrides before deploying this declaration elsewhere.', + blocking: false, + ), + ); + } + final foreign = info.foreignKeys.toList() + ..sort( + (a, b) => jsonEncode([a.columns, a.target, a.targetColumns, a.onDelete]) + .compareTo( + jsonEncode([b.columns, b.target, b.targetColumns, b.onDelete]), + ), + ); + for (final key in foreign) { + final target = infos[key.target]; + if (target == null || + ![ + target.primaryKey, + ...target.uniqueKeys, + for (final i in target.indexes) + if (i.unique) i.columns, + ].any((k) => _same(k, key.targetColumns)) || + key.columns.asMap().entries.any( + (entry) => + _importType( + info.columns.singleWhere((c) => c.name == entry.value), + dialect, + )?.$1 != + _importType( + target.columns.singleWhere( + (c) => c.name == key.targetColumns[entry.key], + ), + dialect, + )?.$1, + ) || + key.onDelete == 'SET NULL' && + key.columns.any( + (c) => !info.columns.singleWhere((f) => f.name == c).nullable, + )) { + issues.add( + SchemaImportIssue( + 'IMPORT.RELATION', + '${info.name}/${key.columns.join(',')}', + 'Target ${key.target} must be imported with a matching unique key, compatible types and nullability.', + ), + ); + continue; + } + final action = switch (key.onDelete) { + 'CASCADE' => 'cascade', + 'SET NULL' => 'setNull', + 'SET DEFAULT' => 'setDefault', + 'NO ACTION' => 'noAction', + _ => 'restrict', + }; + var relation = names.take('$entity${_importCap(entities[key.target]!)}'); + while (fieldNames[info.name]!.used.contains(relation)) { + relation = names.take(relation); + } + fieldNames[info.name]!.used.add(relation); + final inverse = fieldNames[key.target]!.take('${entity}Rows'); + b.writeln( + 'final $relation = $entity.key(${selector(info.name, key.columns)}).references(${entities[key.target]}.key(${selector(key.target, key.targetColumns)}), inverse: ${_literal(inverse)}, onDelete: .$action);', + ); + } + } + if (infos.isEmpty) { + b.writeln('// No supported tables were imported. See the import report.'); + } + return ImportedSchema._( + dialect, + schema, + DartFormatter(languageVersion: DartFormatter.latestLanguageVersion) + .format(b.toString()), + entities, + fields, + issues, + ); +} diff --git a/lib/src/generate/migrations.dart b/lib/src/generate/migrations.dart new file mode 100644 index 00000000..77a274ea --- /dev/null +++ b/lib/src/generate/migrations.dart @@ -0,0 +1,192 @@ +part of '../generate.dart'; + +/// Regenerates only static imports. Fingerprints stay in the migration files. +Future writeMigrationRegistry( + String directory, { + SqlDialect? dialect, +}) async { + dialect = await _registryDialect(directory, dialect); + final root = Directory(directory); + await root.create(recursive: true); + final ids = await _migrationIds(root); + final path = p.join(directory, 'migrations.g.dart'); + await _replaceSource( + path, + _formatMigration(migrationHistorySource(ids, dialect: dialect)), + ); + return path; +} + +/// Saves a new fixed migration and updates the registry. Never replaces a file. +Future writeMigration( + Migration migration, { + required String directory, + required MigrationHistory history, +}) async { + final previous = history.checked; + final all = [...previous, migration]; + validateMigrations(all, dialect: history.dialect); + await _registryDialect(directory, history.dialect); + final root = Directory(directory); + await root.create(recursive: true); + await _checkMigrationFiles(root, previous); + final source = _formatMigration(migrationSource(migration)); + final registry = _formatMigration( + migrationHistorySource(all.map((m) => m.id), dialect: history.dialect), + ); + final file = File(p.join(directory, 'm${migration.id}.dart')); + if (await FileSystemEntity.type(file.path, followLinks: false) != + FileSystemEntityType.notFound) { + throw GenerationException('Migration already exists: ${file.path}'); + } + await file.create(exclusive: true); + try { + await file.writeAsString(source, flush: true); + await _replaceSource(p.join(directory, 'migrations.g.dart'), registry); + } catch (_) { + await file.delete(); + rethrow; + } + return file.path; +} + +/// Records a reviewed edit to the latest unpublished migration. Its source is +/// retained verbatim apart from the fingerprint literal. Applied files must not +/// be edited or recorded again; this offline tool cannot know deployment state. +Future recordMigration( + String id, { + required String directory, + required MigrationHistory history, +}) async { + if (history.entries.isEmpty || history.entries.last.$1.id != id) { + throw const GenerationException( + 'Only the latest unpublished migration can be recorded.', + ); + } + MigrationHistory( + history.entries.take(history.entries.length - 1), + dialect: history.dialect, + ).checked; + final migrations = history.entries.map((e) => e.$1).toList(); + validateMigrations(migrations, dialect: history.dialect); + await _registryDialect(directory, history.dialect); + await _checkMigrationFiles(Directory(directory), migrations); + final path = p.join(directory, 'm$id.dart'); + final file = File(path); + final content = await file.readAsString(); + final unit = parseString(content: content, path: path).unit; + final fingerprints = [ + for (final declaration + in unit.declarations.whereType()) + if (declaration.variables.isConst) + for (final variable in declaration.variables.variables) + if (variable.name.lexeme == 'migrationChecksum') variable, + ]; + if (fingerprints.length != 1 || + fingerprints.single.initializer is! SimpleStringLiteral) { + throw const GenerationException( + 'Use one const migrationChecksum string literal.', + ); + } + final literal = fingerprints.single.initializer!; + await _replaceSource( + path, + content.replaceRange( + literal.offset, + literal.end, + _literal(migrations.last.checksum), + ), + ); + return migrations.last.checksum; +} + +Future> _migrationIds(Directory directory) async { + final ids = []; + await for (final entity in directory.list(followLinks: false)) { + final name = p.basename(entity.path); + if (name == 'migrations.g.dart' || !name.endsWith('.dart')) continue; + final match = RegExp(r'^m([0-9]+_[a-z][a-z0-9_]*)\.dart$').firstMatch(name); + if (match == null || entity is! File) { + throw GenerationException( + 'Expected a regular m.dart migration: ${entity.path}', + ); + } + ids.add(match.group(1)!); + } + return ids..sort(); +} + +Future _checkMigrationFiles( + Directory directory, + List migrations, +) async { + final actual = await _migrationIds(directory); + final expected = migrations.map((m) => m.id).toList(); + if (!_same(actual, expected)) { + throw const GenerationException( + 'Migration registry is stale. Regenerate static imports, then run the command again.', + ); + } +} + +String _formatMigration(String source) => + DartFormatter(languageVersion: DartFormatter.latestLanguageVersion) + .format(source); + +Future _replaceSource(String path, String source) async { + final kind = await FileSystemEntity.type(path, followLinks: false); + if (kind != FileSystemEntityType.notFound && + kind != FileSystemEntityType.file) { + throw GenerationException('Expected a regular source file: $path'); + } + final temporary = File( + '$path.$pid.${DateTime.now().microsecondsSinceEpoch}.tmp', + ); + await temporary.create(exclusive: true); + try { + await temporary.writeAsString(source, flush: true); + await temporary.rename(path); + } finally { + if (await temporary.exists()) await temporary.delete(); + } +} + +/// A saved target is never changed by registry regeneration, including when empty. +Future _registryDialect( + String directory, + SqlDialect? requested, +) async { + final file = File(p.join(directory, 'migrations.g.dart')); + final kind = await FileSystemEntity.type(file.path, followLinks: false); + if (kind == FileSystemEntityType.notFound) { + return requested ?? + (throw const GenerationException( + 'Choose --dialect sqlite, postgres, mysql or mariadb when initializing a migration registry.', + )); + } + if (kind != FileSystemEntityType.file) { + throw GenerationException('Expected a regular source file: ${file.path}'); + } + final unit = parseString(content: await file.readAsString()).unit; + final values = [ + for (final d in unit.declarations.whereType()) + if (d.variables.isConst) + for (final v in d.variables.variables) + if (v.name.lexeme == 'migrationDialect') v.initializer?.toSource(), + ]; + final saved = values.length == 1 + ? switch (values.single) { + 'SqlDialect.sqlite' => SqlDialect.sqlite, + 'SqlDialect.postgres' => SqlDialect.postgres, + 'SqlDialect.mysql' => SqlDialect.mysql, + 'SqlDialect.mariadb' => SqlDialect.mariadb, + _ => null, + } + : null; + if (saved == null || requested != null && requested != saved) { + throw const GenerationException( + 'Migration registry has a different or invalid database target. Use a separate history for another engine.', + ); + } + return saved; +} diff --git a/lib/src/generate/model.dart b/lib/src/generate/model.dart new file mode 100644 index 00000000..51fcc414 --- /dev/null +++ b/lib/src/generate/model.dart @@ -0,0 +1,196 @@ +part of '../generate.dart'; + +bool _mysqlDialect(SqlDialect dialect) => + dialect == SqlDialect.mysql || dialect == SqlDialect.mariadb; + +// A Database instance member takes precedence over a generated extension getter. +const _databaseMembers = { + 'sql', + 'driver', + 'onQuery', + 'onAcquire', + 'onDecode', + 'capabilities', + 'dialect', + 'inTransaction', + 'inSession', + 'table', + 'registerSchema', + 'invalidate', + 'execute', + 'session', + 'discard', + 'transaction', + 'savepoint', + 'close', + 'checkActive', + 'run', + 'executeOn', + 'executeCommand', + 'atomic', + 'streamRows', + 'observeDecode', + 'markFailed', + 'hashCode', + 'runtimeType', + 'noSuchMethod', + 'toString', +}; + +final class _Field { + final String name; + final String column; + final String type; + final String codec; + final String storage; + final bool nullable; + final bool id; + final bool generated; + final bool unique; + final String? defaultSql; + final String? clientDefault; + final ComputedColumn? computed; + final int? integerBits; + final int? decimalPrecision; + final int? decimalScale; + final int? temporalPrecision; + const _Field({ + required this.name, + required this.column, + required this.type, + required this.codec, + required this.storage, + required this.nullable, + this.id = false, + this.generated = false, + this.unique = false, + this.defaultSql, + this.clientDefault, + this.computed, + this.integerBits, + this.decimalPrecision, + this.decimalScale, + this.temporalPrecision, + }); + Column snapshot() { + final Codec physical = switch (storage) { + 'integer' => Codecs.integer, + 'bigint' => Codecs.bigint, + 'decimal' => Codecs.decimal, + 'text' => Codecs.text, + 'real' => Codecs.real, + 'boolean' => Codecs.boolean, + 'instant' => Codecs.dateTime, + 'date' => Codecs.date, + 'time' => Codecs.time, + 'local_datetime' => Codecs.localDateTime, + 'blob' => Codecs.bytes, + 'json' => Codecs.json, + _ => throw GenerationException('Unknown storage type $storage.'), + }; + return Column( + column, + nullable ? physical.nullable() : physical, + nullable: nullable, + generated: generated, + defaultSql: defaultSql, + computed: computed, + integerBits: integerBits, + decimalPrecision: decimalPrecision, + decimalScale: decimalScale, + temporalPrecision: temporalPrecision, + ); + } +} + +final class _Entity { + final String name; + final String table; + final String row; + final List<_Field> fields; + + /// Null for structural Record rows; otherwise the primary constructor's + /// named parameters. Positional parameters retain declaration order. + final Set? constructorNamedFields; + List primaryKey; + final List> uniqueKeys; + final List<_Index> indexes = []; + final List<_Edge> edges = []; + final List checks = []; + _Entity( + this.name, + this.table, + this.row, + this.fields, { + this.constructorNamedFields, + }) : primaryKey = [ + for (final f in fields) + if (f.id) f.name, + ], + uniqueKeys = [ + for (final f in fields) + if (f.unique) [f.name], + ]; + String get symbol => name[0].toUpperCase() + name.substring(1); + String get fieldsType => '${symbol}Fields'; + String get setType => '${symbol}TableSet'; + String get rowType => 'models.$row'; + _Field field(String name) => fields.firstWhere( + (f) => f.name == name, + orElse: () => throw GenerationException('$this has no field $name.'), + ); + List columns(List keys) => [ + for (final key in keys) field(key).column, + ]; + TableSchema snapshot() => TableSchema( + table, + columns: [for (final f in fields) f.snapshot()], + primaryKey: columns(primaryKey), + uniqueKeys: [for (final key in uniqueKeys) columns(key)], + indexes: [ + for (final i in indexes) + IndexSchema(i.name, columns(i.keys), unique: i.unique), + ], + foreignKeys: [ + for (final edge in edges) + if (edge.isForeignKey) + ForeignKey( + columns(edge.parentKeys), + edge.target.table, + edge.target.columns(edge.childKeys), + onDelete: edge.onDelete!, + ), + ], + checks: checks, + ); + @override + String toString() => name; +} + +final class _Index( + final String name, + final List keys, + final bool unique, +); + +final class _Edge( + final String name, + final _Entity target, + final List parentKeys, + final List childKeys, + final String? onDelete, { + final bool inverse = false, +}) { + bool get isForeignKey => !inverse && onDelete != null; +} + +String _snake(String value) => value + .replaceAllMapped(RegExp(r'([a-z0-9])([A-Z])'), (m) => '${m[1]}_${m[2]}') + .toLowerCase(); +String _literal(String value) => jsonEncode(value).replaceAll(r'$', r'\$'); +String _strings(List values) => '[${values.map(_literal).join(', ')}]'; +String _columnSymbol(_Entity entity, _Field field) => + '_${entity.name}${field.name[0].toUpperCase()}${field.name.substring(1)}'; +bool _same(List a, List b) => + a.length == b.length && + [for (var i = 0; i < a.length; i++) a[i] == b[i]].every((v) => v); diff --git a/lib/src/generate/queries.dart b/lib/src/generate/queries.dart new file mode 100644 index 00000000..ee2c676f --- /dev/null +++ b/lib/src/generate/queries.dart @@ -0,0 +1,470 @@ +part of '../generate.dart'; + +/// Offline generated code and its fixed SQL/codec contracts. Native checks are +/// explicit: generation does not claim that a database has accepted the SQL. +final class GeneratedQueries { + final String dart; + final Map manifest; + const GeneratedQueries(this.dart, this.manifest); +} + +Future generateQueries( + String sourcePath, { + String? outputPath, +}) async { + final source = p.normalize(p.absolute(sourcePath)); + final output = p.normalize( + p.absolute(outputPath ?? p.setExtension(source, '.queries.dart')), + ); + if (source == output || p.extension(output) != '.dart') { + throw const GenerationException( + 'Query output must be a separate .dart file.', + ); + } + final contexts = AnalysisContextCollection(includedPaths: [source]); + try { + final resolved = await contexts + .contextFor(source) + .currentSession + .getResolvedUnit(source); + if (resolved is! ResolvedUnitResult) { + throw GenerationException('Cannot analyze $source.'); + } + final errors = resolved.diagnostics.where( + (e) => e.severity.name.toLowerCase() == 'error', + ); + if (errors.isNotEmpty) { + throw GenerationException(errors.map((e) => e.toString()).join('\n')); + } + return await _generateQueries( + resolved.unit, + resolved.libraryElement, + p.relative(source, from: p.dirname(output)).replaceAll(r'\', '/'), + (uri) => uri.scheme == 'file' + ? p + .relative(uri.toFilePath(), from: p.dirname(output)) + .replaceAll(r'\', '/') + : uri.toString(), + (path) => File(p.join(p.dirname(source), path)).readAsString(), + ); + } finally { + await contexts.dispose(); + } +} + +Future writeGeneratedQueries(String source, {String? output}) async { + output ??= p.setExtension(source, '.queries.dart'); + final result = await generateQueries(source, outputPath: output); + final file = File(output); + await file.parent.create(recursive: true); + await file.writeAsString(result.dart); +} + +/// Re-analysis prevents checking stale SQL, parameter codecs or generated code. +Future checkGeneratedQueries( + String source, { + String? output, +}) async { + output ??= p.setExtension(source, '.queries.dart'); + final result = await generateQueries(source, outputPath: output); + if (await File(output).readAsString() != result.dart) { + throw const GenerationException( + 'Named SQL output is stale. Run queries generate first.', + ); + } + return result; +} + +final class _NamedQuery( + final _Entity result, + final List<_Field> parameters, + final Map sql, +); + +Future _generateQueries( + CompilationUnit unit, + LibraryElement library, + String sourceImport, + String Function(Uri) importUri, + Future Function(String) readSql, +) async { + final names = _DartNames(library.uri, importUri); + final reader = _SchemaReader(unit, library.typeSystem, names); + final aliases = { + for (final alias in unit.declarations.whereType()) + alias.name.lexeme: alias, + }; + final queries = <_NamedQuery>[]; + RecordTypeAnnotation record(TypeAnnotation type) { + if (type is RecordTypeAnnotation) return type; + final alias = aliases[type.toSource()]; + if (alias == null || + alias.typeParameters != null || + alias.type is! RecordTypeAnnotation) { + throw const GenerationException( + 'Named SQL requires non-generic record typedefs in the source library.', + ); + } + return alias.type as RecordTypeAnnotation; + } + + List<_Field> fields(RecordTypeAnnotation record, {required bool parameters}) { + if (record.positionalFields.isNotEmpty) { + throw const GenerationException( + 'Named SQL records require named fields.', + ); + } + final fields = [ + for (final f + in record.namedFields?.fields ?? []) + reader._readField(f, f.name.lexeme, f.type.type, f.metadata), + ]; + for (final f in fields) { + if (f.id || + f.generated || + f.unique || + f.defaultSql != null || + f.clientDefault != null || + f.computed != null || + f.temporalPrecision != null || + f.integerBits != null || + f.decimalPrecision != null) { + throw const GenerationException( + 'Query fields accept codec and column-name annotations only.', + ); + } + if (parameters && f.column != _snake(f.name)) { + throw const GenerationException( + 'SQL parameters use Dart field names, without ColumnName.', + ); + } + } + if (!parameters && + (fields.isEmpty || + fields.map((f) => f.column).toSet().length != fields.length)) { + throw const GenerationException( + 'Query results require distinct named columns.', + ); + } + return fields; + } + + for (final declaration + in unit.declarations.whereType()) { + for (final variable in declaration.variables.variables) { + final call = variable.initializer; + if (call is! MethodInvocation || + call.methodName.name != 'sqlQuery' || + call.methodName.element?.library?.uri.toString() != + 'package:orm/schema.dart') { + continue; + } + final types = call.typeArguments?.arguments; + if (types == null || + types.length != 2 || + types.first is RecordTypeAnnotation) { + throw const GenerationException( + 'sqlQuery requires an explicit result typedef.', + ); + } + final name = variable.name.lexeme; + if (name.startsWith('_')) { + throw const GenerationException('Named queries must be public.'); + } + if (_databaseMembers.contains(name)) { + throw GenerationException('$name conflicts with a Database member.'); + } + if (types.first.toSource().startsWith('_')) { + throw const GenerationException( + 'Query result typedefs must be public.', + ); + } + final resultFields = fields(record(types.first), parameters: false); + final parameterFields = fields(record(types.last), parameters: true); + final sql = {}; + for (final dialect in SqlDialect.values) { + final path = reader._namedString(call, dialect.name); + if (path == null) continue; + if (path.isEmpty || + p.url.isAbsolute(path) || + path.contains(r'\') || + Uri.parse(path).hasScheme) { + throw const GenerationException( + 'SQL paths must be relative to the declaring library.', + ); + } + final source = await readSql(path); + final template = SqlTemplate(source, dialect: dialect); + if (template.parameters.length != parameterFields.length || + !parameterFields.every( + (f) => template.parameters.contains(f.name), + )) { + throw GenerationException( + '$name (${dialect.name}): SQL parameters differ from the record fields.', + ); + } + sql[dialect] = source; + } + if (sql.isEmpty) { + throw GenerationException('$name needs at least one SQL file.'); + } + if (queries.any((q) => _snake(q.result.name) == _snake(name))) { + throw GenerationException('Conflicting query name $name.'); + } + queries.add( + _NamedQuery( + _Entity( + name, + '_orm_sql_${_snake(name)}', + types.first.toSource(), + resultFields, + ), + parameterFields, + sql, + ), + ); + } + } + if (queries.isEmpty) { + throw const GenerationException( + 'No sqlQuery() declarations found.', + ); + } + return GeneratedQueries( + DartFormatter(languageVersion: library.languageVersion.effective) + .format(_emitQueries(queries, sourceImport, names)), + { + 'format': 1, + 'source': sourceImport, + 'queries': [ + for (final query in queries) + { + 'name': query.result.name, + 'parameters': [ + for (final f in query.parameters) + {'name': f.name, 'type': f.storage, 'nullable': f.nullable}, + ], + 'columns': [ + for (final f in query.result.fields) + {'name': f.column, 'type': f.storage, 'nullable': f.nullable}, + ], + 'sql': { + for (final entry in query.sql.entries) + entry.key.name: entry.value, + }, + }, + ], + }, + ); +} + +String _emitQueries( + List<_NamedQuery> queries, + String sourceImport, + _DartNames names, +) { + final b = StringBuffer('// GENERATED CODE - DO NOT MODIFY BY HAND.\n\n') + ..writeln("import 'package:orm/sql.dart';") + ..writeln('import ${_literal(sourceImport)} as models;') + ..writeln( + 'export ${_literal(sourceImport)} show ${queries.map((q) => q.result.row).toSet().join(', ')};', + ); + if (names.typedData) b.writeln("import 'dart:typed_data';"); + for (final (uri, prefix) in names.imports) { + b.writeln('import ${_literal(uri)} as $prefix;'); + } + if (queries.any((q) => q.parameters.isNotEmpty)) { + b.writeln( + 'Expr _bindSqlParameter(T input, Codec codec) => value(input, codec);', + ); + } + for (final query in queries) { + final entity = query.result; + for (final f in entity.fields) { + b.writeln( + 'final ${_queryColumn(entity, f)} = Column<${f.type}>(${_literal(f.column)}, ${f.codec}, nullable: ${f.nullable});', + ); + } + b.writeln( + 'final class ${entity.fieldsType} extends Fields { ${entity.fieldsType}(super.table);', + ); + for (final f in entity.fields) { + b.writeln('late final ${f.name} = column(${_queryColumn(entity, f)});'); + } + b.writeln('}'); + b.writeln( + 'final _sqlDefinition${entity.symbol} = SqlQueryDefinition<${entity.rowType}, ${entity.fieldsType}>(' + 'Table(TableSchema(${_literal(entity.table)}, columns: [${entity.fields.map((f) => _queryColumn(entity, f)).join(', ')}]), ' + '${entity.fieldsType}.new, (row) => ${_recordSelection(entity.fields, 'row')}), {' + '${query.sql.entries.map((entry) => 'SqlDialect.${entry.key.name}: SqlTemplate(${_literal(entry.value)}, dialect: SqlDialect.${entry.key.name})').join(', ')}' + '});', + ); + b.writeln( + 'extension ${entity.symbol}Sql on QueryContext {' + 'Query<${entity.rowType}, ${entity.fieldsType}> ${entity.name}(' + '${query.parameters.isEmpty ? '' : '{${query.parameters.map((f) => '${f.nullable ? '' : 'required '}${f.type} ${f.name}').join(', ')}}'}' + ') => _sqlDefinition${entity.symbol}.bind(this, {' + '${query.parameters.map((f) => '${_literal(f.name)}: _bindSqlParameter(${f.name}, ${f.codec})').join(', ')}' + '}); }', + ); + } + return b.toString(); +} + +String _queryColumn(_Entity entity, _Field field) => + '_sqlColumn${entity.symbol}_${entity.fields.indexOf(field)}'; + +var _queryCheckSerial = 0; + +/// Compile each declared query on one backend, without executing its SELECT. +/// SQLite, MySQL and MariaDB report structure only. PostgreSQL additionally +/// checks native storage families. These checks do not prove expression +/// nullability or custom codecs. +Future>> checkSqlQueries( + SqlDatabase db, + GeneratedQueries generated, +) => db.session((session) async { + final reports = >[]; + for (final query + in (generated.manifest['queries']! as List) + .cast>()) { + final source = (query['sql']! as Map)[db.dialect.name]; + if (source == null) continue; + final parameters = (query['parameters']! as List) + .cast>(); + final columns = (query['columns']! as List).cast>(); + final command = SqlTemplate(source as String, dialect: db.dialect).compile( + db.capabilities, + { + for (final parameter in parameters) + parameter['name']! as String: value( + null, + Codec(parameter['type']! as String, (v) => v, (v) => v), + ), + }, + ); + String quote(String name) => _mysqlDialect(db.dialect) + ? '`${name.replaceAll('`', '``')}`' + : '"${name.replaceAll('"', '""')}"'; + final alias = quote('_orm_check'); + final projected = + 'SELECT ${columns.map((c) => '$alias.${quote(c['name']! as String)}').join(', ')} ' + 'FROM (\n${command.sql}\n) AS $alias'; + List? nativeTypes; + try { + if (_mysqlDialect(db.dialect)) { + final name = + '_orm_check_${DateTime.now().microsecondsSinceEpoch}_${_queryCheckSerial++}'; + var prepared = false; + await session.execute(SqlCommand('SET @$name = ?', [projected])); + try { + // EXPLAIN can execute stored functions while planning a derived + // table (verified on MariaDB 11.8). PREPARE resolves the projection + // and placeholders without executing the application statement. + await session.execute( + SqlCommand('PREPARE ${quote(name)} FROM @$name'), + ); + prepared = true; + } finally { + try { + if (prepared) { + await session.execute( + SqlCommand('DEALLOCATE PREPARE ${quote(name)}'), + ); + } + await session.execute(SqlCommand('SET @$name = NULL')); + } catch (_) { + // A failed cleanup must not leave a prepared statement or query + // text attached to a connection returned to another borrower. + await session.discard(); + rethrow; + } + } + } else if (db.dialect == SqlDialect.sqlite) { + await session.execute( + SqlCommand('EXPLAIN $projected', command.parameters), + ); + } else { + final name = + '_orm_check_${DateTime.now().microsecondsSinceEpoch}_${_queryCheckSerial++}'; + await session.execute( + SqlCommand('PREPARE ${quote(name)} AS $projected'), + ); + try { + final result = await session.execute( + SqlCommand( + r'SELECT pg_catalog.format_type(t.kind::oid, NULL) ' + r'FROM pg_catalog.pg_prepared_statements p, ' + r'unnest(p.result_types) WITH ORDINALITY AS t(kind, position) ' + r'WHERE p.name = $1 ORDER BY t.position', + [name], + ), + ); + nativeTypes = [for (final row in result.rows) row.single as String]; + if (nativeTypes.length != columns.length) { + throw const GenerationException( + 'Database returned a different result shape.', + ); + } + for (var i = 0; i < columns.length; i++) { + if (!_queryStorageMatches( + columns[i]['type']! as String, + nativeTypes[i], + )) { + throw GenerationException( + '${columns[i]['name']}: declared ${columns[i]['type']}, database returns ${nativeTypes[i]}. Use an explicit SQL cast or matching codec storage.', + ); + } + } + } finally { + await session.execute(SqlCommand('DEALLOCATE ${quote(name)}')); + } + } + } catch (error) { + throw GenerationException( + '${query['name']} (${db.dialect.name}): $error', + ); + } + reports.add({ + 'name': query['name'], + 'dialect': db.dialect.name, + 'structureChecked': true, + 'storageTypesChecked': nativeTypes != null, + 'nullabilityChecked': false, + 'columns': [ + for (var i = 0; i < columns.length; i++) + { + ...columns[i], + if (nativeTypes != null) 'nativeType': nativeTypes[i], + }, + ], + }); + } + if (reports.isEmpty) { + throw const GenerationException('No named queries target this database.'); + } + return reports; +}); + +bool _queryStorageMatches(String storage, String native) => switch (storage) { + 'integer' || + 'bigint' || + 'decimal' => {'smallint', 'integer', 'bigint', 'numeric'}.contains(native), + 'real' => { + 'real', + 'double precision', + 'numeric', + 'smallint', + 'integer', + 'bigint', + }.contains(native), + 'text' => {'text', 'character varying', 'character', 'name'}.contains(native), + 'boolean' => native == 'boolean', + 'instant' => native == 'timestamp with time zone', + 'date' => native == 'date', + 'time' => native == 'time without time zone', + 'local_datetime' => native == 'timestamp without time zone', + 'json' => {'json', 'jsonb'}.contains(native), + 'blob' => native == 'bytea', + _ => false, +}; diff --git a/lib/src/generate/reader.dart b/lib/src/generate/reader.dart new file mode 100644 index 00000000..b682a446 --- /dev/null +++ b/lib/src/generate/reader.dart @@ -0,0 +1,634 @@ +part of '../generate.dart'; + +final class _SchemaReader( + final CompilationUnit unit, + final TypeSystem typeSystem, + final _DartNames names, +) { + final Map entities = {}; + + List<_Entity> read() { + final aliases = { + for (final d in unit.declarations.whereType()) + d.name.lexeme: d, + }; + final classes = { + for (final d in unit.declarations.whereType()) + d.namePart.typeName.lexeme: d, + }; + final variables = [ + for (final d + in unit.declarations.whereType()) + ...d.variables.variables, + ]; + for (final variable in variables) { + final call = variable.initializer; + if (call is! MethodInvocation || call.methodName.name != 'entity') { + continue; + } + if (call.methodName.element?.library?.uri.toString() != + 'package:orm/schema.dart') { + continue; + } + final types = call.typeArguments?.arguments; + if (types == null || types.length != 1) { + _fail(call, 'entity requires an explicit model type.'); + } + final row = types.single.toSource(); + final alias = aliases[row]; + final declaration = classes[row]; + final List<_Field> fields; + final AstNode model; + Set? constructorNamedFields; + if (declaration != null) { + model = declaration; + final parsed = _readClass(declaration); + fields = parsed.$1; + constructorNamedFields = parsed.$2; + } else if (alias != null && + alias.type is RecordTypeAnnotation && + alias.typeParameters == null) { + final record = alias.type as RecordTypeAnnotation; + model = record; + if (record.positionalFields.isNotEmpty || record.namedFields == null) { + _fail(record, 'Model records must have named fields only.'); + } + fields = [ + for (final f in record.namedFields!.fields) + _readField(f, f.name.lexeme, f.type.type, f.metadata), + ]; + } else { + _fail( + call, + 'entity<$row> requires a final primary-constructor class or a non-generic named record typedef in this file.', + ); + } + if (fields.isEmpty) _fail(model, 'A model needs fields.'); + final name = variable.name.lexeme; + if (name.startsWith('_') || _databaseMembers.contains(name)) { + _fail( + variable, + 'Entity $name must be public and cannot shadow a Database member. Rename the Dart declaration and keep table: for its physical name.', + ); + } + final table = _namedString(call, 'table') ?? _snake(name); + if (entities.values.any((e) => e.table == table)) { + _fail(call, 'Duplicate physical table $table.'); + } + if (fields.map((f) => f.column).toSet().length != fields.length) { + _fail(model, 'Duplicate physical column name.'); + } + final entity = _Entity( + name, + table, + row, + fields, + constructorNamedFields: constructorNamedFields, + ); + entities[name] = entity; + } + if (entities.isEmpty) { + throw const GenerationException('No entity() declarations found.'); + } + // Keys and indexes are collected before relations, independent of source order. + for (final variable in variables) { + final call = variable.initializer; + if (call is! MethodInvocation || call.target is! SimpleIdentifier) { + continue; + } + final entity = entities[(call.target as SimpleIdentifier).name]; + if (entity == null) continue; + final method = call.methodName.name; + if (method == 'check') { + final expression = call.argumentList.arguments.first.argumentExpression; + if (expression is! SimpleStringLiteral || + expression.value.trim().isEmpty) { + _fail(expression, 'CHECK requires a non-empty SQL string literal.'); + } + final name = _named(call, 'name') is NullLiteral + ? null + : _namedString(call, 'name') ?? _snake(variable.name.lexeme); + final check = CheckSchema.forDialects( + name, + sqlite: _namedString(call, 'sqlite') ?? expression.value, + postgres: _namedString(call, 'postgres') ?? expression.value, + mysql: _namedString(call, 'mysql') ?? expression.value, + mariadb: _namedString(call, 'mariadb') ?? expression.value, + ); + if (SqlDialect.values.every( + (d) => check.expression(d).trim().isEmpty, + ) || + name != null && + (name.isEmpty || entity.checks.any((c) => c.name == name))) { + _fail( + call, + 'CHECK expressions and names must be non-empty; names must be unique per table.', + ); + } + entity.checks.add(check); + continue; + } + if (!{'primaryKey', 'unique', 'index'}.contains(method)) continue; + final keys = _selector( + call.argumentList.arguments.first.argumentExpression, + entity, + ); + switch (method) { + case 'primaryKey': + if (entity.primaryKey.isNotEmpty) { + _fail(call, 'Primary key declared twice.'); + } + entity.primaryKey = keys; + case 'unique': + entity.uniqueKeys.add(keys); + case 'index': + entity.indexes.add( + _Index( + _namedString(call, 'name') ?? _snake(variable.name.lexeme), + keys, + _named(call, 'unique')?.toSource() == 'true', + ), + ); + } + } + for (final variable in variables) { + final call = variable.initializer; + if (call is! MethodInvocation || + !{'references', 'relatesTo'}.contains(call.methodName.name) || + call.methodName.element?.library?.uri.toString() != + 'package:orm/schema.dart') { + continue; + } + final foreignKey = call.methodName.name == 'references'; + final kind = foreignKey ? 'Foreign key' : 'Relationship'; + if (call.target is! MethodInvocation) { + _fail(call, '${call.methodName.name} must follow entity.key().'); + } + final (source, local) = _key(call.target!); + final (target, remote) = _key( + call.argumentList.arguments.first.argumentExpression, + ); + if (local.length != remote.length) { + _fail(call, '$kind arity mismatch.'); + } + final targets = [ + target.primaryKey, + ...target.uniqueKeys, + for (final i in target.indexes) + if (i.unique) i.keys, + ]; + if (foreignKey && !targets.any((key) => _same(key, remote))) { + _fail( + call, + 'Foreign key target must be a primary or unique key in the same column order.', + ); + } + for (var i = 0; i < local.length; i++) { + if (source.field(local[i]).storage != target.field(remote[i]).storage) { + _fail(call, '$kind storage types differ.'); + } + } + final action = + _named(call, 'onDelete')?.toSource().split('.').last ?? 'restrict'; + final onDelete = !foreignKey + ? null + : switch (action) { + 'restrict' => 'RESTRICT', + 'cascade' => 'CASCADE', + 'setNull' => 'SET NULL', + 'setDefault' => 'SET DEFAULT', + 'noAction' => 'NO ACTION', + _ => throw GenerationException( + 'Unsupported referential action $action.', + ), + }; + if (action == 'setNull' && + local.any((key) => !source.field(key).nullable)) { + _fail(call, 'SET NULL requires nullable foreign key columns.'); + } + _addEdge( + source, + _Edge(variable.name.lexeme, target, local, remote, onDelete), + call, + ); + final inverse = _namedString(call, 'inverse'); + if (inverse != null) { + _addEdge( + target, + _Edge(inverse, source, remote, local, onDelete, inverse: true), + call, + ); + } + } + final indexNames = {}; + final symbols = {'appSchema', 'AppTables'}; + for (final entity in entities.values) { + for (final symbol in [ + entity.fieldsType, + entity.setType, + '${entity.symbol}Updates', + '${entity.name}Schema', + '${entity.name}Table', + for (final field in entity.fields) _columnSymbol(entity, field), + ]) { + if (!symbols.add(symbol)) { + throw GenerationException( + 'Generated symbol $symbol is ambiguous. Rename an entity or Dart field while keeping its physical SQL name.', + ); + } + } + if (entity.fields.every((f) => f.computed != null)) { + throw GenerationException( + '${entity.name} needs at least one ordinary column.', + ); + } + for (final key in entity.primaryKey) { + if (entity.field(key).nullable) { + throw GenerationException( + '${entity.name} primary keys cannot be nullable.', + ); + } + } + for (final f in entity.fields.where((f) => f.generated)) { + if (f.storage != 'integer' || !_same(entity.primaryKey, [f.name])) { + throw GenerationException( + 'Generated identity requires a single integer primary key: ${entity.name}.${f.name}.', + ); + } + } + for (final index in entity.indexes) { + if (!indexNames.add(index.name)) { + throw GenerationException('Duplicate index name ${index.name}.'); + } + } + } + return entities.values.toList(); + } + + (List<_Field>, Set) _readClass(ClassDeclaration declaration) { + final primary = declaration.namePart; + if (primary is! PrimaryConstructorDeclaration || + primary.typeName.lexeme.startsWith('_') || + declaration.finalKeyword == null || + declaration.abstractKeyword != null || + declaration.extendsClause != null || + declaration.implementsClause != null || + declaration.withClause != null || + declaration.body.members.isNotEmpty || + primary.typeParameters != null || + primary.constructorName != null) { + _fail( + declaration, + 'Use a public final class with an unnamed primary constructor, declaring fields only, and no inheritance or type parameters. Put behavior in extensions.', + ); + } + final fields = <_Field>[]; + final named = {}; + for (final parameter in primary.formalParameters.parameters) { + if (!parameter.isFinal || + !parameter.isRequired || + parameter.defaultClause != null || + parameter.functionTypedSuffix != null || + parameter.type == null || + parameter.name == null || + parameter.name!.lexeme.startsWith('_')) { + _fail( + parameter, + 'Model parameters must be public, explicitly typed, required final declaring fields without Dart defaults. Use @Default.sql or @ClientDefault for insert defaults.', + ); + } + final name = parameter.name!.lexeme; + fields.add( + _readField(parameter, name, parameter.type!.type, parameter.metadata), + ); + if (parameter.isNamed) named.add(name); + } + return (fields, named); + } + + _Field _readField( + AstNode field, + String name, + DartType? type, + Iterable metadata, + ) { + if (type == null) _fail(field, 'Cannot resolve the field type.'); + final nullable = typeSystem.isNullable(type); + if ({'table', 'column', 'readColumn'}.contains(name)) { + _fail( + field, + 'Field name $name conflicts with the fields API. Use another Dart name and @ColumnName.', + ); + } + var id = false, generated = false, unique = false; + String? column, defaultSql, clientDefault; + ComputedColumn? computed; + int? integerBits; + int? decimalPrecision, decimalScale, temporalPrecision; + Annotation? custom; + for (final annotation in metadata) { + final value = annotation.elementAnnotation?.computeConstantValue(); + final annotationType = value?.type; + if (annotationType is! InterfaceType || + annotationType.element.library.uri.toString() != + 'package:orm/schema.dart') { + continue; + } + switch (annotationType.element.name) { + case 'Id': + id = true; + generated = value!.getField('generated')!.toBoolValue()!; + case 'Unique': + unique = true; + case 'ColumnName': + column = value!.getField('name')!.toStringValue(); + case 'Default': + defaultSql = value!.getField('expression')!.toStringValue(); + case 'Computed': + if (computed != null) { + _fail(annotation, 'Computed may only appear once.'); + } + final sql = value!.getField('expression')!.toStringValue()!; + computed = ComputedColumn.forDialects( + sqlite: value.getField('sqlite')?.toStringValue() ?? sql, + postgres: value.getField('postgres')?.toStringValue() ?? sql, + mysql: value.getField('mysql')?.toStringValue() ?? sql, + mariadb: value.getField('mariadb')?.toStringValue() ?? sql, + storage: + ComputedStorage.values[value + .getField('storage')! + .getField('index')! + .toIntValue()!], + ); + if (SqlDialect.values.every( + (d) => computed!.expression(d).trim().isEmpty, + )) { + _fail(annotation, 'Computed SQL expressions must be non-empty.'); + } + case 'ClientDefault': + if (clientDefault != null) { + _fail(annotation, 'ClientDefault may only appear once.'); + } + final reference = + annotation.arguments!.arguments.single.argumentExpression; + final signature = reference.staticType; + if (signature is! FunctionType || + signature.typeParameters.isNotEmpty || + signature.formalParameters.any((p) => p.isRequired) || + signature.returnType is DynamicType || + !typeSystem.isSubtypeOf(signature.returnType, type)) { + _fail( + annotation, + 'ClientDefault requires a synchronous, zero-required-argument factory returning $type.', + ); + } + final function = value!.getField('factory')?.toFunctionValue(); + if (function == null) { + _fail( + annotation, + 'ClientDefault requires a constant function reference.', + ); + } + clientDefault = names.factoryReference( + reference, + function, + signature, + ); + case 'IntegerBits': + if (integerBits != null) { + _fail(annotation, 'IntegerBits may only appear once.'); + } + integerBits = value!.getField('value')!.toIntValue(); + case 'UseCodec': + if (custom != null) { + _fail(annotation, 'UseCodec may only appear once.'); + } + custom = annotation; + case 'TemporalPrecision': + if (temporalPrecision != null) { + _fail(annotation, 'TemporalPrecision may only appear once.'); + } + temporalPrecision = value!.getField('digits')!.toIntValue(); + case 'DecimalDigits': + if (decimalPrecision != null) { + _fail(annotation, 'DecimalDigits may only appear once.'); + } + decimalPrecision = value!.getField('precision')!.toIntValue(); + decimalScale = value.getField('scale')!.toIntValue(); + default: + _fail(annotation, 'Unsupported ORM annotation.'); + } + } + String storage, codec; + if (computed != null && + (generated || defaultSql != null || clientDefault != null)) { + _fail(field, 'Computed columns cannot have identity or insert defaults.'); + } + if (custom != null) { + final value = custom.elementAnnotation!.computeConstantValue()!.getField( + 'codec', + )!; + final reference = custom.arguments!.arguments.single.argumentExpression; + // Constant evaluation erases extension types to their representation. + // Static compatibility must use the resolved source expression instead. + final codecType = reference.staticType; + if (codecType is! InterfaceType || + codecType.element.name != 'Codec' || + codecType.element.library.uri.toString() != + 'package:orm/values.dart') { + _fail(custom, 'Use a const Codec.'); + } + final domain = codecType.typeArguments.single; + bool same(DartType a, DartType b) => + typeSystem.isSubtypeOf(a, b) && typeSystem.isSubtypeOf(b, a); + if (!same(domain, type) && + !same(domain, typeSystem.promoteToNonNull(type))) { + _fail(custom, 'Codec<$domain> does not match field type $type.'); + } + storage = value.getField('sqlType')?.toStringValue() ?? ''; + if (!{ + 'integer', + 'bigint', + 'decimal', + 'real', + 'text', + 'boolean', + 'instant', + 'date', + 'time', + 'local_datetime', + 'blob', + 'json', + }.contains(storage)) { + _fail(custom, 'Unknown codec storage type $storage.'); + } + codec = names.codecReference(reference); + if (nullable && !typeSystem.isNullable(domain)) codec += '.nullable()'; + } else if (type is InterfaceType && type.element is EnumElement) { + storage = 'text'; + codec = names.enumeration(type.element as EnumElement); + if (nullable) codec += '.nullable()'; + } else { + if (type is! InterfaceType || + !{ + 'dart:core', + 'dart:typed_data', + 'package:orm/values.dart', + }.contains(type.element.library.uri.toString())) { + _fail(field, 'This type needs an explicit @UseCodec.'); + } + final mapping = switch (type.element.name) { + 'int' => ('integer', 'integer'), + 'String' => ('text', 'text'), + 'double' => ('real', 'real'), + 'bool' => ('boolean', 'boolean'), + 'DateTime' => ('instant', 'dateTime'), + 'LocalDate' => ('date', 'date'), + 'LocalTime' => ('time', 'time'), + 'LocalDateTime' => ('local_datetime', 'localDateTime'), + 'BigInt' => ('bigint', 'bigint'), + 'Decimal' => ('decimal', 'decimal'), + 'Uint8List' => ('blob', 'bytes'), + _ => throw GenerationException( + 'Unsupported field type $type; declare @UseCodec.', + ), + }; + storage = mapping.$1; + codec = 'Codecs.${mapping.$2}${nullable ? '.nullable()' : ''}'; + } + if (decimalPrecision != null && + (storage != 'decimal' || + decimalPrecision < 1 || + decimalPrecision > 1000 || + decimalScale! < -1000 || + decimalScale > 1000)) { + _fail( + field, + 'DecimalDigits requires decimal storage, precision 1..1000 and scale -1000..1000.', + ); + } + if (temporalPrecision != null && + (!{'time', 'local_datetime', 'instant'}.contains(storage) || + temporalPrecision < 0 || + temporalPrecision > 6)) { + _fail( + field, + 'TemporalPrecision requires time, local timestamp or instant storage and 0..6 digits.', + ); + } + if (integerBits != null && + (storage != 'integer' || !{16, 32, 64}.contains(integerBits))) { + _fail( + field, + 'IntegerBits requires integer storage and a width of 16, 32 or 64.', + ); + } + return _Field( + name: name, + column: column ?? _snake(name), + type: names.type(type), + codec: codec, + storage: storage, + nullable: nullable, + id: id, + generated: generated, + unique: unique, + defaultSql: defaultSql, + clientDefault: clientDefault, + computed: computed, + integerBits: integerBits, + decimalPrecision: decimalPrecision, + decimalScale: decimalScale, + temporalPrecision: temporalPrecision, + ); + } + + (_Entity, List) _key(Expression expression) { + if (expression is! MethodInvocation || + expression.methodName.name != 'key' || + expression.target is! SimpleIdentifier) { + _fail(expression, 'Expected entity.key((row) => row.field).'); + } + final call = expression; + final entity = entities[(call.target as SimpleIdentifier).name]; + if (entity == null) _fail(call, 'Key refers to an unknown entity.'); + return ( + entity, + _selector(call.argumentList.arguments.first.argumentExpression, entity), + ); + } + + List _selector(Expression expression, _Entity entity) { + if (expression is! FunctionExpression || + expression.parameters?.parameters.length != 1 || + expression.body is! ExpressionFunctionBody) { + _fail(expression, 'Use a one-argument arrow selector.'); + } + final function = expression; + final parameter = function.parameters!.parameters.single.name!.lexeme; + final body = (function.body as ExpressionFunctionBody).expression; + if (body is RecordLiteral && + body.fields.any((f) => f is RecordLiteralNamedField)) { + _fail( + body, + 'Keys use positional records to preserve declared column order.', + ); + } + final parts = body is RecordLiteral + ? body.fields.map((f) => f.fieldExpression).toList() + : [body]; + final keys = []; + for (final part in parts) { + final name = switch (part) { + PrefixedIdentifier(:final prefix, :final identifier) + when prefix.name == parameter => + identifier.name, + PropertyAccess( + target: SimpleIdentifier(:final name), + :final propertyName, + ) + when name == parameter => + propertyName.name, + _ => _fail( + part, + 'Key selectors accept direct fields or positional records of fields only.', + ), + }; + entity.field(name); + if (keys.contains(name)) _fail(part, 'A key cannot repeat a field.'); + keys.add(name); + } + if (keys.isEmpty) _fail(expression, 'Empty key.'); + return keys; + } + + void _addEdge(_Entity source, _Edge edge, AstNode node) { + if (!RegExp(r'^[a-zA-Z][a-zA-Z0-9_]*$').hasMatch(edge.name) || + source.fields.any((f) => f.name == edge.name) || + source.edges.any((e) => e.name == edge.name) || + {'table', 'column'}.contains(edge.name)) { + _fail(node, 'Invalid or duplicate relationship name ${edge.name}.'); + } + source.edges.add(edge); + } + + Expression? _named(MethodInvocation call, String name) { + for (final argument + in call.argumentList.arguments.whereType()) { + if (argument.name.lexeme == name) return argument.argumentExpression; + } + return null; + } + + String? _namedString(MethodInvocation call, String name) { + final expression = _named(call, name); + if (expression == null) return null; + if (expression is! SimpleStringLiteral) { + _fail(expression, '$name requires a literal string.'); + } + return expression.value; + } + + Never _fail(AstNode node, String message) => + throw GenerationException('$message (offset ${node.offset})'); +} diff --git a/lib/src/generate/types.dart b/lib/src/generate/types.dart new file mode 100644 index 00000000..848534e1 --- /dev/null +++ b/lib/src/generate/types.dart @@ -0,0 +1,164 @@ +part of '../generate.dart'; + +/// Resolve symbols by defining library rather than copying source import text. +final class _DartNames(final Uri source, final String Function(Uri) importUri) { + final Map _prefixes = {}; + bool typedData = false; + Iterable<(String, String)> get imports sync* { + for (final entry in _prefixes.entries) { + yield (importUri(entry.key), entry.value); + } + } + + String name(Element element) { + final library = element.library; + if (element.isPrivate || element.name == null || library == null) { + throw GenerationException( + 'Generated clients require public symbols: ${element.displayName}.', + ); + } + final uri = library.uri; + if (uri.toString() == 'dart:typed_data') typedData = true; + if ({ + 'dart:core', + 'dart:typed_data', + 'package:orm/orm.dart', + 'package:orm/values.dart', + 'package:orm/schema_model.dart', + }.contains(uri.toString())) { + return element.name!; + } + final prefix = uri == source + ? 'models' + : _prefixes.putIfAbsent(uri, () => 'types${_prefixes.length}'); + return '$prefix.${element.name}'; + } + + String type(DartType value) { + String arguments(List values) => + values.isEmpty ? '' : '<${values.map(type).join(', ')}>'; + String suffix(NullabilitySuffix suffix) => + suffix == NullabilitySuffix.question ? '?' : ''; + if (value.alias case final alias?) { + return '${name(alias.element)}${arguments(alias.typeArguments)}${suffix(alias.nullabilitySuffix)}'; + } + return switch (value) { + InterfaceType() => + '${name(value.element)}${arguments(value.typeArguments)}${suffix(value.nullabilitySuffix)}', + RecordType() => + '(${[for (final field in value.positionalFields) type(field.type), if (value.namedFields.isNotEmpty) '{${value.namedFields.map((f) => '${type(f.type)} ${f.name}').join(', ')}}'].join(', ')}${value.positionalFields.length == 1 && value.namedFields.isEmpty ? ',' : ''})${suffix(value.nullabilitySuffix)}', + DynamicType() => 'dynamic', + _ => throw GenerationException( + 'Unsupported persistent value type $value.', + ), + }; + } + + String codecReference(Expression expression) { + final element = switch (expression) { + Identifier() => expression.element, + PropertyAccess() => expression.propertyName.element, + _ => null, + }; + final variable = element is PropertyAccessorElement + ? element.variable + : element; + if (variable is! VariableElement || + !variable.isConst || + !variable.isStatic || + variable.isPrivate) { + throw const GenerationException( + 'UseCodec requires a public const codec variable or static field.', + ); + } + if (variable case FieldElement(:final enclosingElement)) { + return '${name(enclosingElement)}.${variable.name}'; + } + return name(variable); + } + + String enumeration(EnumElement element) { + final values = {}; + final labels = {}; + for (final field in element.fields.where((f) => f.isEnumConstant)) { + String? label; + for (final annotation in field.metadata.annotations) { + final value = annotation.computeConstantValue(); + final type = value?.type; + if (type is InterfaceType && + type.element.name == 'EnumValue' && + type.element.library.uri.toString() == 'package:orm/schema.dart') { + if (label != null) { + throw GenerationException( + 'EnumValue appears twice on ${field.name}.', + ); + } + label = value!.getField('value')!.toStringValue()!; + } + } + label ??= field.name!; + if (!labels.add(label)) { + throw GenerationException('Duplicate enum storage label $label.'); + } + values[field.name!] = label; + } + if (values.isEmpty) { + throw GenerationException('An empty enum cannot be stored.'); + } + final symbol = name(element); + return 'Codecs.enumeration<$symbol>({${values.entries.map((e) => '$symbol.${e.key}: ${_literal(e.value)}').join(', ')}})'; + } + + String factoryReference( + Expression expression, + ExecutableElement function, + FunctionType signature, + ) { + final element = switch (expression) { + Identifier() => expression.element, + PropertyAccess() => expression.propertyName.element, + _ => null, + }; + final variable = element is PropertyAccessorElement + ? element.variable + : element; + if (variable is VariableElement) { + if (!variable.isConst || !variable.isStatic || variable.isPrivate) { + throw const GenerationException( + 'ClientDefault factory variables must be public const references.', + ); + } + if (variable case FieldElement(:final enclosingElement)) { + return '${name(enclosingElement)}.${variable.name}'; + } + return name(variable); + } + if (function.isPrivate) { + throw const GenerationException( + 'ClientDefault requires a public factory.', + ); + } + if (function is ConstructorElement) { + return '${type(signature.returnType)}.${function.name}'; + } + final arguments = expression is FunctionReference + ? expression.typeArgumentTypes + : null; + if (function.typeParameters.isNotEmpty && + (arguments == null || arguments.isEmpty)) { + throw const GenerationException( + 'Instantiate generic ClientDefault factories explicitly.', + ); + } + final suffix = arguments == null || arguments.isEmpty + ? '' + : '<${arguments.map(type).join(', ')}>'; + if (function is TopLevelFunctionElement) return '${name(function)}$suffix'; + if (function is MethodElement && function.isStatic) { + return '${name(function.enclosingElement!)}.${function.name}$suffix'; + } + throw const GenerationException( + 'ClientDefault requires a public top-level function, static method or constructor.', + ); + } +} diff --git a/lib/src/instant.dart b/lib/src/instant.dart new file mode 100644 index 00000000..d7a5a3cd --- /dev/null +++ b/lib/src/instant.dart @@ -0,0 +1,123 @@ +part of '../values.dart'; + +// Intersection of PostgreSQL's finite lower bound and Dart DateTime's range. +const _minInstantMicros = -210866803200000000; +const _maxInstantMicros = 8640000000000000000; +const _unixEpochJulian = 2440588; +const _maxInstantJulian = 102440588; + +DateTime _decodeInstant(Object? value) => switch (value) { + DateTime() => _checkedInstant(value), + String() => _parseInstant(value), + _ => throw const FormatException('Expected a UTC instant or timestamp text.'), +}; + +DateTime _checkedInstant(DateTime value) { + final utc = value.toUtc(), millis = value.millisecondsSinceEpoch; + // Epoch milliseconds fit JavaScript's exact integer range; total microseconds + // do not. DateTime stores the remaining microsecond component separately. + if (millis < _minInstantMicros ~/ 1000 || + millis > _maxInstantMicros ~/ 1000 || + millis == _maxInstantMicros ~/ 1000 && utc.microsecond != 0) { + throw const FormatException( + 'Instant exceeds the common DateTime/PostgreSQL range.', + ); + } + return utc; +} + +String _encodeInstant(DateTime value) { + final utc = _checkedInstant(value); + final date = LocalDate(utc.year, utc.month, utc.day); + final time = LocalTime( + utc.hour, + utc.minute, + utc.second, + utc.millisecond * 1000 + utc.microsecond, + ); + return '${date._civil} $time+00${date.year <= 0 ? ' BC' : ''}'; +} + +final _instantSyntax = RegExp( + r'^(-?\d{4,7})-(\d{2})-(\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?)(Z|[+-]\d{2}(?::?\d{2})?(?::?\d{2})?)?( BC)?$', + caseSensitive: false, +); + +DateTime _parseInstant(String text) { + final match = _instantSyntax.firstMatch(text); + if (match == null || match.end != text.length) { + throw const FormatException( + 'Expected a timestamp with at most six fractional digits.', + ); + } + try { + var year = int.parse(match[1]!); + if (match[6] != null) { + if (year <= 0) { + throw const FormatException('BC requires a positive era year.'); + } + year = 1 - year; + } + final month = int.parse(match[2]!), day = int.parse(match[3]!); + if (month < 1 || + month > 12 || + day < 1 || + day > LocalDate._monthDays(year, month)) { + throw const FormatException('Invalid calendar date in instant.'); + } + final zone = match[5]; + var offset = 0; + if (zone != null && zone.toUpperCase() != 'Z') { + final digits = zone.substring(1).replaceAll(':', ''); + final hours = int.parse(digits.substring(0, 2)); + final minutes = digits.length >= 4 + ? int.parse(digits.substring(2, 4)) + : 0; + final seconds = digits.length == 6 + ? int.parse(digits.substring(4, 6)) + : 0; + if (hours > 15 || minutes > 59 || seconds > 59) { + throw const FormatException('Invalid UTC offset.'); + } + offset = + ((hours * 60 + minutes) * 60 + seconds) * + 1000000 * + (zone[0] == '-' ? -1 : 1); + } + // Zone-less database storage is explicitly UTC (SQLite CURRENT_TIMESTAMP), + // independent of the process's local timezone. Validate before multiplying + // days into int64 microseconds, including offset crossings at either bound. + final time = LocalTime.parse(match[4]!).microseconds - offset; + final ordinal = + _gregorianJulianDay(year, month, day) + + _floorDiv(time, _microsecondsPerDay); + final within = time % _microsecondsPerDay; + if (ordinal < 0 || + ordinal > _maxInstantJulian || + ordinal == _maxInstantJulian && within != 0) { + throw const FormatException( + 'Instant exceeds the common DateTime/PostgreSQL range.', + ); + } + if (const bool.fromEnvironment('dart.library.js_interop')) { + final date = LocalDate.fromJulianDay(ordinal); + final clock = LocalTime.fromMicroseconds(within); + return DateTime.utc( + date.year, + date.month, + date.day, + clock.hour, + clock.minute, + clock.second, + clock.microsecond ~/ 1000, + clock.microsecond % 1000, + ); + } + return DateTime.fromMicrosecondsSinceEpoch( + (ordinal - _unixEpochJulian) * _microsecondsPerDay + within, + isUtc: true, + ); + } on RangeError { + throw const FormatException('Invalid timestamp components.'); + } +} diff --git a/lib/src/migrate/backfill.dart b/lib/src/migrate/backfill.dart new file mode 100644 index 00000000..5daf9e0c --- /dev/null +++ b/lib/src/migrate/backfill.dart @@ -0,0 +1,373 @@ +part of '../../migrate.dart'; + +/// A reviewed, bounded update using this migration's historical table definition. +/// Expressions and completion SQL are trusted migration code. Primary keys must +/// remain immutable, including in application writes and triggers. +final class Backfill extends MigrationStep { + final TableSchema table; + final Map set; + final String where; + final String doneWhen; + final int batchSize; + late final List> _keys = [ + for (final name in table.primaryKey) + table.columns.singleWhere((c) => c.name == name), + ]; + + Backfill( + this.table, { + required Map set, + this.where = 'TRUE', + required this.doneWhen, + this.batchSize = 1000, + }) : set = Map.unmodifiable(set) { + SchemaSnapshot([table]); + if (batchSize < 1 || + set.isEmpty || + where.trim().isEmpty || + set.entries.any( + (e) => + e.value.trim().isEmpty || + !table.columns.any( + (c) => c.name == e.key && !c.generated && c.computed == null, + ) || + table.primaryKey.contains(e.key), + )) { + throw ArgumentError( + 'Backfill needs a positive batch size and assignments to existing non-key columns.', + ); + } + if (table.primaryKey.isEmpty || + _keys.any((c) => c.nullable || c.codec.sqlType == 'json')) { + throw const OrmException( + 'MIGRATION.BACKFILL_KEY', + 'Backfill requires a non-null primary key with lossless cursor storage; JSON keys are unsupported.', + ); + } + if (_sqlWords(doneWhen).firstOrNull != 'SELECT') { + throw const OrmException( + 'MIGRATION.PROBE', + 'Backfill completion must be a SELECT returning one boolean.', + ); + } + } + + @override + Map toJson() => { + 'kind': 'backfill', + 'table': _tableJson(table), + 'set': set, + 'where': where, + 'doneWhen': doneWhen, + 'batchSize': batchSize, + }; + + List _saveKey(List row) => [ + for (var i = 0; i < _keys.length; i++) + switch (_keys[i].codec.sqlType) { + 'text' => row[i] as String, + 'integer' => (row[i] as int).toString(), + 'bigint' => row[i].toString(), + 'decimal' => Codecs.decimal.decode(row[i]).toString(), + 'instant' => + Codecs.dateTime.encode(Codecs.dateTime.decode(row[i])) as String, + 'date' => Codecs.date.decode(row[i]).toString(), + 'time' => Codecs.time.decode(row[i]).toString(), + 'local_datetime' => Codecs.localDateTime.decode(row[i]).toString(), + 'timestamp' => + row[i] is DateTime + ? (row[i] as DateTime).toUtc().toIso8601String() + : row[i] as String, + 'boolean' => Codecs.boolean.decode(row[i]) ? '1' : '0', + 'blob' => base64Encode((row[i] as List)), + 'real' when (row[i] as num).isFinite => row[i].toString(), + _ => throw const OrmException( + 'MIGRATION.BACKFILL_KEY', + 'Backfill keys must have lossless, finite storage values.', + ), + }, + ]; + List _loadKey(List row, SqlDialect dialect) { + if (row.length != _keys.length) { + throw const OrmException( + 'MIGRATION.CHECKPOINT', + 'Backfill cursor key length differs from the historical primary key.', + ); + } + return [ + for (var i = 0; i < row.length; i++) + switch (_keys[i].codec.sqlType) { + 'integer' => int.parse(row[i]), + 'real' => double.parse(row[i]), + 'instant' when _isMysql(dialect) => Codecs.dateTime.decode(row[i]), + 'timestamp' when dialect == SqlDialect.postgres => DateTime.parse( + row[i], + ), + 'boolean' => + dialect == SqlDialect.postgres ? row[i] == '1' : int.parse(row[i]), + 'blob' => base64Decode(row[i]), + _ => row[i], + }, + ]; + } +} + +/// Durable counters and primary-key cursors. Keys use lossless strings (binary +/// keys use base64), so JSON transport never rounds 64-bit integer identifiers. +final class BackfillProgress { + final List? upperKey; + final List? lastKey; + final int rows; + final int batches; + BackfillProgress._( + List? upper, + List? last, + this.rows, + this.batches, + ) : upperKey = upper == null ? null : List.unmodifiable(upper), + lastKey = last == null ? null : List.unmodifiable(last); + factory BackfillProgress._read(Object? data) { + final json = data as Map; + if (json['format'] != 1) { + throw const OrmException( + 'MIGRATION.CHECKPOINT', + 'Unknown backfill checkpoint format.', + ); + } + final value = BackfillProgress._( + (json['upper'] as List?)?.cast(), + (json['last'] as List?)?.cast(), + json['rows'] as int, + json['batches'] as int, + ); + if (value.rows < 0 || + value.batches < 0 || + value.batches > value.rows || + (value.rows > 0 && (value.lastKey == null || value.batches == 0)) || + (value.rows == 0 && value.lastKey != null) || + (value.lastKey != null && value.upperKey == null) || + value.upperKey?.isEmpty == true || + value.lastKey?.isEmpty == true) { + throw const OrmException( + 'MIGRATION.CHECKPOINT', + 'Invalid backfill checkpoint.', + ); + } + return value; + } + Map toJson() => { + 'format': 1, + 'upper': upperKey, + 'last': lastKey, + 'rows': rows, + 'batches': batches, + }; +} + +Future _verifyBackfill(SqlDatabase db, Backfill step) async { + if ((!db.capabilities.returning && !_isMysql(db.dialect)) || + db.capabilities.maxParameters < 7 || + db.capabilities.maxParameters < step._keys.length * 2) { + throw const OrmException( + 'MIGRATION.BACKFILL_CAPABILITY', + 'Backfill needs RETURNING and room for both cursor bounds.', + ); + } + final result = await verifySchema(db, SchemaSnapshot([step.table])); + if (!result.matches || + (_isMysql(db.dialect) && result.unmanaged.isNotEmpty)) { + throw OrmException( + 'MIGRATION.DRIFT', + [ + ...result.differences, + ...result.unmanaged.map((o) => '${o.kind}: ${o.name}'), + ].join('\n'), + ); + } + if (db.dialect == SqlDialect.postgres) { + final security = await db.execute( + SqlCommand( + r'''SELECT row_security_active(c.oid), pg_table_is_visible(c.oid) +FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = current_schema() AND c.relname = $1''', + [step.table.name], + ), + ); + if (security.rows.single[0] == true || security.rows.single[1] != true) { + throw const OrmException( + 'MIGRATION.BACKFILL_SCOPE', + 'The historical table is shadowed or has active row security. Backfill requires an unshadowed table and a role that sees all its rows.', + ); + } + } else if (db.dialect == SqlDialect.sqlite && + (await db.execute( + SqlCommand( + "SELECT 1 FROM sqlite_temp_schema WHERE type IN ('table', 'view') AND name = ?1 COLLATE NOCASE", + [step.table.name], + ), + )).rows.isNotEmpty) { + throw const OrmException( + 'MIGRATION.BACKFILL_SCOPE', + 'A temporary object shadows the historical table.', + ); + } +} + +String _mark(SqlDialect dialect, int n) => switch (dialect) { + SqlDialect.sqlite => '?$n', + SqlDialect.postgres => '\$$n', + SqlDialect.mysql || SqlDialect.mariadb => '?', +}; +String _tuple(List values) => + values.length == 1 ? values.single : '(${values.join(', ')})'; + +/// Exactly one transaction: selected rows, writes and frontier advance commit together. +Future _backfillChunk( + SqlDatabase tx, + Migration migration, + int index, + Backfill step, + BackfillProgress? saved, + _BackfillBudget budget, + void Function(String) phase, +) async { + budget.check(); + final names = step.table.primaryKey.map(_quote).toList(), + table = _quote(step.table.name); + final columns = names.join(', '), key = _tuple(names); + phase('scan'); + var progress = saved; + if (progress == null) { + final upper = await tx.execute( + SqlCommand( + 'SELECT $columns FROM $table WHERE (${step.where}) ORDER BY ${names.map((n) => '$n DESC').join(', ')} LIMIT 1', + ), + ); + progress = BackfillProgress._( + upper.rows.isEmpty ? null : step._saveKey(upper.rows.single), + null, + 0, + 0, + ); + } + final parameters = []; + String bound(List values) { + final start = parameters.length + 1; + parameters.addAll(step._loadKey(values, tx.dialect)); + return _tuple([ + for (var i = 0; i < values.length; i++) + _isMysql(tx.dialect) && + {'bigint', 'decimal'}.contains(step._keys[i].codec.sqlType) + ? 'CAST(${_mark(tx.dialect, start + i)} AS ${_mysqlColumnType(step._keys[i])})' + : _mark(tx.dialect, start + i), + ]); + } + + final capacity = tx.capabilities.maxParameters ~/ names.length; + final size = step.batchSize < capacity ? step.batchSize : capacity; + final conditions = [ + if (progress.lastKey != null) '$key > ${bound(progress.lastKey!)}', + if (progress.upperKey != null) '$key <= ${bound(progress.upperKey!)}', + '(${step.where})', + ]; + final selected = progress.upperKey == null + ? const SqlResult([]) + : await tx.execute( + SqlCommand( + 'SELECT $columns FROM $table WHERE ${conditions.join(' AND ')} ORDER BY ${names.map((n) => '$n ASC').join(', ')} LIMIT $size${tx.dialect != SqlDialect.sqlite ? ' FOR UPDATE' : ''}', + parameters, + ), + ); + if (selected.rows.isEmpty) { + phase('verify'); + if (!await _probe(tx, step.doneWhen)) { + throw const OrmException( + 'MIGRATION.POSTCONDITION', + 'Backfill completion condition is false. Inspect the data and checkpoint before resuming.', + ); + } + phase('record'); + await _checkpoint( + tx, + migration, + index, + .complete, + 'complete', + backfill: progress, + ); + return true; + } + final keys = selected.rows.map(step._saveKey).toList(); + if (keys.map(jsonEncode).toSet().length != keys.length) { + throw const OrmException( + 'MIGRATION.BACKFILL_KEY', + 'The scan returned duplicate primary keys; its ordering is not unique.', + ); + } + parameters.clear(); + final tuples = keys.map(bound).toList(); + final predicate = names.length == 1 + ? '$key IN (${tuples.join(', ')})' + : '$key IN (${tx.dialect == SqlDialect.sqlite ? 'VALUES ' : ''}${tuples.join(', ')})'; + phase('update'); + final updated = await tx.execute( + SqlCommand( + 'UPDATE $table SET ${step.set.entries.map((e) => '${_quote(e.key)} = ${e.value}').join(', ')} WHERE $predicate${_isMysql(tx.dialect) ? '' : ' AND (${step.where}) RETURNING $columns'}', + parameters, + ), + changedTables: [step.table.name], + ); + final expected = keys.map(jsonEncode).toSet(), + actual = (_isMysql(tx.dialect) ? selected.rows : updated.rows) + .map(step._saveKey) + .map(jsonEncode) + .toSet(); + if ((!_isMysql(tx.dialect) && updated.rows.length != keys.length) || + actual.length != expected.length || + !actual.containsAll(expected)) { + throw const OrmException( + 'MIGRATION.BACKFILL_CHANGED', + 'Backfill did not update exactly its selected primary keys.', + ); + } + // Also detect AFTER triggers deleting or moving selected primary keys. + final retained = await tx.execute( + SqlCommand('SELECT count(*) FROM $table WHERE $predicate', parameters), + ); + if (retained.rows.single.single != keys.length) { + throw const OrmException( + 'MIGRATION.BACKFILL_CHANGED', + 'A backfill trigger removed or changed selected primary keys.', + ); + } + phase('record'); + await _checkpoint( + tx, + migration, + index, + .running, + 'backfill', + backfill: BackfillProgress._( + progress.upperKey, + keys.last, + progress.rows + keys.length, + progress.batches + 1, + ), + ); + budget.used(); + return false; +} + +final class _BackfillPaused implements Exception {} + +final class _BackfillBudget { + int? remaining; + _BackfillBudget(this.remaining); + void check() { + if (remaining == 0) throw _BackfillPaused(); + } + + void used() { + if (remaining != null) remaining = remaining! - 1; + } +} diff --git a/lib/src/migrate/catalog.dart b/lib/src/migrate/catalog.dart new file mode 100644 index 00000000..ffff1b34 --- /dev/null +++ b/lib/src/migrate/catalog.dart @@ -0,0 +1,551 @@ +part of '../../migrate.dart'; + +final class CatalogObject { + final String kind; + final String name; + final String definition; + const CatalogObject(this.kind, this.name, this.definition); +} + +final class TableInfo { + final String name; + final List columns; + final List primaryKey; + final List> uniqueKeys; + final List foreignKeys; + final List indexes; + final List unmanaged; + final List checks; + const TableInfo({ + required this.name, + required this.columns, + required this.primaryKey, + required this.uniqueKeys, + required this.foreignKeys, + required this.indexes, + required this.unmanaged, + this.checks = const [], + }); +} + +final class SchemaVerification { + final List differences; + final List unmanaged; + const SchemaVerification(this.differences, this.unmanaged); + + /// Equality of the declared schema facts; inspect unmanaged objects separately. + bool get matches => differences.isEmpty; +} + +Future inspectTable(SqlDatabase db, String table) async { + if (_isMysql(db.dialect)) return _mysqlTable(db, table); + final columns = await inspectColumns(db, table); + final primary = [], + unique = >[], + indexes = []; + final foreign = [], unmanaged = []; + final checks = []; + if (db.dialect == SqlDialect.sqlite) { + final info = await db.execute( + SqlCommand('PRAGMA table_xinfo(${_quote(table)})'), + ); + final primaryRows = info.rows.where((r) => (r[5] as int) > 0).toList() + ..sort((a, b) => (a[5] as int).compareTo(b[5] as int)); + primary.addAll(primaryRows.map((r) => r[1] as String)); + final list = await db.execute( + SqlCommand('PRAGMA index_list(${_quote(table)})'), + ); + for (final row in list.rows) { + final name = row[1] as String; + final parts = await db.execute( + SqlCommand('PRAGMA index_xinfo(${_quote(name)})'), + ); + final keys = parts.rows.where((r) => r[5] == 1).toList(); + final sql = await db.execute( + SqlCommand( + 'SELECT sql FROM sqlite_schema WHERE type = \'index\' AND name = ?1', + [name], + ), + ); + final definition = sql.rows.firstOrNull?.first as String? ?? ''; + if (row[4] == 1 || + keys.any( + (r) => + r[2] == null || + r[3] != 0 || + (r[4] as String).toLowerCase() != + (columns.firstWhere((c) => c.name == r[2]).collation ?? + 'BINARY') + .toLowerCase(), + )) { + unmanaged.add(CatalogObject('index', name, definition)); + continue; + } + final names = keys.map((r) => r[2] as String).toList(); + if (row[3] == 'u') { + unique.add(names); + } else if (row[3] != 'pk') { + indexes.add(IndexSchema(name, names, unique: row[2] == 1)); + } + } + final keys = await db.execute( + SqlCommand('PRAGMA foreign_key_list(${_quote(table)})'), + ); + final grouped = >>{}; + for (final row in keys.rows) { + (grouped[row[0] as int] ??= []).add(row); + } + for (final rows in grouped.values) { + rows.sort((a, b) => (a[1] as int).compareTo(b[1] as int)); + if (rows.any((r) => r[4] == null) || + rows.first[5] != 'NO ACTION' || + rows.first[7] != 'NONE') { + unmanaged.add( + CatalogObject( + 'foreign key', + '$table#${rows.first[0]}', + rows.toString(), + ), + ); + continue; + } + foreign.add( + ForeignKey( + rows.map((r) => r[3] as String).toList(), + rows.first[2] as String, + rows.map((r) => r[4] as String).toList(), + onDelete: rows.first[6] as String, + ), + ); + } + final objects = await db.execute( + SqlCommand( + "SELECT type, name, sql FROM sqlite_schema WHERE (tbl_name = ?1 AND type = 'trigger') OR type = 'view'", + [table], + ), + ); + for (final row in objects.rows) { + unmanaged.add( + CatalogObject(row[0] as String, row[1] as String, row[2] as String), + ); + } + final ddl = await db.execute( + SqlCommand( + "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?1", + [table], + ), + ); + final sql = ddl.rows.firstOrNull?.first as String?; + var withoutChecks = sql ?? ''; + if (sql != null) { + // Storage checks retain their existing column-width/precision meaning. + final remaining = _withoutIntegerChecks(sql, columns); + final parsed = _sqliteChecks(remaining); + checks.addAll(parsed.map((c) => CheckInfo(c.name, c.expression))); + final text = StringBuffer(); + var start = 0; + for (final check in parsed) { + text.write(remaining.substring(start, check.start)); + start = check.end; + } + withoutChecks = (text..write(remaining.substring(start))).toString(); + } + if (sql != null && + _sqlWords( + _withoutStorageCollations(_withoutComputed(withoutChecks), columns), + ).any( + { + 'CHECK', + 'DEFERRABLE', + 'COLLATE', + 'STRICT', + 'WITHOUT', + 'AUTOINCREMENT', + 'CONFLICT', + }.contains, + )) { + unmanaged.add(CatalogObject('table options', table, sql)); + } + } else { + final constraints = await db.execute( + SqlCommand( + r''' +SELECT c.conname, c.contype::text, + ARRAY(SELECT a.attname::text FROM unnest(c.conkey) WITH ORDINALITY k(num, ord) + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.num ORDER BY k.ord), + t.relname, + ARRAY(SELECT a.attname::text FROM unnest(c.confkey) WITH ORDINALITY k(num, ord) + JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.num ORDER BY k.ord), + c.confdeltype::text, c.confupdtype::text, c.confmatchtype::text, + c.condeferrable, c.convalidated, pg_get_constraintdef(c.oid), tn.nspname, + coalesce((to_jsonb(i)->>'indnullsnotdistinct')::boolean, false), + pg_get_expr(c.conbin, c.conrelid), c.connoinherit, c.conislocal, c.coninhcount, + coalesce((to_jsonb(c)->>'conenforced')::boolean, true) +FROM pg_constraint c JOIN pg_class r ON r.oid = c.conrelid +JOIN pg_namespace n ON n.oid = r.relnamespace +LEFT JOIN pg_class t ON t.oid = c.confrelid +LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace +LEFT JOIN pg_index i ON i.indexrelid = c.conindid +WHERE n.nspname = current_schema() AND r.relname = $1''', + [table], + ), + ); + final schemaName = (await db.execute(SqlCommand('SELECT current_schema()'))) + .rows + .single + .single; + for (final row in constraints.rows) { + final kind = row[1] as String, + keys = (row[2] as List).cast(); + if (kind == 'p') { + primary.addAll(keys); + if (row[8] == true || row[9] != true) { + unmanaged.add( + CatalogObject('constraint', row[0] as String, row[10] as String), + ); + } + } else if (kind == 'u' && + row[8] == false && + row[9] == true && + row[12] == false) { + unique.add(keys); + } else if (kind == 'f' && + row[6] == 'a' && + row[7] == 's' && + row[8] == false && + row[9] == true && + row[11] == schemaName) { + foreign.add( + ForeignKey( + keys, + row[3] as String, + (row[4] as List).cast(), + onDelete: switch (row[5]) { + 'a' => 'NO ACTION', + 'r' => 'RESTRICT', + 'c' => 'CASCADE', + 'n' => 'SET NULL', + 'd' => 'SET DEFAULT', + _ => throw StateError('Unknown FK action'), + }, + ), + ); + } else if (kind == 'c' && + row[9] == true && + row[14] == false && + row[15] == true && + row[16] == 0 && + row[17] == true) { + checks.add(CheckInfo(row[0] as String, row[13] as String)); + } else if (kind != 'n') { + unmanaged.add( + CatalogObject('constraint', row[0] as String, row[10] as String), + ); + } + } + final list = await db.execute( + SqlCommand( + r''' +SELECT ic.relname, i.indisunique, i.indisvalid AND i.indisready AND i.indislive, + ARRAY(SELECT a.attname::text FROM unnest(i.indkey) WITH ORDINALITY k(num, ord) + LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.num ORDER BY k.ord), + pg_get_indexdef(i.indexrelid), i.indexprs IS NULL AND i.indpred IS NULL + AND i.indnatts = i.indnkeyatts AND am.amname = 'btree' + AND ic.reloptions IS NULL + AND NOT coalesce((to_jsonb(i)->>'indnullsnotdistinct')::boolean, false) + AND NOT EXISTS(SELECT 1 FROM unnest(i.indoption) v WHERE v <> 0) + AND NOT EXISTS(SELECT 1 FROM unnest(i.indclass) v JOIN pg_opclass o ON o.oid = v WHERE NOT o.opcdefault) + AND NOT EXISTS(SELECT 1 FROM unnest(i.indkey, i.indcollation) k(num, collation_oid) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.num WHERE k.collation_oid <> a.attcollation) +FROM pg_index i JOIN pg_class t ON t.oid = i.indrelid +JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_class ic ON ic.oid = i.indexrelid +JOIN pg_am am ON am.oid = ic.relam +WHERE n.nspname = current_schema() AND t.relname = $1 +AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid AND c.contype IN ('p', 'u', 'x'))''', + [table], + ), + ); + for (final row in list.rows) { + final keys = row[3] as List; + if (row[2] != true || row[5] != true || keys.contains(null)) { + unmanaged.add( + CatalogObject('index', row[0] as String, row[4] as String), + ); + } else { + indexes.add( + IndexSchema( + row[0] as String, + keys.cast(), + unique: row[1] as bool, + ), + ); + } + } + final extras = await db.execute( + SqlCommand( + r''' +SELECT 'trigger', t.tgname, pg_get_triggerdef(t.oid) +FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE c.relname = $1 AND n.nspname = current_schema() AND NOT t.tgisinternal +UNION ALL SELECT 'policy', policyname, + jsonb_build_object('permissive', permissive, 'roles', roles, 'command', cmd, + 'using', qual, 'withCheck', with_check)::text +FROM pg_policies WHERE schemaname = current_schema() AND tablename = $1 +UNION ALL SELECT 'row_security', c.relname, + jsonb_build_object('enabled', c.relrowsecurity, 'forced', c.relforcerowsecurity)::text +FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE c.relname = $1 AND n.nspname = current_schema() +AND (c.relrowsecurity OR c.relforcerowsecurity + OR EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid))''', + [table], + ), + ); + for (final row in extras.rows) { + unmanaged.add( + CatalogObject(row[0] as String, row[1] as String, row[2] as String), + ); + } + } + return TableInfo( + name: table, + columns: columns, + primaryKey: primary, + uniqueKeys: unique, + foreignKeys: foreign, + indexes: indexes, + unmanaged: unmanaged, + checks: List.unmodifiable(checks), + ); +} + +Future verifySchema( + SqlDatabase db, + SchemaSnapshot expected, +) async { + if (_isMysql(db.dialect)) return _mysqlVerifySchema(db, expected); + final differences = [], unmanaged = []; + for (final table in expected.tables) { + final actual = await inspectTable(db, table.name); + unmanaged.addAll(actual.unmanaged); + final columns = {for (final c in actual.columns) c.name: c}; + var checkContextMatches = true; + for (final column in table.columns) { + final found = columns.remove(column.name), + path = '${table.name}.${column.name}'; + if (found == null) { + checkContextMatches = false; + differences.add('$path is missing'); + continue; + } + if (found.storageType != _columnStorageType(column, db.dialect)) { + checkContextMatches = false; + differences.add('$path type differs'); + } + if (found.nullable != column.nullable) { + differences.add('$path nullability differs'); + } + if ((found.temporalPrecision ?? 6) != (column.temporalPrecision ?? 6)) { + differences.add('$path temporal precision differs'); + } + if (!_matchesDecimalDigits(column, found)) { + differences.add('$path decimal precision/scale differs'); + } + if (db.dialect == SqlDialect.sqlite && + !_matchesCollation(column, found)) { + differences.add('$path collation differs'); + } + if (column.codec.sqlType == 'integer' && + (found.integerBits ?? 64) != (column.integerBits ?? 64)) { + differences.add('$path integer width differs'); + } + if (_columnDefault(found.defaultSql, column) != + _columnDefault( + column.defaultSql == null + ? null + : _coerceColumn(column.defaultSql!, column, db.dialect), + column, + )) { + differences.add('$path default differs'); + } + if (db.dialect == SqlDialect.postgres && + column.computed == null && + found.computed == null && + found.generated != column.generated) { + differences.add('$path generation differs'); + } + if (db.dialect == SqlDialect.sqlite && + found.generated && + found.computed == null) { + differences.add('$path uses an unmodeled generated expression'); + } + } + for (final name in columns.keys) { + differences.add('${table.name}.$name is unmanaged'); + } + differences.addAll( + await _verifyComputed( + db, + table, + actual.columns, + contextMatches: checkContextMatches, + ), + ); + void compare(String kind, Object? desired, Object? found) { + if (_hash(desired) != _hash(found)) { + differences.add('${table.name} $kind differs'); + } + } + + List set(Iterable values) => + values.map((v) => jsonEncode(_canonical(v))).toList()..sort(); + compare('primary key', table.primaryKey, actual.primaryKey); + compare('unique keys', set(table.uniqueKeys), set(actual.uniqueKeys)); + // Expected SQL may no longer resolve when a referenced column/type drifted. + // Report that schema drift instead of attempting an invalid EXPLAIN. + final checkMatches = checkContextMatches + ? await _matchChecks(db, table.name, table.checks, actual.checks) + : List.filled(table.checks.length, null); + if (checkMatches.any((i) => i == null) || + checkMatches.length != actual.checks.length) { + differences.add('${table.name} checks differs'); + for (var i = 0; i < actual.checks.length; i++) { + if (!checkMatches.contains(i)) { + final c = actual.checks[i]; + unmanaged.add( + CatalogObject('check', c.name ?? '${table.name}#$i', c.expression), + ); + } + } + } + compare( + 'foreign keys', + set(table.foreignKeys.map(_foreignKeyJson)), + set(actual.foreignKeys.map(_foreignKeyJson)), + ); + compare( + 'indexes', + set(table.indexes.map(_indexJson)), + set(actual.indexes.map(_indexJson)), + ); + } + return SchemaVerification( + List.unmodifiable(differences), + List.unmodifiable(unmanaged), + ); +} + +String? _columnDefault(String? value, Column column) { + var normalized = _normalizeDefault(value); + var temporalPrefix = ''; + if (normalized != null && + column.temporalPrecision != null && + column.temporalPrecision != 6) { + final inner = _uncoerceTemporal( + normalized, + column.codec.sqlType, + column.temporalPrecision!, + ); + if (inner != null) { + normalized = _normalizeDefault(inner); + temporalPrefix = 'temporal cast:'; + } + final type = _columnStorageType( + column, + SqlDialect.postgres, + ).toLowerCase().replaceFirst('timestamptz', 'timestamp'); + final native = column.codec.sqlType == 'instant' + ? '$type with time zone' + : type; + if (normalized != null && normalized.endsWith('::$native')) { + normalized = _normalizeDefault( + normalized.substring(0, normalized.length - native.length - 2), + ); + } + } + if (normalized != null && + normalized.startsWith("'") && + normalized.endsWith("'")) { + final literal = normalized.substring(1, normalized.length - 1); + final parsed = switch (column.codec.sqlType) { + 'instant' => _instantDefault(literal, column.temporalPrecision ?? 6), + 'date' => LocalDate.tryParse(literal), + 'time' => LocalTime.tryParse(literal), + 'local_datetime' => LocalDateTime.tryParse(literal), + _ => null, + }; + if (parsed != null) { + final digits = column.temporalPrecision ?? 6; + final rounded = switch (parsed) { + LocalTime() => parsed.withPrecision(digits), + LocalDateTime() => parsed.withPrecision(digits), + DateTime() => parsed.withPrecision(digits), + _ => parsed, + }; + return '$temporalPrefix$rounded'; + } + } + if (normalized == null || column.codec.sqlType != 'decimal') { + return normalized == null ? null : '$temporalPrefix$normalized'; + } + final unwrapped = column.decimalPrecision == null + ? null + : _uncoerceDecimalDefault( + normalized, + column.decimalPrecision!, + column.decimalScale ?? 0, + ); + final prefix = unwrapped == null ? '' : 'decimal cast:'; + normalized = unwrapped == null ? normalized : _normalizeDefault(unwrapped)!; + // PostgreSQL removes the quotes from a NUMERIC literal. Compare finite + // literals by value, leaving arbitrary SQL expressions unchanged. + final literal = normalized.startsWith("'") && normalized.endsWith("'") + ? normalized.substring(1, normalized.length - 1) + : normalized; + return '$prefix${Decimal.tryParse(literal)?.toString() ?? normalized}'; +} + +String? _normalizeDefault(String? value) { + if (value == null) return null; + var normalized = value.trim(); + while (normalized.startsWith('(') && normalized.endsWith(')')) { + var depth = 0; + var whole = true; + var quoted = false; + for (var i = 0; i < normalized.length; i++) { + if (normalized[i] == "'") { + if (quoted && i + 1 < normalized.length && normalized[i + 1] == "'") { + i++; + continue; + } + quoted = !quoted; + } + if (quoted) continue; + if (normalized[i] == '(') depth++; + if (normalized[i] == ')' && --depth == 0 && i != normalized.length - 1) { + whole = false; + break; + } + } + if (!whole) break; + normalized = normalized.substring(1, normalized.length - 1).trim(); + } + // Strip casts only from literals. Expression casts can discard time or + // precision and must remain visible to drift verification. + return normalized.replaceFirstMapped( + RegExp( + r"^('(?:[^']|'')*'|[-+]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][-+]?[0-9]+)?|true|false)::(?:text|bigint|integer|boolean|double precision|numeric|timestamp with time zone|date|time without time zone|timestamp without time zone)$", + caseSensitive: false, + ), + (match) => match[1]!, + ); +} + +String? _instantDefault(String literal, int digits) { + try { + return Codecs.dateTime.encode( + Codecs.dateTime.decode(literal).withPrecision(digits), + ) as String; + } on FormatException { + return null; + } +} diff --git a/lib/src/migrate/checks.dart b/lib/src/migrate/checks.dart new file mode 100644 index 00000000..82538c86 --- /dev/null +++ b/lib/src/migrate/checks.dart @@ -0,0 +1,156 @@ +part of '../../migrate.dart'; + +/// An enforced, validated row CHECK read from this database's catalog. +final class CheckInfo { + final String? name; + final String expression; + const CheckInfo(this.name, this.expression); +} + +TableSchema _withChecks(TableSchema table, List checks) => + TableSchema( + table.name, + columns: table.columns, + primaryKey: table.primaryKey, + uniqueKeys: table.uniqueKeys, + foreignKeys: table.foreignKeys, + indexes: table.indexes, + checks: checks, + ); + +({List removed, List added}) _checkDelta( + List before, + List after, + SqlDialect dialect, +) { + final added = after.toList(), removed = []; + for (final old in before) { + final index = added.indexWhere( + (c) => + c.name == old.name && + c.expression(dialect) == old.expression(dialect), + ); + if (index < 0) { + removed.add(old); + } else { + added.removeAt(index); + } + } + return (removed: removed, added: added); +} + +String _checkSignature(String expression) { + var tokens = _sqliteTokens(expression); + // Strip enclosing parentheses, not parentheses that determine precedence. + while (tokens.length >= 2 && + tokens.first.text == '(' && + tokens.last.text == ')') { + var depth = 0, enclosing = true; + for (var i = 0; i < tokens.length - 1; i++) { + if (tokens[i].text == '(') depth++; + if (tokens[i].text == ')') depth--; + if (depth == 0) { + enclosing = false; + break; + } + } + if (!enclosing) break; + tokens = tokens.sublist(1, tokens.length - 1); + } + return jsonEncode( + tokens + .map( + (t) => t.text.startsWith('i:') + ? _sqliteName(t.text.substring(2)) + : t.text.startsWith('s:') + ? t.text + : _sqliteName(t.text), + ) + .toList(), + ); +} + +// PostgreSQL rewrites IN, casts and parentheses in stored expressions. Ask its +// planner to render both projections in the same typed context; never compare +// expressions by deleting casts or precedence-bearing syntax. EXPLAIN does not +// run the SELECT, though PostgreSQL can evaluate immutable constants in planning. +Future> _checkExpressions( + SqlDatabase db, + String table, + List expressions, +) async { + if (db.dialect == SqlDialect.sqlite) { + return expressions.map(_checkSignature).toList(); + } + if (expressions.isEmpty) return const []; + final result = await db.execute( + SqlCommand( + 'EXPLAIN (VERBOSE, FORMAT JSON, COSTS OFF) SELECT ' + '${expressions.map((e) => '($e\n)').join(', ')} FROM ONLY ${_quote(table)}', + ), + ); + final json = Codecs.json.decode(result.rows.single.single) as List; + final plan = + (json.single as Map)['Plan'] as Map; + final output = (plan['Output'] as List).cast(); + if (output.length != expressions.length) { + throw const OrmException('SCHEMA.CHECK', 'CHECK projection arity differs.'); + } + return output; +} + +/// One actual constraint is consumed per declaration, including unnamed copies. +Future> _matchChecks( + SqlDatabase db, + String table, + List expected, + List actual, +) async { + if (expected.isEmpty) return const []; + final signatures = await _checkExpressions(db, table, [ + ...expected.map((c) => c.expression(db.dialect)), + ...actual.map((c) => c.expression), + ]); + final used = {}, matches = []; + for (var i = 0; i < expected.length; i++) { + int? found; + for (var j = 0; j < actual.length; j++) { + final name = expected[i].name; + final sameName = + name == null || + (db.dialect == SqlDialect.sqlite + ? actual[j].name != null && + _sqliteName(name) == _sqliteName(actual[j].name!) + : name == actual[j].name); + if (!used.contains(j) && + sameName && + signatures[i] == signatures[expected.length + j]) { + found = j; + used.add(j); + break; + } + } + matches.add(found); + } + return matches; +} + +Future _dropCheck( + SqlDatabase db, + String table, + CheckSchema check, +) async { + final actual = (await inspectTable(db, table)).checks; + final match = (await _matchChecks(db, table, [check], actual)).single; + if (match == null || actual[match].name == null) { + throw OrmException( + 'MIGRATION.DRIFT', + 'Expected CHECK is missing or differs on $table.', + ); + } + await db.execute( + SqlCommand( + 'ALTER TABLE ${_quote(table)} DROP CONSTRAINT ${_quote(actual[match].name!)}', + ), + ); +} diff --git a/lib/src/migrate/computed.dart b/lib/src/migrate/computed.dart new file mode 100644 index 00000000..94c3e5b2 --- /dev/null +++ b/lib/src/migrate/computed.dart @@ -0,0 +1,195 @@ +part of '../../migrate.dart'; + +Map _computedJson(ComputedColumn value) => { + 'sqlite': value.sqlite, + 'postgres': value.postgres, + if (value.mysql != null && value.mysql != value.postgres) + 'mysql': value.mysql, + if (value.mariadb != null && value.mariadb != value.postgres) + 'mariadb': value.mariadb, + 'storage': value.storage.name, +}; + +typedef _SqliteComputed = ({ + String column, + ComputedColumn value, + int start, + int end, +}); + +List<_SqliteComputed> _sqliteComputed(String sql) { + final tokens = _sqliteTokens(sql), result = <_SqliteComputed>[]; + var depth = 0, beginning = false; + String? column; + for (var i = 0; i < tokens.length; i++) { + final token = tokens[i]; + if (token.text == '(') { + depth++; + if (depth == 1) beginning = true; + } else if (token.text == ')') { + depth--; + } else if (depth == 1 && token.text == ',') { + beginning = true; + column = null; + } else if (depth == 1 && beginning) { + column = + { + 'CONSTRAINT', + 'PRIMARY', + 'UNIQUE', + 'CHECK', + 'FOREIGN', + }.contains(token.text) + ? null + : token.text.startsWith('i:') || token.text.startsWith('s:') + ? token.text.substring(2) + : sql.substring(token.start, token.end); + beginning = false; + } else if (depth == 1 && + column != null && + token.text == 'AS' && + i + 1 < tokens.length && + tokens[i + 1].text == '(') { + var nested = 1, end = i + 2; + for (; end < tokens.length; end++) { + if (tokens[end].text == '(') nested++; + if (tokens[end].text == ')' && --nested == 0) break; + } + if (nested != 0) break; + final expression = sql + .substring(tokens[i + 1].end, tokens[end].start) + .trim(); + var start = i; + if (i >= 2 && + tokens[i - 2].text == 'GENERATED' && + tokens[i - 1].text == 'ALWAYS') { + start -= 2; + } + var storage = ComputedStorage.virtual; + if (end + 1 < tokens.length && + {'STORED', 'VIRTUAL'}.contains(tokens[end + 1].text)) { + storage = tokens[++end].text == 'STORED' + ? ComputedStorage.stored + : ComputedStorage.virtual; + } + result.add(( + column: column, + value: ComputedColumn(expression, storage: storage), + start: tokens[start].start, + end: tokens[end].end, + )); + i = end; + } + } + return result; +} + +Map _sqliteComputedColumns(String sql) => { + for (final c in _sqliteComputed(sql)) _sqliteName(c.column): c.value, +}; + +String _withoutComputed(String sql) { + final result = StringBuffer(); + var start = 0; + for (final c in _sqliteComputed(sql)) { + result.write(sql.substring(start, c.start)); + start = c.end; + } + return (result..write(sql.substring(start))).toString(); +} + +ComputedColumn? _declarationComputed(ColumnInfo column) { + final value = column.computed; + if (value != null && + column.storageType == 'TEXT' && + column.temporalPrecision != null) { + final expression = _uncoerceTemporal( + value.sqlite, + _temporalCollationKind(column.collation)!, + column.temporalPrecision!, + ); + return expression == null + ? value + : ComputedColumn(expression, storage: value.storage); + } + if (value == null || + column.storageType != 'TEXT' || + column.decimalPrecision == null) { + return value; + } + final expression = _uncoerceDecimalDefault( + value.sqlite, + column.decimalPrecision!, + column.decimalScale ?? 0, + ); + return expression == null + ? value + : ComputedColumn(expression, storage: value.storage); +} + +Future> _verifyComputed( + SqlDatabase db, + TableSchema table, + List actual, { + bool contextMatches = true, +}) async { + final differences = [], expressions = [], paths = []; + for (final column in table.columns) { + final found = actual.where((c) => c.name == column.name).firstOrNull; + final expected = column.computed, observed = found?.computed; + if (expected == null && observed == null) continue; + final path = '${table.name}.${column.name}'; + if (!contextMatches || + expected == null || + observed == null || + expected.storage != observed.storage) { + differences.add('$path computed expression or storage differs'); + continue; + } + paths.add(path); + final pair = [ + _coerceColumn(expected.expression(db.dialect), column, db.dialect), + observed.expression(db.dialect), + ]; + // PostgreSQL stores assignment casts in pg_attrdef. Compare both sides in + // their actual column type, including numeric precision and scale. + expressions.addAll( + db.dialect == SqlDialect.postgres + ? pair.map( + (sql) => + 'CAST(($sql\n) AS ${_columnStorageType(column, db.dialect)})', + ) + : pair, + ); + } + final signatures = await _checkExpressions(db, table.name, expressions); + for (var i = 0; i < paths.length; i++) { + if (signatures[i * 2] != signatures[i * 2 + 1]) { + differences.add('${paths[i]} computed expression or storage differs'); + } + } + return differences; +} + +TableSchema _materializedColumns(TableSchema table) => TableSchema( + table.name, + columns: [ + for (final c in table.columns) + Column( + c.name, + c.codec, + nullable: c.nullable, + generated: c.generated, + defaultSql: c.defaultSql, + integerBits: c.integerBits, + decimalPrecision: c.decimalPrecision, + decimalScale: c.decimalScale, + temporalPrecision: c.temporalPrecision, + ), + ], + primaryKey: table.primaryKey, + uniqueKeys: table.uniqueKeys, + indexes: table.indexes, + checks: table.checks, + foreignKeys: table.foreignKeys, +); diff --git a/lib/src/migrate/diff.dart b/lib/src/migrate/diff.dart new file mode 100644 index 00000000..37c55178 --- /dev/null +++ b/lib/src/migrate/diff.dart @@ -0,0 +1,534 @@ +part of '../../migrate.dart'; + +/// Explicit physical renames. Column maps are keyed by the final table name. +/// Swaps and chains need separate migrations to make intermediate names explicit. +final class SchemaRenames { + final Map tables; + final Map> columns; + const SchemaRenames({this.tables = const {}, this.columns = const {}}); +} + +Migration _diff( + String id, { + required SqlDialect dialect, + required SchemaSnapshot from, + required SchemaSnapshot to, + required SchemaRenames renames, + required String? previous, + required bool allowDestructive, + required Map> using, +}) { + if (_isMysql(dialect)) { + return _mysqlDiff( + id, + dialect: dialect, + from: from, + to: to, + renames: renames, + previous: previous, + allowDestructive: allowDestructive, + using: using, + ); + } + final renameSql = []; + final oldNames = from.tables.map((t) => t.name).toSet(); + final newNames = to.tables.map((t) => t.name).toSet(); + void validateRenames( + Map map, + Set old, + Set next, + ) { + if (map.values.toSet().length != map.length || + map.entries.any( + (e) => + !old.contains(e.key) || + old.contains(e.value) || + !next.contains(e.value), + )) { + throw const OrmException( + 'MIGRATION.RENAME', + 'Rename sources must exist, targets must be new, and targets cannot overlap.', + ); + } + } + + validateRenames(renames.tables, oldNames, newNames); + for (final entry in renames.tables.entries) { + renameSql.add( + ExecuteSql( + 'ALTER TABLE ${_quote(entry.key)} RENAME TO ${_quote(entry.value)}', + ), + ); + } + final renamedTables = { + for (final t in from.tables) renames.tables[t.name] ?? t.name: t, + }; + for (final entry in renames.columns.entries) { + final old = renamedTables[entry.key], + next = to.tables.where((t) => t.name == entry.key).firstOrNull; + if (old == null || next == null) { + throw const OrmException( + 'MIGRATION.RENAME', + 'Column rename references a missing table.', + ); + } + validateRenames( + entry.value, + old.columns.map((c) => c.name).toSet(), + next.columns.map((c) => c.name).toSet(), + ); + for (final rename in entry.value.entries) { + renameSql.add( + ExecuteSql( + 'ALTER TABLE ${_quote(entry.key)} RENAME COLUMN ${_quote(rename.key)} TO ${_quote(rename.value)}', + ), + ); + } + } + String tableName(String name) => renames.tables[name] ?? name; + String columnName(String table, String column) => + renames.columns[tableName(table)]?[column] ?? column; + final computedRenames = from.tables + .where( + (t) => + t.columns.any((c) => c.computed != null) && + (renames.tables.containsKey(t.name) || + (renames.columns[tableName(t.name)]?.isNotEmpty ?? false)), + ) + .toList(); + // CHECK SQL is deliberately not rewritten as text. Remove it before native + // renames, then validate the explicitly declared target expressions afterward. + final checkedRenames = from.tables + .where( + (t) => + t.checks.isNotEmpty && + (renames.tables.containsKey(t.name) || + (renames.columns[tableName(t.name)]?.isNotEmpty ?? false)), + ) + .toList(); + final before = { + for (final table in from.tables) + tableName(table.name): TableSchema( + tableName(table.name), + checks: checkedRenames.contains(table) ? const [] : table.checks, + columns: [ + for (final c in table.columns) + Column( + columnName(table.name, c.name), + c.codec, + nullable: c.nullable, + generated: c.generated, + defaultSql: c.defaultSql, + computed: c.computed, + integerBits: c.integerBits, + decimalPrecision: c.decimalPrecision, + decimalScale: c.decimalScale, + temporalPrecision: c.temporalPrecision, + ), + ], + primaryKey: table.primaryKey + .map((c) => columnName(table.name, c)) + .toList(), + uniqueKeys: [ + for (final key in table.uniqueKeys) + key.map((c) => columnName(table.name, c)).toList(), + ], + indexes: [ + for (final index in table.indexes) + IndexSchema( + index.name, + index.columns.map((c) => columnName(table.name, c)).toList(), + unique: index.unique, + ), + ], + foreignKeys: [ + for (final key in table.foreignKeys) + ForeignKey( + key.columns.map((c) => columnName(table.name, c)).toList(), + tableName(key.target), + key.targetColumns.map((c) => columnName(key.target, c)).toList(), + onDelete: key.onDelete, + ), + ], + ), + }; + final after = {for (final t in to.tables) t.name: t}; + final removedTables = before.keys.toSet().difference(after.keys.toSet()); + final addedTables = after.keys.toSet().difference(before.keys.toSet()); + final shared = before.keys.toSet().intersection(after.keys.toSet()); + final dropped = [...removedTables]; + for (final name in shared) { + final next = after[name]!; + dropped.addAll( + before[name]!.columns + .where((c) => !next.columns.any((n) => c.name == n.name)) + .map((c) => '$name.${c.name}'), + ); + } + if (dropped.isNotEmpty && !allowDestructive) { + throw OrmException( + 'MIGRATION.DESTRUCTIVE', + 'Explicit renames or allowDestructive are required for: ${dropped.join(', ')}.', + ); + } + for (final name in shared) { + final old = {for (final c in before[name]!.columns) c.name: c}; + for (final c in after[name]!.columns) { + final prior = old[c.name]; + if (prior == null && + c.computed == null && + (c.generated || (!c.nullable && c.defaultSql == null))) { + throw OrmException( + 'MIGRATION.BACKFILL', + '$name.${c.name} requires an explicit backfill before becoming required.', + ); + } + if (prior != null && prior.generated != c.generated) { + throw OrmException( + 'MIGRATION.IDENTITY', + '$name.${c.name} identity changes require a manual migration.', + ); + } + if (dialect == SqlDialect.sqlite && + prior != null && + prior.computed == null && + c.computed != null && + !allowDestructive) { + throw OrmException( + 'MIGRATION.DESTRUCTIVE', + '$name.${c.name} replaces stored values with a computed expression; review and allowDestructive explicitly.', + ); + } + if (dialect == SqlDialect.postgres && + prior != null && + ((prior.computed == null && c.computed != null) || + (prior.computed != null && + c.computed != null && + prior.computed!.storage != c.computed!.storage) || + (prior.computed?.storage == ComputedStorage.virtual && + c.computed == null))) { + throw OrmException( + 'MIGRATION.COMPUTED', + '$name.${c.name} needs a reviewed PostgreSQL replacement/materialization migration for this computed-mode change.', + ); + } + if (prior != null && c.computed == null && !_sameStorage(prior, c)) { + if (using[name]?[c.name] == null) { + throw OrmException( + 'MIGRATION.CAST', + '$name.${c.name} needs an explicit ${dialect.name} conversion expression.', + ); + } + } + } + } + for (final table in using.entries) { + for (final entry in table.value.entries) { + final old = before[table.key]?.columns + .where((c) => c.name == entry.key) + .firstOrNull; + final next = after[table.key]?.columns + .where((c) => c.name == entry.key) + .firstOrNull; + if (old == null || + next == null || + next.computed != null || + _sameStorage(old, next) || + entry.value.trim().isEmpty) { + throw OrmException( + 'MIGRATION.CAST', + '${table.key}.${entry.key} conversion must correspond to an actual type change.', + ); + } + } + } + final steps = []; + for (final table in { + ...checkedRenames, + if (dialect == SqlDialect.sqlite) ...computedRenames, + }) { + if (dialect == SqlDialect.sqlite) { + var target = _withChecks(table, const []); + if (computedRenames.contains(table)) { + target = _materializedColumns(target); + } + steps.add( + RebuildTable( + table, + target, + copy: {for (final c in table.columns) c.name: _quote(c.name)}, + ), + ); + } else { + for (final check in table.checks) { + steps.add( + DropConstraint(table.name, { + 'kind': 'c', + 'name': check.name, + 'expression': check.expression(dialect), + }), + ); + } + } + } + steps.addAll(renameSql); + final addedForeignKeys = <(String, ForeignKey)>[]; + if (dialect == SqlDialect.postgres) { + bool keysChanged(String name) { + final a = before[name], b = after[name]; + return a == null || + b == null || + _hash([ + a.primaryKey, + a.uniqueKeys, + a.indexes.where((i) => i.unique).map(_indexJson).toList(), + ]) != + _hash([ + b.primaryKey, + b.uniqueKeys, + b.indexes.where((i) => i.unique).map(_indexJson).toList(), + ]); + } + + bool typesChanged(String name) { + final a = before[name], b = after[name]; + return a == null || + b == null || + a.columns.any( + (c) => + b.columns.any((n) => c.name == n.name && !_sameStorage(c, n)), + ); + } + + for (final old in before.values) { + final next = after[old.name]; + for (final key in old.foreignKeys) { + final retained = + next?.foreignKeys.any((k) => _sameForeignKey(k, key)) ?? false; + if (!retained || + keysChanged(key.target) || + typesChanged(key.target) || + typesChanged(old.name)) { + steps.add( + DropConstraint(old.name, {'kind': 'f', ..._foreignKeyJson(key)}), + ); + if (retained) addedForeignKeys.add((old.name, key)); + } + } + if (next != null) { + for (final key in next.foreignKeys) { + if (!old.foreignKeys.any((k) => _sameForeignKey(k, key))) { + addedForeignKeys.add((old.name, key)); + } + } + } + } + } + for (final name in removedTables) { + steps.add(DropTable(name)); + } + for (final name in addedTables) { + final table = after[name]!; + steps.add(ExecuteSql(_createTable(table, dialect))); + for (final index in table.indexes) { + steps.add(ExecuteSql(_createIndex(name, index))); + } + if (dialect == SqlDialect.postgres) { + addedForeignKeys.addAll(table.foreignKeys.map((k) => (name, k))); + } + } + for (final name in shared) { + var old = before[name]!; + if (dialect == SqlDialect.sqlite && + computedRenames.any((t) => tableName(t.name) == name)) { + old = _materializedColumns(old); + } + final next = after[name]!; + final oldColumns = {for (final c in old.columns) c.name: c}; + final newColumns = {for (final c in next.columns) c.name: c}; + final removed = oldColumns.keys.toSet().difference(newColumns.keys.toSet()); + final added = newColumns.keys.toSet().difference(oldColumns.keys.toSet()); + final changed = oldColumns.keys + .toSet() + .intersection(newColumns.keys.toSet()) + .where( + (c) => + _hash(_columnJson(oldColumns[c]!)) != + _hash(_columnJson(newColumns[c]!)), + ) + .toList(); + final keysChanged = + _hash([ + old.primaryKey, + old.uniqueKeys, + old.foreignKeys.map(_foreignKeyJson).toList(), + ]) != + _hash([ + next.primaryKey, + next.uniqueKeys, + next.foreignKeys.map(_foreignKeyJson).toList(), + ]); + final checks = _checkDelta(old.checks, next.checks, dialect); + if (dialect == SqlDialect.sqlite && + (removed.isNotEmpty || + changed.isNotEmpty || + keysChanged || + checks.removed.isNotEmpty || + checks.added.isNotEmpty || + added.any((c) => !_canAddSqlite(newColumns[c]!)))) { + steps.add( + RebuildTable( + old, + next, + copy: { + for (final column in newColumns.keys.where( + (c) => + oldColumns.containsKey(c) && newColumns[c]!.computed == null, + )) + column: using[name]?[column] ?? _quote(column), + }, + ), + ); + continue; + } + if (dialect == SqlDialect.postgres) { + for (final check in checks.removed) { + steps.add( + DropConstraint(name, { + 'kind': 'c', + 'name': check.name, + 'expression': check.expression(dialect), + }), + ); + } + if (_hash(old.primaryKey) != _hash(next.primaryKey) && + old.primaryKey.isNotEmpty) { + steps.add( + DropConstraint(name, {'kind': 'p', 'columns': old.primaryKey}), + ); + } + for (final key in old.uniqueKeys) { + if (!next.uniqueKeys.any((k) => _hash(k) == _hash(key))) { + steps.add(DropConstraint(name, {'kind': 'u', 'columns': key})); + } + } + } + for (final index in old.indexes) { + if (!next.indexes.any( + (i) => _hash(_indexJson(i)) == _hash(_indexJson(index)), + )) { + steps.add(ExecuteSql('DROP INDEX ${_quote(index.name)}')); + } + } + for (final column in removed) { + steps.add( + ExecuteSql('ALTER TABLE ${_quote(name)} DROP COLUMN ${_quote(column)}'), + ); + } + for (final column in added) { + steps.add( + ExecuteSql( + 'ALTER TABLE ${_quote(name)} ADD COLUMN ${_columnDefinition(newColumns[column]!, dialect)}', + ), + ); + } + for (final column in changed) { + final a = oldColumns[column]!, b = newColumns[column]!; + final prefix = + 'ALTER TABLE ${_quote(name)} ALTER COLUMN ${_quote(column)}'; + final typeChanged = !_sameStorage(a, b); + if (a.computed != null && b.computed == null) { + steps.add(ExecuteSql('$prefix DROP EXPRESSION')); + } + if (typeChanged && a.defaultSql != null) { + steps.add(ExecuteSql('$prefix DROP DEFAULT')); + } + if (typeChanged) { + steps.add( + ExecuteSql( + '$prefix TYPE ${_columnStorageType(b, dialect)}' + '${b.computed != null ? '' : ' USING (${using[name]![column]})'}', + ), + ); + } + if (b.computed != null && + (a.computed?.expression(dialect) != b.computed!.expression(dialect) || + typeChanged)) { + steps.add( + ExecuteSql( + '$prefix SET EXPRESSION AS (${_coerceColumn(b.computed!.expression(dialect), b, dialect)}\n)', + ), + ); + } + if (a.nullable != b.nullable) { + steps.add( + ExecuteSql('$prefix ${b.nullable ? 'DROP' : 'SET'} NOT NULL'), + ); + } + if (a.defaultSql != b.defaultSql || + (typeChanged && b.defaultSql != null)) { + steps.add( + ExecuteSql( + '$prefix ${b.defaultSql == null ? 'DROP DEFAULT' : 'SET DEFAULT (${b.defaultSql})'}', + ), + ); + } + } + if (dialect == SqlDialect.postgres) { + if (checks.added.isNotEmpty) { + steps.add( + ExecuteSql( + 'ALTER TABLE ${_quote(name)} ' + '${checks.added.map((c) => 'ADD ${_checkDefinition(c, dialect)}').join(', ')}', + ), + ); + } + if (_hash(old.primaryKey) != _hash(next.primaryKey) && + next.primaryKey.isNotEmpty) { + steps.add( + ExecuteSql( + 'ALTER TABLE ${_quote(name)} ADD PRIMARY KEY (${next.primaryKey.map(_quote).join(', ')})', + ), + ); + } + for (final key in next.uniqueKeys) { + if (!old.uniqueKeys.any((k) => _hash(k) == _hash(key))) { + steps.add( + ExecuteSql( + 'ALTER TABLE ${_quote(name)} ADD UNIQUE (${key.map(_quote).join(', ')})', + ), + ); + } + } + } + for (final index in next.indexes) { + if (!old.indexes.any( + (i) => _hash(_indexJson(i)) == _hash(_indexJson(index)), + )) { + steps.add(ExecuteSql(_createIndex(name, index))); + } + } + } + for (final (name, key) in addedForeignKeys) { + steps.add( + ExecuteSql('ALTER TABLE ${_quote(name)} ADD ${_foreignKey(key)}'), + ); + } + return Migration.steps( + id, + steps, + dialect: dialect, + snapshot: to, + previous: previous, + ); +} + +bool _sameForeignKey(ForeignKey a, ForeignKey b) => + _hash(_foreignKeyJson(a)) == _hash(_foreignKeyJson(b)); +bool _canAddSqlite(Column column) => column.computed != null + ? column.computed!.storage == ComputedStorage.virtual + : column.defaultSql == null || + RegExp( + r"^(NULL|TRUE|FALSE|[+-]?[0-9]+(\.[0-9]+)?|'([^']|'')*'|[xX]'[0-9a-fA-F]*')$", + caseSensitive: false, + ).hasMatch(column.defaultSql!.trim()); diff --git a/lib/src/migrate/history.dart b/lib/src/migrate/history.dart new file mode 100644 index 00000000..93af2982 --- /dev/null +++ b/lib/src/migrate/history.dart @@ -0,0 +1,603 @@ +part of '../../migrate.dart'; + +/// One reviewed plan for one database engine. A history cannot change engines. +final class Migration { + final String id; + final SqlDialect dialect; + final List steps; + final SchemaSnapshot? snapshot; + final String? previous; + Migration( + String id, + List statements, { + required SqlDialect dialect, + SchemaSnapshot? snapshot, + String? previous, + }) : this.steps( + id, + statements.map(ExecuteSql.new).toList(), + dialect: dialect, + snapshot: snapshot, + previous: previous, + ); + + Migration.steps( + this.id, + List steps, { + required this.dialect, + SchemaSnapshot? snapshot, + this.previous, + }) : steps = List.unmodifiable(steps.map((s) => _targetStep(s, dialect))), + snapshot = snapshot?.forDialect(dialect) { + if (!RegExp(r'^[0-9]+_[a-z][a-z0-9_]*$').hasMatch(id)) { + throw ArgumentError( + 'Migration IDs use a numeric prefix and lowercase name.', + ); + } + } + + factory Migration.create( + String id, + List schema, { + required SqlDialect dialect, + }) => Migration.steps( + id, + _isMysql(dialect) + ? _mysqlCreateSteps(schema, dialect) + : [ + for (final command in createSchema(schema, dialect)) + ExecuteSql(command.sql), + ], + dialect: dialect, + snapshot: SchemaSnapshot(schema), + ); + + factory Migration.diff( + String id, { + required SqlDialect dialect, + required SchemaSnapshot from, + required SchemaSnapshot to, + SchemaRenames renames = const SchemaRenames(), + String? previous, + bool allowDestructive = false, + Map> using = const {}, + }) => _diff( + id, + dialect: dialect, + from: from.forDialect(dialect), + to: to.forDialect(dialect), + renames: renames, + previous: previous, + allowDestructive: allowDestructive, + using: using, + ); + + Map toJson() => { + 'format': 3, + 'id': id, + 'dialect': dialect.name, + if (snapshot != null) 'snapshot': snapshot!.toJson(), + if (previous != null) 'previous': previous, + 'steps': [for (final step in steps) step.toJson()], + }; + late final String checksum = _hash(toJson()); +} + +MigrationStep _targetStep(MigrationStep step, SqlDialect dialect) => + switch (step) { + CheckedTableSql() => CheckedTableSql( + step.sql, + before: step.before == null + ? null + : _targetTable(step.before!, dialect), + after: step.after == null ? null : _targetTable(step.after!, dialect), + ), + RebuildTable() => RebuildTable( + _targetTable(step.before, dialect), + _targetTable(step.after, dialect), + copy: step.copy, + ), + Backfill() => Backfill( + _targetTable(step.table, dialect), + set: step.set, + where: step.where, + doneWhen: step.doneWhen, + batchSize: step.batchSize, + ), + _ => step, + }; + +final class MigrationStatus { + final String id; + final String checksum; + const MigrationStatus(this.id, this.checksum); +} + +final class Migrator { + final SqlDatabase database; + final Duration lockTimeout; + const Migrator( + this.database, { + this.lockTimeout = const Duration(seconds: 30), + }); + + /// Registers a verified existing database without replaying creation SQL. + Future baseline( + List migrations, { + required SchemaSnapshot expected, + }) async { + if (_isMysql(database.dialect)) { + return _mysqlBaseline(this, migrations, expected); + } + validateMigrations(migrations, dialect: database.dialect); + if (database.inTransaction) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Baseline requires an outer session.', + ); + } + if (migrations.isEmpty || + migrations.last.snapshot?.checksum != + expected.forDialect(database.dialect).checksum) { + throw const OrmException( + 'MIGRATION.BASELINE', + 'The final migration must carry the expected baseline snapshot.', + ); + } + return _migrationSession( + database, + lockTimeout, + (session) => session.transaction( + (tx) async { + if ((await Migrator(tx).history()).isNotEmpty || + (await _progress(tx)).isNotEmpty) { + throw const OrmException( + 'MIGRATION.BASELINE', + 'Migration history already exists.', + ); + } + final verification = await verifySchema(tx, expected); + if (!verification.matches) { + throw OrmException( + 'MIGRATION.DRIFT', + verification.differences.join('\n'), + ); + } + await tx.execute( + SqlCommand( + 'CREATE TABLE IF NOT EXISTS "_orm_migrations" (id TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)', + ), + ); + for (final migration in migrations) { + await _recordMigration(tx, migration); + } + return verification; + }, + options: database.dialect == SqlDialect.sqlite + ? const SqliteTransaction(mode: .immediate) + : const PostgresTransaction(), + ), + ); + } + + Future> history() async { + if (!await _hasMigrationTable(database, '_orm_migrations')) return []; + final rows = await database.execute( + SqlCommand('SELECT id, checksum FROM "_orm_migrations" ORDER BY id'), + ); + return [ + for (final row in rows.rows) + MigrationStatus(row[0] as String, row[1] as String), + ]; + } + + /// Durable step checkpoints, including failed stages of partially applied migrations. + Future> progress() => _progress(database); + + Future> plan(List migrations) async { + validateMigrations(migrations, dialect: database.dialect); + if (!database.inSession) await _checkMigrationVersion(database); + final applied = await history(); + _validateApplied(migrations, applied); + _validateProgress(migrations, applied, await progress(), database.dialect); + return migrations.skip(applied.length).toList(growable: false); + } + + /// Checks startup compatibility in one read snapshot. Defaults to the latest + /// bundled migration; an explicit inclusive range supports compatible releases. + /// Does not apply migrations, verify catalog drift or lock out later upgrades. + Future requireVersion( + List migrations, { + String? minimum, + String? maximum, + }) { + migrations = List.unmodifiable(migrations); + validateMigrations(migrations, dialect: database.dialect); + if (migrations.isEmpty) { + throw ArgumentError( + 'Version compatibility requires a nonempty migration history.', + ); + } + final upper = migrations.indexWhere( + (m) => m.id == (maximum ?? migrations.last.id), + ); + final lower = migrations.indexWhere( + (m) => m.id == (minimum ?? maximum ?? migrations.last.id), + ); + if (lower < 0 || upper < lower) { + throw ArgumentError( + 'Minimum/maximum must name an ordered range in the bundled history.', + ); + } + if (database.inTransaction) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Version compatibility requires an outer session for a consistent read snapshot.', + ); + } + return database.transaction( + (tx) async { + final reader = Migrator(tx); + final applied = await reader.history(); + if (applied.length > migrations.length) { + throw const OrmException( + 'MIGRATION.VERSION', + 'Database is newer than the bundled migration history.', + ); + } + _validateApplied(migrations, applied); + final checkpoints = await reader.progress(); + final finished = applied.map((m) => m.id).toSet(); + for (final row in checkpoints) { + if (!finished.contains(row.id)) { + throw OrmException( + 'MIGRATION.INCOMPLETE', + 'Migration ${row.id} has unfinished recovery work; restore a completed version before starting the application.', + ); + } + } + _validateProgress(migrations, applied, checkpoints, tx.dialect); + final current = applied.length - 1; + if (current < lower || current > upper) { + throw OrmException( + 'MIGRATION.VERSION', + 'Expected ${migrations[lower].id} through ${migrations[upper].id}; database is ${applied.lastOrNull?.id ?? "unversioned"}.', + ); + } + return applied.last; + }, + options: switch (database.dialect) { + SqlDialect.postgres => const PostgresTransaction( + isolation: .repeatableRead, + readOnly: true, + ), + SqlDialect.mysql => const MysqlTransaction( + isolation: .repeatableRead, + readOnly: true, + ), + SqlDialect.mariadb => const MariadbTransaction( + isolation: .repeatableRead, + readOnly: true, + ), + SqlDialect.sqlite => const SqliteTransaction(), + }, + ); + } + + /// Applies pending migrations, returning the IDs completed by this call. + /// Ordinary migration batches are atomic. [CheckedSql] and [Backfill] opt + /// their containing migration into durable per-step recovery. + /// + /// [maxBackfillBatches] bounds nonempty data batches across this invocation. + /// Reaching the limit returns normally with unfinished migrations still + /// pending; completion verification can require a subsequent call. + Future> apply( + List migrations, { + int? maxBackfillBatches, + }) async { + if (maxBackfillBatches != null && maxBackfillBatches < 1) { + throw ArgumentError.value(maxBackfillBatches, 'maxBackfillBatches'); + } + migrations = List.unmodifiable(migrations); + if (database.inTransaction) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Migrations require a dedicated outer transaction.', + ); + } + validateMigrations(migrations, dialect: database.dialect); + Future> run(SqlDatabase session) async { + if (_isMysql(session.dialect)) { + return _applyRecoverableMysql( + session, + migrations, + _BackfillBudget(maxBackfillBatches), + ); + } + if (migrations.any( + (m) => m.steps.any((s) => s is CheckedSql || s is Backfill), + )) { + return session.dialect == SqlDialect.sqlite + ? _applyRecoverableSqlite( + session, + migrations, + _BackfillBudget(maxBackfillBatches), + ) + : _applyRecoverable( + session, + migrations, + _BackfillBudget(maxBackfillBatches), + ); + } + return _migrationTransaction(session, (tx) async { + await tx.execute(SqlCommand(_historyDdl)); + final pending = await Migrator(tx).plan(migrations); + for (final migration in pending) { + for (final step in migration.steps) { + await _executeStep(tx, step); + } + await _recordMigration(tx, migration); + } + return [for (final migration in pending) migration.id]; + }, rebuild: _needsRebuild(migrations, session.dialect)); + } + + return _migrationSession(database, lockTimeout, run); + } +} + +void _validateApplied( + List migrations, + List applied, +) { + if (applied.length > migrations.length) { + throw const OrmException( + 'MIGRATION.HISTORY', + 'Local history is missing applied migrations.', + ); + } + for (var i = 0; i < applied.length; i++) { + if (applied[i].id != migrations[i].id || + applied[i].checksum != migrations[i].checksum) { + throw OrmException( + 'MIGRATION.CHECKSUM', + 'Applied migration ${applied[i].id} differs from local history.', + ); + } + } +} + +/// Validates local ordering, checksum links and operations without opening a database. +void validateMigrations( + List migrations, { + required SqlDialect dialect, +}) { + String? last; + String? previousChecksum; + for (final migration in migrations) { + if (last != null && last.compareTo(migration.id) >= 0) { + throw const OrmException( + 'MIGRATION.ORDER', + 'Migration IDs must be unique and strictly ordered.', + ); + } + last = migration.id; + if (migration.previous != null && migration.previous != previousChecksum) { + throw const OrmException( + 'MIGRATION.CHAIN', + 'Migration previous-checksum chain is broken.', + ); + } + previousChecksum = migration.checksum; + if (migration.dialect != dialect) { + throw OrmException( + 'MIGRATION.TARGET', + '${migration.id} targets ${migration.dialect.name}, but this history requires ${dialect.name}.', + ); + } + if (_isMysql(dialect) && migration.id.length > 191) { + throw const OrmException( + 'MIGRATION.ID', + 'MySQL migration IDs must not exceed 191 characters.', + ); + } + for (final step in migration.steps) { + if (step is CheckedTableSql) { + if (!_isMysql(dialect)) { + throw const OrmException( + 'MIGRATION.TARGET', + 'CheckedTableSql requires MySQL or MariaDB.', + ); + } + for (final table in [ + step.before, + step.after, + ].whereType()) { + _validateSchema([table], dialect); + } + _validateSql(step.sql, transactional: false); + _validateMysqlStatement(step.sql); + if (!{ + 'CREATE', + 'ALTER', + 'DROP', + 'RENAME', + }.contains(_sqlWords(step.sql).first)) { + throw const OrmException( + 'MIGRATION.DDL', + 'CheckedTableSql must contain a reviewed table DDL statement.', + ); + } + continue; + } + if (step is Backfill) { + _validateSchema([step.table], dialect); + continue; + } + if (step is CheckedSql) { + if (dialect != SqlDialect.postgres) { + throw const OrmException( + 'MIGRATION.TARGET', + 'Recoverable autocommit migrations currently require PostgreSQL.', + ); + } + _validateSql(step.sql, transactional: false); + for (final probe in [step.readyWhen, step.doneWhen]) { + if (_sqlWords(probe).firstOrNull != 'SELECT') { + throw const OrmException( + 'MIGRATION.PROBE', + 'Recovery conditions must be SELECT queries.', + ); + } + } + continue; + } + if (step is DropTable) { + if (_isMysql(dialect)) { + throw const OrmException( + 'MIGRATION.RECOVERY', + 'MySQL table removal requires CheckedTableSql with its before schema.', + ); + } + continue; + } + if (step is RebuildTable) { + if (dialect != SqlDialect.sqlite) { + throw const OrmException( + 'MIGRATION.TARGET', + 'Table rebuilds are SQLite operations.', + ); + } + _validateSchema([step.before], dialect); + _validateSchema([step.after], dialect); + continue; + } + if (step is DropConstraint) { + if (dialect != SqlDialect.postgres) { + throw const OrmException( + 'MIGRATION.TARGET', + 'Constraint resolution is a PostgreSQL operation.', + ); + } + continue; + } + final sql = (step as ExecuteSql).sql; + _validateSql(sql, transactional: true); + if (_isMysql(dialect)) { + _validateMysqlStatement(sql); + } + if (_isMysql(dialect) && + !{ + 'INSERT', + 'UPDATE', + 'DELETE', + 'REPLACE', + }.contains(_sqlWords(sql).first)) { + throw const OrmException( + 'MIGRATION.RECOVERY', + 'MySQL ExecuteSql accepts DML only. DDL requires CheckedTableSql with reviewed before/after schemas.', + ); + } + } + } +} + +void _validateSql(String sql, {required bool transactional}) { + final words = _sqlWords(sql); + if (words.isEmpty) { + throw const OrmException( + 'MIGRATION.EMPTY', + 'Migration contains an empty statement.', + ); + } + if ({ + 'BEGIN', + 'COMMIT', + 'ROLLBACK', + 'END', + 'SAVEPOINT', + 'RELEASE', + 'PRAGMA', + 'START', + 'ABORT', + }.contains(words.first) || + (transactional && + (words.first == 'VACUUM' || words.contains('CONCURRENTLY')))) { + throw const OrmException( + 'MIGRATION.TRANSACTION', + 'Transaction control belongs to the runner; autocommit operations need CheckedSql.', + ); + } +} + +Future _recordMigration( + SqlDatabase db, + Migration migration, +) => db + .execute( + SqlCommand( + 'INSERT INTO "_orm_migrations" (id, checksum, applied_at) VALUES (${[for (var i = 1; i <= 3; i++) _mark(db.dialect, i)].join(', ')})', + [ + migration.id, + migration.checksum, + DateTime.now().toUtc().toIso8601String(), + ], + ), + ) + .then((_) {}); + +// Only classifies transaction control. The database remains the SQL parser. +List _sqlWords(String sql) { + final words = []; + var i = 0; + while (i < sql.length) { + if (sql.startsWith('--', i)) { + final end = sql.indexOf('\n', i + 2); + i = end < 0 ? sql.length : end + 1; + } else if (sql.startsWith('/*', i)) { + var depth = 1; + i += 2; + while (i < sql.length && depth > 0) { + if (sql.startsWith('/*', i)) { + depth++; + i += 2; + } else if (sql.startsWith('*/', i)) { + depth--; + i += 2; + } else { + i++; + } + } + } else if (sql[i] == "'" || sql[i] == '"') { + final quote = sql[i++]; + while (i < sql.length) { + if (sql[i++] == quote) { + if (i < sql.length && sql[i] == quote) { + i++; + } else { + break; + } + } + } + } else if (sql[i] == r'$') { + final tag = RegExp(r'^\$[a-zA-Z0-9_]*\$') + .firstMatch(sql.substring(i)) + ?.group(0); + if (tag == null) { + i++; + } else { + final end = sql.indexOf(tag, i + tag.length); + i = end < 0 ? sql.length : end + tag.length; + } + } else if (RegExp(r'[a-zA-Z_]').hasMatch(sql[i])) { + final start = i++; + while (i < sql.length && RegExp(r'[a-zA-Z0-9_]').hasMatch(sql[i])) { + i++; + } + words.add(sql.substring(start, i).toUpperCase()); + } else { + i++; + } + } + return words; +} diff --git a/lib/src/migrate/mysql_catalog.dart b/lib/src/migrate/mysql_catalog.dart new file mode 100644 index 00000000..fa45b867 --- /dev/null +++ b/lib/src/migrate/mysql_catalog.dart @@ -0,0 +1,605 @@ +part of '../../migrate.dart'; + +// information_schema reports some textual metadata with a BLOB wire type. +// Decode only these known catalog-query result cells, never application BLOBs. +List> _mysqlCatalogRows(SqlResult result) => [ + for (final row in result.rows) + [for (final value in row) value is List ? utf8.decode(value) : value], +]; + +String _mysqlExpression(String? expression) { + if (expression == null) return ''; + final normalized = _normalizeDefault(expression)!; + if (normalized.toUpperCase() == 'NULL') return ''; + final tokens = _sqliteTokens(normalized); + final normalizedTokens = [ + for (var i = 0; i < tokens.length; i++) + if (!(tokens[i].text == '_UTF8MB4' && + i + 1 < tokens.length && + tokens[i + 1].text.startsWith('s:'))) + tokens[i].text.startsWith('i:') + ? tokens[i].text.substring(2).toUpperCase() + : tokens[i].text == 'NOW' && + i + 1 < tokens.length && + tokens[i + 1].text == '(' + ? 'CURRENT_TIMESTAMP' + : tokens[i].text, + ]; + return jsonEncode(_mysqlExpressionTree(normalizedTokens) ?? normalizedTokens); +} + +// MySQL inserts parentheses around binary CHECK/generated expressions. Parse +// only this small operator grammar so redundant parentheses compare equally +// while a * (b + c) remains different from a * b + c. Unknown SQL is compared +// strictly as tokens; never discard precedence-bearing punctuation. +Object? _mysqlExpressionTree(List input) { + const precedence = { + 'OR': 10, + 'AND': 20, + '=': 30, + '<>': 30, + '!=': 30, + '<': 30, + '>': 30, + '<=': 30, + '>=': 30, + '+': 40, + '-': 40, + '*': 50, + '/': 50, + '%': 50, + }; + final tokens = []; + for (var i = 0; i < input.length; i++) { + final combined = i + 1 < input.length ? '${input[i]}${input[i + 1]}' : ''; + if (const {'<=', '>=', '<>', '!='}.contains(combined)) { + tokens.add(combined); + i++; + } else { + tokens.add(input[i]); + } + } + var offset = 0, depth = 0; + late Object Function(int) expression; + Object atom() { + if (offset == tokens.length || ++depth > 128) throw const FormatException(); + try { + final token = tokens[offset++]; + if (token == '(') { + final result = expression(0); + if (offset == tokens.length || tokens[offset++] != ')') { + throw const FormatException(); + } + return result; + } + if (token == '+' || token == '-' || token == 'NOT') { + return ['unary', token, expression(token == 'NOT' ? 25 : 60)]; + } + if (precedence.containsKey(token) || + const {')', ',', '.'}.contains(token)) { + throw const FormatException(); + } + if (offset < tokens.length && tokens[offset] == '(') { + offset++; + final arguments = []; + if (offset == tokens.length) throw const FormatException(); + while (tokens[offset] != ')') { + arguments.add(expression(0)); + if (offset == tokens.length) throw const FormatException(); + if (tokens[offset] != ',') break; + offset++; + if (offset == tokens.length || tokens[offset] == ')') { + throw const FormatException(); + } + } + if (offset == tokens.length || tokens[offset++] != ')') { + throw const FormatException(); + } + return ['call', token, arguments]; + } + return ['atom', token]; + } finally { + depth--; + } + } + + expression = (minimum) { + var left = atom(); + while (offset < tokens.length) { + final priority = precedence[tokens[offset]]; + if (priority == null || priority < minimum) break; + final operator = tokens[offset++]; + left = ['binary', operator, left, expression(priority + 1)]; + } + return left; + }; + try { + final result = expression(0); + return offset == tokens.length ? result : null; + } on FormatException catch (_) { + return null; + } +} + +String _mysqlDefaultExpression(String? expression, Column column) { + if (expression == null) return ''; + final sql = _normalizeDefault(expression)!; + if (sql.toUpperCase() == 'NULL') return ''; + final clock = RegExp( + r'^(?:CURRENT_TIMESTAMP|NOW)(?:\(([0-6]?)\))?$', + caseSensitive: false, + ).firstMatch(sql); + if (clock != null) { + return 'current_timestamp:${clock[1]?.isNotEmpty == true ? clock[1] : '0'}'; + } + final tokens = _sqliteTokens(sql); + if (tokens.length == 1 && + {'TRUE', 'FALSE'}.contains(tokens.single.text) && + { + 'integer', + 'bigint', + 'decimal', + 'real', + 'boolean', + }.contains(column.codec.sqlType)) { + return tokens.single.text == 'TRUE' ? 'number:1' : 'number:0'; + } + if ({ + 'integer', + 'bigint', + 'decimal', + 'real', + 'boolean', + }.contains(column.codec.sqlType)) { + final text = tokens.length == 1 && tokens.single.text.startsWith('s:') + ? tokens.single.text.substring(2) + : sql; + if (RegExp(r'^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$') + .hasMatch(text)) { + return 'number:${Decimal.parse(text)}'; + } + } + if (tokens.length == 1 && tokens.single.text.startsWith('s:')) { + final text = tokens.single.text.substring(2); + try { + final temporal = switch (column.codec.sqlType) { + 'date' => Codecs.date.decode(text).toString(), + 'time' => Codecs.time.decode(text).toString(), + 'local_datetime' => Codecs.localDateTime.decode(text).toString(), + 'instant' => Codecs.dateTime.encode(Codecs.dateTime.decode(text)), + _ => null, + }; + if (temporal != null) return 'temporal:$temporal'; + } on FormatException catch (_) { + // An unrecognized literal stays structurally distinct rather than being + // guessed equivalent to a server value. + } + } + return _mysqlExpression(sql); +} + +String _mysqlCatalogDefault(String expression, SqlDialect dialect) { + if (dialect == SqlDialect.mysql) { + // MySQL COLUMN_DEFAULT adds a transport escape layer to generated + // expressions, even though the driver has already decoded its wire value. + return expression.replaceAllMapped(RegExp(r"\\(['\\])"), (m) => m[1]!); + } + // MariaDB serializes string literals using backslash escapes independently + // of NO_BACKSLASH_ESCAPES. Return valid SQL for our fixed session mode. + final result = StringBuffer(); + var offset = 0; + for (final token in _sqliteTokens(expression)) { + if (!token.text.startsWith('s:')) continue; + final value = token.text + .substring(2) + .replaceAllMapped( + RegExp(r'\\([0bnrtZ\\])'), + (m) => switch (m[1]) { + '0' => '\u0000', + 'b' => '\b', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'Z' => '\u001a', + _ => '\\', + }, + ); + result.write(expression.substring(offset, token.start)); + result.write("'${value.replaceAll("'", "''")}'"); + offset = token.end; + } + result.write(expression.substring(offset)); + return result.toString(); +} + +Future> _mysqlChecks( + SqlDatabase db, + String table, +) async { + final rows = await db.execute( + SqlCommand( + db.dialect == SqlDialect.mysql + ? '''SELECT cc.CONSTRAINT_NAME, cc.CHECK_CLAUSE, tc.ENFORCED +FROM information_schema.CHECK_CONSTRAINTS cc +JOIN information_schema.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_SCHEMA=cc.CONSTRAINT_SCHEMA AND tc.CONSTRAINT_NAME=cc.CONSTRAINT_NAME +WHERE tc.TABLE_SCHEMA=DATABASE() AND tc.TABLE_NAME=? AND tc.CONSTRAINT_TYPE='CHECK' ''' + : '''SELECT CONSTRAINT_NAME, CHECK_CLAUSE, 'YES' +FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=?''', + [table], + ), + ); + return [ + for (final row in _mysqlCatalogRows(rows)) + CheckInfo( + row[0] as String, + '${row[1]}${row[2] == 'YES' ? '' : ' /* NOT ENFORCED */'}', + ), + ]; +} + +Future> _mysqlColumns( + SqlDatabase db, + String table, +) async { + final rows = await db.execute( + SqlCommand( + '''SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, +EXTRA, GENERATION_EXPRESSION, NUMERIC_PRECISION, NUMERIC_SCALE, DATETIME_PRECISION, COLLATION_NAME, CHARACTER_MAXIMUM_LENGTH +FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY ORDINAL_POSITION''', + [table], + ), + ); + final checks = db.dialect == SqlDialect.mariadb + ? await _mysqlChecks(db, table) + : const []; + return [ + for (final row in _mysqlCatalogRows(rows)) + _mysqlColumnInfo(row, db.dialect, checks), + ]; +} + +ColumnInfo _mysqlColumnInfo( + List row, + SqlDialect dialect, + List checks, +) { + final name = row[0] as String, kind = (row[1] as String).toLowerCase(); + final declared = (row[2] as String).toLowerCase(), + extra = (row[5] as String).toUpperCase(); + final precision = row[7] as int?, + scale = row[8] as int?, + digits = row[9] as int?; + final jsonAlias = + kind == 'longtext' && + dialect == SqlDialect.mariadb && + row[10] == 'utf8mb4_bin' && + checks.any( + (c) => + c.name == name && + _mysqlExpression(c.expression) == + _mysqlExpression('json_valid(${_quote(name)})'), + ); + final storage = declared.contains('unsigned') || declared.contains('zerofill') + ? declared.toUpperCase() + : switch (kind) { + 'smallint' => 'SMALLINT', + 'int' || 'integer' => 'INT', + 'bigint' => 'BIGINT', + 'tinyint' when declared == 'tinyint(1)' => 'TINYINT(1)', + 'varchar' => 'VARCHAR(${row[11]})', + 'decimal' => 'DECIMAL($precision,$scale)', + 'time' => 'TIME(${digits ?? 0})', + 'datetime' => 'DATETIME(${digits ?? 0})', + 'longtext' when jsonAlias => 'JSON', + _ => declared.toUpperCase(), + }; + String? defaultSql = row[4] as String?; + if (defaultSql != null) { + if (dialect == SqlDialect.mariadb || extra.contains('DEFAULT_GENERATED')) { + defaultSql = _mysqlCatalogDefault(defaultSql, dialect); + } else if ({ + 'varchar', + 'char', + 'text', + 'longtext', + 'date', + 'datetime', + 'timestamp', + 'time', + }.contains(kind)) { + defaultSql = "'${defaultSql.replaceAll("'", "''")}'"; + } + } + if (defaultSql?.toUpperCase() == 'NULL') defaultSql = null; + final expression = row[6] as String?; + return ColumnInfo( + name: name, + storageType: storage, + nullable: row[3] == 'YES', + defaultSql: defaultSql, + generated: extra.contains('AUTO_INCREMENT'), + computed: expression == null || expression.isEmpty + ? null + : ComputedColumn( + expression, + storage: extra.contains('STORED') || extra.contains('PERSISTENT') + ? .stored + : .virtual, + ), + integerBits: switch (kind) { + 'smallint' => 16, + 'int' || 'integer' => 32, + 'bigint' => 64, + _ => null, + }, + decimalPrecision: kind == 'decimal' ? precision : null, + decimalScale: kind == 'decimal' ? scale : null, + temporalPrecision: {'time', 'datetime', 'timestamp'}.contains(kind) + ? (digits ?? 0) + : null, + collation: row[10] as String?, + ); +} + +Future _mysqlTable(SqlDatabase db, String table) async { + final columns = await _mysqlColumns(db, table); + final primary = [], + unique = >[], + indexes = []; + final foreign = [], unmanaged = []; + final properties = await db.execute( + SqlCommand( + '''SELECT TABLE_TYPE, ENGINE, TABLE_COLLATION, CREATE_OPTIONS +FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?''', + [table], + ), + ); + if (properties.rows.isNotEmpty) { + final row = _mysqlCatalogRows(properties).single; + if (row[0] != 'BASE TABLE' || + row[1] != 'InnoDB' || + row[2] != 'utf8mb4_bin' || + (row[3] as String).isNotEmpty) { + unmanaged.add(CatalogObject('table options', table, row.toString())); + } + final shown = await db.execute( + SqlCommand('SHOW CREATE TABLE ${_quote(table)}'), + ); + if (shown.rows.isNotEmpty && + RegExp( + r'^CREATE\s+TEMPORARY\s+TABLE\b', + caseSensitive: false, + ).hasMatch(_mysqlCatalogRows(shown).single[1] as String)) { + unmanaged.add( + CatalogObject( + 'temporary table', + table, + _mysqlCatalogRows(shown).single[1] as String, + ), + ); + } + } + final details = await db.execute( + SqlCommand( + '''SELECT COLUMN_NAME, EXTRA, COLUMN_COMMENT, COLUMN_TYPE, COLLATION_NAME +FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?''', + [table], + ), + ); + for (final row in _mysqlCatalogRows(details)) { + final extra = (row[1] as String) + .toUpperCase() + .replaceAll('AUTO_INCREMENT', '') + .replaceAll('DEFAULT_GENERATED', '') + .replaceAll('STORED GENERATED', '') + .replaceAll('VIRTUAL GENERATED', '') + .replaceAll('PERSISTENT', '') + .trim(); + final found = columns.singleWhere((c) => c.name == row[0]); + if (extra.isNotEmpty || + row[2] != '' || + (row[3] as String).contains('unsigned') || + found.storageType.startsWith('VARCHAR') && + found.collation != 'utf8mb4_bin' || + !RegExp( + r'^(SMALLINT|INT|BIGINT|TINYINT\(1\)|VARCHAR\([0-9]+\)|DECIMAL\([0-9]+,[0-9]+\)|DOUBLE|DATE|TIME\([0-6]\)|DATETIME\([0-6]\)|JSON|LONGBLOB)$', + ).hasMatch(found.storageType)) { + unmanaged.add( + CatalogObject('column', '$table.${row[0]}', row.toString()), + ); + } + } + final statistics = await db.execute( + SqlCommand( + '''SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME, +SUB_PART, COLLATION, INDEX_TYPE, ${db.dialect == SqlDialect.mysql ? "EXPRESSION, IS_VISIBLE" : "NULL, IF(IGNORED='NO','YES','NO')"} +FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? ORDER BY INDEX_NAME, SEQ_IN_INDEX''', + [table], + ), + ); + final grouped = >>{}; + for (final row in _mysqlCatalogRows(statistics)) { + (grouped[row[0] as String] ??= []).add(row); + } + for (final entry in grouped.entries) { + final rows = entry.value; + if (rows.any( + (r) => + r[3] == null || + r[4] != null || + r[5] != 'A' || + r[6] != 'BTREE' || + r[7] != null || + r[8] != 'YES', + )) { + unmanaged.add(CatalogObject('index', entry.key, rows.toString())); + continue; + } + final keys = rows.map((r) => r[3] as String).toList(); + if (entry.key == 'PRIMARY') { + primary.addAll(keys); + } else if (rows.first[1] == 0 && + entry.key == _mysqlUniqueName(table, keys)) { + unique.add(keys); + } else { + indexes.add(IndexSchema(entry.key, keys, unique: rows.first[1] == 0)); + } + } + final references = await db.execute( + SqlCommand( + '''SELECT k.CONSTRAINT_NAME, k.COLUMN_NAME, k.REFERENCED_TABLE_NAME, +k.REFERENCED_COLUMN_NAME, r.DELETE_RULE, r.UPDATE_RULE, k.REFERENCED_TABLE_SCHEMA=DATABASE() +FROM information_schema.KEY_COLUMN_USAGE k +JOIN information_schema.REFERENTIAL_CONSTRAINTS r ON r.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME=k.CONSTRAINT_NAME AND r.TABLE_NAME=k.TABLE_NAME +WHERE k.TABLE_SCHEMA=DATABASE() AND k.TABLE_NAME=? AND k.REFERENCED_TABLE_NAME IS NOT NULL +ORDER BY k.CONSTRAINT_NAME, k.ORDINAL_POSITION''', + [table], + ), + ); + final foreignRows = >>{}; + for (final row in _mysqlCatalogRows(references)) { + (foreignRows[row[0] as String] ??= []).add(row); + } + for (final entry in foreignRows.entries) { + final rows = entry.value, row = entry.value.first; + if (!{'RESTRICT', 'NO ACTION'}.contains(row[5]) || row[6] != 1) { + unmanaged.add(CatalogObject('foreign key', entry.key, rows.toString())); + continue; + } + final key = ForeignKey( + rows.map((r) => r[1] as String).toList(), + row[2] as String, + rows.map((r) => r[3] as String).toList(), + onDelete: row[4] as String, + ); + foreign.add(key); + if (entry.key != _mysqlForeignName(table, key)) { + unmanaged.add( + CatalogObject('constraint name', entry.key, rows.toString()), + ); + } + } + final checks = (await _mysqlChecks(db, table)) + .where( + (check) => !columns.any( + (column) => + db.dialect == SqlDialect.mariadb && + column.storageType == 'JSON' && + check.name == column.name && + _mysqlExpression(check.expression) == + _mysqlExpression('json_valid(${_quote(column.name)})'), + ), + ) + .toList(); + for (final check in checks) { + if (check.expression.endsWith('/* NOT ENFORCED */')) { + unmanaged.add(CatalogObject('check', check.name!, check.expression)); + } + } + final triggers = await db.execute( + SqlCommand( + '''SELECT TRIGGER_NAME, ACTION_STATEMENT +FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE() AND EVENT_OBJECT_TABLE=?''', + [table], + ), + ); + unmanaged.addAll( + _mysqlCatalogRows(triggers) + .map((r) => CatalogObject('trigger', r[0] as String, r[1] as String)), + ); + return TableInfo( + name: table, + columns: columns, + primaryKey: primary, + uniqueKeys: unique, + foreignKeys: foreign, + indexes: indexes, + unmanaged: unmanaged, + checks: checks, + ); +} + +Future _mysqlVerifySchema( + SqlDatabase db, + SchemaSnapshot expected, +) async { + final differences = [], unmanaged = []; + for (final source in expected.tables) { + final table = _mysqlPhysicalTable(source), + actual = await _mysqlTable(db, source.name); + unmanaged.addAll(actual.unmanaged); + final columns = {for (final column in actual.columns) column.name: column}; + for (final column in table.columns) { + final found = columns.remove(column.name), + name = '${table.name}.${column.name}'; + if (found == null) { + differences.add('$name is missing'); + continue; + } + if (found.storageType != _mysqlColumnType(column)) { + differences.add('$name type differs'); + } + if (found.nullable != column.nullable) { + differences.add('$name nullability differs'); + } + if (found.generated != column.generated) { + differences.add('$name generation differs'); + } + if (_mysqlDefaultExpression(found.defaultSql, column) != + _mysqlDefaultExpression(column.defaultSql, column)) { + differences.add('$name default differs'); + } + if (_mysqlExpression(found.computed?.expression(db.dialect)) != + _mysqlExpression(column.computed?.expression(db.dialect)) || + found.computed?.storage != column.computed?.storage) { + differences.add('$name computed expression differs'); + } + } + for (final name in columns.keys) { + differences.add('${table.name}.$name is unmanaged'); + } + List set(Iterable items) => + items.map((item) => jsonEncode(_canonical(item))).toList()..sort(); + void compare(String name, Object expected, Object found) { + if (_hash(expected) != _hash(found)) { + differences.add('${table.name} $name differs'); + } + } + + Map fk(ForeignKey key) => { + ..._foreignKeyJson(key), + 'onDelete': key.onDelete == 'NO ACTION' ? 'RESTRICT' : key.onDelete, + }; + compare('primary key', table.primaryKey, actual.primaryKey); + compare('unique keys', set(table.uniqueKeys), set(actual.uniqueKeys)); + compare( + 'indexes', + set(table.indexes.map(_indexJson)), + set(actual.indexes.map(_indexJson)), + ); + compare( + 'foreign keys', + set(table.foreignKeys.map(fk)), + set(actual.foreignKeys.map(fk)), + ); + final unmatched = actual.checks.toList(); + for (final check in table.checks) { + final match = unmatched.indexWhere( + (c) => + (check.name == null || check.name == c.name) && + _mysqlExpression(check.expression(db.dialect)) == + _mysqlExpression(c.expression), + ); + if (match < 0) { + differences.add('${table.name} checks differs'); + } else { + unmatched.removeAt(match); + } + } + if (unmatched.isNotEmpty) differences.add('${table.name} checks differs'); + } + return SchemaVerification( + List.unmodifiable(differences), + List.unmodifiable(unmanaged), + ); +} diff --git a/lib/src/migrate/mysql_diff.dart b/lib/src/migrate/mysql_diff.dart new file mode 100644 index 00000000..568e3c54 --- /dev/null +++ b/lib/src/migrate/mysql_diff.dart @@ -0,0 +1,332 @@ +part of '../../migrate.dart'; + +Migration _mysqlDiff( + String id, { + required SqlDialect dialect, + required SchemaSnapshot from, + required SchemaSnapshot to, + required SchemaRenames renames, + required String? previous, + required bool allowDestructive, + required Map> using, +}) { + if (using.isNotEmpty) { + throw const OrmException( + 'MIGRATION.CAST', + 'MySQL/MariaDB ALTER does not support USING. Backfill an explicit replacement column before changing its storage.', + ); + } + final state = { + for (final table in from.tables) table.name: _mysqlPhysicalTable(table), + }; + final target = { + for (final table in to.tables) table.name: _mysqlPhysicalTable(table), + }; + final steps = []; + void validateNames( + Map names, + Set old, + Set next, + ) { + if (names.values.toSet().length != names.length || + names.entries.any( + (e) => + !old.contains(e.key) || + old.contains(e.value) || + !next.contains(e.value), + )) { + throw const OrmException( + 'MIGRATION.RENAME', + 'Rename sources must exist and targets must be new, distinct target names.', + ); + } + } + + validateNames(renames.tables, state.keys.toSet(), target.keys.toSet()); + String tableName(String name) => renames.tables[name] ?? name; + String columnName(String table, String column) => + renames.columns[tableName(table)]?[column] ?? column; + for (final entry in renames.columns.entries) { + final before = state.values + .where((t) => tableName(t.name) == entry.key) + .firstOrNull; + final after = target[entry.key]; + if (before == null || after == null) { + throw const OrmException( + 'MIGRATION.RENAME', + 'Column rename requires a surviving table.', + ); + } + validateNames( + entry.value, + before.columns.map((c) => c.name).toSet(), + after.columns.map((c) => c.name).toSet(), + ); + } + void append(String sql, TableSchema? before, TableSchema? after) { + steps.add(CheckedTableSql(sql, before: before, after: after)); + if (before != null) state.remove(before.name); + if (after != null) state[after.name] = after; + } + + bool same(Object? a, Object? b) => _hash(a) == _hash(b); + bool columnChanges(String table, List columns) { + final old = state[table], next = target[tableName(table)]; + if (old == null || next == null || tableName(table) != table) return true; + return columns.any((name) { + final prior = old.columns.where((c) => c.name == name).firstOrNull; + final after = next.columns + .where((c) => c.name == columnName(table, name)) + .firstOrNull; + return prior == null || + after == null || + name != after.name || + !_sameStorage(prior, after); + }); + } + + // Drop affected FK constraints first, including incoming keys on other tables. + // Their explicit backing indexes remain until the ordinary table diff below. + for (final before in state.values.toList()) { + final next = target[tableName(before.name)]; + final drop = before.foreignKeys + .where( + (key) => + next == null || + columnChanges(before.name, key.columns) || + columnChanges(key.target, key.targetColumns) || + !next.foreignKeys.any( + (candidate) => + same(_foreignKeyJson(key), _foreignKeyJson(candidate)), + ) || + !same( + state[key.target]?.primaryKey, + target[tableName(key.target)]?.primaryKey, + ) || + !same( + state[key.target]?.uniqueKeys, + target[tableName(key.target)]?.uniqueKeys, + ), + ) + .toList(); + if (drop.isNotEmpty) { + append( + 'ALTER TABLE ${_quote(before.name)} ${drop.map((key) => 'DROP FOREIGN KEY ${_quote(_mysqlForeignName(before.name, key))}').join(', ')}', + before, + _mysqlCopy( + before, + foreignKeys: before.foreignKeys + .where((key) => !drop.contains(key)) + .toList(), + ), + ); + } + } + for (final before in state.values.toList()) { + if (target.containsKey(tableName(before.name))) continue; + if (!allowDestructive) { + throw OrmException( + 'MIGRATION.DESTRUCTIVE', + 'Dropping ${before.name} requires allowDestructive.', + ); + } + append('DROP TABLE ${_quote(before.name)}', before, null); + } + for (final entry in renames.tables.entries) { + final before = state[entry.key]!; + final changes = [ + for (final key in before.uniqueKeys) + 'RENAME INDEX ${_quote(_mysqlUniqueName(entry.key, key))} TO ${_quote(_mysqlUniqueName(entry.value, key))}', + 'RENAME TO ${_quote(entry.value)}', + ]; + append( + 'ALTER TABLE ${_quote(before.name)} ${changes.join(', ')}', + before, + _mysqlCopy(before, name: entry.value), + ); + } + for (final after in target.values) { + final before = state[after.name]; + if (before == null) { + final created = _mysqlCopy(after, foreignKeys: []); + append(_mysqlCreateTable(created, dialect), null, created); + continue; + } + final names = renames.columns[after.name] ?? const {}; + String renamed(String name) => names[name] ?? name; + final operations = []; + final desired = _mysqlCopy(after, foreignKeys: before.foreignKeys); + final removed = before.columns + .where( + (column) => !after.columns.any((c) => c.name == renamed(column.name)), + ) + .toList(); + if (removed.isNotEmpty && !allowDestructive) { + throw OrmException( + 'MIGRATION.DESTRUCTIVE', + 'Removing columns from ${after.name} requires allowDestructive.', + ); + } + for (final column in before.columns) { + final next = after.columns + .where((c) => c.name == renamed(column.name)) + .firstOrNull; + if (next == null) { + operations.add('DROP COLUMN ${_quote(column.name)}'); + continue; + } + if (column.generated != next.generated) { + throw const OrmException( + 'MIGRATION.IDENTITY', + 'Identity changes require a reviewed manual migration.', + ); + } + if (!_sameStorage(column, next) && !_mysqlSafeWiden(column, next)) { + throw OrmException( + 'MIGRATION.CAST', + '${after.name}.${next.name} requires a reviewed replacement-column/backfill migration.', + ); + } + if ((column.computed == null) != (next.computed == null) || + column.computed?.storage != next.computed?.storage) { + throw const OrmException( + 'MIGRATION.COMPUTED', + 'Changing stored/generated mode requires an explicit reviewed replacement migration.', + ); + } + if (column.name != next.name) { + operations.add( + 'CHANGE COLUMN ${_quote(column.name)} ${_mysqlColumn(next, dialect)}', + ); + } else if (!same(_columnJson(column), _columnJson(next))) { + operations.add('MODIFY COLUMN ${_mysqlColumn(next, dialect)}'); + } + } + for (final column in after.columns) { + if (before.columns.any((c) => renamed(c.name) == column.name)) continue; + if (column.generated || + column.computed == null && + !column.nullable && + column.defaultSql == null) { + throw OrmException( + 'MIGRATION.BACKFILL', + '${after.name}.${column.name} needs a nullable/defaulted stage and explicit backfill.', + ); + } + operations.add('ADD COLUMN ${_mysqlColumn(column, dialect)}'); + } + if (!same(before.primaryKey.map(renamed).toList(), after.primaryKey)) { + if (before.primaryKey.isNotEmpty) operations.add('DROP PRIMARY KEY'); + if (after.primaryKey.isNotEmpty) { + operations.add( + 'ADD PRIMARY KEY (${after.primaryKey.map(_quote).join(', ')})', + ); + } + } + final pendingUnique = after.uniqueKeys.toList(); + for (final key in before.uniqueKeys) { + final mapped = key.map(renamed).toList(); + final i = pendingUnique.indexWhere((k) => same(k, mapped)); + final oldName = _mysqlUniqueName(after.name, key); + if (i < 0) { + operations.add('DROP INDEX ${_quote(oldName)}'); + } else { + final nextName = _mysqlUniqueName(after.name, mapped); + if (oldName != nextName) { + operations.add( + 'RENAME INDEX ${_quote(oldName)} TO ${_quote(nextName)}', + ); + } + pendingUnique.removeAt(i); + } + } + for (final key in pendingUnique) { + operations.add( + 'ADD CONSTRAINT ${_quote(_mysqlUniqueName(after.name, key))} UNIQUE (${key.map(_quote).join(', ')})', + ); + } + final pendingIndexes = after.indexes.toList(); + for (final index in before.indexes) { + final mapped = IndexSchema( + index.name, + index.columns.map(renamed).toList(), + unique: index.unique, + ); + final i = pendingIndexes.indexWhere( + (candidate) => same(_indexJson(mapped), _indexJson(candidate)), + ); + if (i < 0) { + operations.add('DROP INDEX ${_quote(index.name)}'); + } else { + pendingIndexes.removeAt(i); + } + } + for (final index in pendingIndexes) { + operations.add( + 'ADD ${index.unique ? 'UNIQUE ' : ''}INDEX ${_quote(index.name)} (${index.columns.map(_quote).join(', ')})', + ); + } + final checks = _checkDelta(before.checks, after.checks, dialect); + for (final check in checks.removed) { + if (check.name == null) { + throw const OrmException( + 'MIGRATION.CHECK', + 'Removing an unnamed MySQL/MariaDB check requires a reviewed step with its catalog name.', + ); + } + operations.add( + 'DROP ${dialect == SqlDialect.mysql ? 'CHECK' : 'CONSTRAINT'} ${_quote(check.name!)}', + ); + } + for (final check in checks.added) { + operations.add('ADD ${_checkDefinition(check, dialect)}'); + } + if (operations.isNotEmpty) { + append( + 'ALTER TABLE ${_quote(after.name)} ${operations.join(', ')}', + before, + desired, + ); + } + } + for (final after in target.values) { + final before = state[after.name]!; + final add = after.foreignKeys + .where( + (key) => !before.foreignKeys.any( + (old) => same(_foreignKeyJson(old), _foreignKeyJson(key)), + ), + ) + .toList(); + if (add.isNotEmpty) { + append( + 'ALTER TABLE ${_quote(after.name)} ${add.map((key) => _mysqlAddForeign(after.name, key)).join(', ')}', + before, + after, + ); + } + } + return Migration.steps( + id, + steps, + dialect: dialect, + snapshot: to, + previous: previous, + ); +} + +bool _mysqlSafeWiden(Column before, Column after) { + if (before.codec.sqlType != after.codec.sqlType) return false; + return switch (before.codec.sqlType) { + 'integer' => (after.integerBits ?? 64) >= (before.integerBits ?? 64), + 'decimal' => + after.decimalPrecision != null && + before.decimalPrecision != null && + (after.decimalScale ?? 0) >= (before.decimalScale ?? 0) && + after.decimalPrecision! - (after.decimalScale ?? 0) >= + before.decimalPrecision! - (before.decimalScale ?? 0), + 'time' || 'instant' || 'local_datetime' => + (after.temporalPrecision ?? 6) >= (before.temporalPrecision ?? 6), + _ => false, + }; +} diff --git a/lib/src/migrate/mysql_recovery.dart b/lib/src/migrate/mysql_recovery.dart new file mode 100644 index 00000000..271ae289 --- /dev/null +++ b/lib/src/migrate/mysql_recovery.dart @@ -0,0 +1,313 @@ +part of '../../migrate.dart'; + +/// One reviewed MySQL/MariaDB DDL statement and its complete physical table +/// pre/postconditions. DDL is autocommit; each successful step is checkpointed. +/// A null before means create, a null after means drop, differing names rename. +final class CheckedTableSql extends MigrationStep { + final String sql; + final TableSchema? before, after; + CheckedTableSql(this.sql, {this.before, this.after}) { + if (before == null && after == null) { + throw ArgumentError( + 'A checked table step needs a before or after schema.', + ); + } + } + @override + Map toJson() => { + 'kind': 'checkedTableSql', + 'sql': sql, + if (before != null) 'before': _tableJson(before!), + if (after != null) 'after': _tableJson(after!), + }; +} + +Future _mysqlExists(SqlDatabase db, String table) async => + (await db.execute( + SqlCommand( + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [table], + ), + )).rows.isNotEmpty; + +Future _mysqlStateMatches( + SqlDatabase db, + TableSchema? present, + TableSchema? other, +) async { + if (present == null) return !await _mysqlExists(db, other!.name); + if (!await _mysqlExists(db, present.name)) return false; + if (other != null && + other.name != present.name && + await _mysqlExists(db, other.name)) { + return false; + } + final verified = await _mysqlVerifySchema(db, SchemaSnapshot([present])); + return verified.matches && verified.unmanaged.isEmpty; +} + +Future _mysqlMigrationLock( + SqlDatabase session, + Duration timeout, + Future Function() action, +) async { + final name = + (await session.execute(SqlCommand('SELECT DATABASE()'))) + .rows + .single + .single + as String?; + if (name == null) { + throw const OrmException( + 'MIGRATION.SCHEMA', + 'Select a MySQL/MariaDB database before migrating.', + ); + } + final key = 'orm_migrate_${_hash(name).substring(0, 48)}'; + Object? primaryFailure; + try { + final locked = (await session.execute( + SqlCommand('SELECT GET_LOCK(?, ?)', [ + key, + timeout.inMicroseconds / 1000000, + ]), + )).rows.single.single; + if (locked != 1) { + throw const OrmException( + 'MIGRATION.LOCK', + 'Could not acquire the database migration lock.', + ); + } + } catch (_) { + // A timed out transport can have acquired the server-side lock; discard it. + await session.discard(); + rethrow; + } + try { + return await action(); + } catch (error) { + primaryFailure = error; + rethrow; + } finally { + try { + if ((await session.execute(SqlCommand('SELECT RELEASE_LOCK(?)', [key]))) + .rows + .single + .single != + 1) { + throw const OrmException( + 'MIGRATION.LOCK', + 'Migration lock release could not be confirmed.', + ); + } + } catch (_) { + try { + await session.discard(); + } catch (_) {} + if (primaryFailure == null) rethrow; + } + } +} + +Future> _applyRecoverableMysql( + SqlDatabase session, + List migrations, + _BackfillBudget budget, +) async { + final pending = await Migrator(session).plan(migrations); + if (pending.isEmpty) return []; + await _mysqlRecoveryTables(session); + final progress = { + for (final value in await _progress(session)) (value.id, value.step): value, + }; + final completed = []; + for (final migration in pending) { + for (var i = 0; i < migration.steps.length; i++) { + if (progress[(migration.id, i)]?.state == .complete) continue; + final step = migration.steps[i]; + var phase = 'inspect'; + try { + await _checkpoint(session, migration, i, .running, phase); + if (step is CheckedTableSql) { + final done = await _mysqlStateMatches( + session, + step.after, + step.before, + ); + if (!done) { + if (!await _mysqlStateMatches(session, step.before, step.after)) { + throw const OrmException( + 'MIGRATION.RECOVERY', + 'The table matches neither reviewed precondition nor postcondition. Repair drift before retrying this unchanged step.', + ); + } + phase = 'execute'; + await _checkpoint(session, migration, i, .running, phase); + await session.execute(SqlCommand(step.sql)); + phase = 'verify'; + if (!await _mysqlStateMatches(session, step.after, step.before)) { + throw const OrmException( + 'MIGRATION.POSTCONDITION', + 'DDL did not establish its reviewed physical table definition.', + ); + } + } + phase = 'record'; + await _checkpoint(session, migration, i, .complete, 'complete'); + } else if (step is Backfill) { + await _verifyBackfill(session, step); + while (!await session.transaction( + (tx) async => _backfillChunk( + tx, + migration, + i, + step, + await _savedBackfill(tx, migration.id, i), + budget, + (value) => phase = value, + ), + )) {} + } else { + // validateMigrations accepts only DML ExecuteSql here. The write and + // its checkpoint share the same InnoDB transaction. + phase = 'execute'; + await session.transaction((tx) async { + await _executeStep(tx, step); + await _checkpoint(tx, migration, i, .complete, 'complete'); + }); + } + } catch (error, stack) { + if (error is _BackfillPaused) return completed; + try { + await _checkpoint( + session, + migration, + i, + .failed, + phase, + failure: error is OrmException + ? error.code + : error.runtimeType.toString(), + ); + } catch (_) {} // Keep a prior running/completed checkpoint intact. + Error.throwWithStackTrace( + OrmException( + 'MIGRATION.STEP', + '${migration.id} step $i failed during $phase. Inspect progress and retry the unchanged history.', + cause: error, + ), + stack, + ); + } + } + if (migration.snapshot != null) { + final verified = await _mysqlVerifySchema(session, migration.snapshot!); + if (!verified.matches || verified.unmanaged.isNotEmpty) { + throw OrmException( + 'MIGRATION.DRIFT', + 'Final schema does not match ${migration.id}: ${verified.differences.join(', ')}; unmanaged=${verified.unmanaged.map((o) => o.name).join(', ')}.', + ); + } + } + await session.transaction((tx) => _recordMigration(tx, migration)); + completed.add(migration.id); + } + return completed; +} + +Future _mysqlRecoveryTables(SqlDatabase db) async { + await db.execute( + SqlCommand('''CREATE TABLE IF NOT EXISTS "_orm_migrations" ( + id VARCHAR(191) NOT NULL PRIMARY KEY, checksum CHAR(64) NOT NULL, applied_at VARCHAR(40) NOT NULL +) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin'''), + ); + await db.execute( + SqlCommand('''CREATE TABLE IF NOT EXISTS "_orm_migration_steps" ( + migration_id VARCHAR(191) NOT NULL, checksum CHAR(64) NOT NULL, step INT NOT NULL, + state VARCHAR(16) NOT NULL, phase VARCHAR(32) NOT NULL, failure TEXT, backfill LONGTEXT, + PRIMARY KEY(migration_id, step) +) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin'''), + ); + // MySQL omits temporary-only tables from information_schema during the first + // history check. Validate the resolved names again after creating the durable + // tables, before any application DDL or history/checkpoint writes. + for (final table in ['_orm_migrations', '_orm_migration_steps']) { + if (!await _hasMigrationTable(db, table)) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Durable migration metadata tables could not be established.', + ); + } + } +} + +Future _mysqlBaseline( + Migrator migrator, + List migrations, + SchemaSnapshot expected, +) async { + validateMigrations(migrations, dialect: migrator.database.dialect); + if (migrator.database.inTransaction) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Baseline needs an outer session.', + ); + } + if (migrations.isEmpty || + migrations.last.snapshot?.checksum != + expected.forDialect(migrator.database.dialect).checksum) { + throw const OrmException( + 'MIGRATION.BASELINE', + 'The final migration must carry the expected baseline snapshot.', + ); + } + return _migrationSession(migrator.database, migrator.lockTimeout, ( + session, + ) async { + if ((await Migrator(session).history()).isNotEmpty || + (await _progress(session)).isNotEmpty) { + throw const OrmException( + 'MIGRATION.BASELINE', + 'Migration history already exists.', + ); + } + final verified = await verifySchema(session, expected); + if (!verified.matches) { + throw OrmException('MIGRATION.DRIFT', verified.differences.join('\n')); + } + await _mysqlRecoveryTables(session); + await session.transaction((tx) async { + for (final migration in migrations) { + await _recordMigration(tx, migration); + } + }); + return verified; + }); +} + +// Migration steps are single reviewed statements. Executable comments may hide +// session/DDL operations from the transaction classifier and are not accepted. +void _validateMysqlStatement(String sql) { + final tokens = _sqliteTokens(sql); + var offset = 0; + for (var i = 0; i <= tokens.length; i++) { + final end = i == tokens.length ? sql.length : tokens[i].start; + final gap = sql.substring(offset, end); + if (RegExp(r'/\*(?:!|M!)', caseSensitive: false).hasMatch(gap) || + RegExp(r'--(?=\S)').hasMatch(gap)) { + throw const OrmException( + 'MIGRATION.SQL', + 'Executable comments and ambiguous MySQL comment delimiters are not supported in migration steps.', + ); + } + if (i == tokens.length) break; + if (tokens[i].text == '#' || + tokens[i].text == ';' && i != tokens.length - 1) { + throw const OrmException( + 'MIGRATION.SQL', + 'Each migration step must contain one SQL statement; use standard SQL comments.', + ); + } + offset = tokens[i].end; + } +} diff --git a/lib/src/migrate/mysql_schema.dart b/lib/src/migrate/mysql_schema.dart new file mode 100644 index 00000000..1bda04d9 --- /dev/null +++ b/lib/src/migrate/mysql_schema.dart @@ -0,0 +1,266 @@ +part of '../../migrate.dart'; + +bool _isMysql(SqlDialect dialect) => + dialect == SqlDialect.mysql || dialect == SqlDialect.mariadb; + +String _mysqlName(String kind, Object signature) => + '_orm_${kind}_${_hash(signature).substring(0, 24)}'; +String _mysqlForeignName(String table, ForeignKey key) => _mysqlName('fk', [ + table, + { + ..._foreignKeyJson(key), + 'onDelete': key.onDelete == 'NO ACTION' ? 'RESTRICT' : key.onDelete, + }, +]); +String _mysqlUniqueName(String table, List columns) => + _mysqlName('uk', [table, columns]); + +TableSchema _mysqlCopy( + TableSchema table, { + String? name, + List>? columns, + List? primaryKey, + List>? uniqueKeys, + List? foreignKeys, + List? indexes, + List? checks, +}) => TableSchema( + name ?? table.name, + columns: columns ?? table.columns, + primaryKey: primaryKey ?? table.primaryKey, + uniqueKeys: uniqueKeys ?? table.uniqueKeys, + foreignKeys: foreignKeys ?? table.foreignKeys, + indexes: indexes ?? table.indexes, + checks: checks ?? table.checks, +); + +TableSchema _mysqlPhysicalTable(TableSchema table) { + final indexes = table.indexes.toList(); + for (final key in table.foreignKeys) { + final candidates = [ + table.primaryKey, + ...table.uniqueKeys, + ...indexes.map((i) => i.columns), + ]; + final covered = candidates.any( + (columns) => + columns.length >= key.columns.length && + key.columns.indexed.every((entry) => columns[entry.$1] == entry.$2), + ); + if (!covered) { + indexes.add( + IndexSchema( + _mysqlName('fkidx', [table.name, key.columns]), + key.columns, + ), + ); + } + } + return _mysqlCopy(table, indexes: indexes); +} + +void _validateMysqlSchema(List tables, SqlDialect dialect) { + void distinct(Iterable names) { + final seen = {}; + for (final name in names) { + if (!seen.add(name.toLowerCase())) { + throw const OrmException( + 'SCHEMA.DUPLICATE', + 'MySQL/MariaDB schema names must be distinct ignoring case.', + ); + } + } + } + + distinct(tables.map((table) => table.name)); + if (dialect == SqlDialect.mysql) { + distinct( + tables + .expand((table) => table.checks.map((check) => check.name)) + .whereType(), + ); + } + void identifier(String value) { + if (value.runes.length > 64) { + throw const OrmException( + 'SCHEMA.IDENTIFIER', + 'MySQL and MariaDB identifiers cannot exceed 64 characters.', + ); + } + } + + for (final table in tables) { + identifier(table.name); + distinct(table.columns.map((column) => column.name)); + distinct(table.indexes.map((index) => index.name)); + distinct(table.checks.map((check) => check.name).whereType()); + if ({ + '_orm_migrations', + '_orm_migration_steps', + }.contains(table.name.toLowerCase())) { + throw const OrmException( + 'SCHEMA.RESERVED', + 'Migration metadata table names are reserved.', + ); + } + for (final column in table.columns) { + identifier(column.name); + if (column.codec.sqlType == 'decimal' && + (column.decimalPrecision == null || + column.decimalPrecision! > 65 || + (column.decimalScale ?? 0) < 0 || + (column.decimalScale ?? 0) > 30 || + (column.decimalScale ?? 0) > column.decimalPrecision!)) { + throw const OrmException( + 'SCHEMA.DECIMAL_DIGITS', + 'MySQL/MariaDB decimals require explicit precision 1..65 and scale 0..30 not exceeding precision.', + ); + } + if (column.computed != null && !column.nullable) { + throw const OrmException( + 'SCHEMA.COMPUTED', + 'Portable MySQL/MariaDB generated columns must be nullable.', + ); + } + } + for (final check in table.checks) { + if (check.name != null) identifier(check.name!); + check.expression(dialect); + } + for (final index in table.indexes) { + identifier(index.name); + } + for (final key in table.foreignKeys) { + if (key.onDelete == 'SET DEFAULT') { + throw const OrmException( + 'SCHEMA.FOREIGN_KEY', + 'InnoDB does not support ON DELETE SET DEFAULT.', + ); + } + } + } +} + +String _mysqlStorageType(String type) => switch (type) { + 'integer' => 'BIGINT', + 'bigint' => 'DECIMAL(65,0)', + 'decimal' => 'DECIMAL', + 'text' => 'VARCHAR(255)', + 'boolean' => 'TINYINT(1)', + 'real' => 'DOUBLE', + 'date' => 'DATE', + 'time' => 'TIME(6)', + 'instant' || 'timestamp' || 'local_datetime' => 'DATETIME(6)', + 'json' => 'JSON', + 'blob' => 'LONGBLOB', + _ => throw OrmException( + 'SCHEMA.TYPE', + 'No MySQL/MariaDB storage mapping for $type.', + ), +}; +String _mysqlColumnType(Column column) => + switch (column.codec.sqlType) { + 'integer' => switch (column.integerBits ?? 64) { + 16 => 'SMALLINT', + 32 => 'INT', + _ => 'BIGINT', + }, + 'decimal' => + 'DECIMAL(${column.decimalPrecision},${column.decimalScale ?? 0})', + 'time' => 'TIME(${column.temporalPrecision ?? 6})', + 'instant' || + 'timestamp' || + 'local_datetime' => 'DATETIME(${column.temporalPrecision ?? 6})', + _ => _mysqlStorageType(column.codec.sqlType), + }; + +String _mysqlColumn(Column column, SqlDialect dialect) { + final result = StringBuffer( + '${_quote(column.name)} ${_mysqlColumnType(column)}', + ); + if (column.codec.sqlType == 'text') { + result.write(' CHARACTER SET utf8mb4 COLLATE utf8mb4_bin'); + } + if (column.computed case final computed?) { + result.write( + ' GENERATED ALWAYS AS (${computed.expression(dialect)}) ${computed.storage.name.toUpperCase()}', + ); + } else { + result.write(column.nullable ? ' NULL' : ' NOT NULL'); + if (column.defaultSql case final value?) { + final sql = _normalizeDefault(value)!; + // MySQL reparses parenthesized string defaults. With + // NO_BACKSLASH_ESCAPES that can change stored backslashes; ordinary + // literal defaults also avoid unnecessary expression-default metadata. + final tokens = _sqliteTokens(sql); + final literal = + tokens.length == 1 && + (tokens.single.text.startsWith('s:') || + {'NULL', 'TRUE', 'FALSE'}.contains(tokens.single.text)) || + RegExp(r'^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$') + .hasMatch(sql); + final requiresExpression = + dialect == SqlDialect.mysql && + {'json', 'blob'}.contains(column.codec.sqlType); + result.write( + ' DEFAULT ${literal && !requiresExpression ? sql : '($sql)'}', + ); + } + if (column.generated) result.write(' AUTO_INCREMENT'); + } + return result.toString(); +} + +String _mysqlCreateTable(TableSchema table, SqlDialect dialect) { + final definitions = [ + for (final column in table.columns) _mysqlColumn(column, dialect), + if (table.primaryKey.isNotEmpty) + 'PRIMARY KEY (${table.primaryKey.map(_quote).join(', ')})', + for (final key in table.uniqueKeys) + 'CONSTRAINT ${_quote(_mysqlUniqueName(table.name, key))} UNIQUE (${key.map(_quote).join(', ')})', + for (final index in table.indexes) + '${index.unique ? 'UNIQUE ' : ''}INDEX ${_quote(index.name)} (${index.columns.map(_quote).join(', ')})', + for (final check in table.checks) _checkDefinition(check, dialect), + ]; + return 'CREATE TABLE ${_quote(table.name)} (${definitions.join(', ')}) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin'; +} + +String _mysqlAddForeign(String table, ForeignKey key) => + 'ADD CONSTRAINT ${_quote(_mysqlForeignName(table, key))} ${_foreignKey(key)}'; + +List _mysqlCreateSchema( + List tables, + SqlDialect dialect, +) { + _validateSchema(tables, dialect); + final physical = tables.map(_mysqlPhysicalTable).toList(); + return List.unmodifiable([ + for (final table in physical) SqlCommand(_mysqlCreateTable(table, dialect)), + for (final table in physical) + if (table.foreignKeys.isNotEmpty) + SqlCommand( + 'ALTER TABLE ${_quote(table.name)} ${table.foreignKeys.map((key) => _mysqlAddForeign(table.name, key)).join(', ')}', + ), + ]); +} + +List _mysqlCreateSteps( + List tables, + SqlDialect dialect, +) { + final physical = tables.map(_mysqlPhysicalTable).toList(); + return [ + for (final table in physical) + CheckedTableSql( + _mysqlCreateTable(table, dialect), + after: _mysqlCopy(table, foreignKeys: []), + ), + for (final table in physical) + if (table.foreignKeys.isNotEmpty) + CheckedTableSql( + 'ALTER TABLE ${_quote(table.name)} ${table.foreignKeys.map((key) => _mysqlAddForeign(table.name, key)).join(', ')}', + before: _mysqlCopy(table, foreignKeys: []), + after: table, + ), + ]; +} diff --git a/lib/src/migrate/recovery.dart b/lib/src/migrate/recovery.dart new file mode 100644 index 00000000..d8d295ef --- /dev/null +++ b/lib/src/migrate/recovery.dart @@ -0,0 +1,787 @@ +part of '../../migrate.dart'; + +/// PostgreSQL autocommit SQL with explicit recovery conditions. Both checks must +/// return exactly one boolean. They describe durable state, not an object name alone. +final class CheckedSql extends MigrationStep { + final String sql; + final String readyWhen; + final String doneWhen; + const CheckedSql(this.sql, {required this.readyWhen, required this.doneWhen}); + + /// A concurrent, ascending B-tree index with default collation/opclasses. + /// The completion check compares its definition as well as ready/valid state. + factory CheckedSql.createIndex(String table, IndexSchema index) { + if (index.columns.isEmpty) throw ArgumentError('An index needs columns.'); + final source = _literal(table), name = _literal(index.name); + final columns = 'ARRAY[${index.columns.map(_literal).join(', ')}]::text[]'; + return CheckedSql( + _createIndex(table, index).replaceFirst('INDEX ', 'INDEX CONCURRENTLY '), + readyWhen: '''SELECT NOT EXISTS ( +SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = current_schema() AND c.relname = $name)''', + doneWhen: + '''SELECT EXISTS ( +SELECT 1 FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid +JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_class t ON t.oid = i.indrelid +JOIN pg_am am ON am.oid = c.relam +WHERE n.nspname = current_schema() AND c.relname = $name AND t.relname = $source +AND t.relnamespace = n.oid AND i.indisvalid AND i.indisready AND i.indislive +AND i.indisunique = ${index.unique} AND NOT i.indisprimary AND NOT i.indisexclusion +AND i.indexprs IS NULL AND i.indpred IS NULL AND i.indnatts = i.indnkeyatts +AND am.amname = 'btree' AND c.reloptions IS NULL +AND NOT EXISTS (SELECT 1 FROM pg_constraint constraint_row WHERE constraint_row.conindid = i.indexrelid AND constraint_row.contype IN ('p', 'u', 'x')) +AND NOT coalesce((to_jsonb(i)->>'indnullsnotdistinct')::boolean, false) +AND NOT EXISTS (SELECT 1 FROM unnest(i.indoption) v WHERE v <> 0) +AND NOT EXISTS (SELECT 1 FROM unnest(i.indclass) v JOIN pg_opclass o ON o.oid = v WHERE NOT o.opcdefault) +AND NOT EXISTS (SELECT 1 FROM unnest(i.indkey, i.indcollation) k(num, collation_oid) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.num WHERE k.collation_oid <> a.attcollation) +AND ARRAY(SELECT a.attname::text FROM unnest(i.indkey) WITH ORDINALITY k(num, ord) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.num ORDER BY k.ord) = $columns)''', + ); + } + @override + Map toJson() => { + 'kind': 'checkedSql', + 'sql': sql, + 'readyWhen': readyWhen, + 'doneWhen': doneWhen, + }; +} + +String _literal(String value) { + var tag = r'$orm$'; + while (value.contains(tag)) { + tag = '${tag.substring(0, tag.length - 1)}_\$'; + } + return '$tag$value$tag'; +} + +enum MigrationStepState { running, complete, failed } + +final class MigrationProgress { + final String id; + final String checksum; + final int step; + final MigrationStepState state; + final String phase; + final String? failure; + final BackfillProgress? backfill; + const MigrationProgress( + this.id, + this.checksum, + this.step, + this.state, + this.phase, + this.failure, { + this.backfill, + }); + Map toJson() => { + 'id': id, + 'checksum': checksum, + 'step': step, + 'state': state.name, + 'phase': phase, + if (failure != null) 'failure': failure, + if (backfill != null) 'backfill': backfill!.toJson(), + }; +} + +// The history schema owns these TEXT cells. MySQL can expose a binary-collated +// TEXT field as bytes; this does not change decoding of application BLOB values. +String? _migrationText(Object? value) => + value is List ? utf8.decode(value) : value as String?; + +Future> _progress(SqlDatabase db) async { + if (!await _hasMigrationTable(db, '_orm_migration_steps')) { + return []; + } + final result = await db.execute( + SqlCommand( + 'SELECT * FROM "_orm_migration_steps" ORDER BY migration_id, step', + ), + ); + final fields = [ + for (final name in [ + 'migration_id', + 'checksum', + 'step', + 'state', + 'phase', + 'failure', + ]) + result.columns.indexOf(name), + ]; + if (fields.any((i) => i < 0)) { + throw const OrmException( + 'MIGRATION.HISTORY', + 'Migration checkpoint table is missing required columns.', + ); + } + final data = result.columns.indexOf('backfill'); + return [ + for (final row in result.rows) + MigrationProgress( + row[fields[0]] as String, + row[fields[1]] as String, + row[fields[2]] as int, + MigrationStepState.values.byName(row[fields[3]] as String), + row[fields[4]] as String, + _migrationText(row[fields[5]]), + backfill: data < 0 || row[data] == null + ? null + : BackfillProgress._read(jsonDecode(_migrationText(row[data])!)), + ), + ]; +} + +void _validateProgress( + List migrations, + List applied, + List progress, + SqlDialect dialect, +) { + final local = {for (final m in migrations) m.id: m}, + finished = applied.map((m) => m.id).toSet(); + final next = migrations.length > applied.length + ? migrations[applied.length].id + : null; + final grouped = >{}; + for (final row in progress) { + final migration = local[row.id]; + if (migration == null || migration.checksum != row.checksum) { + throw OrmException( + 'MIGRATION.CHECKSUM', + 'Attempted migration ${row.id} differs from local history.', + ); + } + if (row.step < 0 || + row.step >= migration.steps.length || + (!finished.contains(row.id) && row.id != next)) { + throw const OrmException( + 'MIGRATION.HISTORY', + 'Migration checkpoint does not match the next pending migration.', + ); + } + if (row.backfill != null && migration.steps[row.step] is! Backfill) { + throw const OrmException( + 'MIGRATION.HISTORY', + 'Backfill cursor belongs to a different step kind.', + ); + } + final step = migration.steps[row.step]; + if (step is Backfill && + ((row.state == .complete && row.backfill == null) || + row.backfill?.upperKey != null && + row.backfill!.upperKey!.length != + step.table.primaryKey.length || + row.backfill?.lastKey != null && + row.backfill!.lastKey!.length != + step.table.primaryKey.length)) { + throw const OrmException( + 'MIGRATION.HISTORY', + 'Backfill checkpoint does not match its primary key or completion state.', + ); + } + (grouped[row.id] ??= []).add(row); + } + for (final entry in grouped.entries) { + final rows = entry.value; + for (var i = 0; i < rows.length; i++) { + if (rows[i].step != i || + (i < rows.length - 1 && rows[i].state != .complete)) { + throw const OrmException( + 'MIGRATION.HISTORY', + 'Migration checkpoints must form a completed prefix followed by at most one unfinished step.', + ); + } + } + if (finished.contains(entry.key) && + (rows.length != local[entry.key]!.steps.length || + rows.last.state != .complete)) { + throw const OrmException( + 'MIGRATION.HISTORY', + 'Applied migration has unfinished checkpoints.', + ); + } + } +} + +Future _migrationSession( + SqlDatabase database, + Duration lockTimeout, + Future Function(SqlDatabase) action, +) { + if (lockTimeout.isNegative) { + throw ArgumentError.value(lockTimeout, 'lockTimeout'); + } + Future run(SqlDatabase session) async { + await _checkMigrationVersion(session); + return _isMysql(session.dialect) + ? _mysqlMigrationLock(session, lockTimeout, () => action(session)) + : session.dialect == SqlDialect.postgres + ? _withMigrationLock(session, lockTimeout, () => action(session)) + : action(session); + } + + return database.inSession ? run(database) : database.session(run); +} + +// Generated PostgreSQL DDL/catalog behavior is supported on 18+. Check the +// actual server before locks, journal creation or recoverable partial commits. +Future _checkMigrationVersion(SqlDatabase db) async { + if (_isMysql(db.dialect)) { + final raw = + '${(await db.execute(SqlCommand('SELECT VERSION()'))).rows.single.single}'; + final maria = raw.toLowerCase().contains('mariadb'); + final match = RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(raw); + final major = match == null ? 0 : int.parse(match[1]!); + final minor = match == null ? 0 : int.parse(match[2]!); + if (maria != (db.dialect == SqlDialect.mariadb) || + (maria + ? major < 11 || major == 11 && minor < 8 + : major < 8 || major == 8 && minor < 4)) { + throw const OrmException( + 'CAPABILITY.VERSION', + 'Migrations require MySQL 8.4+ or MariaDB 11.8+ with the matching driver.', + ); + } + return; + } + if (db.dialect != SqlDialect.postgres) return; // SQLite driver checks 3.35+. + final result = await db.execute(SqlCommand('SHOW server_version_num')); + final version = int.tryParse('${result.rows.single.single}'); + if (version == null || version < 180000) { + throw const OrmException( + 'CAPABILITY.VERSION', + 'PostgreSQL migrations require server version 18 or newer.', + ); + } +} + +Future _withMigrationLock( + SqlDatabase session, + Duration timeout, + Future Function() action, +) async { + final schemaKey = + (await session.execute(SqlCommand('SELECT hashtext(current_schema())'))) + .rows + .single + .single + as int?; + if (schemaKey == null) { + throw const OrmException( + 'MIGRATION.SCHEMA', + 'A current PostgreSQL schema is required.', + ); + } + final watch = Stopwatch()..start(); + var delay = 10; + while (true) { + bool locked; + try { + locked = + (await session.execute( + SqlCommand(r'SELECT pg_try_advisory_lock(182983479, $1)', [ + schemaKey, + ]), + )).rows.single.single + as bool; + } catch (_) { + await session.discard(); + rethrow; + } + if (locked) break; + if (watch.elapsed >= timeout) { + throw const OrmException( + 'MIGRATION.LOCK_TIMEOUT', + 'Timed out waiting for the migration runner lock.', + ); + } + final remaining = (timeout - watch.elapsed).inMilliseconds.clamp(1, delay); + // No database transaction/snapshot remains open while waiting. A blocking + // advisory-lock SELECT can deadlock CREATE INDEX CONCURRENTLY snapshot waits. + await Future.delayed(Duration(milliseconds: remaining)); + delay = (delay * 2).clamp(10, 250); + } + Object? primaryFailure; + try { + return await action(); + } catch (error) { + primaryFailure = error; + rethrow; + } finally { + try { + final unlocked = await session.execute( + SqlCommand(r'SELECT pg_advisory_unlock(182983479, $1)', [schemaKey]), + ); + if (unlocked.rows.single.single != true) { + throw const OrmException( + 'MIGRATION.LOCK', + 'Migration lock was not held.', + ); + } + } catch (_) { + try { + await session.discard(); + } catch (_) {} + if (primaryFailure == null) rethrow; + } + } +} + +Future> _applyRecoverable( + SqlDatabase session, + List migrations, + _BackfillBudget budget, +) async { + final pending = await Migrator(session).plan(migrations); + if (pending.isEmpty) return []; + await session.transaction(_recoveryTables); + final progress = { + for (final p in await _progress(session)) (p.id, p.step): p, + }; + final atomic = []; + final completed = []; + Future flush() async { + if (atomic.isEmpty) return; + await session.transaction((tx) async { + for (final migration in atomic) { + for (final step in migration.steps) { + await _executeStep(tx, step); + } + await _recordMigration(tx, migration); + } + }); + completed.addAll(atomic.map((m) => m.id)); + atomic.clear(); + } + + for (final migration in pending) { + final steps = migration.steps; + if (!steps.any((s) => s is CheckedSql || s is Backfill)) { + atomic.add(migration); + continue; + } + await flush(); + for (var i = 0; i < steps.length; i++) { + if (progress[(migration.id, i)]?.state == .complete) continue; + final step = steps[i]; + var phase = step is CheckedSql || step is Backfill + ? 'inspect' + : 'execute'; + try { + await _checkpoint(session, migration, i, .running, phase); + if (step is Backfill) { + await _verifyBackfill(session, step); + while (!await session.transaction( + (tx) async => _backfillChunk( + tx, + migration, + i, + step, + await _savedBackfill(tx, migration.id, i), + budget, + (value) => phase = value, + ), + )) {} + } else if (step is CheckedSql) { + if (!await _probe(session, step.doneWhen)) { + if (!await _probe(session, step.readyWhen)) { + throw const OrmException( + 'MIGRATION.RECOVERY', + 'Neither completion nor safe-to-run condition holds. Inspect and repair the database before retrying this unchanged migration.', + ); + } + phase = 'execute'; + await _checkpoint(session, migration, i, .running, phase); + await session.execute(SqlCommand(step.sql)); + phase = 'verify'; + await _checkpoint(session, migration, i, .running, phase); + if (!await _probe(session, step.doneWhen)) { + throw const OrmException( + 'MIGRATION.POSTCONDITION', + 'Nontransactional SQL did not establish its declared completion condition.', + ); + } + } + phase = 'record'; + await _checkpoint(session, migration, i, .complete, 'complete'); + } else { + await session.transaction((tx) async { + await _executeStep(tx, step); + await _checkpoint(tx, migration, i, .complete, 'complete'); + }); + } + } catch (error, stack) { + if (error is _BackfillPaused) return completed; + try { + // Never overwrite a completion record after an uncertain commit. + await _checkpoint( + session, + migration, + i, + .failed, + phase, + failure: error is OrmException + ? error.code + : error.runtimeType.toString(), + ); + } catch (_) { + /* The durable running checkpoint remains recoverable. */ + } + Error.throwWithStackTrace( + OrmException( + 'MIGRATION.STEP', + '${migration.id} step $i failed during $phase. Inspect migration progress before retrying.', + cause: error, + ), + stack, + ); + } + } + await session.transaction((tx) => _recordMigration(tx, migration)); + completed.add(migration.id); + } + await flush(); + return completed; +} + +bool _needsRebuild(List migrations, SqlDialect dialect) => + dialect == SqlDialect.sqlite && + migrations.any( + (m) => m.steps.any((s) => s is RebuildTable || s is DropTable), + ); + +Future _migrationTransaction( + SqlDatabase session, + Future Function(SqlDatabase) action, { + bool rebuild = false, +}) async { + int? foreignKeys; + try { + if (rebuild) { + foreignKeys = + (await session.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single + as int; + await session.execute(SqlCommand('PRAGMA foreign_keys = OFF')); + if ((await session.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single != + 0) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Foreign keys must be disabled before a rebuild transaction.', + ); + } + } + return await session.transaction( + (tx) async { + final result = await action(tx); + if (rebuild && + (await tx.execute(SqlCommand('PRAGMA foreign_key_check'))) + .rows + .isNotEmpty) { + throw const OrmException( + 'MIGRATION.FOREIGN_KEY', + 'Rebuilt schema contains foreign key violations.', + ); + } + return result; + }, + options: session.dialect == SqlDialect.sqlite + ? const SqliteTransaction(mode: .immediate) + : const PostgresTransaction(), + ); + } finally { + if (foreignKeys != null) { + try { + await session.execute( + SqlCommand( + 'PRAGMA foreign_keys = ${foreignKeys == 1 ? 'ON' : 'OFF'}', + ), + ); + if ((await session.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single != + foreignKeys) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Failed to restore SQLite foreign keys.', + ); + } + } catch (_) { + await session.discard(); + rethrow; + } + } + } +} + +/// SQLite serializes each chunk using BEGIN IMMEDIATE. Re-read durable history +/// inside that lock, so other workers may contribute chunks without replaying any. +Future> _applyRecoverableSqlite( + SqlDatabase session, + List migrations, + _BackfillBudget budget, +) async { + await _migrationTransaction(session, (tx) async { + await Migrator(tx).plan(migrations); + await _recoveryTables(tx); + }); + final atomic = [], completed = []; + Future flush() async { + if (atomic.isEmpty) return; + final applied = await _migrationTransaction(session, (tx) async { + final pending = (await Migrator(tx).plan(migrations)) + .map((m) => m.id) + .toSet(); + final applied = []; + for (final migration in atomic) { + if (!pending.contains(migration.id)) continue; + for (final step in migration.steps) { + await _executeStep(tx, step); + } + await _recordMigration(tx, migration); + applied.add(migration.id); + } + return applied; + }, rebuild: _needsRebuild(atomic, SqlDialect.sqlite)); + completed.addAll(applied); + atomic.clear(); + } + + for (final migration in migrations) { + final steps = migration.steps; + if (!steps.any((s) => s is Backfill)) { + atomic.add(migration); + continue; + } + await flush(); + for (var i = 0; i < steps.length; i++) { + final step = steps[i]; + var verified = false, phase = 'inspect'; + try { + while (!await _migrationTransaction(session, (tx) async { + final pending = await Migrator(tx).plan(migrations); + if (!pending.any((m) => m.id == migration.id)) return true; + final current = (await _progress(tx)) + .where((p) => p.id == migration.id && p.step == i) + .firstOrNull; + if (current?.state == .complete) return true; + if (current == null) { + await _checkpoint(tx, migration, i, .running, 'inspect'); + return false; + } + if (step is Backfill) { + if (!verified) { + phase = 'inspect'; + await _verifyBackfill(tx, step); + verified = true; + } + return _backfillChunk( + tx, + migration, + i, + step, + current.backfill, + budget, + (value) => phase = value, + ); + } + phase = 'execute'; + await _executeStep(tx, step); + phase = 'record'; + await _checkpoint(tx, migration, i, .complete, 'complete'); + return true; + }, rebuild: step is RebuildTable || step is DropTable)) {} + } catch (error, stack) { + if (error is _BackfillPaused) return completed; + try { + await _migrationTransaction( + session, + (tx) => _checkpoint( + tx, + migration, + i, + .failed, + phase, + failure: error is OrmException + ? error.code + : error.runtimeType.toString(), + ), + ); + } catch (_) { + /* An existing durable checkpoint remains resumable. */ + } + Error.throwWithStackTrace( + OrmException( + 'MIGRATION.STEP', + '${migration.id} step $i failed during $phase. Inspect migration progress before retrying.', + cause: error, + ), + stack, + ); + } + } + final recorded = await _migrationTransaction(session, (tx) async { + if (!(await Migrator(tx).plan(migrations)) + .any((m) => m.id == migration.id)) { + return false; + } + await _recordMigration(tx, migration); + return true; + }); + if (recorded) completed.add(migration.id); + } + await flush(); + return completed; +} + +Future _probe(SqlDatabase db, String sql) async { + final result = await db.execute(SqlCommand(sql)); + if (result.rows.length != 1 || + result.rows.single.length != 1 || + !(result.rows.single.single is bool || + db.dialect != SqlDialect.postgres && + result.rows.single.single is int && + {0, 1}.contains(result.rows.single.single))) { + throw const OrmException( + 'MIGRATION.PROBE', + 'A recovery condition must return exactly one boolean.', + ); + } + return result.rows.single.single == true || result.rows.single.single == 1; +} + +Future _checkpoint( + SqlDatabase db, + Migration migration, + int step, + MigrationStepState state, + String phase, { + String? failure, + BackfillProgress? backfill, +}) async { + await db.execute( + SqlCommand( + ''' +INSERT INTO "_orm_migration_steps" (migration_id, checksum, step, state, phase, failure, backfill) +VALUES (${[for (var i = 1; i <= 7; i++) _mark(db.dialect, i)].join(', ')}) +${_isMysql(db.dialect) ? "ON DUPLICATE KEY UPDATE phase = IF(state = 'complete', phase, VALUES(phase)), failure = IF(state = 'complete', failure, VALUES(failure)), backfill = IF(state = 'complete', backfill, COALESCE(VALUES(backfill), backfill)), state = IF(state = 'complete', state, VALUES(state))" : "ON CONFLICT (migration_id, step) DO UPDATE SET state = EXCLUDED.state, phase = EXCLUDED.phase, failure = EXCLUDED.failure, backfill = coalesce(EXCLUDED.backfill, \"_orm_migration_steps\".backfill) WHERE \"_orm_migration_steps\".state <> 'complete'"} ''', + [ + migration.id, + migration.checksum, + step, + state.name, + phase, + failure, + backfill == null ? null : jsonEncode(backfill.toJson()), + ], + ), + ); +} + +Future _recoveryTables(SqlDatabase tx) async { + await tx.execute(SqlCommand(_historyDdl)); + await tx.execute( + SqlCommand('''CREATE TABLE IF NOT EXISTS "_orm_migration_steps" ( + migration_id TEXT NOT NULL, checksum TEXT NOT NULL, step INTEGER NOT NULL, + state TEXT NOT NULL, phase TEXT NOT NULL, failure TEXT, backfill TEXT, + PRIMARY KEY(migration_id, step))'''), + ); + final shape = await tx.execute( + SqlCommand('SELECT * FROM "_orm_migration_steps" LIMIT 0'), + ); + if (!shape.columns.contains('backfill')) { + await tx.execute( + SqlCommand('ALTER TABLE "_orm_migration_steps" ADD COLUMN backfill TEXT'), + ); + } +} + +Future _savedBackfill( + SqlDatabase tx, + String id, + int step, +) async { + final row = await tx.execute( + SqlCommand( + 'SELECT backfill FROM "_orm_migration_steps" WHERE migration_id = ${_mark(tx.dialect, 1)} AND step = ${_mark(tx.dialect, 2)}', + [id, step], + ), + ); + final data = row.rows.single.single; + return data == null + ? null + : BackfillProgress._read(jsonDecode(_migrationText(data)!)); +} + +const _historyDdl = + 'CREATE TABLE IF NOT EXISTS "_orm_migrations" (id TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)'; +Future _hasMigrationTable(SqlDatabase db, String table) async { + if (_isMysql(db.dialect)) { + final rows = await db.execute( + SqlCommand( + 'SELECT TABLE_TYPE, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', + [table], + ), + ); + if (rows.rows.any((r) => r[0] != 'BASE TABLE' || r[1] != 'InnoDB')) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Durable migration metadata must use InnoDB base tables.', + ); + } + if (rows.rows.isNotEmpty) { + final definition = await db.execute( + SqlCommand('SHOW CREATE TABLE ${_quote(table)}'), + ); + if ((definition.rows.single[1] as String).toUpperCase().startsWith( + 'CREATE TEMPORARY TABLE', + )) { + throw const OrmException( + 'MIGRATION.SESSION', + 'A temporary table shadows durable migration metadata.', + ); + } + } + return rows.rows.isNotEmpty; + } + if (db.dialect == SqlDialect.sqlite) { + final rows = await db.execute( + SqlCommand( + "SELECT name, CASE WHEN type = 'table' THEN 0 ELSE 2 END FROM main.sqlite_schema WHERE name = ?1 COLLATE NOCASE UNION ALL SELECT name, 1 FROM sqlite_temp_schema WHERE name = ?1 COLLATE NOCASE", + [table], + ), + ); + if (rows.rows.any((r) => r[1] != 0)) { + throw const OrmException( + 'MIGRATION.SESSION', + 'A temporary or non-table object shadows durable migration metadata.', + ); + } + return rows.rows.isNotEmpty; + } + final rows = await db.execute( + SqlCommand( + r'''SELECT + EXISTS(SELECT 1 FROM pg_catalog.pg_tables WHERE schemaname = current_schema() AND tablename::text = $1), + coalesce((SELECT n.nspname = current_schema() AND c.relkind IN ('r', 'p') FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = to_regclass(quote_ident($1))), true)''', + [table], + ), + ); + if (rows.rows.single[1] != true) { + throw const OrmException( + 'MIGRATION.SESSION', + 'Another schema shadows durable migration metadata.', + ); + } + return rows.rows.single[0] == true; +} diff --git a/lib/src/migrate/schema.dart b/lib/src/migrate/schema.dart new file mode 100644 index 00000000..01c0d06b --- /dev/null +++ b/lib/src/migrate/schema.dart @@ -0,0 +1,665 @@ +part of '../../migrate.dart'; + +/// Creates a new schema. Applications should execute the resulting SQL through +/// reviewed migrations; this does not inspect or mutate an existing database. +List createSchema(List tables, SqlDialect dialect) { + if (_isMysql(dialect)) return _mysqlCreateSchema(tables, dialect); + final commands = []; + _validateSchema(tables, dialect); + for (final table in tables) { + commands.add(SqlCommand(_createTable(table, dialect))); + } + if (dialect == SqlDialect.postgres) { + // Creating constraints after all tables also supports cycles and self links. + for (final table in tables) { + for (final key in table.foreignKeys) { + commands.add( + SqlCommand( + 'ALTER TABLE ${_quote(table.name)} ADD ${_foreignKey(key)}', + ), + ); + } + } + } + for (final table in tables) { + for (final index in table.indexes) { + commands.add( + SqlCommand( + 'CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${_quote(index.name)} ' + 'ON ${_quote(table.name)} (${index.columns.map(_quote).join(', ')})', + ), + ); + } + } + return List.unmodifiable(commands); +} + +void _validateSchema(List tables, [SqlDialect? dialect]) { + if (dialect != null && _isMysql(dialect)) { + _validateMysqlSchema(tables, dialect); + } + String identifier(String name) { + if (name.isEmpty || + name.contains('\u0000') || + dialect == SqlDialect.postgres && utf8.encode(name).length > 63) { + throw const OrmException( + 'SCHEMA.IDENTIFIER', + 'Identifiers must be non-empty, contain no NUL, and fit PostgreSQL\'s 63-byte limit.', + ); + } + return dialect == SqlDialect.sqlite ? _sqliteName(name) : name; + } + + final names = {}; + final indexes = {}; + for (final table in tables) { + if (!names.add(identifier(table.name))) { + throw const OrmException('SCHEMA.DUPLICATE', 'Duplicate table name.'); + } + } + for (final table in tables) { + final columns = {}; + if (table.columns.every((c) => c.computed != null)) { + throw const OrmException( + 'SCHEMA.COMPUTED', + 'A table needs at least one ordinary column.', + ); + } + final checkNames = {}; + for (final check in table.checks) { + if ((dialect == null + ? check.sqlite.trim().isEmpty && check.postgres.trim().isEmpty + : check.expression(dialect).trim().isEmpty) || + check.name != null && !checkNames.add(identifier(check.name!))) { + throw const OrmException( + 'SCHEMA.CHECK', + 'CHECK expressions and names must be non-empty; names must be unique per table.', + ); + } + } + for (final column in table.columns) { + if (dialect != null) _storageType(column.codec.sqlType, dialect); + if (column.computed case final computed?) { + if ((dialect == null + ? computed.sqlite.trim().isEmpty && + computed.postgres.trim().isEmpty + : computed.expression(dialect).trim().isEmpty) || + column.generated || + column.defaultSql != null || + column.clientDefault != null || + (dialect == SqlDialect.sqlite && + table.primaryKey.contains(column.name))) { + throw const OrmException( + 'SCHEMA.COMPUTED', + 'Computed columns require non-empty SQL, no identity/default, and cannot be a SQLite primary key.', + ); + } + } + if (column.decimalPrecision != null || column.decimalScale != null) { + if (column.codec.sqlType != 'decimal' || + column.decimalPrecision == null || + column.decimalPrecision! < 1 || + column.decimalPrecision! > 1000 || + (column.decimalScale ?? 0) < -1000 || + (column.decimalScale ?? 0) > 1000) { + throw const OrmException( + 'SCHEMA.DECIMAL_DIGITS', + 'Decimal precision must be 1..1000 and scale -1000..1000 on a decimal column.', + ); + } + } + if (column.temporalPrecision != null && + (!{ + 'time', + 'local_datetime', + 'instant', + }.contains(column.codec.sqlType) || + column.temporalPrecision! < 0 || + column.temporalPrecision! > 6)) { + throw const OrmException( + 'SCHEMA.TEMPORAL_PRECISION', + 'Temporal precision requires time, local timestamp or instant storage and 0..6 digits.', + ); + } + if (column.integerBits != null && + (column.codec.sqlType != 'integer' || + !{16, 32, 64}.contains(column.integerBits))) { + throw const OrmException( + 'SCHEMA.INTEGER_BITS', + 'Integer width must be 16, 32 or 64 on an integer column.', + ); + } + if (!columns.add(identifier(column.name))) { + throw const OrmException('SCHEMA.DUPLICATE', 'Duplicate column name.'); + } + } + for (final key in [ + if (table.primaryKey.isNotEmpty) table.primaryKey, + ...table.uniqueKeys, + ...table.indexes.map((i) => i.columns), + ]) { + if (key.isEmpty || + key.toSet().length != key.length || + key.any((name) => !table.columns.any((c) => c.name == name))) { + throw const OrmException( + 'SCHEMA.KEY', + 'Keys require distinct, declared columns and cannot be empty.', + ); + } + if (dialect == SqlDialect.postgres && + key.any( + (name) => table.columns.any( + (c) => + c.name == name && + c.computed?.storage == ComputedStorage.virtual, + ), + )) { + throw const OrmException( + 'SCHEMA.COMPUTED', + 'PostgreSQL 18 does not support indexes or unique keys on virtual computed columns.', + ); + } + } + if (table.columns.any( + (c) => c.nullable && table.primaryKey.contains(c.name), + )) { + throw const OrmException( + 'SCHEMA.KEY', + 'Primary key columns cannot be nullable.', + ); + } + for (final index in table.indexes) { + final name = identifier(index.name); + if (names.contains(name) || !indexes.add(name)) { + throw const OrmException( + 'SCHEMA.DUPLICATE', + 'Tables and indexes must have distinct names in a schema.', + ); + } + } + for (final key in table.foreignKeys) { + _foreignKey(key); + identifier(key.target); + if (key.columns.toSet().length != key.columns.length || + key.targetColumns.toSet().length != key.targetColumns.length || + key.columns.any( + (name) => !table.columns.any((c) => c.name == name), + )) { + throw const OrmException( + 'SCHEMA.FOREIGN_KEY', + 'Foreign keys require distinct, declared local columns.', + ); + } + for (final name in key.targetColumns) { + identifier(name); + } + } + final generated = table.columns.where((c) => c.generated).toList(); + if (generated.length > 1 || + generated.any( + (c) => + c.codec.sqlType != 'integer' || + c.nullable || + table.primaryKey.length != 1 || + table.primaryKey.single != c.name, + )) { + throw const OrmException( + 'SCHEMA.IDENTITY', + 'Identity requires a non-null single integer primary key.', + ); + } + } +} + +String _createTable(TableSchema table, SqlDialect dialect, {String? name}) { + final definitions = []; + for (final c in table.columns) { + definitions.add(_columnDefinition(c, dialect)); + } + if (table.primaryKey.isNotEmpty && + !(dialect == SqlDialect.sqlite && + table.columns.any((c) => c.generated))) { + definitions.add('PRIMARY KEY (${table.primaryKey.map(_quote).join(', ')})'); + } + for (final key in table.uniqueKeys) { + definitions.add('UNIQUE (${key.map(_quote).join(', ')})'); + } + definitions.addAll(table.checks.map((c) => _checkDefinition(c, dialect))); + if (dialect == SqlDialect.sqlite) { + for (final key in table.foreignKeys) { + definitions.add(_foreignKey(key)); + } + } + + return 'CREATE TABLE ${_quote(name ?? table.name)} (${definitions.join(', ')})'; +} + +String _checkDefinition(CheckSchema check, SqlDialect dialect) => + '${check.name == null ? '' : 'CONSTRAINT ${_quote(check.name!)} '}CHECK (${check.expression(dialect)}\n)'; + +String _columnDefinition(Column c, SqlDialect dialect) { + if (_isMysql(dialect)) return _mysqlColumn(c, dialect); + final b = StringBuffer('${_quote(c.name)} ${_columnStorageType(c, dialect)}'); + if (dialect == SqlDialect.sqlite && + _sqliteCollation(c.codec.sqlType) != 'binary') { + b.write(' COLLATE "${_sqliteCollation(c.codec.sqlType)}"'); + } + if (c.generated) { + b.write( + dialect == SqlDialect.sqlite + ? ' PRIMARY KEY' + : ' GENERATED BY DEFAULT AS IDENTITY', + ); + } + if (!c.nullable) b.write(' NOT NULL'); + if (c.computed case final computed?) { + b.write( + ' GENERATED ALWAYS AS (${_coerceColumn(computed.expression(dialect), c, dialect)}\n) ${computed.storage.name.toUpperCase()}', + ); + } + if (c.defaultSql case final value?) { + b.write(' DEFAULT (${_coerceColumn(value, c, dialect)})'); + } + if (dialect == SqlDialect.sqlite && + c.temporalPrecision != null && + c.temporalPrecision != 6) { + b.write( + ' CHECK (${_temporalCheck(c.name, c.codec.sqlType, c.temporalPrecision!)})', + ); + } + if (dialect == SqlDialect.sqlite && c.decimalPrecision != null) { + b.write( + ' CHECK (${_decimalCheck(c.name, c.decimalPrecision!, c.decimalScale ?? 0)})', + ); + } + if (dialect == SqlDialect.sqlite && + c.integerBits != null && + c.integerBits != 64) { + b.write(' CHECK (${_integerCheck(c.name, c.integerBits!)})'); + } + return b.toString(); +} + +String _createIndex(String table, IndexSchema index) => + 'CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${_quote(index.name)} ' + 'ON ${_quote(table)} (${index.columns.map(_quote).join(', ')})'; + +String _foreignKey(ForeignKey key) { + if (key.columns.isEmpty || + key.columns.length != key.targetColumns.length || + !{ + 'RESTRICT', + 'NO ACTION', + 'CASCADE', + 'SET NULL', + 'SET DEFAULT', + }.contains(key.onDelete)) { + throw const OrmException( + 'SCHEMA.FOREIGN_KEY', + 'Invalid foreign key declaration.', + ); + } + return 'FOREIGN KEY (${key.columns.map(_quote).join(', ')}) REFERENCES ${_quote(key.target)} ' + '(${key.targetColumns.map(_quote).join(', ')}) ON DELETE ${key.onDelete}'; +} + +String _storageType(String type, SqlDialect dialect) => _isMysql(dialect) + ? _mysqlStorageType(type) + : switch ((dialect, type)) { + (SqlDialect.sqlite, 'integer') => 'INTEGER', + ( + SqlDialect.sqlite, + 'bigint' || + 'text' || + 'timestamp' || + 'instant' || + 'json' || + 'decimal' || + 'date' || + 'time' || + 'local_datetime', + ) => + 'TEXT', + (SqlDialect.sqlite, 'boolean') => 'INTEGER', + (SqlDialect.sqlite, 'real') => 'REAL', + (SqlDialect.sqlite, 'blob') => 'BLOB', + (SqlDialect.postgres, 'integer') => 'BIGINT', + (SqlDialect.postgres, 'bigint' || 'decimal') => 'NUMERIC', + (SqlDialect.postgres, 'text') => 'TEXT', + (SqlDialect.postgres, 'real') => 'DOUBLE PRECISION', + (SqlDialect.postgres, 'boolean') => 'BOOLEAN', + (SqlDialect.postgres, 'timestamp' || 'instant') => 'TIMESTAMPTZ', + (SqlDialect.postgres, 'date') => 'DATE', + (SqlDialect.postgres, 'time') => 'TIME WITHOUT TIME ZONE', + (SqlDialect.postgres, 'local_datetime') => + 'TIMESTAMP WITHOUT TIME ZONE', + (SqlDialect.postgres, 'json') => 'JSONB', + (SqlDialect.postgres, 'blob') => 'BYTEA', + _ => throw OrmException( + 'SCHEMA.TYPE', + 'No $dialect mapping for $type.', + ), + }; + +String _columnStorageType(Column column, SqlDialect dialect) => + _isMysql(dialect) + ? _mysqlColumnType(column) + : dialect == SqlDialect.postgres && + column.codec.sqlType == 'decimal' && + column.decimalPrecision != null + ? 'NUMERIC(${column.decimalPrecision},${column.decimalScale ?? 0})' + : dialect == SqlDialect.postgres && column.codec.sqlType == 'integer' + ? switch (column.integerBits ?? 64) { + 16 => 'SMALLINT', + 32 => 'INTEGER', + _ => 'BIGINT', + } + : dialect == SqlDialect.postgres && + column.temporalPrecision != null && + column.temporalPrecision != 6 + ? switch (column.codec.sqlType) { + 'time' => 'TIME(${column.temporalPrecision}) WITHOUT TIME ZONE', + 'local_datetime' => + 'TIMESTAMP(${column.temporalPrecision}) WITHOUT TIME ZONE', + 'instant' => 'TIMESTAMPTZ(${column.temporalPrecision})', + _ => throw const OrmException( + 'SCHEMA.TEMPORAL_PRECISION', + 'Temporal precision requires temporal storage.', + ), + } + : _storageType(column.codec.sqlType, dialect); + +bool _sameStorage(Column a, Column b) => + a.codec.sqlType == b.codec.sqlType && + (a.temporalPrecision ?? 6) == (b.temporalPrecision ?? 6) && + (a.integerBits ?? 64) == (b.integerBits ?? 64) && + a.decimalPrecision == b.decimalPrecision && + (a.decimalScale ?? 0) == (b.decimalScale ?? 0); + +String _coerceColumn( + String expression, + Column column, + SqlDialect dialect, +) => dialect == SqlDialect.sqlite && column.decimalPrecision != null + ? 'orm_decimal_cast_v1($expression, ${column.decimalPrecision}, ${column.decimalScale ?? 0})' + : dialect == SqlDialect.sqlite && + column.temporalPrecision != null && + column.temporalPrecision != 6 + ? "orm_temporal_cast_v1($expression, '${column.codec.sqlType}', ${column.temporalPrecision})" + : expression; + +String _temporalCheck(String name, String kind, int digits) => + "${_quote(name)} IS NULL OR orm_temporal_fits_v1(${_quote(name)}, '$kind', $digits)"; + +String _decimalCheck(String name, int precision, int scale) => + '${_quote(name)} IS NULL OR orm_decimal_fits_v1(${_quote(name)}, $precision, $scale)'; + +String _integerCheck(String name, int bits) { + final column = _quote(name), max = bits == 16 ? 32767 : 2147483647; + return "$column IS NULL OR (typeof($column) = 'integer' AND $column BETWEEN ${-max - 1} AND $max)"; +} + +/// Actual catalog columns, rather than a claimed migration version. +final class ColumnInfo { + final String name; + final String storageType; + final bool nullable; + final String? defaultSql; + final bool generated; + final ComputedColumn? computed; + final int? integerBits; + final String? collation; + final int? decimalPrecision; + final int? decimalScale; + final int? temporalPrecision; + const ColumnInfo({ + required this.name, + required this.storageType, + required this.nullable, + this.defaultSql, + this.generated = false, + this.computed, + this.integerBits, + this.collation, + this.decimalPrecision, + this.decimalScale, + this.temporalPrecision, + }); + + /// Removes the managed SQLite default coercion when drafting a declaration. + String? get declarationDefaultSql => + defaultSql != null && storageType == 'TEXT' && decimalPrecision != null + ? _uncoerceDecimalDefault( + _normalizeDefault(defaultSql)!, + decimalPrecision!, + decimalScale ?? 0, + ) ?? + defaultSql + : defaultSql != null && storageType == 'TEXT' && temporalPrecision != null + ? _uncoerceTemporal( + _normalizeDefault(defaultSql)!, + _temporalCollationKind(collation)!, + temporalPrecision!, + ) ?? + defaultSql + : defaultSql; + + /// SQL suitable for a declaration, without ORM storage coercion wrappers. + ComputedColumn? get declarationComputed => _declarationComputed(this); +} + +Future> inspectColumns( + SqlDatabase db, + String table, +) async { + if (_isMysql(db.dialect)) return _mysqlColumns(db, table); + if (db.dialect == SqlDialect.sqlite) { + final rows = await db.execute( + SqlCommand('PRAGMA table_xinfo(${_quote(table)})'), + ); + final indexes = await db.execute( + SqlCommand('PRAGMA index_list(${_quote(table)})'), + ); + final rowidPrimaryKey = !indexes.rows.any((r) => r[3] == 'pk'); + final ddl = await db.execute( + SqlCommand( + "SELECT sql FROM main.sqlite_schema WHERE type = 'table' AND name = ?1", + [table], + ), + ); + final sql = ddl.rows.firstOrNull?.first as String? ?? ''; + final checks = _sqliteChecks(sql); + final computed = _sqliteComputedColumns(sql); + final collations = { + for (final c in _sqliteColumnCollations(sql)) + _sqliteName(c.column): c.collation, + }; + final columns = []; + for (final row in rows.rows) { + final name = row[1] as String; + final type = (row[2] as String).toUpperCase(); + final collation = collations[_sqliteName(name)] ?? 'BINARY'; + final digits = + type == 'TEXT' && collation.toLowerCase() == 'orm_decimal_v1' + ? _sqliteDecimalDigits(name, checks) + : null; + columns.add( + ColumnInfo( + name: name, + storageType: type, + collation: collation, + decimalPrecision: digits?.$1, + decimalScale: digits?.$2, + temporalPrecision: + type == 'TEXT' && _temporalCollationKind(collation) != null + ? _sqliteTemporalPrecision( + name, + _temporalCollationKind(collation)!, + checks, + ) + : null, + nullable: row[3] == 0 && !(row[5] != 0 && rowidPrimaryKey), + defaultSql: row[4] as String?, + generated: (row[6] as int) > 0, + computed: computed[_sqliteName(name)], + integerBits: type == 'INTEGER' + ? _sqliteIntegerBits(name, checks) + : null, + ), + ); + } + return columns; + } + + final result = await db.execute( + SqlCommand( + ''' +SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod), + NOT a.attnotnull, pg_get_expr(d.adbin, d.adrelid), + a.attidentity <> '' OR a.attgenerated <> '', a.attgenerated::text +FROM pg_catalog.pg_attribute a +JOIN pg_catalog.pg_class c ON c.oid = a.attrelid +JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum +WHERE c.relname = \$1 AND n.nspname = current_schema() AND a.attnum > 0 AND NOT a.attisdropped +ORDER BY a.attnum''', + [table], + ), + ); + return [ + for (final row in result.rows) + ColumnInfo( + name: row[0] as String, + storageType: _postgresStorageName(row[1] as String), + temporalPrecision: _postgresTemporalPrecision(row[1] as String), + nullable: row[2] as bool, + defaultSql: row[5] == '' ? row[3] as String? : null, + generated: row[4] as bool, + computed: row[5] == '' + ? null + : ComputedColumn( + row[3] as String, + storage: row[5] == 's' + ? ComputedStorage.stored + : ComputedStorage.virtual, + ), + decimalPrecision: _postgresDecimalDigits(row[1] as String)?.$1, + decimalScale: _postgresDecimalDigits(row[1] as String)?.$2, + integerBits: switch ((row[1] as String).toUpperCase()) { + 'SMALLINT' => 16, + 'INTEGER' => 32, + 'BIGINT' => 64, + _ => null, + }, + ), + ]; +} + +/// Column drift check. Constraints, indexes and unmanaged objects are separate +/// catalog checks; this method does not pretend that columns prove full equality. +Future> verifyColumns( + SqlDatabase db, + List tables, +) async { + final differences = []; + for (final table in tables) { + final inspected = await inspectColumns(db, table.name); + final actual = {for (final c in inspected) c.name: c}; + var contextMatches = true; + for (final expected in table.columns) { + final column = actual.remove(expected.name); + final path = '${table.name}.${expected.name}'; + if (column == null) { + contextMatches = false; + differences.add('$path is missing'); + continue; + } + if (column.storageType != _columnStorageType(expected, db.dialect)) { + contextMatches = false; + differences.add('$path type is ${column.storageType}'); + } + if (column.nullable != expected.nullable) { + differences.add('$path nullability differs'); + } + if ((column.temporalPrecision ?? 6) != + (expected.temporalPrecision ?? 6)) { + differences.add('$path temporal precision differs'); + } + if (!_matchesDecimalDigits(expected, column)) { + differences.add('$path decimal precision/scale differs'); + } + if (db.dialect == SqlDialect.sqlite && + !_matchesCollation(expected, column)) { + differences.add('$path collation differs'); + } + if (expected.codec.sqlType == 'integer' && + (column.integerBits ?? 64) != (expected.integerBits ?? 64)) { + differences.add('$path integer width differs'); + } + } + for (final extra in actual.keys) { + differences.add('${table.name}.$extra is unmanaged'); + } + differences.addAll( + await _verifyComputed( + db, + table, + inspected, + contextMatches: contextMatches, + ), + ); + } + return differences; +} + +bool _matchesCollation(Column expected, ColumnInfo actual) => + (actual.collation ?? 'BINARY').toLowerCase() == + _sqliteCollation(expected.codec.sqlType); + +String _sqliteCollation(String type) => switch (type) { + 'decimal' || + 'date' || + 'time' || + 'local_datetime' || + 'instant' => 'orm_${type}_v1', + _ => 'binary', +}; + +bool _matchesDecimalDigits(Column expected, ColumnInfo actual) => + expected.decimalPrecision == actual.decimalPrecision && + (expected.decimalScale ?? 0) == (actual.decimalScale ?? 0); + +(int, int)? _postgresDecimalDigits(String type) { + final match = RegExp( + r'^numeric\((\d+),(-?\d+)\)$', + caseSensitive: false, + ).firstMatch(type.replaceAll(' ', '')); + return match == null ? null : (int.parse(match[1]!), int.parse(match[2]!)); +} + +String? _temporalCollationKind(String? collation) => + switch (collation?.toLowerCase()) { + 'orm_time_v1' => 'time', + 'orm_local_datetime_v1' => 'local_datetime', + 'orm_instant_v1' => 'instant', + _ => null, + }; + +int? _postgresTemporalPrecision(String type) { + final match = RegExp( + r'^(?:time|timestamp)\(([0-6])\) (?:with|without) time zone$', + caseSensitive: false, + ).firstMatch(type); + return match == null ? null : int.parse(match[1]!); +} + +String _postgresStorageName(String type) { + var name = type.toUpperCase().replaceFirstMapped( + RegExp(r'^TIMESTAMP(\([0-6]\))? WITH TIME ZONE$'), + (m) => 'TIMESTAMPTZ${m[1] ?? ''}', + ); + if (_postgresTemporalPrecision(type) == 6) { + name = name.replaceFirst('(6)', ''); + } + return name; +} diff --git a/lib/src/migrate/snapshot.dart b/lib/src/migrate/snapshot.dart new file mode 100644 index 00000000..839b4d1f --- /dev/null +++ b/lib/src/migrate/snapshot.dart @@ -0,0 +1,106 @@ +part of '../../migrate.dart'; + +/// Physical schema facts saved as Dart with migrations. Storage codecs support DDL; +/// custom domain decoding remains in the generated application client. +final class SchemaSnapshot { + final List tables; + SchemaSnapshot(List tables) + : tables = List.unmodifiable(tables) { + _validateSchema(tables); + } + + /// Resolves shared declarations to physical facts for this engine only. + SchemaSnapshot forDialect(SqlDialect dialect) { + _validateSchema(tables, dialect); + return SchemaSnapshot([for (final t in tables) _targetTable(t, dialect)]); + } + + Map toJson() => { + 'format': 1, + 'tables': [for (final table in tables) _tableJson(table)], + }; + String get checksum => _hash(toJson()); +} + +TableSchema _targetTable(TableSchema table, SqlDialect dialect) { + final target = TableSchema( + table.name, + columns: [ + for (final c in table.columns) + Column( + c.name, + c.codec, + nullable: c.nullable, + generated: c.generated, + defaultSql: c.defaultSql, + computed: c.computed == null + ? null + : ComputedColumn( + c.computed!.expression(dialect), + storage: c.computed!.storage, + ), + integerBits: c.integerBits, + decimalPrecision: c.decimalPrecision, + decimalScale: c.decimalScale, + temporalPrecision: c.temporalPrecision, + ), + ], + primaryKey: table.primaryKey, + uniqueKeys: table.uniqueKeys, + indexes: table.indexes, + foreignKeys: table.foreignKeys, + checks: [ + for (final c in table.checks) CheckSchema(c.name, c.expression(dialect)), + ], + ); + return _isMysql(dialect) ? _mysqlPhysicalTable(target) : target; +} + +Map _checkJson(CheckSchema check) => { + 'name': check.name, + 'sqlite': check.sqlite, + 'postgres': check.postgres, + if (check.mysql != null && check.mysql != check.postgres) + 'mysql': check.mysql, + if (check.mariadb != null && check.mariadb != check.postgres) + 'mariadb': check.mariadb, +}; + +Map _columnJson(Column column) => { + 'name': column.name, + 'type': column.codec.sqlType, + 'nullable': column.nullable, + 'generated': column.generated, + if (column.defaultSql != null) 'default': column.defaultSql, + if (column.computed != null) 'computed': _computedJson(column.computed!), + if (column.integerBits != null && column.integerBits != 64) + 'integerBits': column.integerBits, + if (column.temporalPrecision != null && column.temporalPrecision != 6) + 'temporalPrecision': column.temporalPrecision, + if (column.decimalPrecision != null) + 'decimalPrecision': column.decimalPrecision, + if (column.decimalPrecision != null && + column.decimalScale != null && + column.decimalScale != 0) + 'decimalScale': column.decimalScale, +}; +Map _indexJson(IndexSchema index) => { + 'name': index.name, + 'columns': index.columns, + 'unique': index.unique, +}; +Map _foreignKeyJson(ForeignKey key) => { + 'columns': key.columns, + 'target': key.target, + 'targetColumns': key.targetColumns, + 'onDelete': key.onDelete, +}; +Map _tableJson(TableSchema table) => { + 'name': table.name, + 'columns': [for (final column in table.columns) _columnJson(column)], + 'primaryKey': table.primaryKey, + 'uniqueKeys': table.uniqueKeys, + 'indexes': [for (final index in table.indexes) _indexJson(index)], + 'foreignKeys': [for (final key in table.foreignKeys) _foreignKeyJson(key)], + if (table.checks.isNotEmpty) 'checks': table.checks.map(_checkJson).toList(), +}; diff --git a/lib/src/migrate/source.dart b/lib/src/migrate/source.dart new file mode 100644 index 00000000..d64b9012 --- /dev/null +++ b/lib/src/migrate/source.dart @@ -0,0 +1,165 @@ +part of '../../migrate.dart'; + +/// Statically imported migrations and their independently recorded fingerprints. +/// Keep these entries in version order. [checked] validates before returning them. +final class MigrationHistory { + final SqlDialect dialect; + final List<(Migration, String)> entries; + MigrationHistory( + Iterable<(Migration, String)> entries, { + required this.dialect, + }) : entries = List.unmodifiable(entries); + + List get checked { + final migrations = []; + for (final (migration, expected) in entries) { + if (migration.checksum != expected) { + throw OrmException( + 'MIGRATION.CHECKSUM', + 'Migration ${migration.id} differs from its recorded Dart definition.', + ); + } + migrations.add(migration); + } + validateMigrations(migrations, dialect: dialect); + return List.unmodifiable(migrations); + } +} + +/// Emits a standalone historical schema library, without application imports. +String schemaSource(SchemaSnapshot schema) => + ''' +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = ${_snapshotSource(schema)}; +'''; + +/// Emits a reviewed migration and its fixed fingerprint as ordinary Dart values. +/// Formatting/comments are not part of the fingerprint; SQL and operation data are. +String migrationSource(Migration migration) => + ''' +// Review before applying. Applied migrations must remain unchanged. +import 'package:orm/migrate.dart'; + +const migrationChecksum = ${_dartValue(migration.checksum)}; +final migration = Migration.steps( + ${_dartValue(migration.id)}, + [${migration.steps.map(_stepSource).join(', ')}], + dialect: SqlDialect.${migration.dialect.name}, + ${migration.previous == null ? '' : 'previous: ${_dartValue(migration.previous)},'} + ${migration.snapshot == null ? '' : 'snapshot: _schema,'} +); +${migration.snapshot == null ? '' : '\nfinal _schema = ${_snapshotSource(migration.snapshot!)};'} +'''; + +/// Emits a static registry. Each file owns its recorded fingerprint so rebuilding +/// this registry does not silently accept edited migration definitions. +String migrationHistorySource( + Iterable ids, { + required SqlDialect dialect, +}) { + final names = ids.toList(); + if (names.toSet().length != names.length || + names.any((id) => !RegExp(r'^[0-9]+_[a-z][a-z0-9_]*$').hasMatch(id))) { + throw ArgumentError('Use distinct migration IDs in version order.'); + } + return ''' +// GENERATED CODE - DO NOT MODIFY BY HAND. +import 'package:orm/migrate.dart'; +${[for (var i = 0; i < names.length; i++) "import 'm${names[i]}.dart' as m$i;"].join('\n')} + +const migrationDialect = SqlDialect.${dialect.name}; +final migrationHistory = MigrationHistory([ + ${[for (var i = 0; i < names.length; i++) '(m$i.migration, m$i.migrationChecksum),'].join('\n')} +], dialect: migrationDialect); +'''; +} + +String _snapshotSource(SchemaSnapshot schema) => + 'SchemaSnapshot([${schema.tables.map(_tableSource).join(', ')}])'; + +String _tableSource(TableSchema table) => + '''TableSchema( + ${_dartValue(table.name)}, + columns: [${table.columns.map(_columnSource).join(', ')}], + primaryKey: ${_dartValue(table.primaryKey)}, + ${table.uniqueKeys.isEmpty ? '' : 'uniqueKeys: ${_dartValue(table.uniqueKeys)},'} + ${table.indexes.isEmpty ? '' : 'indexes: [${table.indexes.map((i) => 'IndexSchema(${_dartValue(i.name)}, ${_dartValue(i.columns)}, unique: ${i.unique})').join(', ')}],'} + ${table.foreignKeys.isEmpty ? '' : 'foreignKeys: [${table.foreignKeys.map((k) => 'ForeignKey(${_dartValue(k.columns)}, ${_dartValue(k.target)}, ${_dartValue(k.targetColumns)}, onDelete: ${_dartValue(k.onDelete)})').join(', ')}],'} + ${table.checks.isEmpty ? '' : 'checks: [${table.checks.map(_checkSource).join(', ')}],'} +)'''; + +String _columnSource(Column column) { + final codec = switch (column.codec.sqlType) { + 'instant' => 'Codecs.dateTime', + 'date' => 'Codecs.date', + 'time' => 'Codecs.time', + 'local_datetime' => 'Codecs.localDateTime', + 'blob' => 'Codecs.bytes', + 'timestamp' => "Codec('timestamp', (v) => v, (v) => v)", + 'integer' || + 'bigint' || + 'decimal' || + 'text' || + 'real' || + 'boolean' || + 'json' => 'Codecs.${column.codec.sqlType}', + final unsupported => throw OrmException( + 'SCHEMA.TYPE', + 'Unknown storage type $unsupported.', + ), + }; + return '''Column( + ${_dartValue(column.name)}, $codec${column.nullable ? '.nullable()' : ''}, + ${column.nullable ? 'nullable: true,' : ''} + ${column.generated ? 'generated: true,' : ''} + ${column.defaultSql == null ? '' : 'defaultSql: ${_dartValue(column.defaultSql)},'} + ${column.computed == null ? '' : 'computed: ${_computedSource(column.computed!)},'} + ${column.integerBits == null ? '' : 'integerBits: ${column.integerBits},'} + ${column.decimalPrecision == null ? '' : 'decimalPrecision: ${column.decimalPrecision},'} + ${column.decimalScale == null ? '' : 'decimalScale: ${column.decimalScale},'} + ${column.temporalPrecision == null ? '' : 'temporalPrecision: ${column.temporalPrecision},'} + )'''; +} + +String _computedSource(ComputedColumn column) => + column.sqlite == column.postgres && + column.mysql == column.sqlite && + column.mariadb == column.sqlite + ? 'ComputedColumn(${_dartValue(column.sqlite)}, storage: ComputedStorage.${column.storage.name})' + : 'ComputedColumn.forDialects(sqlite: ${_dartValue(column.sqlite)}, postgres: ${_dartValue(column.postgres)}, mysql: ${_dartValue(column.mysql)}, mariadb: ${_dartValue(column.mariadb)}, storage: ComputedStorage.${column.storage.name})'; +String _checkSource(CheckSchema check) => + check.sqlite == check.postgres && + check.mysql == check.sqlite && + check.mariadb == check.sqlite + ? 'CheckSchema(${_dartValue(check.name)}, ${_dartValue(check.sqlite)})' + : 'CheckSchema.forDialects(${_dartValue(check.name)}, sqlite: ${_dartValue(check.sqlite)}, postgres: ${_dartValue(check.postgres)}, mysql: ${_dartValue(check.mysql)}, mariadb: ${_dartValue(check.mariadb)})'; + +String _stepSource(MigrationStep step) => switch (step) { + ExecuteSql() => 'ExecuteSql(${_dartValue(step.sql)})', + CheckedTableSql() => + 'CheckedTableSql(${_dartValue(step.sql)}, before: ${step.before == null ? 'null' : _tableSource(step.before!)}, after: ${step.after == null ? 'null' : _tableSource(step.after!)})', + DropTable() => 'DropTable(${_dartValue(step.table)})', + DropConstraint() => + 'DropConstraint(${_dartValue(step.table)}, ${_dartValue(step.constraint)})', + RebuildTable() => + 'RebuildTable(${_tableSource(step.before)}, ${_tableSource(step.after)}, copy: ${_dartValue(step.copy)})', + CheckedSql() => + 'CheckedSql(${_dartValue(step.sql)}, readyWhen: ${_dartValue(step.readyWhen)}, doneWhen: ${_dartValue(step.doneWhen)})', + Backfill() => + 'Backfill(${_tableSource(step.table)}, set: ${_dartValue(step.set)}, where: ${_dartValue(step.where)}, doneWhen: ${_dartValue(step.doneWhen)}, batchSize: ${step.batchSize})', +}; + +String _dartValue(Object? value) => switch (value) { + String() => jsonEncode(value).replaceAll(r'$', r'\$'), + null || bool() || int() => '$value', + List() => '[${value.map(_dartValue).join(', ')}]', + Map() => + '{${value.entries.map((e) => '${_dartValue(e.key)}: ${_dartValue(e.value)}').join(', ')}}', + _ => throw ArgumentError.value( + value, + 'value', + 'Unsupported migration literal.', + ), +}; diff --git a/lib/src/migrate/sqlite_checks.dart b/lib/src/migrate/sqlite_checks.dart new file mode 100644 index 00000000..1ce7ece2 --- /dev/null +++ b/lib/src/migrate/sqlite_checks.dart @@ -0,0 +1,331 @@ +part of '../../migrate.dart'; + +typedef _SqliteToken = ({String text, int start, int end}); +typedef _SqliteCheck = ({ + String signature, + String expression, + String? name, + int start, + int end, +}); + +bool _sqliteWord(int c) => + c >= 128 || + c >= 48 && c <= 57 || + c >= 65 && c <= 90 || + c >= 97 && c <= 122 || + c == 95 || + c == 36; +String _sqliteName(String name) => String.fromCharCodes( + name.codeUnits.map((c) => c >= 65 && c <= 90 ? c + 32 : c), +); + +// Tokenize constraint expressions without mistaking quoted defaults/comments for +// constraints. Storage ranges still require exact emitted signatures below. +// This is not a general SQL parser. +List<_SqliteToken> _sqliteTokens(String sql) { + final result = <_SqliteToken>[]; + var i = 0; + while (i < sql.length) { + if (sql[i].trim().isEmpty) { + i++; + continue; + } + if (sql.startsWith('--', i)) { + final end = sql.indexOf('\n', i + 2); + i = end < 0 ? sql.length : end + 1; + continue; + } + if (sql.startsWith('/*', i)) { + final end = sql.indexOf('*/', i + 2); + i = end < 0 ? sql.length : end + 2; + continue; + } + final start = i, first = sql[i]; + String text; + if ({"'", '"', '`', '['}.contains(first)) { + final close = first == '[' ? ']' : first; + final value = StringBuffer(); + i++; + while (i < sql.length) { + if (sql[i] == close) { + i++; + if (first != '[' && i < sql.length && sql[i] == close) { + value.write(close); + i++; + continue; + } + break; + } + value.write(sql[i++]); + } + text = '${first == "'" ? 's' : 'i'}:${value.toString()}'; + } else if (_sqliteWord(sql.codeUnitAt(i))) { + i++; + while (i < sql.length && _sqliteWord(sql.codeUnitAt(i))) { + i++; + } + text = String.fromCharCodes( + sql + .substring(start, i) + .codeUnits + .map((c) => c >= 97 && c <= 122 ? c - 32 : c), + ); + } else { + text = sql[i++]; + } + result.add((text: text, start: start, end: i)); + } + return result; +} + +List<_SqliteCheck> _sqliteChecks(String sql) { + final tokens = _sqliteTokens(sql), checks = <_SqliteCheck>[]; + for (var i = 0; i + 1 < tokens.length; i++) { + if (tokens[i].text != 'CHECK' || tokens[i + 1].text != '(') continue; + var depth = 1, end = i + 2; + for (; end < tokens.length; end++) { + if (tokens[end].text == '(') depth++; + if (tokens[end].text == ')' && --depth == 0) break; + } + if (depth != 0) break; + final named = i >= 2 && tokens[i - 2].text == 'CONSTRAINT'; + final name = named ? tokens[i - 1] : null; + checks.add(( + signature: jsonEncode( + tokens.sublist(i + 2, end).map((t) => t.text).toList(), + ), + expression: sql.substring(tokens[i + 1].end, tokens[end].start).trim(), + name: name == null + ? null + : name.text.startsWith('i:') || name.text.startsWith('s:') + ? name.text.substring(2) + : sql.substring(name.start, name.end), + start: named ? tokens[i - 2].start : tokens[i].start, + end: tokens[end].end, + )); + i = end; + } + return checks; +} + +String _integerSignature(String name, int bits) => jsonEncode( + _sqliteTokens(_integerCheck(name, bits)).map((t) => t.text).toList(), +); + +int _sqliteIntegerBits(String name, List<_SqliteCheck> checks) { + for (final bits in [16, 32]) { + final signature = _integerSignature(name, bits); + if (checks.any((c) => c.name == null && c.signature == signature)) { + return bits; + } + } + return 64; +} + +String _withoutIntegerChecks(String sql, List columns) { + final known = { + for (final c in columns.where((c) => c.temporalPrecision != null)) + _temporalSignature( + c.name, + _temporalCollationKind(c.collation)!, + c.temporalPrecision!, + ), + for (final c in columns.where((c) => c.decimalPrecision != null)) + _decimalSignature(c.name, c.decimalPrecision!, c.decimalScale ?? 0), + for (final c in columns.where((c) => c.storageType == 'INTEGER')) + for (final bits in [16, 32]) _integerSignature(c.name, bits), + }; + final result = StringBuffer(); + var start = 0; + for (final check in _sqliteChecks(sql)) { + if (check.name != null || !known.contains(check.signature)) continue; + result.write(sql.substring(start, check.start)); + start = check.end; + } + return (result..write(sql.substring(start))).toString(); +} + +String _decimalSignature(String name, int precision, int scale) => jsonEncode( + _sqliteTokens(_decimalCheck(name, precision, scale)) + .map((t) => t.text) + .toList(), +); + +(int, int)? _sqliteDecimalDigits(String name, List<_SqliteCheck> checks) { + for (final check in checks) { + if (check.name != null) continue; + final tokens = (jsonDecode(check.signature) as List).cast(); + final index = tokens.indexOf('ORM_DECIMAL_FITS_V1'); + if (index < 0 || index + 7 >= tokens.length) continue; + final precision = int.tryParse(tokens[index + 4]); + final end = tokens.indexOf(')', index + 6); + if (end < 0) continue; + final scale = int.tryParse(tokens.sublist(index + 6, end).join()); + if (precision == null || + precision < 1 || + precision > 1000 || + scale == null || + scale < -1000 || + scale > 1000) { + continue; + } + if (check.signature == _decimalSignature(name, precision, scale)) { + return (precision, scale); + } + } + return null; +} + +String? _uncoerceDecimalDefault(String sql, int precision, int scale) { + final tokens = _sqliteTokens(sql); + if (tokens.length < 8 || + tokens[0].text != 'ORM_DECIMAL_CAST_V1' || + tokens[1].text != '(' || + tokens.last.text != ')') { + return null; + } + final separators = []; + var depth = 0; + for (var i = 2; i < tokens.length - 1; i++) { + if (tokens[i].text == '(') depth++; + if (tokens[i].text == ')') depth--; + if (depth < 0) return null; + if (depth == 0 && tokens[i].text == ',') separators.add(i); + } + if (separators.length != 2 || depth != 0) return null; + final [a, b] = separators; + if (int.tryParse(tokens.sublist(a + 1, b).map((t) => t.text).join()) != + precision || + int.tryParse( + tokens.sublist(b + 1, tokens.length - 1).map((t) => t.text).join(), + ) != + scale) { + return null; + } + return sql.substring(tokens[2].start, tokens[a].start).trim(); +} + +typedef _SqliteCollation = ({ + String column, + String collation, + int start, + int end, +}); + +// Only column-level clauses at the CREATE TABLE body's outer depth. COLLATE +// inside CHECK/default expressions and table/index constraints is not erased. +List<_SqliteCollation> _sqliteColumnCollations(String sql) { + final tokens = _sqliteTokens(sql), result = <_SqliteCollation>[]; + var depth = 0; + String? column; + var beginning = false; + String identifier(String token) => + token.startsWith('i:') || token.startsWith('s:') + ? token.substring(2) + : token; + for (var i = 0; i < tokens.length; i++) { + final token = tokens[i]; + if (token.text == '(') { + depth++; + if (depth == 1) beginning = true; + } else if (token.text == ')') { + depth--; + } else if (depth == 1 && token.text == ',') { + beginning = true; + column = null; + } else if (depth == 1 && beginning) { + column = + { + 'CONSTRAINT', + 'PRIMARY', + 'UNIQUE', + 'CHECK', + 'FOREIGN', + }.contains(token.text) + ? null + : identifier(token.text); + beginning = false; + } else if (depth == 1 && + column != null && + token.text == 'COLLATE' && + i + 1 < tokens.length) { + final next = tokens[++i]; + result.add(( + column: column, + collation: identifier(next.text), + start: token.start, + end: next.end, + )); + } + } + return result; +} + +String _withoutStorageCollations(String sql, List columns) { + final names = { + for (final c in columns.where((c) => c.storageType == 'TEXT')) + _sqliteName(c.name), + }; + final result = StringBuffer(); + var start = 0; + for (final c in _sqliteColumnCollations(sql)) { + if (!names.contains(_sqliteName(c.column)) || + !{ + 'orm_decimal_v1', + 'orm_date_v1', + 'orm_instant_v1', + 'orm_time_v1', + 'orm_local_datetime_v1', + }.contains(c.collation.toLowerCase())) { + continue; + } + result.write(sql.substring(start, c.start)); + start = c.end; + } + return (result..write(sql.substring(start))).toString(); +} + +String _temporalSignature(String name, String kind, int digits) => jsonEncode( + _sqliteTokens(_temporalCheck(name, kind, digits)).map((t) => t.text).toList(), +); +int? _sqliteTemporalPrecision( + String name, + String kind, + List<_SqliteCheck> checks, +) { + for (var digits = 0; digits < 6; digits++) { + final signature = _temporalSignature(name, kind, digits); + if (checks.any((c) => c.name == null && c.signature == signature)) { + return digits; + } + } + return null; +} + +String? _uncoerceTemporal(String sql, String kind, int digits) { + final tokens = _sqliteTokens(sql); + if (tokens.length < 8 || + tokens[0].text != 'ORM_TEMPORAL_CAST_V1' || + tokens[1].text != '(' || + tokens.last.text != ')') { + return null; + } + final separators = []; + var depth = 0; + for (var i = 2; i < tokens.length - 1; i++) { + if (tokens[i].text == '(') depth++; + if (tokens[i].text == ')') depth--; + if (depth < 0) return null; + if (depth == 0 && tokens[i].text == ',') separators.add(i); + } + if (separators.length != 2 || depth != 0) return null; + final [a, b] = separators; + if (b != a + 2 || + tokens[a + 1].text != 's:$kind' || + tokens.length != b + 3 || + int.tryParse(tokens[b + 1].text) != digits) { + return null; + } + return sql.substring(tokens[2].start, tokens[a].start).trim(); +} diff --git a/lib/src/migrate/step.dart b/lib/src/migrate/step.dart new file mode 100644 index 00000000..10787b04 --- /dev/null +++ b/lib/src/migrate/step.dart @@ -0,0 +1,246 @@ +part of '../../migrate.dart'; + +/// An explicit operation in a reviewed migration. +sealed class MigrationStep { + const MigrationStep(); + Map toJson(); +} + +final class ExecuteSql(final String sql) extends MigrationStep { + @override + Map toJson() => {'kind': 'sql', 'sql': sql}; +} + +/// A reviewed table removal. The SQLite runner checks foreign keys before commit. +final class DropTable(final String table) extends MigrationStep { + @override + Map toJson() => {'kind': 'dropTable', 'table': table}; +} + +/// SQLite's copy-and-replace operation. Expressions are trusted migration SQL. +/// Both snapshots are after any explicit table/column renames. +final class RebuildTable extends MigrationStep { + final TableSchema before; + final TableSchema after; + final Map copy; + RebuildTable(this.before, this.after, {required Map copy}) + : copy = Map.unmodifiable(copy) { + if (before.name != after.name || + copy.isEmpty || + copy.keys.any( + (key) => + !after.columns.any((c) => c.name == key && c.computed == null), + )) { + throw const OrmException( + 'MIGRATION.REBUILD', + 'Rebuild requires the same table name and an explicit target-column copy map.', + ); + } + } + @override + Map toJson() => { + 'kind': 'rebuild', + 'before': _tableJson(before), + 'after': _tableJson(after), + 'copy': copy, + }; +} + +/// Resolves the actual PostgreSQL name by a constraint signature. This also +/// works for baselined databases whose constraint names were chosen elsewhere. +final class DropConstraint extends MigrationStep { + final String table; + final Map constraint; + DropConstraint(this.table, Map constraint) + : constraint = _freezeJson(constraint) as Map; + @override + Map toJson() => { + 'kind': 'dropConstraint', + 'table': table, + 'constraint': constraint, + }; +} + +Future _executeStep(SqlDatabase db, MigrationStep step) async { + switch (step) { + case CheckedSql() || CheckedTableSql() || Backfill(): + throw const OrmException( + 'MIGRATION.TRANSACTION', + 'This step requires the recovery runner.', + ); + case ExecuteSql(): + await db.execute(SqlCommand(step.sql)); + case DropTable(): + await db.execute(SqlCommand('DROP TABLE ${_quote(step.table)}')); + case RebuildTable(): + await _rebuild(db, step); + case DropConstraint(): + if (step.constraint['kind'] == 'c') { + await _dropCheck( + db, + step.table, + CheckSchema( + step.constraint['name'] as String?, + step.constraint['expression'] as String, + ), + ); + return; + } + final rows = await db.execute( + SqlCommand( + r''' +SELECT c.conname, c.contype::text, + ARRAY(SELECT a.attname::text FROM unnest(c.conkey) WITH ORDINALITY k(num, ord) + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.num ORDER BY k.ord), + t.relname, + ARRAY(SELECT a.attname::text FROM unnest(c.confkey) WITH ORDINALITY k(num, ord) + JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.num ORDER BY k.ord), + c.confdeltype::text +FROM pg_constraint c JOIN pg_class r ON r.oid = c.conrelid +JOIN pg_namespace n ON n.oid = r.relnamespace +LEFT JOIN pg_class t ON t.oid = c.confrelid +WHERE n.nspname = current_schema() AND r.relname = $1''', + [step.table], + ), + ); + final matches = rows.rows + .where( + (row) => + _hash({ + 'kind': row[1], + 'columns': row[2], + if (row[1] == 'f') ...{ + 'target': row[3], + 'targetColumns': row[4], + 'onDelete': switch (row[5]) { + 'a' => 'NO ACTION', + 'r' => 'RESTRICT', + 'c' => 'CASCADE', + 'n' => 'SET NULL', + 'd' => 'SET DEFAULT', + _ => '', + }, + }, + }) == + _hash(step.constraint), + ) + .toList(); + if (matches.length != 1) { + throw OrmException( + 'MIGRATION.DRIFT', + 'Expected exactly one matching constraint on ${step.table}; found ${matches.length}.', + ); + } + await db.execute( + SqlCommand( + 'ALTER TABLE ${_quote(step.table)} DROP CONSTRAINT ${_quote(matches.single.first as String)}', + ), + ); + } +} + +Future _rebuild(SqlDatabase db, RebuildTable step) async { + final before = step.before, after = step.after, name = after.name; + final actual = await inspectTable(db, name); + final checkMatches = await _matchChecks( + db, + name, + before.checks, + actual.checks, + ); + if (actual.checks.length > checkMatches.whereType().length) { + throw OrmException( + 'MIGRATION.UNMANAGED', + '$name has undeclared CHECK constraints.', + ); + } + final unsupported = actual.unmanaged + .where((o) => !{'index', 'trigger', 'view'}.contains(o.kind)) + .toList(); + if (unsupported.isNotEmpty || + actual.columns.any((c) => c.generated && c.computed == null)) { + throw OrmException( + 'MIGRATION.UNMANAGED', + '$name has unmodeled table constraints/options; provide a manual migration to preserve them.', + ); + } + final check = await verifySchema(db, SchemaSnapshot([before])); + final differences = check.differences + .where((d) => d != '$name indexes differs') + .toList(); + for (final index in before.indexes) { + if (!actual.indexes.any( + (i) => _hash(_indexJson(i)) == _hash(_indexJson(index)), + )) { + differences.add('$name.${index.name} index differs'); + } + } + if (differences.isNotEmpty) { + throw OrmException('MIGRATION.DRIFT', differences.join('\n')); + } + final removed = before.columns + .map((c) => c.name) + .toSet() + .difference(after.columns.map((c) => c.name).toSet()); + if (removed.isNotEmpty && actual.unmanaged.any((o) => o.kind == 'trigger')) { + throw OrmException( + 'MIGRATION.UNMANAGED', + '$name has triggers and removed columns; update them in a manual migration.', + ); + } + final objects = await db.execute( + SqlCommand( + "SELECT type, name, sql FROM sqlite_schema WHERE tbl_name = ?1 AND type IN ('index', 'trigger') AND sql IS NOT NULL ORDER BY type, name", + [name], + ), + ); + final managedIndexes = before.indexes.map((i) => i.name).toSet(); + final targetIndexes = after.indexes.map((i) => i.name).toSet(); + for (final row in objects.rows) { + if (row[0] == 'index' && + !managedIndexes.contains(row[1]) && + targetIndexes.contains(row[1])) { + throw OrmException( + 'MIGRATION.UNMANAGED', + 'New index ${row[1]} conflicts with an unmanaged index.', + ); + } + } + final temporary = '_orm_rebuild_$name'; + await db.execute( + SqlCommand(_createTable(after, SqlDialect.sqlite, name: temporary)), + ); + await db.execute( + SqlCommand( + 'INSERT INTO ${_quote(temporary)} (${step.copy.keys.map(_quote).join(', ')}) ' + 'SELECT ${step.copy.entries.map((e) => _coerceColumn(e.value, after.columns.firstWhere((c) => c.name == e.key), db.dialect)).join(', ')} FROM ${_quote(name)}', + ), + ); + await db.execute(SqlCommand('DROP TABLE ${_quote(name)}')); + // Views remain present while their source is temporarily absent. Legacy mode + // prevents SQLite from reparsing those views during this one rename. + final legacy = (await db.execute(SqlCommand('PRAGMA legacy_alter_table'))) + .rows + .single + .single; + try { + await db.execute(SqlCommand('PRAGMA legacy_alter_table = ON')); + await db.execute( + SqlCommand('ALTER TABLE ${_quote(temporary)} RENAME TO ${_quote(name)}'), + ); + } finally { + await db.execute( + SqlCommand('PRAGMA legacy_alter_table = ${legacy == 1 ? 'ON' : 'OFF'}'), + ); + } + for (final index in after.indexes) { + await db.execute(SqlCommand(_createIndex(name, index))); + } + for (final row in objects.rows) { + if (row[0] == 'index' && managedIndexes.contains(row[1])) continue; + await db.execute(SqlCommand(row[2] as String)); + } + for (final view in actual.unmanaged.where((o) => o.kind == 'view')) { + await db.execute(SqlCommand('SELECT * FROM ${_quote(view.name)} LIMIT 0')); + } +} diff --git a/lib/src/mutation.dart b/lib/src/mutation.dart new file mode 100644 index 00000000..3ee5f388 --- /dev/null +++ b/lib/src/mutation.dart @@ -0,0 +1,412 @@ +part of '../sql.dart'; + +final class Assignment { + final Field field; + final _Node? _value; + const Assignment._(this.field, this._value); +} + +enum _MutationKind { insert, update, delete } + +final class Mutation { + final QueryContext database; + final F _fields; + final _QueryState _state; + final _MutationKind _kind; + final List _assignments; + final F Function(TableRef)? _createFields; + final _Conflict? _conflict; + final List>? _rows; + Mutation._( + this.database, + this._fields, + this._state, + this._kind, + List assignments, { + this._createFields, + this._conflict, + this._rows, + }) : _assignments = List.unmodifiable(assignments); + + Mutation onConflictDoNothing({ + List> Function(F)? target, + }) => _withConflict(target == null ? [] : target(_fields), const [], null); + + /// MySQL/MariaDB update on any duplicate unique key, matching native semantics. + Mutation onDuplicateKeyUpdate({ + required List Function(F existing, F incoming) set, + }) { + if (_kind != _MutationKind.insert || _createFields == null) { + throw const OrmException( + 'MUTATION.CONFLICT', + 'Upsert applies to an insert.', + ); + } + final incoming = _createFields(TableRef(_state.source.schema)); + final assignments = set(_fields, incoming); + if (assignments.isEmpty) { + throw const OrmException( + 'MUTATION.EMPTY', + 'Conflict update needs assignments.', + ); + } + return Mutation._( + database, + _fields, + _state, + _kind, + _assignments, + createFields: _createFields, + conflict: _Conflict( + const [], + List.unmodifiable(assignments), + incoming.table, + ), + ); + } + + Mutation onConflictUpdate({ + required List> Function(F) target, + required List Function(F existing, F incoming) set, + }) { + if (_createFields == null) { + throw const OrmException( + 'MUTATION.CONFLICT', + 'Upsert applies to an insert.', + ); + } + final incoming = _createFields(TableRef(_state.source.schema)); + final assignments = set(_fields, incoming); + if (assignments.isEmpty) { + throw const OrmException( + 'MUTATION.EMPTY', + 'Conflict update needs assignments.', + ); + } + return _withConflict(target(_fields), assignments, incoming.table); + } + + Mutation _withConflict( + List> target, + List assignments, + TableRef? incoming, + ) { + if (_kind != _MutationKind.insert) { + throw const OrmException( + 'MUTATION.CONFLICT', + 'Upsert applies to an insert.', + ); + } + final schema = _state.source.schema; + final names = [for (final field in target) field.definition.name]; + if (names.isEmpty && assignments.isNotEmpty) { + throw const OrmException( + 'MUTATION.CONFLICT', + 'Conflict update requires a unique key target.', + ); + } + if (target.any((f) => f.table != _state.source)) { + throw const OrmException( + 'QUERY.SCOPE', + 'Conflict target belongs to another table.', + ); + } + final keys = [ + schema.primaryKey, + ...schema.uniqueKeys, + for (final i in schema.indexes) + if (i.unique) i.columns, + ]; + if ((names.isNotEmpty || assignments.isNotEmpty) && + !keys.any( + (k) => + k.length == names.length && + k.indexed.every((entry) => entry.$2 == names[entry.$1]), + )) { + throw const OrmException( + 'MUTATION.CONFLICT', + 'Conflict target must be a declared unique key.', + ); + } + return Mutation._( + database, + _fields, + _state, + _kind, + _assignments, + createFields: _createFields, + conflict: _Conflict( + List.unmodifiable(names), + List.unmodifiable(assignments), + incoming, + ), + ); + } + + SqlCommand _compile([_SelectionPlan? selection]) { + if (selection != null && selection.columns.isEmpty) { + throw const OrmException( + 'QUERY.EMPTY_SELECTION', + 'Select at least one field.', + ); + } + if (selection != null && + selection.columns.any((e) => _aggregate(e._node) || _window(e._node))) { + throw const OrmException( + 'MUTATION.RETURNING', + 'RETURNING cannot contain aggregate or window functions.', + ); + } + if (selection != null && + (selection.relations.isNotEmpty || selection.joins.isNotEmpty)) { + throw const OrmException( + 'MUTATION.RELATION', + 'RETURNING selects scalar fields; query relations after the mutation.', + ); + } + if (_state.limit != null || + _state.joins.isNotEmpty || + _state.union != null || + _state.ctes.isNotEmpty || + _state.offset != null || + _state.order.isNotEmpty || + _state.group.isNotEmpty || + _state.having != null || + _state.distinct) { + throw const OrmException( + 'MUTATION.QUERY', + 'Mutations accept a table and WHERE; select keys for paginated mutations.', + ); + } + final w = _Writer( + database.dialect, + {_state.source: 't0'}, + database: database, + exactDecimal: database.capabilities.exactDecimal, + temporal: database.capabilities.temporal, + ); + void validate(List assignments) { + final names = {}; + for (final a in assignments) { + if (a._value case final node?) { + if (_aggregate(node) || _window(node)) { + throw const OrmException( + 'QUERY.AGGREGATE', + 'Assignments cannot contain aggregate or window functions. Use a scalar subquery.', + ); + } + } + if (a._value == null && + !a.field.definition.generated && + a.field.definition.defaultSql == null) { + throw const OrmException( + 'MUTATION.DEFAULT', + 'Column has no declared database default.', + ); + } + if (a.field.table != _state.source) { + throw const OrmException( + 'QUERY.SCOPE', + 'Assignment belongs to another table.', + ); + } + if (!names.add(a.field.definition.name)) { + throw const OrmException( + 'MUTATION.DUPLICATE', + 'A column can be assigned only once.', + ); + } + } + } + + validate(_assignments); + if (_state.predicate case final predicate?) { + if (_aggregate(predicate._node) || _window(predicate._node)) { + throw const OrmException( + 'QUERY.AGGREGATE', + 'Mutation predicates cannot contain aggregate or window functions. Use a subquery.', + ); + } + } + for (final row in _rows ?? >[]) { + validate(row); + } + if (_conflict case final conflict?) validate(conflict.assignments); + String assigned(Assignment a) { + if (a._value case final expression?) return expression.write(w); + if (a.field.definition.defaultSql == null) { + throw const OrmException( + 'MUTATION.DEFAULT', + 'Column has no declared database default.', + ); + } + if (database.dialect == SqlDialect.sqlite) { + throw const OrmException( + 'CAPABILITY.DEFAULT', + 'SQLite does not support SET column = DEFAULT.', + ); + } + return 'DEFAULT'; + } + + final table = w.mysql && _kind == _MutationKind.insert + ? w.quote(_state.source.schema.name) + : '${w.quote(_state.source.schema.name)} AS ${w.quote('t0')}'; + if (w.mysql && _kind == _MutationKind.insert) { + w.unqualifiedTable = _state.source; + } + final b = StringBuffer(); + switch (_kind) { + case _MutationKind.insert: + final values = _assignments.where((a) => a._value != null).toList(); + b.write('INSERT INTO $table'); + if (values.isEmpty) { + b.write(w.mysql ? ' () VALUES ()' : ' DEFAULT VALUES'); + } else { + b.write( + ' (${values.map((a) => w.quote(a.field.definition.name)).join(', ')})', + ); + final rows = _rows ?? [_assignments]; + b.write( + ' VALUES ${rows.map((row) => '(${row.where((a) => a._value != null).map(assigned).join(', ')})').join(', ')}', + ); + } + case _MutationKind.update: + if (_assignments.isEmpty) { + throw const OrmException( + 'MUTATION.EMPTY', + 'Update needs an assignment.', + ); + } + b.write( + 'UPDATE $table SET ${_assignments.map((a) => '${w.quote(a.field.definition.name)} = ${assigned(a)}').join(', ')}', + ); + case _MutationKind.delete: + b.write('DELETE FROM $table'); + } + if (_state.predicate case final predicate?) { + b.write(' WHERE ${predicate._node.write(w)}'); + } + if (_conflict case final conflict?) { + if (w.mysql) { + if (conflict.target.isNotEmpty) { + throw const OrmException( + 'CAPABILITY.CONFLICT_TARGET', + 'MySQL/MariaDB cannot select a conflict target. Use onDuplicateKeyUpdate.', + ); + } + b.write(' ON DUPLICATE KEY UPDATE '); + if (conflict.assignments.isEmpty) { + throw const OrmException( + 'CAPABILITY.DO_NOTHING', + 'MySQL/MariaDB cannot ignore only duplicate keys without update side effects.', + ); + } else { + w.aliases[conflict.incoming!] = 'excluded'; + b.write( + conflict.assignments + .map( + (a) => '${w.quote(a.field.definition.name)} = ${assigned(a)}', + ) + .join(', '), + ); + w.aliases.remove(conflict.incoming); + } + } else { + if (conflict.target.isEmpty && conflict.assignments.isNotEmpty) { + throw const OrmException( + 'CAPABILITY.DUPLICATE_KEY', + 'Use onConflictUpdate with a declared target on this database.', + ); + } + b.write(' ON CONFLICT'); + if (conflict.target.isNotEmpty) { + b.write(' (${conflict.target.map(w.quote).join(', ')})'); + } + if (conflict.assignments.isEmpty) { + b.write(' DO NOTHING'); + } else { + w.aliases[conflict.incoming!] = 'excluded'; + b.write( + ' DO UPDATE SET ${conflict.assignments.map((a) => '${w.quote(a.field.definition.name)} = ${assigned(a)}').join(', ')}', + ); + w.aliases.remove(conflict.incoming); + } + } + } + if (selection != null) { + if (!database.capabilities.returning) { + throw const OrmException( + 'CAPABILITY.RETURNING', + 'Driver does not support RETURNING.', + ); + } + w.unqualified = database.dialect == SqlDialect.sqlite; + b.write( + ' RETURNING ${selection.columns.map((e) => e._node.write(w)).join(', ')}', + ); + } + return w.finish(b.toString()); + } + + SqlCommand compile() => _compile(); + Future execute({ + ExecutionOptions options = const ExecutionOptions(), + }) async => (await database.executeCommand( + compile(), + options: options, + changedTables: [_state.source.schema], + affectedOnly: true, + cascade: _kind == _MutationKind.delete, + )).affectedRows; + Returning returning(Selection Function(F) selection) => + Returning._(this, selection(_fields)); +} + +final class Returning { + final Mutation _mutation; + final Selection _selection; + Returning._(this._mutation, this._selection); + Future> get({ + ExecutionOptions options = const ExecutionOptions(), + }) async { + final plan = _SelectionPlan(); + final decode = _selection._bind(plan); + final command = _mutation._compile(plan); + final result = await _mutation.database.executeCommand( + command, + options: options, + changedTables: [_mutation._state.source.schema], + affectedOnly: true, + cascade: _mutation._kind == _MutationKind.delete, + ); + return _mutation.database.observeDecode( + command.sql, + result.rows.length, + () => [for (final row in result.rows) decode(row)], + ); + } + + Future single({ + ExecutionOptions options = const ExecutionOptions(), + }) async { + final result = await get(options: options); + if (result.length != 1) { + throw OrmException( + 'QUERY.CARDINALITY', + 'Expected one returned row, received ${result.length}.', + ); + } + return result.single; + } + + Future first({ + ExecutionOptions options = const ExecutionOptions(), + }) async => (await get(options: options)).firstOrNull; +} + +final class _Conflict( + final List target, + final List assignments, + final TableRef? incoming, +); diff --git a/lib/src/mysql/connection.dart b/lib/src/mysql/connection.dart new file mode 100644 index 00000000..36ab5096 --- /dev/null +++ b/lib/src/mysql/connection.dart @@ -0,0 +1,233 @@ +part of '../../drivers/mysql.dart'; + +final class _MysqlConnection implements SqlConnection { + final mysql.MySQLConnection _connection; + final Duration _queryTimeout; + bool _active = true; + bool _invalid = false; + Future? _pending; + Future? _releasing; + + _MysqlConnection(this._connection, this._queryTimeout); + + // The upstream client discards OK/EOF status flags. Do not infer server + // transaction state from the last SQL string or from a client-side bool. + @override + bool? get transactionActive => null; + + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) { + if (!_active || _invalid || !_connection.connected) { + return Future.error( + const OrmException( + 'SESSION.CLOSED', + 'MySQL connection lease has ended.', + ), + ); + } + options.check(); + if (options.cancellation != null) { + return Future.error( + const OrmException( + 'CAPABILITY.CANCELLATION', + 'The MySQL driver does not support statement cancellation.', + ), + ); + } + if (_pending != null) { + return Future.error( + const OrmException( + 'CONNECTION.BUSY', + 'Await the active statement before using the MySQL connection again.', + ), + ); + } + final parameters = command.parameters.map(_mysqlParameter).toList(); + late final Future operation; + operation = _execute(command.sql, parameters) + .timeout( + options.timeout ?? _queryTimeout, + onTimeout: () { + _discard(); + throw const OrmException( + 'OPERATION.TIMEOUT', + 'MySQL statement timed out; the connection was discarded and the statement outcome may be unknown.', + ); + }, + ) + .whenComplete(() { + if (identical(_pending, operation)) _pending = null; + }); + _pending = operation; + return operation; + } + + Future _execute(String sql, List parameters) async { + sql = _mysqlWithoutTerminator(sql); + mysql.PreparedStmt? statement; + try { + final mysql.IResultSet result; + if (parameters.isEmpty) { + // Upstream prepare() waits for an EOF that the protocol does not send + // for statements with no parameters and no result columns (e.g. DDL + // and ROLLBACK). Text protocol needs no value interpolation here. + result = await _connection.execute(sql); + } else { + // Never use execute(sql, namedParams): that substitutes values into + // SQL text. Parameterized statements use the binary protocol. + statement = await _connection.prepare(sql); + if (_invalid) { + throw const OrmException( + 'SESSION.CLOSED', + 'Connection was discarded.', + ); + } + result = await statement.execute(parameters); + } + final columns = result.cols.toList(); + final lastId = result.lastInsertID; + return SqlResult( + [ + for (final row in result.rows) + [ + for (var i = 0; i < columns.length; i++) + _mysqlValue( + row.colAt(i), + columns[i].type.intVal, + binary: parameters.isNotEmpty, + ), + ], + ], + columns: [for (final column in columns) column.name], + affectedRows: _checkedMysqlInt(result.affectedRows), + lastInsertId: lastId == BigInt.zero ? null : _checkedMysqlInt(lastId), + ); + } on mysql.MySQLServerException catch (error) { + throw MysqlFailure(error); + } on mysql.MySQLClientException catch (error) { + _discard(); + throw OrmException( + 'DRIVER.PROTOCOL', + 'MySQL client could not complete the statement; connection discarded.', + cause: error, + ); + } on SocketException catch (error) { + _discard(); + throw OrmException( + 'DRIVER.CONNECTION', + 'MySQL connection was lost; the statement outcome may be unknown.', + cause: error, + ); + } finally { + if (statement != null && !_invalid && _connection.connected) { + try { + await statement.deallocate(); + } catch (_) { + _discard(); + rethrow; + } + } + } + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + options.check(); + throw const OrmException( + 'CAPABILITY.STREAMING', + 'MySQL streaming is not supported by this adapter. Use a bounded query.', + ); + } + + void _discard() { + _invalid = true; + _connection.getSocket().destroy(); + } + + @override + Future invalidate() async { + if (_active || _invalid) _discard(); + } + + Future _release() => _releasing ??= (() async { + _active = false; + try { + await _pending; + } catch (_) { + // execute() has already classified and surfaced this failure. + } + if (_invalid || !_connection.connected) return; + try { + // A raw caller may leave a transaction open, and server status flags are + // unavailable. ROLLBACK is harmless when no transaction is active. + await _execute('ROLLBACK', const []).timeout(_queryTimeout); + } catch (error) { + _discard(); + throw OrmException( + 'CONNECTION.CLEANUP', + 'MySQL lease cleanup failed; the connection was discarded.', + cause: error, + ); + } + })(); +} + +// The upstream text client can replace a SELECT result with an empty trailing +// statement's OK packet when a delimiter is followed by comments. Remove only +// the final SQL delimiter, retaining quoted values and routine-body delimiters. +// Our session explicitly enables NO_BACKSLASH_ESCAPES and ANSI_QUOTES. +String _mysqlWithoutTerminator(String sql) { + var i = 0, terminator = -1; + while (i < sql.length) { + final c = sql[i]; + if (c.trim().isEmpty) { + i++; + continue; + } + if (c == '#' || + sql.startsWith('--', i) && + (i + 2 == sql.length || sql.codeUnitAt(i + 2) <= 32)) { + final end = sql.indexOf('\n', i); + i = end < 0 ? sql.length : end + 1; + continue; + } + if (sql.startsWith('/*', i)) { + final end = sql.indexOf('*/', i + 2); + if (end < 0) return sql; + // Version comments contain executable SQL, so a preceding semicolon + // cannot be assumed to be the statement's final effective token. + if (sql.startsWith('/*!', i) || sql.startsWith('/*M!', i)) { + terminator = -1; + } + i = end + 2; + continue; + } + terminator = c == ';' ? i : -1; + if (c == "'" || c == '"' || c == '`') { + final quote = c; + i++; + var closed = false; + while (i < sql.length) { + if (sql[i++] != quote) continue; + if (i < sql.length && sql[i] == quote) { + i++; + } else { + closed = true; + break; + } + } + if (!closed) return sql; + } else { + i++; + } + } + return terminator < 0 + ? sql + : sql.replaceRange(terminator, terminator + 1, ''); +} diff --git a/lib/src/mysql/values.dart b/lib/src/mysql/values.dart new file mode 100644 index 00000000..c0884f33 --- /dev/null +++ b/lib/src/mysql/values.dart @@ -0,0 +1,105 @@ +part of '../../drivers/mysql.dart'; + +final _mysqlMinInt = BigInt.from(-9223372036854775808); +final _mysqlMaxInt = BigInt.from(9223372036854775807); + +int _checkedMysqlInt(BigInt value) { + if (value < _mysqlMinInt || value > _mysqlMaxInt) { + throw const FormatException('MySQL integer exceeds Dart signed int64.'); + } + return value.toInt(); +} + +Object? _mysqlParameter(Object? value) => switch (value) { + null || String() || int() || bool() || Uint8List() => value, + double() => _finiteMysqlReal(value), + SqlReal() => _finiteMysqlReal(value.value), + BigInt() => value.toString(), + Decimal() => value.toString(), + SqlJson() => jsonEncode(value.value), + DateTime() => _mysqlDateTime(value), + LocalDate() => _mysqlDate(value), + LocalTime() => value.toString(), + LocalDateTime() => _mysqlLocalDateTime(value), + _ => throw ArgumentError.value( + value.runtimeType, + 'parameter', + 'Unsupported MySQL value type.', + ), +}; + +double _finiteMysqlReal(double value) { + if (!value.isFinite) { + throw const FormatException('MySQL floating-point values must be finite.'); + } + return value; +} + +String _mysqlDate(LocalDate value) { + if (value.year < 1000 || value.year > 9999) { + throw const FormatException( + 'MySQL dates must be between years 1000 and 9999.', + ); + } + return value.toString(); +} + +String _mysqlLocalDateTime(LocalDateTime value) { + _mysqlDate(value.date); + return value.toString(); +} + +String _mysqlDateTime(DateTime value) { + final utc = value.toUtc(); + final date = _mysqlDate(LocalDate(utc.year, utc.month, utc.day)); + final time = LocalTime( + utc.hour, + utc.minute, + utc.second, + utc.millisecond * 1000 + utc.microsecond, + ); + return '$date $time'; +} + +Object? _mysqlValue(Object? value, int type, {required bool binary}) { + if (value == null) return null; + switch (type) { + case protocol.mysqlColumnTypeString: + case protocol.mysqlColumnTypeVarString: + case protocol.mysqlColumnTypeVarChar: + // The text protocol client treats binary collations as binary storage. + // Supported CHAR/VARCHAR columns are UTF-8 even with utf8mb4_bin. Actual + // BINARY/VARBINARY storage is intentionally outside this adapter's contract. + return value is Uint8List ? utf8.decode(value) : value; + case protocol.mysqlColumnTypeTiny: + case protocol.mysqlColumnTypeShort: + case protocol.mysqlColumnTypeLong: + case protocol.mysqlColumnTypeLongLong: + case protocol.mysqlColumnTypeInt24: + case protocol.mysqlColumnTypeYear: + return _checkedMysqlInt(BigInt.parse(value as String)); + case protocol.mysqlColumnTypeFloat: + case protocol.mysqlColumnTypeDouble: + return double.parse(value as String); + case protocol.mysqlColumnTypeDate: + return (value as String).split(' ').first; + case protocol.mysqlColumnTypeTime: + case protocol.mysqlColumnTypeTime2: + case protocol.mysqlColumnTypeTimestamp: + case protocol.mysqlColumnTypeTimestamp2: + case protocol.mysqlColumnTypeDateTime: + case protocol.mysqlColumnTypeDateTime2: + // mysql_client_plus formats the binary microsecond integer without + // leading zeroes: 1 microsecond arrives as '.1', not '.000001'. The + // binary protocol always encodes the full microsecond count. + final text = value as String; + final dot = text.lastIndexOf('.'); + return !binary || dot < 0 + ? text + : '${text.substring(0, dot + 1)}${text.substring(dot + 1).padLeft(6, '0')}'; + case protocol.mysqlColumnTypeJson: + return SqlJson(value); + default: + return value; + } +} diff --git a/lib/src/named_sql.dart b/lib/src/named_sql.dart new file mode 100644 index 00000000..2342f9c0 --- /dev/null +++ b/lib/src/named_sql.dart @@ -0,0 +1,262 @@ +part of '../sql.dart'; + +/// Fixed SQL with :named bound values. Quoted text, identifiers and comments are +/// preserved. This tokenizer is not a SQL parser; use database query checks. +final class SqlTemplate { + final SqlDialect dialect; + final List _parts; + final Set parameters; + SqlTemplate._(this.dialect, this._parts, this.parameters); + factory SqlTemplate(String source, {required SqlDialect dialect}) { + _checkSqlText(source); + final mysql = dialect == SqlDialect.mysql || dialect == SqlDialect.mariadb; + final parts = [], names = {}; + final text = StringBuffer(); + var i = 0, ended = false; + String? firstWord; + bool identifier(int code) => + code >= 65 && code <= 90 || code >= 97 && code <= 122 || code == 95; + bool continuation(int code) => identifier(code) || code >= 48 && code <= 57; + Never fail(String message) => + throw OrmException('SQL.TEMPLATE', '$message at offset $i.'); + while (i < source.length) { + final c = source[i]; + if (c.trim().isEmpty) { + text.write(c); + i++; + continue; + } + if (source.startsWith('--', i) && + (!mysql || + i + 2 == source.length || + source.codeUnitAt(i + 2) <= 32) || + mysql && c == '#') { + final end = source.indexOf('\n', i); + text.write(source.substring(i, end < 0 ? source.length : end)); + i = end < 0 ? source.length : end; + continue; + } + if (source.startsWith('/*', i)) { + if (mysql && + (source.startsWith('/*!', i) || source.startsWith('/*M!', i))) { + fail('Executable comments are not allowed in named SQL'); + } + final start = i; + i += 2; + var depth = 1; + while (i < source.length && depth > 0) { + if (dialect == SqlDialect.postgres && source.startsWith('/*', i)) { + depth++; + i += 2; + } else if (source.startsWith('*/', i)) { + depth--; + i += 2; + } else { + i++; + } + } + if (depth != 0) fail('Unterminated block comment'); + text.write(source.substring(start, i)); + continue; + } + if (ended) fail('Only one SQL statement is allowed'); + if (c == ';') { + ended = true; + i++; + continue; + } + if (c == "'" || + c == '"' || + (dialect == SqlDialect.sqlite || mysql) && c == '`' || + dialect == SqlDialect.sqlite && c == '[') { + final start = i, close = c == '[' ? ']' : c; + final escapes = + c == "'" && + dialect == SqlDialect.postgres && + i > 0 && + source[i - 1].toUpperCase() == 'E' && + (i < 2 || !continuation(source.codeUnitAt(i - 2))); + i++; + var closed = false; + while (i < source.length) { + if (source[i] == '\\' && c == "'" && dialect == SqlDialect.postgres) { + if (!escapes) { + fail("Use E-strings or dollar quotes for PostgreSQL backslashes"); + } + i += 2; + } else if (source[i] == close) { + i++; + if (close != ']' && i < source.length && source[i] == close) { + i++; + } else { + closed = true; + break; + } + } else { + i++; + } + } + if (!closed) fail('Unterminated quote'); + text.write(source.substring(start, i)); + continue; + } + if (c == r'$') { + final tag = dialect == SqlDialect.postgres + ? RegExp(r'^\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$') + .firstMatch(source.substring(i)) + ?.group(0) + : null; + if (tag == null) fail('Use :name parameters'); + final end = source.indexOf(tag, i + tag.length); + if (end < 0) fail('Unterminated dollar quote'); + text.write(source.substring(i, end + tag.length)); + i = end + tag.length; + continue; + } + if (c == ':' && + (i == 0 || source[i - 1] != ':') && + i + 1 < source.length && + identifier(source.codeUnitAt(i + 1))) { + var end = i + 2; + while (end < source.length && continuation(source.codeUnitAt(end))) { + end++; + } + final name = source.substring(i + 1, end); + parts.add(text.toString()); + text.clear(); + parts.add(_SqlSlot(name)); + names.add(name); + i = end; + continue; + } + if (c == '?' && (dialect == SqlDialect.sqlite || mysql) || + c == '@' && dialect == SqlDialect.sqlite) { + fail('Use :name parameters'); + } + if (c == '\u0001' || c == '\u0002') fail('Reserved control character'); + if (identifier(source.codeUnitAt(i))) { + var end = i + 1; + while (end < source.length && + (continuation(source.codeUnitAt(end)) || + dialect == SqlDialect.postgres && source[end] == r'$')) { + end++; + } + firstWord ??= source.substring(i, end).toUpperCase(); + text.write(source.substring(i, end)); + i = end; + continue; + } + text.write(c); + i++; + } + if (!{'SELECT', 'WITH', 'VALUES'}.contains(firstWord)) { + fail('A named query requires SELECT, WITH or VALUES'); + } + parts.add(text.toString()); + return SqlTemplate._( + dialect, + List.unmodifiable(parts), + Set.unmodifiable(names), + ); + } + + SqlCommand compile( + Capabilities capabilities, + Map> arguments, + ) { + final w = _Writer( + capabilities.dialect, + {}, + exactDecimal: capabilities.exactDecimal, + temporal: capabilities.temporal, + ); + final sql = _write(w, arguments); + final command = w.finish(sql); + if (command.parameters.length > capabilities.maxParameters) { + throw const OrmException( + 'QUERY.PARAMETERS', + 'Named SQL exceeds the parameter limit.', + ); + } + return command; + } + + String _write(_Writer w, Map> arguments) { + if (w.dialect != dialect) { + throw const OrmException( + 'QUERY.DIALECT', + 'SQL template belongs to another dialect.', + ); + } + if (arguments.length != parameters.length || + !parameters.every(arguments.containsKey)) { + throw const OrmException( + 'SQL.PARAMETERS', + 'Named SQL argument names differ from its declaration.', + ); + } + final bound = {}; + return _parts + .map( + (part) => switch (part) { + String() => part, + _SqlSlot(:final name) => bound.putIfAbsent(name, () { + final expression = arguments[name]!; + if (_unwrapStorage(expression._node) is! _Parameter) { + throw const OrmException( + 'SQL.PARAMETERS', + 'Named SQL arguments must be bound values.', + ); + } + return expression._node.write(w); + }), + _ => throw StateError('Unknown SQL fragment'), + }, + ) + .join(); + } +} + +final class _SqlSlot(final String name); + +/// Generated query definitions reuse table fields and the ordinary Query +/// compiler. Their CTE source makes table mutations invalid before execution. +final class SqlQueryDefinition { + final Table result; + final Map sql; + SqlQueryDefinition(this.result, Map sql) + : sql = Map.unmodifiable(sql); + Query bind(QueryContext db, Map> arguments) { + final template = sql[db.dialect]; + if (template == null) { + throw const OrmException( + 'QUERY.DIALECT', + 'No SQL source was declared for this backend.', + ); + } + final fields = result.createFields(TableRef(result.schema)); + return Query._( + db, + fields, + _QueryState( + fields.table, + ctes: [ + _SqlCte(result.schema.name, template, Map.unmodifiable(arguments)), + ], + ), + result.selectRow(fields), + ); + } +} + +final class _SqlCte( + @override final String name, + final SqlTemplate template, + final Map> arguments, +) implements _CteDefinition { + @override + String _writeDefinition(_Writer writer) { + if (writer.reads case final reads?) reads.opaque = true; + return '${writer.quote(name)} AS (\n${template._write(writer, arguments)}\n)'; + } +} diff --git a/lib/src/observation.dart b/lib/src/observation.dart new file mode 100644 index 00000000..c98c2660 --- /dev/null +++ b/lib/src/observation.dart @@ -0,0 +1,44 @@ +part of '../orm.dart'; + +/// Synchronous ORM decoding/mapping and associated row grouping. Excludes SQL, +/// acquisition, compilation, waiting for the consumer and other client work. +final class DecodeEvent { + /// Null for a batch RETURNING result assembled from multiple commands. + final String? sql; + final int inputRows; + final Duration elapsed; + final Object? error; + const DecodeEvent({ + required this.sql, + required this.inputRows, + required this.elapsed, + this.error, + }); +} + +extension _DatabaseObservation on Database { + T _observeDecode(String? sql, int inputRows, T Function() action) { + final observer = onDecode; + if (observer == null) return action(); + final clock = Stopwatch()..start(); + Object? error; + try { + return action(); + } catch (e) { + error = e; + rethrow; + } finally { + clock.stop(); + try { + observer( + DecodeEvent( + sql: sql, + inputRows: inputRows, + elapsed: clock.elapsed, + error: error, + ), + ); + } catch (_) {} // Observers cannot change a result or its failure. + } + } +} diff --git a/lib/src/plan.dart b/lib/src/plan.dart new file mode 100644 index 00000000..c4318378 --- /dev/null +++ b/lib/src/plan.dart @@ -0,0 +1,218 @@ +part of '../sql.dart'; + +/// A physical SQL output slot, including association keys and presence markers. +/// Expressions without a direct column source have null table/column names. +typedef PlannedColumn = ({ + int index, + String codecType, + String? table, + String? column, + bool presence, +}); +typedef PlannedJoin = ({String table, bool left, bool relation}); + +/// A non-executing description of one SQL statement and its dependent batches. +/// Bound values are omitted; literal SQL text is retained. A batch SQL template +/// contains one parent key tuple. +final class QueryPlan { + final SqlDialect dialect; + final String sql; + final int parameterCount; + final List columns; + final List joins; + final List loads; + final List reads; + final bool opaqueReads; + QueryPlan._( + this.dialect, + SqlCommand command, + List columns, + List joins, + List loads, + _ReadTables reads, + ) : sql = command.sql, + parameterCount = command.parameters.length, + columns = List.unmodifiable(columns), + joins = List.unmodifiable(joins), + loads = List.unmodifiable(loads), + reads = List.unmodifiable( + reads.tables.map((t) => t.name).toSet().toList()..sort(), + ), + opaqueReads = reads.opaque; + + /// Statement templates in this tree, not the number of executed statements. + int get sqlTemplateCount => + 1 + + loads.fold( + 0, + (count, load) => count + (load.query?.sqlTemplateCount ?? 0), + ); + + Map toJson() => { + 'dialect': dialect.name, + 'sql': sql, + 'parameterCount': parameterCount, + 'columns': [ + for (final c in columns) + { + 'index': c.index, + 'codecType': c.codecType, + if (c.table != null) 'table': c.table, + if (c.column != null) 'column': c.column, + if (c.presence) 'presence': true, + }, + ], + 'joins': [ + for (final j in joins) + { + 'table': j.table, + 'kind': j.left ? 'left' : 'inner', + 'relation': j.relation, + }, + ], + 'loads': loads.map((l) => l.toJson()).toList(), + 'reads': reads, + 'opaqueReads': opaqueReads, + 'sqlTemplateCount': sqlTemplateCount, + }; +} + +/// One batch per chunk of distinct non-null parent keys, conditional on data. +/// For nested loads, this rule applies separately to each returned parent batch. +final class RelationLoadPlan { + final List parentColumns; + final List childColumns; + final int keyWidth; + final int? limitPerParent; + final int offsetPerParent; + final int? fixedParameters; + final int? maxKeysPerBatch; + + /// Null when take(0) skips this relationship entirely. + final QueryPlan? query; + RelationLoadPlan._( + List parentColumns, + List childColumns, + this.keyWidth, + this.limitPerParent, + this.offsetPerParent, + this.fixedParameters, + this.maxKeysPerBatch, + this.query, + ) : parentColumns = List.unmodifiable(parentColumns), + childColumns = List.unmodifiable(childColumns); + bool get skipped => query == null; + Map toJson() => { + 'strategy': 'batch', + 'parentColumns': parentColumns, + 'childColumns': childColumns, + 'keyWidth': keyWidth, + 'limitPerParent': limitPerParent, + 'offsetPerParent': offsetPerParent, + 'skipped': skipped, + if (!skipped) 'fixedParameters': fixedParameters, + if (!skipped) 'maxKeysPerBatch': maxKeysPerBatch, + if (query != null) 'query': query!.toJson(), + }; +} + +QueryPlan _inspectQuery(Query query) { + final plan = query._plan().$1, reads = _ReadTables(includeRaw: true); + final command = query._compile(plan, reads: reads); + return _describeStatement(query.database, query._state, plan, command, reads); +} + +QueryPlan _describeStatement( + QueryContext db, + _QueryState state, + _SelectionPlan plan, + SqlCommand command, + _ReadTables reads, +) { + final loads = [for (final load in plan.relations) load.inspect(db)]; + final columns = []; + for (final (i, expression) in plan.columns.indexed) { + final node = _unwrapStorage(expression._node); + final (table, column) = switch (node) { + _ColumnNode() => (node.table.schema.name, node.name), + _Presence() => (node.alias.fields.table.schema.name, node.alias._marker), + _ => (null, null), + }; + columns.add(( + index: i, + codecType: expression.codec.sqlType, + table: table, + column: column, + presence: node is _Presence, + )); + } + return QueryPlan._( + db.dialect, + command, + columns, + [ + for (final join in state.joins) + ( + table: join.alias.fields.table.schema.name, + left: join.left, + relation: false, + ), + for (final join in plan.joins) + ( + table: join.alias.fields.table.schema.name, + left: join.left, + relation: true, + ), + ], + loads, + reads, + ); +} + +RelationLoadPlan _inspectRelation( + QueryContext db, + _TypedRelationBinding binding, +) { + final relation = binding.relation, width = relation._child.length; + final state = relation._state; + if (state.limit == 0) { + return RelationLoadPlan._( + binding.parentIndices, + const [], + width, + 0, + state.offset ?? 0, + null, + null, + null, + ); + } + final plan = _SelectionPlan(); + // Bind descriptions, never decode a fake row. + relation._selection._bind(plan); + final childIndices = [for (final key in relation._child) plan.column(key)]; + final reads = _ReadTables(includeRaw: true); + // Null placeholders require no codec or fabricated domain object. The command + // is a template only and is never sent to a driver. + final command = binding._compile(db, plan, [ + _RelationKey(List.filled(width, null)), + ], reads: reads); + final fixed = command.parameters.length - width; + final capacity = (db.capabilities.maxParameters - fixed) ~/ width; + if (capacity < 1) { + throw const OrmException( + 'QUERY.PARAMETERS', + 'No parameter capacity remains for relation keys.', + ); + } + return RelationLoadPlan._( + binding.parentIndices, + childIndices, + width, + state.limit, + state.offset ?? 0, + fixed, + capacity, + _describeStatement(db, state, plan, command, reads), + ); +} diff --git a/lib/src/postgres/temporal.dart b/lib/src/postgres/temporal.dart new file mode 100644 index 00000000..edf0e386 --- /dev/null +++ b/lib/src/postgres/temporal.dart @@ -0,0 +1,86 @@ +part of '../../drivers/postgres.dart'; + +/// Native calendar values and UTC instants, without integer overflow. +/// Local calendar types cover PostgreSQL's full finite range; UTC instants +/// additionally respect Dart DateTime's range. +/// Pass this registry to PoolSettings before borrowing a pool with +/// PostgresDriver.borrow(temporal: true). +pg.TypeRegistry postgresTypeRegistry() => pg.TypeRegistry( + codecs: { + pg.Type.date.oid!: _TemporalCodec('date'), + pg.Type.time.oid!: _TemporalCodec('time'), + pg.Type.timestampWithoutTimezone.oid!: _TemporalCodec('local_datetime'), + pg.Type.timestampWithTimezone.oid!: _TemporalCodec('instant'), + }, +); + +final class _TemporalCodec(final String kind) implements pg.Codec { + @override + pg.EncodedValue? encode(pg.TypedValue input, pg.CodecContext context) { + final value = input.value; + if (value == null) return pg.EncodedValue.null$(); + if (kind == 'instant' && value is DateTime) { + return pg.EncodedValue.text( + Uint8List.fromList( + context.encoding.encode(Codecs.dateTime.encode(value) as String), + ), + ); + } + if (value is! String && + value is! LocalDate && + value is! LocalTime && + value is! LocalDateTime) { + return null; + } + return pg.EncodedValue.text( + Uint8List.fromList(context.encoding.encode(value.toString())), + ); + } + + @override + Object? decode(pg.EncodedValue input, pg.CodecContext context) { + final bytes = input.bytes; + if (bytes == null) return null; + if (input.isText) { + final text = context.encoding.decode(bytes); + if (text == 'infinity' || text == '-infinity') return text; + if (kind == 'instant') return text; + return switch (kind) { + 'date' => LocalDate.parse(text), + 'time' => LocalTime.parse(text), + _ => LocalDateTime.parse(text), + }; + } + final data = ByteData.sublistView(bytes); + if (kind == 'date') { + final days = data.getInt32(0); + if (days == 2147483647) return 'infinity'; + if (days == -2147483648) return '-infinity'; + return LocalDate.fromJulianDay(2451545 + days); + } + final ticks = data.getInt64(0); + if (kind == 'time') return LocalTime.fromMicroseconds(ticks); + if (ticks == 9223372036854775807) return 'infinity'; + if (ticks == -9223372036854775808) return '-infinity'; + if (ticks < -211813488000000000 || ticks >= 9223371331200000000) { + throw const FormatException('Non-finite or unsupported local timestamp.'); + } + if (kind == 'instant' && ticks <= 8639053315200000000) { + // Check before adding the epoch offset: PostgreSQL's maximum is beyond + // DateTime's range and would otherwise overflow a native int64. + return DateTime.fromMicrosecondsSinceEpoch( + ticks + 946684800000000, + isUtc: true, + ); + } + const day = 86400000000; + final days = ticks ~/ day - (ticks < 0 && ticks % day != 0 ? 1 : 0); + final local = LocalDateTime( + LocalDate.fromJulianDay(2451545 + days), + LocalTime.fromMicroseconds(ticks % day), + ); + // Preserve out-of-DateTime finite values in raw results. Typed instant + // decoding rejects them, without overflowing or interrupting protocol reads. + return kind == 'instant' ? '$local+00' : local; + } +} diff --git a/lib/src/query.dart b/lib/src/query.dart new file mode 100644 index 00000000..5fc9b30d --- /dev/null +++ b/lib/src/query.dart @@ -0,0 +1,676 @@ +part of '../sql.dart'; + +final class _QueryState { + final TableRef source; + final Expr? predicate; + final List order; + final List> group; + final Expr? having; + final int? limit; + final int? offset; + final bool distinct; + final List<_Join> joins; + final List<_CteDefinition> ctes; + final _UnionSource? union; + const _QueryState( + this.source, { + this.predicate, + this.order = const [], + this.group = const [], + this.having, + this.limit, + this.offset, + this.distinct = false, + this.joins = const [], + this.ctes = const [], + this.union, + }); + _QueryState copy({ + Expr? predicate, + List? order, + List>? group, + Expr? having, + int? limit, + int? offset, + bool? distinct, + List<_Join>? joins, + List<_CteDefinition>? ctes, + }) => _QueryState( + source, + predicate: predicate ?? this.predicate, + order: order ?? this.order, + group: group ?? this.group, + having: having ?? this.having, + limit: limit ?? this.limit, + offset: offset ?? this.offset, + distinct: distinct ?? this.distinct, + joins: joins ?? this.joins, + ctes: ctes ?? this.ctes, + union: union, + ); +} + +class Query { + final QueryContext database; + final F _fields; + final _QueryState _state; + final Selection _selection; + Query._(this.database, this._fields, this._state, this._selection); + + Query _copy(_QueryState state) => + Query._(database, _fields, state, _selection); + Query where(Expr Function(F) condition) { + final next = condition(_fields); + return _copy(_state.copy(predicate: _state.predicate?.and(next) ?? next)); + } + + Query orderBy(List Function(F) order) => + _copy(_state.copy(order: List.unmodifiable(order(_fields)))); + Query groupBy(List> Function(F) group) => + _copy(_state.copy(group: List.unmodifiable(group(_fields)))); + Query having(Expr Function(F) condition) => + _copy(_state.copy(having: condition(_fields))); + Query take(int count) { + if (count < 0) throw ArgumentError.value(count, 'count'); + return _copy(_state.copy(limit: count)); + } + + Query skip(int count) { + if (count < 0) throw ArgumentError.value(count, 'count'); + return _copy(_state.copy(offset: count)); + } + + Query distinct() => _copy(_state.copy(distinct: true)); + Query select(Selection Function(F) selection) => + Query._(database, _fields, _state, selection(_fields)); + + /// Maps decoded rows in Dart. This does not create SQL columns or change + /// database DISTINCT/UNION semantics; compose SQL set operations first. + Query map(S Function(R) mapper) => + Query._(database, _fields, _state, _selection.map(mapper)); + + Query join( + TableAlias alias, { + required Expr Function(F, G) on, + }) => _join(alias, on(_fields, alias.fields), false); + Query leftJoin( + TableAlias alias, { + required Expr Function(F, G) on, + }) => _join(alias, on(_fields, alias.fields), true); + Query _join( + TableAlias alias, + Expr on, + bool left, + ) { + if (alias.fields.table == _state.source || + _state.joins.any((j) => j.alias.fields.table == alias.fields.table)) { + throw const OrmException( + 'QUERY.ALIAS', + 'Create a fresh alias for each table occurrence.', + ); + } + return _copy( + _state.copy( + joins: List.unmodifiable([..._state.joins, _Join(alias, on, left)]), + ), + ); + } + + Expr scalar() { + final selection = _selection; + if (selection is! Expr) { + throw const OrmException( + 'QUERY.SCALAR', + 'A scalar subquery selects one SQL expression.', + ); + } + if (_state.limit != 1 && + !(_state.group.isEmpty && _aggregate(selection._node))) { + throw const OrmException( + 'QUERY.SCALAR', + 'Use take(1) or an ungrouped aggregate for a scalar subquery.', + ); + } + return Expr._(_Subquery(this), selection.codec.nullable()); + } + + Expr existsExpression() => + Expr._(_Subquery(this, exists: true), Codecs.boolean); + Cte asCte(String name) => Cte._(this, name); + + (_SelectionPlan, _Decoder) _plan({bool deduplicate = true}) { + final plan = _SelectionPlan(deduplicate: deduplicate); + final decode = _selection._bind(plan); + if (plan.columns.isEmpty) { + throw const OrmException( + 'QUERY.EMPTY_SELECTION', + 'Select at least one field.', + ); + } + return (plan, decode); + } + + SqlCommand _compile(_SelectionPlan plan, {_ReadTables? reads}) { + final w = _Writer( + database.dialect, + {}, + database: database, + reads: reads, + exactDecimal: database.capabilities.exactDecimal, + temporal: database.capabilities.temporal, + ); + return w.finish(_write(w, plan, decodeResult: true)); + } + + String _write( + _Writer w, + _SelectionPlan plan, { + bool aliasColumns = false, + bool decodeResult = false, + }) { + if (w.mysql && + decodeResult && + _state.distinct && + plan.columns.any((e) => e.codec.sqlType == 'json')) { + throw const OrmException( + 'CAPABILITY.JSON_DISTINCT', + 'Project native JSON DISTINCT through a CTE before decoding the result.', + ); + } + if (!identical(w.database, database)) { + throw const OrmException( + 'QUERY.SESSION', + 'Compose queries from the same database or transaction session.', + ); + } + final joins = [..._state.joins, ...plan.joins]; + if (_state.union == null && + !_state.ctes.any((cte) => cte.name == _state.source.schema.name)) { + w.reads?.tables.add(_state.source.schema); + } + for (final join in joins) { + if (join.alias._cte == null) { + w.reads?.tables.add(join.alias.fields.table.schema); + } + } + if (w.dialect != database.dialect) { + throw const OrmException( + 'QUERY.DIALECT', + 'A statement cannot mix SQL dialects.', + ); + } + if (!database.capabilities.windowFunctions && + [ + ...plan.columns.map((e) => e._node), + ..._state.order.map((o) => o.expression._node), + ].any(_window)) { + throw const OrmException( + 'CAPABILITY.WINDOW', + 'This driver does not support window functions.', + ); + } + _validateGrouping(_state.copy(joins: joins), plan); + final saved = Map.of(w.aliases); + final markers = Set.of(w.leftJoins); + if (w.aliases.containsKey(_state.source)) { + throw const OrmException( + 'QUERY.ALIAS', + 'A nested query needs a fresh source occurrence.', + ); + } + w.aliases[_state.source] = 't${w.aliases.length}'; + final rootAlias = w.aliases[_state.source]!; + for (final join in joins) { + if (w.aliases.containsKey(join.alias.fields.table)) { + throw const OrmException( + 'QUERY.ALIAS', + 'Create a fresh occurrence for each join.', + ); + } + w.aliases[join.alias.fields.table] = 't${w.aliases.length}'; + if (join.left) w.leftJoins.add(join.alias.fields.table); + } + final savedAverageInputs = w.averageInputs; + w.averageInputs = _AverageInputs( + w, + _state.source.schema.columns.isEmpty + ? w.quote(rootAlias) + : '${w.quote(rootAlias)}.${w.quote(_state.source.schema.columns.first.name)}', + ); + try { + final buffer = StringBuffer(); + final ctes = {}; + for (final cte in [ + ..._state.ctes, + for (final join in joins) ?join.alias._cte, + ]) { + if (ctes.containsKey(cte.name) && !identical(ctes[cte.name], cte)) { + throw const OrmException( + 'QUERY.CTE', + 'Different CTEs cannot use the same name in one scope.', + ); + } + ctes[cte.name] = cte; + } + if (ctes.isNotEmpty) { + buffer.write( + 'WITH ${ctes.values.map((c) => c._writeDefinition(w)).join(', ')} ', + ); + } + final stage = + w.dialect == SqlDialect.postgres && + [ + ...plan.columns.map((e) => e._node), + ..._state.order.map((o) => o.expression._node), + ].any(_needsWindowStage) + ? _WindowStage( + w, + preWindow: [ + ...plan.columns.map((e) => e._node), + ..._state.order.map((o) => o.expression._node), + ].any(_averageWindow), + ) + : null; + final columns = []; + final order = []; + if (stage != null) w.project = stage.capture; + try { + for (var i = 0; i < plan.columns.length; i++) { + columns.add( + '${decodeResult ? _projection(plan.columns[i], w) : plan.columns[i]._node.write(w)}${aliasColumns ? ' AS ${w.quote('c$i')}' : ''}', + ); + } + for (final term in _state.order) { + if (stage != null) { + // PostgreSQL DISTINCT requires ORDER BY to reuse selected expressions. + final match = plan.columns.indexWhere( + (e) => _sameSqlNode(e._node, term.expression._node), + ); + order.add( + match < 0 ? term._write(w) : '${match + 1}${term._suffix}', + ); + } else { + order.add(term._write(w)); + } + } + } finally { + w.project = null; + } + buffer.write( + 'SELECT ${stage == null && _state.distinct ? 'DISTINCT ' : ''}', + ); + buffer.write( + stage == null ? columns.join(', ') : stage.inputs.join(', '), + ); + buffer.write( + ' FROM ${_state.union == null ? w.quote(_state.source.schema.name) : '(${_state.union!.write(w)})'} AS ${w.quote(rootAlias)}', + ); + final visible = {...saved, _state.source: rootAlias}; + for (final join in joins) { + final ref = join.alias.fields.table; + final alias = w.aliases[ref]!; + visible[ref] = alias; + final check = _Writer( + w.dialect, + visible, + database: w.database, + exactDecimal: w.exactDecimal, + temporal: w.temporal, + )..leftJoins.addAll(w.leftJoins.where(visible.containsKey)); + join.on._node.write(check); + final table = w.quote(ref.schema.name); + final source = join.left + ? '(SELECT *, 1 AS ${w.quote(join.alias._marker)} FROM $table)' + : table; + buffer.write( + ' ${join.left ? 'LEFT JOIN' : 'JOIN'} $source AS ${w.quote(alias)} ON ${join.on._node.write(w)}', + ); + } + final joinEnd = buffer.length; + if (_state.predicate case final predicate?) { + buffer.write(' WHERE ${predicate._node.write(w)}'); + } + if (_state.group.isNotEmpty) { + buffer.write( + ' GROUP BY ${_state.group.map((e) => e._node.write(w)).join(', ')}', + ); + } + if (_state.having case final having?) { + buffer.write(' HAVING ${having._node.write(w)}'); + } + if (w.averageInputs!.joins.isNotEmpty) { + final text = buffer.toString(); + buffer.clear(); + buffer.write( + '${text.substring(0, joinEnd)}${w.averageInputs!.joins.join()}${text.substring(joinEnd)}', + ); + } + if (stage != null) { + final inner = stage.wrap(buffer.toString()); + buffer.clear(); + buffer.write( + 'SELECT ${_state.distinct ? 'DISTINCT ' : ''}${columns.join(', ')} FROM ($inner) AS ${w.quote(stage.alias)}', + ); + } + if (_state.order.isNotEmpty) { + buffer.write(' ORDER BY ${order.join(', ')}'); + } + if (_state.limit case final limit?) { + buffer.write(' LIMIT ${w.parameter(limit)}'); + } + if (_state.offset case final offset?) { + if (_state.limit == null) { + if (database.dialect == SqlDialect.sqlite) buffer.write(' LIMIT -1'); + if (w.mysql) buffer.write(' LIMIT 18446744073709551615'); + } + buffer.write(' OFFSET ${w.parameter(offset)}'); + } + return buffer.toString(); + } finally { + w.averageInputs = savedAverageInputs; + w.aliases + ..clear() + ..addAll(saved); + w.leftJoins + ..clear() + ..addAll(markers); + } + } + + SqlCommand compile() => _compile(_plan().$1); + + /// Inspects SQL and conditional relation batches without acquiring a connection. + /// This does not ask the database optimizer for an execution plan. + QueryPlan inspect() => _inspectQuery(this); + + /// Physical read dependencies used by subscriptions, including relation batches. + ({Set tables, bool opaque}) get dependencies { + final reads = _ReadTables()..query(this); + return (tables: Set.unmodifiable(reads.tables), opaque: reads.opaque); + } + + /// Rebind the root description to an execution context. Nested queries retain + /// their original context and are rejected when they do not match. + Query bind(QueryContext context) => + Query._(context, _fields, _state, _selection); + + Future> get({ExecutionOptions options = const ExecutionOptions()}) { + options.check(); + return database.run((connection) async { + final (plan, decode) = _plan(); + final command = _compile(plan); + final result = await database.executeOn( + connection, + command, + options: options, + ); + final rows = await _expandRelations( + database, + connection, + plan, + result.rows, + options: options, + ); + return database.observeDecode( + command.sql, + rows.length, + () => [for (final row in rows) decode(row)], + ); + }, acquire: options.acquisition); + } + + Future first({ + ExecutionOptions options = const ExecutionOptions(), + }) async { + final rows = await take(_state.limit == 0 ? 0 : 1).get(options: options); + return rows.isEmpty ? null : rows.first; + } + + Future single({ + ExecutionOptions options = const ExecutionOptions(), + }) async { + final rows = await take( + _state.limit == null || _state.limit! > 2 ? 2 : _state.limit!, + ).get(options: options); + if (rows.length != 1) { + throw OrmException( + 'QUERY.CARDINALITY', + 'Expected one row, received ${rows.length}.', + ); + } + return rows.single; + } + + Future count({ + ExecutionOptions options = const ExecutionOptions(), + }) async { + final inner = compile(); + final result = await database.execute( + SqlCommand( + 'SELECT COUNT(*) FROM (${inner.sql}) AS ${_Writer(database.dialect, {}).quote('orm_count')}', + inner.parameters, + ), + options: options, + ); + return Codecs.integer.decode(result.rows.single.single); + } + + Future exists({ + ExecutionOptions options = const ExecutionOptions(), + }) async { + final inner = take(_state.limit == 0 ? 0 : 1).compile(); + final result = await database.execute( + SqlCommand('SELECT EXISTS (${inner.sql})', inner.parameters), + options: options, + ); + return Codecs.boolean.decode(result.rows.single.single); + } + + Mutation update(List Function(F) assignments) => Mutation._( + database, + _fields, + _state, + _MutationKind.update, + assignments(_fields), + ); + Mutation delete() => + Mutation._(database, _fields, _state, _MutationKind.delete, const []); +} + +// A scalar SQL subquery cannot contain an enclosing query's window function. +// Evaluate its inputs in that query, then perform decimal division/rounding in +// an outer projection. Filtering, grouping and windows stay inside; DISTINCT, +// ordering and pagination apply to the final values. This remains one statement. +// Window averages additionally share their input before the native component +// windows run, including when the input is itself a grouped aggregate. +bool _needsWindowStage(_Node node) => + node is _WindowNode && node.function is _DecimalAverage || + node is _DecimalRatio && _children(node).any(_window) || + _children(node).any(_needsWindowStage); + +bool _averageWindow(_Node node) => + node is _WindowNode && node.function is _DecimalAverage || + _children(node).any(_averageWindow); +bool _averageInput(_Node node) => + node is _AverageInput || _children(node).any(_averageInput); + +final class _WindowStage(final _Writer writer, {final bool preWindow = false}) { + late final String alias = '_orm_window_${writer.aliases.length}'; + late final String inputAlias = '_orm_inputs_${writer.aliases.length}'; + final List inputs = []; + final List windows = []; + final Map<_AverageInput, String> _shared = {}; + + String wrap(String sql) => preWindow + ? 'SELECT ${windows.join(', ')} FROM ($sql) AS ${writer.quote(inputAlias)}' + : sql; + + String? capture(_Node node) { + if (_needsWindowStage(node)) return null; + final output = preWindow ? windows : inputs; + final index = output.length; + final saved = writer.project; + writer.project = preWindow ? _capturePre : null; + try { + final sql = preWindow ? node.write(writer) : node.writeSql(writer); + output.add('$sql AS ${writer.quote('w$index')}'); + } finally { + writer.project = saved; + } + return '${writer.quote(alias)}.${writer.quote('w$index')}'; + } + + String? _capturePre(_Node node) { + if (node is _AverageInput) { + return _shared.putIfAbsent(node, () => _input(node.child)); + } + if (node is _WindowNode || _averageInput(node)) return null; + return _input(node); + } + + String _input(_Node node) { + final index = inputs.length; + final saved = writer.project; + writer.project = null; + try { + inputs.add('${node.writeSql(writer)} AS ${writer.quote('p$index')}'); + } finally { + writer.project = saved; + } + return '${writer.quote(inputAlias)}.${writer.quote('p$index')}'; + } +} + +class TableSet extends Query { + final Table definition; + TableSet(QueryContext database, Table definition) + : this._( + database, + definition, + definition.createFields(TableRef(definition.schema)), + ); + TableSet._(QueryContext database, this.definition, F fields) + : super._( + database, + fields, + _QueryState(fields.table), + definition.selectRow(fields), + ); + + Mutation insert(List Function(F) assignments) => Mutation._( + database, + _fields, + _state, + _MutationKind.insert, + _insertDefaults(assignments(_fields)), + createFields: definition.createFields, + ); + BatchInsert insertMany( + Iterable rows, + List Function(F, T) values, + ) => BatchInsert._(database, _fields, _state, [ + for (final row in rows) + List.unmodifiable(_insertDefaults(values(_fields, row))), + ]); + + List _insertDefaults(List assignments) { + final defaults = definition.schema.clientDefaults; + if (defaults.isEmpty) return assignments; + final assigned = {for (final a in assignments) a.field.definition.name}; + return [ + ...assignments, + for (final column in defaults) + if (!assigned.contains(column.name)) + _fields.column(column).set(column.clientDefault!()), + ]; + } + + Future createRow( + List Function(F) assignments, { + ExecutionOptions options = const ExecutionOptions(), + }) { + final mutation = insert(assignments); + if (database.capabilities.returning) { + return mutation.returning(definition.selectRow).single(options: options); + } + final keys = definition.schema.primaryKey; + final values = {}; + final generated = {}; + for (final key in keys) { + final column = definition.schema.columns.firstWhere((c) => c.name == key); + final assignment = mutation._assignments + .where((a) => a.field.definition.name == key) + .firstOrNull; + if (assignment?._value case final _Parameter parameter) { + values[key] = parameter; + } else if (column.generated && assignment?._value == null) { + generated.add(key); + } else { + throw const OrmException( + 'CAPABILITY.CREATE_KEY', + 'Reading an inserted row without RETURNING requires literal primary keys or a generated identity.', + ); + } + } + if (keys.isEmpty || generated.length > 1) { + throw const OrmException( + 'CAPABILITY.CREATE_KEY', + 'Reading an inserted row requires a primary key and at most one generated identity.', + ); + } + Future create(QueryContext tx) async { + try { + final bound = Mutation._( + tx, + mutation._fields, + mutation._state, + mutation._kind, + mutation._assignments, + createFields: mutation._createFields, + ); + final result = await tx.executeCommand( + bound.compile(), + options: options, + changedTables: [definition.schema], + affectedOnly: true, + cascade: false, + ); + if (generated.isNotEmpty) { + if (result.lastInsertId == null) { + throw const OrmException( + 'DRIVER.IDENTITY', + 'The driver did not return the generated identity.', + ); + } + values[generated.single] = _Parameter(result.lastInsertId); + } + _Node? predicate; + for (final key in keys) { + final equal = _Binary( + _ColumnNode(_state.source, key), + '=', + values[key]!, + ); + predicate = predicate == null + ? equal + : _Binary(predicate, 'AND', equal); + } + return await Query._( + tx, + _fields, + _state.copy( + predicate: Expr._(predicate!, Codecs.boolean.nullable()), + ), + definition.selectRow(_fields), + ).single(options: options); + } catch (_) { + tx.markFailed(); + rethrow; + } + } + + return database.inTransaction + ? create(database) + : database.atomic(create, acquire: options.acquisition); + } +} diff --git a/lib/src/query_reads.dart b/lib/src/query_reads.dart new file mode 100644 index 00000000..96d30bc0 --- /dev/null +++ b/lib/src/query_reads.dart @@ -0,0 +1,27 @@ +part of '../sql.dart'; + +/// SQL compilation visits subqueries and CTE definitions with the same writer. +/// Batch relationships are additional queries and must be visited separately. +final class _ReadTables { + final bool includeRaw; + _ReadTables({this.includeRaw = false}); + bool opaque = false; + final Set tables = Set.identity(); + void query(Query query) { + final (plan, _) = query._plan(); + query._write( + _Writer( + query.database.dialect, + {}, + database: query.database, + reads: this, + exactDecimal: query.database.capabilities.exactDecimal, + temporal: query.database.capabilities.temporal, + ), + plan, + ); + for (final binding in plan.relations) { + binding.collectReads(query.database, this); + } + } +} diff --git a/lib/src/query_stream.dart b/lib/src/query_stream.dart new file mode 100644 index 00000000..3ec01ce1 --- /dev/null +++ b/lib/src/query_stream.dart @@ -0,0 +1,33 @@ +part of '../sql.dart'; + +extension QueryStreaming on Query { + /// One database cursor, fetched on demand. Related rows are loaded per root + /// batch on the same connection. Pause/await-for consumption provides demand. + Stream stream({ + int batchSize = 128, + ExecutionOptions options = const ExecutionOptions(), + }) { + if (batchSize < 1) throw ArgumentError.value(batchSize, 'batchSize'); + final (plan, decode) = _plan(); + final command = _compile(plan); + return database.streamRows( + command, + batchSize: batchSize, + options: options, + decode: (connection, rows, execution) async { + final expanded = await _expandRelations( + database, + connection, + plan, + rows, + options: execution, + ); + return database.observeDecode( + command.sql, + expanded.length, + () => [for (final row in expanded) decode(row)], + ); + }, + ); + } +} diff --git a/lib/src/relation.dart b/lib/src/relation.dart new file mode 100644 index 00000000..ece991ba --- /dev/null +++ b/lib/src/relation.dart @@ -0,0 +1,486 @@ +part of '../sql.dart'; + +enum ToOneStrategy { automatic, join, batch } + +/// A relationship is a query description. Constructing or selecting it performs +/// no I/O. List results are loaded in batches on the root query's connection. +final class Relation { + final TableAlias _alias; + final List> _parent; + final List> _child; + final F _fields; + final _QueryState _state; + final Selection _selection; + + factory Relation( + Table target, { + required List> parent, + required List> Function(F) child, + }) { + final alias = target.alias(); + final fields = alias.fields; + final keys = child(fields); + if (parent.isEmpty || + parent.length != keys.length || + keys.any((key) => key.table != fields.table)) { + throw ArgumentError( + 'Relationship keys need equal non-zero arity and fields from the target occurrence.', + ); + } + return Relation._( + alias, + List.unmodifiable(parent), + List.unmodifiable(keys), + fields, + _QueryState(fields.table), + target.selectRow(fields), + ); + } + Relation._( + this._alias, + this._parent, + this._child, + this._fields, + this._state, + this._selection, + ); + Relation _copy(_QueryState state) => + Relation._(_alias, _parent, _child, _fields, state, _selection); + Relation where(Expr Function(F) predicate) { + final next = predicate(_fields); + return _copy(_state.copy(predicate: _state.predicate?.and(next) ?? next)); + } + + Relation orderBy(List Function(F) order) => + _copy(_state.copy(order: List.unmodifiable(order(_fields)))); + Relation take(int count) { + if (count < 0) throw ArgumentError.value(count, 'count'); + return _copy(_state.copy(limit: count)); + } + + Relation skip(int count) { + if (count < 0) throw ArgumentError.value(count, 'count'); + return _copy(_state.copy(offset: count)); + } + + Relation select(Selection Function(F) selection) => + Relation._(_alias, _parent, _child, _fields, _state, selection(_fields)); + Selection> many() => _RelationSelection(this); + bool get _uniqueTarget { + final names = _child.map((key) => key.definition.name).toSet(); + final schema = _fields.table.schema; + return [ + schema.primaryKey, + ...schema.uniqueKeys, + for (final index in schema.indexes) + if (index.unique) index.columns, + ].any((key) => key.isNotEmpty && key.every(names.contains)); + } + + bool _useJoin(ToOneStrategy strategy) { + if (strategy == ToOneStrategy.join && !_uniqueTarget) { + throw const OrmException( + 'RELATION.JOIN_KEY', + 'A to-one join must cover a declared primary or unique target key.', + ); + } + return strategy != ToOneStrategy.batch && _uniqueTarget; + } + + Selection one({ToOneStrategy strategy = ToOneStrategy.automatic}) => + _useJoin(strategy) + ? _JoinedRelationSelection(this) + : _oneBatch().map((rows) => rows.firstOrNull); + + Selection> _oneBatch() => + _copy( + _state.copy( + order: _state.order.isEmpty + ? [for (final key in _child) key.asc()] + : _state.order, + limit: _state.limit != null && _state.limit! < 2 ? _state.limit : 2, + ), + ).many().map((rows) { + if (rows.length > 1) { + throw const OrmException( + 'RELATION.CARDINALITY', + 'Expected at most one related row.', + ); + } + return rows; + }); + + Selection required({ToOneStrategy strategy = ToOneStrategy.automatic}) => + _useJoin(strategy) + ? _JoinedRelationSelection(this, required: true) + : _oneBatch().map((rows) { + if (rows.isEmpty) { + throw const OrmException( + 'RELATION.MISSING', + 'A required related row is not visible.', + ); + } + return rows.single; + }); + Expr any() => Expr._(_RelationSubquery(this, false), Codecs.boolean); + Expr none() => any().not(); + Expr every(Expr Function(F) condition) { + final conditionNode = condition(_fields)._node; + return where( + (_) => Expr._( + _Unary('IS NOT TRUE', conditionNode, postfix: true), + Codecs.boolean, + ), + ).none(); + } + + Expr count() => Expr._(_RelationSubquery(this, true), Codecs.integer); +} + +final class _JoinedRelationSelection( + final Relation relation, { + final bool required = false, +}) extends Selection { + @override + _Decoder _bind(_SelectionPlan plan) { + Expr match(int i) => Expr._( + _Binary(relation._child[i]._node, '=', relation._parent[i]._node), + Codecs.boolean.nullable(), + ); + Expr predicate = match(0); + for (var i = 1; i < relation._child.length; i++) { + predicate = predicate.and(match(i)); + } + if (relation._state.predicate case final filter?) { + predicate = predicate.and(filter); + } + // A proven unique match has at most one row, so skipping it or taking zero + // must produce an absent relation, without filtering out the root row. + if (relation._state.limit == 0 || (relation._state.offset ?? 0) > 0) { + predicate = predicate.and(value(false, Codecs.boolean)); + } + final existing = plan.joins + .where((join) => join.alias.fields.table == relation._fields.table) + .firstOrNull; + if (existing == null) { + plan.joins.add(_Join(relation._alias, predicate, true)); + } else if (!_sameSqlNode(existing.on._node, predicate._node)) { + throw const OrmException( + 'RELATION.ALIAS', + 'Use a fresh relation getter for differently filtered joins.', + ); + } + final marker = plan.column( + Expr._(_Presence(relation._alias), Codecs.integer.nullable()), + ); + final decode = plan.optional(relation._fields.table, relation._selection); + return (row) { + if (row[marker] != null) return decode(row); + if (required) { + throw const OrmException( + 'RELATION.MISSING', + 'A required related row is not visible.', + ); + } + // one() instantiates a nullable R. Presence is independent of value nullability. + return null as R; + }; + } +} + +final class _RelationSubquery( + final Relation relation, + final bool count, +) extends _Node { + @override + String writeSql(_Writer w) { + if (relation._state.limit != null || relation._state.offset != null) { + throw const OrmException( + 'RELATION.AGGREGATE', + 'Apply relation count/any/every before pagination.', + ); + } + final source = relation._state.source; + w.reads?.tables.add(source.schema); + final previous = w.aliases[source]; + final alias = 't${w.aliases.length}'; + w.aliases[source] = alias; + try { + final predicates = [ + for (var i = 0; i < relation._child.length; i++) + _Binary( + relation._child[i]._node, + '=', + relation._parent[i]._node, + ).write(w), + if (relation._state.predicate case final p?) p._node.write(w), + ]; + final query = + 'SELECT ${count ? 'COUNT(*)' : '1'} FROM ${w.quote(source.schema.name)} AS ${w.quote(alias)} WHERE ${predicates.join(' AND ')}'; + return count ? '($query)' : 'EXISTS ($query)'; + } finally { + if (previous == null) { + w.aliases.remove(source); + } else { + w.aliases[source] = previous; + } + } + } +} + +final class _RelationSelection( + final Relation relation, +) extends Selection> { + @override + _Decoder> _bind(_SelectionPlan plan) { + final index = plan.relations.length; + final parentIndices = [ + for (final key in relation._parent) plan.column(key), + ]; + plan.relations.add(_TypedRelationBinding(relation, parentIndices)); + return (row) => row[plan.columns.length + index] as List; + } +} + +abstract class _RelationBinding { + RelationLoadPlan inspect(QueryContext db); + void collectReads(QueryContext db, _ReadTables reads); + Future> load( + QueryContext db, + SqlConnection connection, + List> parents, { + ExecutionOptions options = const ExecutionOptions(), + }); +} + +final class _TypedRelationBinding( + final Relation relation, + final List parentIndices, +) extends _RelationBinding { + @override + RelationLoadPlan inspect(QueryContext db) => _inspectRelation(db, this); + @override + void collectReads(QueryContext db, _ReadTables reads) => reads.query( + Query._(db, relation._fields, relation._state, relation._selection), + ); + + @override + Future> load( + QueryContext db, + SqlConnection connection, + List> parents, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + final keys = <_RelationKey>{}; + final parentKeys = [ + for (final row in parents) + _RelationKey([ + for (var i = 0; i < parentIndices.length; i++) + _relationValue(relation._parent[i], row[parentIndices[i]]), + ]), + ]; + for (final key in parentKeys) { + if (!key.values.contains(null)) keys.add(key); + } + final grouped = <_RelationKey, List>{}; + if (keys.isEmpty || relation._state.limit == 0) { + return [for (final _ in parents) []]; + } + final plan = _SelectionPlan(); + final decode = relation._selection._bind(plan); + final childIndices = [for (final key in relation._child) plan.column(key)]; + final all = keys.toList(); + final baseCount = + _compile(db, plan, [all.first]).parameters.length - + relation._child.length; + final chunkSize = + (db.capabilities.maxParameters - baseCount) ~/ relation._child.length; + if (chunkSize < 1) { + throw const OrmException( + 'QUERY.PARAMETERS', + 'No parameter capacity remains for relation keys.', + ); + } + for (var offset = 0; offset < all.length; offset += chunkSize) { + final end = offset + chunkSize < all.length + ? offset + chunkSize + : all.length; + final command = _compile(db, plan, all.sublist(offset, end)); + final result = await db.executeOn(connection, command, options: options); + final rows = await _expandRelations( + db, + connection, + plan, + result.rows, + options: options, + ); + db.observeDecode(command.sql, rows.length, () { + for (final row in rows) { + final key = _RelationKey([ + for (var i = 0; i < childIndices.length; i++) + _relationValue(relation._child[i], row[childIndices[i]]), + ]); + (grouped[key] ??= []).add(decode(row)); + } + }); + } + return [ + for (final key in parentKeys) List.unmodifiable(grouped[key] ?? []), + ]; + } + + SqlCommand _compile( + QueryContext db, + _SelectionPlan plan, + List<_RelationKey> keys, { + _ReadTables? reads, + }) { + final state = relation._state; + final w = _Writer( + db.dialect, + {}, + database: db, + reads: reads, + exactDecimal: db.capabilities.exactDecimal, + temporal: db.capabilities.temporal, + ); + Expr predicate = Expr._( + _RelationKeys(relation._child, keys), + Codecs.boolean, + ); + if (state.predicate case final filter?) predicate = predicate.and(filter); + final paginated = state.limit != null || state.offset != null; + final sqlPlan = _SelectionPlan() + ..columns.addAll(plan.columns) + ..required.addAll(plan.required) + ..guarded.addAll(plan.guarded) + ..joins.addAll(plan.joins); + if (paginated) { + if (state.order.isEmpty) { + throw const OrmException( + 'RELATION.ORDER', + 'Per-parent pagination requires explicit ordering.', + ); + } + sqlPlan.columns.add( + rowNumber(partitionBy: relation._child, orderBy: state.order), + ); + } + final query = Query._( + db, + relation._fields, + _QueryState( + state.source, + predicate: predicate, + order: paginated ? const [] : state.order, + ), + relation._selection, + ); + var text = query._write(w, sqlPlan, aliasColumns: true, decodeResult: true); + if (paginated) { + final offset = state.offset ?? 0; + final rank = w.quote('c${plan.columns.length}'); + text = + 'SELECT ${[for (var i = 0; i < plan.columns.length; i++) w.quote('c$i')].join(', ')} ' + 'FROM ($text) AS ${w.quote('orm_partition')} WHERE $rank > ${w.parameter(offset)}'; + if (state.limit case final limit?) { + text += ' AND $rank <= ${w.parameter(offset + limit)}'; + } + text += ' ORDER BY $rank'; + } + return w.finish(text); + } +} + +Future>> _expandRelations( + QueryContext db, + SqlConnection connection, + _SelectionPlan plan, + List> source, { + ExecutionOptions options = const ExecutionOptions(), +}) async { + if (plan.relations.isEmpty || source.isEmpty) return source; + final rows = List>.generate(source.length, (i) { + final row = source[i]; + final expanded = List.filled( + row.length + plan.relations.length, + null, + ); + expanded.setRange(0, row.length, row); + return expanded; + }, growable: false); + for (var i = 0; i < plan.relations.length; i++) { + final values = await plan.relations[i].load( + db, + connection, + rows, + options: options, + ); + for (var row = 0; row < rows.length; row++) { + rows[row][plan.columns.length + i] = values[row]; + } + } + return rows; +} + +// Row-value IN avoids a deep OR tree for large composite-key batches. +final class _RelationKeys( + final List> columns, + final List<_RelationKey> keys, +) extends _Node { + @override + String writeSql(_Writer w) { + // Keys stay comparable storage values while grouping. Restore floating SQL + // intent only when binding them (JS cannot identify an integral double). + Object? parameter(int index, Object? value) => + value != null && columns[index].codec.sqlType == 'real' + ? Codecs.real.encode(Codecs.real.decode(value)) + : value; + if (columns.length == 1) { + return _In(columns.single._node, [ + for (final key in keys) + _Parameter( + parameter(0, key.values.single), + storageType: columns.single.codec.sqlType, + ), + ]).write(w); + } + return '(${columns.map((c) => c._node.write(w)).join(', ')}) IN (${w.dialect == SqlDialect.sqlite ? 'VALUES ' : ''}' + '${keys.map((key) => '(${[for (var i = 0; i < key.values.length; i++) _Parameter(parameter(i, key.values[i]), storageType: columns[i].codec.sqlType).write(w)].join(', ')})').join(', ')})'; + } +} + +final class _RelationKey(final List values) { + @override + int get hashCode => + Object.hashAll(values.map((v) => v is Uint8List ? Object.hashAll(v) : v)); + @override + bool operator ==(Object other) { + if (other is! _RelationKey || values.length != other.values.length) { + return false; + } + for (var i = 0; i < values.length; i++) { + final a = values[i], b = other.values[i]; + if (a is Uint8List && b is Uint8List) { + if (a.length != b.length) return false; + for (var j = 0; j < a.length; j++) { + if (a[j] != b[j]) return false; + } + } else if (a != b) { + return false; + } + } + return true; + } +} + +Object? _relationValue(Expr key, Object? value) => value == null + ? null + : switch (key.codec.sqlType) { + 'decimal' => Codecs.decimal.decode(value).toString(), + 'instant' => Codecs.dateTime.encode(Codecs.dateTime.decode(value)), + 'date' => Codecs.date.decode(value).toString(), + 'time' => Codecs.time.decode(value).toString(), + 'local_datetime' => Codecs.localDateTime.decode(value).toString(), + _ => value, + }; diff --git a/lib/src/runtime/database.dart b/lib/src/runtime/database.dart new file mode 100644 index 00000000..94e43bc8 --- /dev/null +++ b/lib/src/runtime/database.dart @@ -0,0 +1,721 @@ +part of '../../runtime.dart'; + +/// SqlDatabase owns its driver. A transaction view borrows one connection and +/// becomes unusable as soon as its callback finishes. +class SqlDatabase { + final Driver driver; + final void Function(QueryEvent)? onQuery; + final void Function(AcquisitionEvent)? onAcquire; + final SqlConnection? _connection; + final bool _transaction; + final _TransactionControl? _control; + final Set> _pending = {}; + final Set Function()> _streams = {}; + final Set Function()> _cursors = {}; + Future _closeCursors() async { + for (final close in _cursors.toList()) { + await close(); + } + } + + Future _stopStreams() => + Future.wait(_streams.toList().map((stop) => stop())); + bool _active = true; + Future? _closing; + bool _childActive = false; + bool _statementFailed = false; + int _savepointId = 0; + final List _invalidations = []; + final Set Function()> _closeListeners = {}; + final Set)> _changeListeners; + + /// Listen for explicitly reported physical table changes after commit. + /// Raw SQL is never parsed to guess the tables it changes. + void Function() addChangeListener(void Function(Set) listener) { + checkActive(); + _changeListeners.add(listener); + return () => _changeListeners.remove(listener); + } + + /// Notify listeners of external or raw-SQL changes. Rollback discards these + /// notifications; an uncertain COMMIT invalidates conservatively. + void notifyChanged(Iterable tables) { + checkActive(); + _notifyChanged(tables); + } + + void _notifyChanged(Iterable tables) { + final names = Set.unmodifiable(tables); + if (names.isEmpty) return; + deferInvalidation(() { + for (final listener in _changeListeners.toList()) { + _notifyInvalidation(() => listener(names)); + } + }); + } + + /// Schedule cache invalidation after the transaction finishes. Rollback drops + /// these callbacks; an uncertain COMMIT runs them conservatively. Savepoint + /// callbacks join the parent transaction only after successful release. + void deferInvalidation(void Function() action) { + if (inTransaction) { + _invalidations.add(action); + } else { + _notifyInvalidation(action); + } + } + + /// Register owned resources to stop before pending SQL is drained on close. + /// Returns a function that removes this listener. + void Function() addCloseListener(Future Function() listener) { + checkActive(); + if (inSession) { + throw const OrmException( + 'SESSION.BORROWED', + 'Close listeners belong to the root runtime that owns the driver.', + ); + } + _closeListeners.add(listener); + return () => _closeListeners.remove(listener); + } + + void _publishInvalidations() { + final callbacks = List.of(_invalidations); + _invalidations.clear(); + for (final callback in callbacks) { + _notifyInvalidation(callback); + } + } + + static void _notifyInvalidation(void Function() callback) { + try { + callback(); + } catch (_) {} // Notification must not change a confirmed database outcome. + } + + /// Mark this transaction as failed when a multi-statement operation stops + /// between statements. Catching its exception cannot commit a partial batch. + void markFailed() { + if (inTransaction) _statementFailed = true; + } + + SqlDatabase(this.driver, {this.onQuery, this.onAcquire}) + : _changeListeners = {}, + _connection = null, + _transaction = false, + _control = null; + SqlDatabase._( + this.driver, + this._connection, + this.onQuery, { + this.onAcquire, + this._transaction = true, + this._control, + required this._changeListeners, + }); + Capabilities get capabilities => driver.capabilities; + SqlDialect get dialect => capabilities.dialect; + bool get inTransaction => _transaction; + bool get inSession => _connection != null; + + void checkActive() { + if (!_active) { + throw const OrmException( + 'SESSION.CLOSED', + 'SqlDatabase session has ended.', + ); + } + _control?.check(); + if (inTransaction && _connection?.transactionActive == false) { + _statementFailed = true; + throw const OrmException( + 'TRANSACTION.ENDED', + 'The database has ended this transaction.', + ); + } + if (_childActive) { + throw const OrmException( + 'SESSION.SAVEPOINT', + 'Use the active nested session.', + ); + } + } + + Future run( + Future Function(SqlConnection) action, { + AcquisitionOptions acquire = const AcquisitionOptions(), + OrmException? acquisitionTimeoutError, + }) { + checkActive(); + acquire.check(); + final connection = _connection == null + ? null + : _sessionConnection(_connection); + final observer = onAcquire; + final clock = observer == null || connection != null + ? null + : (Stopwatch()..start()); + var entered = false; + void report(Object? error) { + clock?.stop(); + try { + observer?.call( + AcquisitionEvent( + elapsed: clock?.elapsed ?? Duration.zero, + reusedConnection: connection != null, + error: error, + ), + ); + } catch (_) {} + } + + Future enter(SqlConnection value) { + entered = true; + report(null); + return action(value); + } + + Future observed(Future result) => observer == null + ? result + : result.onError((Object error, StackTrace stack) { + if (!entered) report(error); + Error.throwWithStackTrace(error, stack); + }); + final callback = observer == null ? action : enter; + if (connection == null && + (acquire.timeout != null || acquire.cancellation != null)) { + final wait = _ConnectionWait( + driver, + callback, + acquire, + timeoutError: acquisitionTimeoutError, + ); + _track(wait.drained); + return observed(wait.result); + } + final result = connection != null + ? Future.sync(() => callback(connection)) + : observer == null + ? driver.run(action) + : Future.sync(() => driver.run(callback)); + final done = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + _track(done); + return observed(result); + } + + void _track(Future done) { + _pending.add(done); + unawaited(done.then((_) => _pending.remove(done))); + } + + SqlConnection _sessionConnection(SqlConnection connection) { + if (!inSession) return connection; + if (!identical( + _physicalConnection(connection), + _physicalConnection(_connection!), + ) || + connection is _SessionConnection && + !identical(connection.owner, this)) { + throw const OrmException( + 'SESSION.CONNECTION', + 'Use the connection owned by this session.', + ); + } + if (connection is _SessionConnection) return connection; + return _SessionConnection(_connection, this); + } + + Future executeOn( + SqlConnection connection, + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) { + checkActive(); + final result = _executeOn( + _sessionConnection(connection), + command, + options: options, + ); + _track(result.then((_) {}, onError: (Object _, StackTrace _) {})); + return result; + } + + Future _executeOn( + SqlConnection connection, + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + options.check(); + if (options.cancellation != null && !capabilities.cancellation) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This driver cannot cancel a running statement.', + ); + } + if (options.timeout != null && !capabilities.statementTimeout) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This driver does not support statement timeouts.', + ); + } + if (command.parameters.length > capabilities.maxParameters) { + throw const OrmException( + 'QUERY.PARAMETERS', + 'Query exceeds the driver parameter limit.', + ); + } + final watch = onQuery == null ? null : (Stopwatch()..start()); + SqlResult? result; + Object? error; + try { + return result = await connection.execute(command, options: options); + } catch (e) { + error = e; + if (inTransaction) _statementFailed = true; + rethrow; + } finally { + watch?.stop(); + // Instrumentation cannot turn a successful commit into an apparent failure. + try { + onQuery?.call( + QueryEvent( + sql: command.sql, + parameterCount: command.parameters.length, + elapsed: watch!.elapsed, + rowCount: result?.rows.length, + error: error, + ), + ); + } catch (_) {} + } + } + + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + Iterable changedTables = const [], + }) { + options.check(); + if (options.cancellation != null && !capabilities.cancellation) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This driver cannot cancel a running statement.', + ); + } + if (options.timeout != null && !capabilities.statementTimeout) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This driver does not support statement timeouts.', + ); + } + final tables = List.unmodifiable(changedTables); + return run((connection) async { + final result = await executeOn(connection, command, options: options); + _notifyChanged(tables); + return result; + }, acquire: options.acquisition); + } + + /// Retains one connection across transactions and session-scoped operations. + /// The borrowed view expires when the callback returns. + Future session( + Future Function(SqlDatabase session) action, { + AcquisitionOptions acquire = const AcquisitionOptions(), + }) { + if (_connection != null) { + throw const OrmException( + 'SESSION.NESTED', + 'This session already owns a connection lease.', + ); + } + return run((connection) async { + final session = SqlDatabase._( + driver, + connection, + onQuery, + onAcquire: onAcquire, + changeListeners: _changeListeners, + transaction: false, + ); + try { + final result = await action(session); + session._active = false; + if (session._pending.isNotEmpty || + session._streams.isNotEmpty || + session._cursors.isNotEmpty) { + throw const OrmException( + 'SESSION.UNAWAITED', + 'Await all work before releasing a session.', + ); + } + return result; + } finally { + final cleanup = await session._drain(); + if (cleanup != null) { + await connection.invalidate(); + throw cleanup; + } + } + }, acquire: acquire); + } + + /// Discards a leased connection after its state can no longer be recovered. + Future discard() async { + final connection = _connection; + if (connection == null) { + throw const OrmException( + 'SESSION.REQUIRED', + 'Discard requires a leased session.', + ); + } + _active = false; + await connection.invalidate(); + } + + Future transaction( + Future Function(SqlDatabase tx) action, { + TransactionOptions? options, + AcquisitionOptions acquire = const AcquisitionOptions(), + Duration? timeout, + CancellationToken? cancellation, + TransactionRetry? retry, + }) { + checkActive(); + acquire.check(); + retry?._check(); + if (options != null && options.dialect != dialect) { + throw const OrmException( + 'TRANSACTION.OPTIONS', + 'Transaction options must match the connected database engine.', + ); + } + if (inTransaction) { + throw const OrmException( + 'TRANSACTION.NESTED', + 'Use savepoint() inside a transaction.', + ); + } + if (timeout != null && timeout <= Duration.zero) { + throw ArgumentError.value(timeout, 'timeout'); + } + if (cancellation?.isCancelled ?? false) { + throw const OrmException( + 'TRANSACTION.CANCELLED', + 'Transaction cancelled before acquisition.', + ); + } + if ((timeout != null || cancellation != null || retry != null) && + !capabilities.cancellation) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'Transaction deadlines require actual statement cancellation.', + ); + } + final link = cancellation != null && acquire.cancellation != null + ? _CancellationLink([cancellation, acquire.cancellation!]) + : null; + final budget = retry == null ? null : _RetryBudget(retry); + try { + final acquireTimeout = budget?.limit(acquire.timeout) ?? acquire.timeout; + final totalLimitsAcquisition = + budget != null && + (acquire.timeout == null || acquireTimeout! < acquire.timeout!); + return run( + (connection) async { + final executionTimeout = budget?.limit(timeout) ?? timeout; + final control = executionTimeout == null && cancellation == null + ? null + : _TransactionControl(executionTimeout, cancellation); + if (_connection != null) _childActive = true; + try { + while (true) { + try { + return await _transactionAttempt( + connection is _SessionConnection + ? connection.inner + : connection, + control, + budget, + options, + action, + ); + } on _RetryAfterRollback catch (failure) { + if (await budget!.next(control!)) continue; + Error.throwWithStackTrace(failure.error, failure.stack); + } + } + } finally { + control?.dispose(); + if (_connection != null) _childActive = false; + } + }, + acquire: AcquisitionOptions( + timeout: acquireTimeout, + cancellation: link?.token ?? cancellation ?? acquire.cancellation, + ), + acquisitionTimeoutError: totalLimitsAcquisition + ? const OrmException( + 'TRANSACTION.TIMEOUT', + 'Transaction retry time budget expired during acquisition.', + ) + : null, + ).whenComplete(() => link?.dispose()); + } catch (_) { + link?.dispose(); + rethrow; + } + } + + Future _transactionAttempt( + SqlConnection connection, + _TransactionControl? control, + _RetryBudget? budget, + TransactionOptions? options, + Future Function(SqlDatabase) action, + ) async { + final scoped = control == null + ? connection + : _TransactionConnection(connection, control); + final tx = SqlDatabase._( + driver, + scoped, + onQuery, + control: control, + onAcquire: onAcquire, + changeListeners: _changeListeners, + ); + var committing = false, began = false; + final begin = + options?.beginCommands ?? + [ + dialect == SqlDialect.mysql || dialect == SqlDialect.mariadb + ? 'START TRANSACTION' + : 'BEGIN', + ]; + try { + for (final statement in begin) { + await _executeOn(scoped, SqlCommand(statement)); + } + began = true; + final result = control == null + ? await action(tx) + : await control.race(() => action(tx)); + tx._active = false; + if (tx._pending.isNotEmpty || + tx._childActive || + tx._streams.isNotEmpty || + tx._cursors.isNotEmpty) { + throw const OrmException( + 'TRANSACTION.UNAWAITED', + 'Await every operation before leaving the transaction.', + ); + } + control?.check(); + if (tx._statementFailed || connection.transactionActive == false) { + throw const OrmException( + 'TRANSACTION.FAILED', + 'A statement failed or the transaction ended. Use a savepoint for recoverable errors.', + ); + } + Future commit(ExecutionOptions execution) { + committing = true; + return _executeOn(connection, SqlCommand('COMMIT'), options: execution); + } + + while (true) { + try { + if (control == null) { + await commit(const ExecutionOptions()); + } else { + await control.execute(commit, const ExecutionOptions()); + } + break; + } catch (error) { + if (error is SqlFailure && error.commitRejected) { + committing = false; + if (budget != null && + error.retryCommit && + connection.transactionActive == true && + await budget.next(control!)) { + continue; + } + } + rethrow; + } + } + // A confirmed COMMIT acknowledgement wins a cancellation race. + tx._publishInvalidations(); + return result; + } catch (error, stack) { + // Classify an actual COMMIT response before applying a deadline error. + final expired = control?.failure; + var cleanup = await tx._drain(); + try { + if (connection.transactionActive != false) { + await _executeOn(connection, SqlCommand('ROLLBACK')); + } + } catch (e) { + cleanup ??= e; + } + if (cleanup != null || !began && begin.length > 1) { + // SET TRANSACTION can survive a failed START TRANSACTION. Discard this + // lease so its next borrower never inherits partially set options. + try { + await connection.invalidate(); + } catch (_) {} + } + if (committing && !(error is SqlFailure && error.commitRejected)) { + tx._publishInvalidations(); + throw OrmException( + 'TRANSACTION.COMMIT', + 'Commit outcome is unknown; do not replay the callback.', + cause: error, + ); + } + if (cleanup != null) { + throw OrmException( + 'TRANSACTION.ROLLBACK', + 'Rollback could not be confirmed; connection discarded.', + cause: error, + ); + } + if ((began || begin.length == 1) && + expired == null && + error is SqlFailure && + error.retryTransaction && + budget != null) { + throw _RetryAfterRollback(error, stack); + } + Error.throwWithStackTrace( + expired == null || identical(expired, error) + ? error + : OrmException(expired.code, expired.message, cause: error), + stack, + ); + } finally { + tx._active = false; + } + } + + Future _drain() async { + _active = false; + Object? error; + try { + await _stopStreams(); + } catch (e) { + error = e; + } + await Future.wait(_pending.toList()); + try { + await _closeCursors(); + } catch (e) { + error ??= e; + } + return error; + } + + Future savepoint(Future Function(SqlDatabase tx) action) { + if (!inTransaction) { + throw const OrmException( + 'TRANSACTION.REQUIRED', + 'Savepoints need a transaction.', + ); + } + return run((connection) async { + _childActive = true; + // This path owns SAVEPOINT/RELEASE. User SQL keeps the guarded lease. + final scoped = connection is _SessionConnection + ? connection.inner + : connection; + final name = 'orm_sp_${_savepointId++}'; + final child = SqlDatabase._( + driver, + scoped, + onQuery, + onAcquire: onAcquire, + changeListeners: _changeListeners, + control: _control, + ); + try { + await _executeOn(scoped, SqlCommand('SAVEPOINT $name')); + final result = _control == null + ? await action(child) + : await _control.race(() => action(child)); + child._active = false; + if (child._pending.isNotEmpty || + child._childActive || + child._streams.isNotEmpty || + child._cursors.isNotEmpty) { + throw const OrmException( + 'TRANSACTION.UNAWAITED', + 'Await every savepoint operation.', + ); + } + if (child._statementFailed) { + throw const OrmException( + 'TRANSACTION.FAILED', + 'A statement in the savepoint failed.', + ); + } + await _executeOn(scoped, SqlCommand('RELEASE SAVEPOINT $name')); + _invalidations.addAll(child._invalidations); + child._invalidations.clear(); + return result; + } catch (error, stack) { + final cleanup = await child._drain(); + final raw = _unscoped(scoped); + try { + if (cleanup != null) throw cleanup; + if (raw.transactionActive == false) { + // SQLite may have rolled back the whole transaction, not this savepoint. + _statementFailed = true; + } else { + await _executeOn(raw, SqlCommand('ROLLBACK TO SAVEPOINT $name')); + await _executeOn(raw, SqlCommand('RELEASE SAVEPOINT $name')); + } + } catch (_) { + _active = false; + _statementFailed = true; + await raw.invalidate(); + } + Error.throwWithStackTrace(error, stack); + } finally { + child._active = false; + _childActive = false; + } + }); + } + + Future close() async { + if (_connection != null) { + throw const OrmException( + 'SESSION.BORROWED', + 'A borrowed session cannot close its driver.', + ); + } + await (_closing ??= _close()); + } + + Future _close() async { + _active = false; + try { + try { + await Future.wait( + _closeListeners.toList().map( + (listener) => Future.sync(listener), + ), + ); + } finally { + _closeListeners.clear(); + _changeListeners.clear(); + await _stopStreams(); + } + } finally { + await Future.wait(_pending.toList()); + await driver.close(); + } + } +} diff --git a/lib/src/runtime/mysql_transaction.dart b/lib/src/runtime/mysql_transaction.dart new file mode 100644 index 00000000..2b2079c6 --- /dev/null +++ b/lib/src/runtime/mysql_transaction.dart @@ -0,0 +1,104 @@ +part of '../../runtime.dart'; + +void _checkMysqlTransactionSql(String sql) { + Never reject() => throw const OrmException( + 'TRANSACTION.STATEMENT', + 'MySQL/MariaDB transactions allow one SELECT, INSERT, UPDATE, DELETE, ' + 'REPLACE, WITH, SHOW, DESCRIBE or EXPLAIN statement. Use transaction() ' + 'and savepoint() for transaction control; execute DDL outside them.', + ); + + bool letter(int char) => + char >= 65 && char <= 90 || char >= 97 && char <= 122; + var index = 0; + var hasStatement = false; + var terminated = false; + while (index < sql.length) { + final char = sql.codeUnitAt(index); + if (char <= 32) { + index++; + continue; + } + final next = index + 1 < sql.length ? sql.codeUnitAt(index + 1) : -1; + if (char == 35 || + char == 45 && + next == 45 && + (index + 2 == sql.length || sql.codeUnitAt(index + 2) <= 32)) { + while (index < sql.length && + sql.codeUnitAt(index) != 10 && + sql.codeUnitAt(index) != 13) { + index++; + } + continue; + } + if (char == 47 && next == 42) { + final start = index + 2; + if (start < sql.length && + (sql.codeUnitAt(start) == 33 || + (sql.codeUnitAt(start) == 77 || sql.codeUnitAt(start) == 109) && + start + 1 < sql.length && + sql.codeUnitAt(start + 1) == 33)) { + reject(); // MySQL /*! ... */ and MariaDB /*M! ... */ execute SQL. + } + final end = sql.indexOf('*/', start); + if (end < 0) reject(); + final nested = sql.indexOf('/*', start); + if (nested >= 0 && nested < end) reject(); + index = end + 2; + continue; + } + if (terminated) reject(); + if (!hasStatement) { + final start = index; + while (index < sql.length && letter(sql.codeUnitAt(index))) { + index++; + } + final keyword = sql.substring(start, index).toUpperCase(); + if (!const { + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'REPLACE', + 'WITH', + 'SHOW', + 'DESCRIBE', + 'DESC', + 'EXPLAIN', + }.contains(keyword)) { + reject(); + } + hasStatement = true; + continue; + } + if (char == 59) { + terminated = true; + index++; + continue; + } + if (char == 39 || char == 34 || char == 96) { + index++; + var closed = false; + while (index < sql.length) { + final quoted = sql.codeUnitAt(index++); + // Reject ambiguous escapes instead of relying on a mutable sql_mode. + // Bound values and doubled quotes work in either backslash mode. + if (quoted == 92 && index < sql.length) { + final escaped = sql.codeUnitAt(index); + if (escaped == char || escaped == 92) reject(); + } + if (quoted != char) continue; + if (index < sql.length && sql.codeUnitAt(index) == char) { + index++; + } else { + closed = true; + break; + } + } + if (!closed) reject(); + continue; + } + index++; + } + if (!hasStatement) reject(); +} diff --git a/lib/src/runtime/options.dart b/lib/src/runtime/options.dart new file mode 100644 index 00000000..d57308a7 --- /dev/null +++ b/lib/src/runtime/options.dart @@ -0,0 +1,72 @@ +part of '../../runtime.dart'; + +sealed class TransactionOptions { + const TransactionOptions(); + SqlDialect get dialect; + List get beginCommands; +} + +enum Isolation { readCommitted, repeatableRead, serializable } + +String _isolationSql(Isolation isolation) => switch (isolation) { + Isolation.readCommitted => 'READ COMMITTED', + Isolation.repeatableRead => 'REPEATABLE READ', + Isolation.serializable => 'SERIALIZABLE', +}; + +final class PostgresTransaction extends TransactionOptions { + final Isolation isolation; + final bool readOnly; + const PostgresTransaction({ + this.isolation = Isolation.readCommitted, + this.readOnly = false, + }); + @override + SqlDialect get dialect => SqlDialect.postgres; + @override + List get beginCommands => [ + 'BEGIN ISOLATION LEVEL ${_isolationSql(isolation)} ${readOnly ? 'READ ONLY' : 'READ WRITE'}', + ]; +} + +enum SqliteTransactionMode { deferred, immediate, exclusive } + +final class SqliteTransaction extends TransactionOptions { + final SqliteTransactionMode mode; + const SqliteTransaction({this.mode = SqliteTransactionMode.deferred}); + @override + SqlDialect get dialect => SqlDialect.sqlite; + @override + List get beginCommands => ['BEGIN ${mode.name.toUpperCase()}']; +} + +final class MysqlTransaction extends TransactionOptions { + final Isolation isolation; + final bool readOnly; + const MysqlTransaction({ + this.isolation = Isolation.repeatableRead, + this.readOnly = false, + }); + @override + SqlDialect get dialect => SqlDialect.mysql; + @override + List get beginCommands => _mysqlBegin(isolation, readOnly); +} + +final class MariadbTransaction extends TransactionOptions { + final Isolation isolation; + final bool readOnly; + const MariadbTransaction({ + this.isolation = Isolation.repeatableRead, + this.readOnly = false, + }); + @override + SqlDialect get dialect => SqlDialect.mariadb; + @override + List get beginCommands => _mysqlBegin(isolation, readOnly); +} + +List _mysqlBegin(Isolation isolation, bool readOnly) => [ + 'SET TRANSACTION ISOLATION LEVEL ${_isolationSql(isolation)}', + 'START TRANSACTION ${readOnly ? 'READ ONLY' : 'READ WRITE'}', +]; diff --git a/lib/src/runtime/session_connection.dart b/lib/src/runtime/session_connection.dart new file mode 100644 index 00000000..99aa9ce2 --- /dev/null +++ b/lib/src/runtime/session_connection.dart @@ -0,0 +1,96 @@ +part of '../../runtime.dart'; + +/// A session's public execution port. The underlying physical lease and its +/// transaction controls stay private to the runtime. +final class _SessionConnection( + final SqlConnection inner, + final SqlDatabase owner, +) implements SqlConnection { + @override + bool? get transactionActive => inner.transactionActive; + + Future _run(Future Function() action, {String? sql}) { + final result = Future.sync(() async { + owner.checkActive(); + try { + if (sql != null && + owner.inTransaction && + (owner.dialect == SqlDialect.mysql || + owner.dialect == SqlDialect.mariadb)) { + _checkMysqlTransactionSql(sql); + } + return await action(); + } catch (_) { + owner.markFailed(); + rethrow; + } + }); + owner._track( + result.then((_) {}, onError: (Object _, StackTrace _) {}), + ); + return result; + } + + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => _run(() => inner.execute(command, options: options), sql: command.sql); + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => _run(() async { + final cursor = _SessionCursor( + await inner.openCursor(command, options: options), + this, + ); + owner._cursors.add(cursor.close); + return cursor; + }, sql: command.sql); + + @override + Future invalidate() { + owner.checkActive(); + owner.markFailed(); + return inner.invalidate(); + } +} + +final class _SessionCursor( + final SqlCursor inner, + final _SessionConnection connection, +) implements SqlCursor { + Future? _closing; + @override + Future fetch( + int count, { + ExecutionOptions options = const ExecutionOptions(), + }) => connection._run(() { + if (_closing != null) { + throw const OrmException('CURSOR.CLOSED', 'Cursor has ended.'); + } + return inner.fetch(count, options: options); + }); + // Cleanup is allowed after the callback/deadline ends and runs only once. + @override + Future close() => _closing ??= _close(); + Future _close() async { + try { + await inner.close(); + } catch (_) { + connection.owner.markFailed(); + rethrow; + } finally { + connection.owner._cursors.remove(close); + } + } +} + +SqlConnection _physicalConnection(SqlConnection connection) => + switch (connection) { + _SessionConnection() => _physicalConnection(connection.inner), + _TransactionConnection() => _physicalConnection(connection.inner), + _ => connection, + }; diff --git a/lib/src/runtime/stream.dart b/lib/src/runtime/stream.dart new file mode 100644 index 00000000..36c22f2a --- /dev/null +++ b/lib/src/runtime/stream.dart @@ -0,0 +1,216 @@ +part of '../../runtime.dart'; + +extension SqlDatabaseStreaming on SqlDatabase { + /// Stream raw rows from a database cursor with demand-driven batch fetches. + Stream> stream( + SqlCommand command, { + int batchSize = 128, + ExecutionOptions options = const ExecutionOptions(), + }) => streamRows( + command, + batchSize: batchSize, + options: options, + decode: (_, rows, _) async => rows, + ); + + Stream streamRows( + SqlCommand command, { + required int batchSize, + required ExecutionOptions options, + required Future> Function( + SqlConnection, + List>, + ExecutionOptions, + ) + decode, + }) { + if (batchSize < 1) throw ArgumentError.value(batchSize, 'batchSize'); + if (!capabilities.streaming) { + throw const OrmException( + 'CAPABILITY.STREAM', + 'This driver does not support database cursors.', + ); + } + if ((options.cancellation != null || options.timeout != null) && + !capabilities.cancellation) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This driver cannot cancel a running statement.', + ); + } + options.check(); + if (command.parameters.length > capabilities.maxParameters) { + throw const OrmException( + 'QUERY.PARAMETERS', + 'Query exceeds the driver parameter limit.', + ); + } + final cancellation = CancellationToken(); + final execution = ExecutionOptions( + cancellation: capabilities.cancellation ? cancellation : null, + timeout: options.timeout, + ); + final finished = Completer(); + Completer? demand; + var stopped = false, started = false; + Object? cleanupFailure, requestedError; + late StreamController controller; + void requestStop() { + stopped = true; + cancellation.cancel(); + demand?.complete(); + demand = null; + } + + Future stop() async { + requestStop(); + if (started) await finished.future; + if (cleanupFailure != null) throw cleanupFailure!; + } + + Future waitForDemand() async { + if (controller.isPaused && !stopped) { + demand ??= Completer(); + await demand!.future; + } + } + + Future observe( + QueryOperation operation, + Future Function() action, + ) async { + final watch = onQuery == null ? null : (Stopwatch()..start()); + Object? error; + T? result; + try { + return result = await action(); + } catch (e) { + error = e; + if (inTransaction) _statementFailed = true; + rethrow; + } finally { + watch?.stop(); + try { + onQuery?.call( + QueryEvent( + operation: operation, + sql: command.sql, + parameterCount: operation == .cursorOpen + ? command.parameters.length + : 0, + elapsed: watch!.elapsed, + rowCount: result is SqlResult ? result.rows.length : null, + error: error, + ), + ); + } catch (_) {} + } + } + + Future produce() async { + started = true; + _streams.add(stop); + final unsubscribe = options.cancellation?.listen(() { + requestedError = const OrmException( + 'OPERATION.CANCELLED', + 'Stream cancelled.', + ); + requestStop(); + }); + try { + await run( + (connection) async { + final ownsTransaction = !inTransaction; + SqlCursor? cursor; + var complete = false, began = false; + try { + if (stopped) return; + if (ownsTransaction) { + await _executeOn( + connection is _SessionConnection + ? connection.inner + : connection, + SqlCommand(switch (dialect) { + SqlDialect.postgres => 'BEGIN READ ONLY', + SqlDialect.mysql || + SqlDialect.mariadb => 'START TRANSACTION READ ONLY', + SqlDialect.sqlite => 'BEGIN', + }), + ); + began = true; + } + cursor = await observe( + .cursorOpen, + () => connection.openCursor(command, options: execution), + ); + while (!stopped) { + await waitForDemand(); + if (stopped) break; + final batch = await observe( + .cursorFetch, + () => cursor!.fetch(batchSize, options: execution), + ); + if (stopped) break; + final rows = await decode(connection, batch.rows, execution); + for (final row in rows) { + await waitForDemand(); + if (stopped) break; + controller.add(row); + } + if (batch.rows.length < batchSize) { + complete = !stopped; + break; + } + } + } finally { + try { + if (cursor != null) await observe(.cursorClose, cursor.close); + if (began) { + await _executeOn( + connection is _SessionConnection + ? connection.inner + : connection, + SqlCommand(complete ? 'COMMIT' : 'ROLLBACK'), + ); + } + } catch (e) { + cleanupFailure = e; + if (inTransaction) _active = false; + await connection.invalidate(); + rethrow; + } + } + }, + acquire: AcquisitionOptions( + timeout: options.acquireTimeout, + cancellation: cancellation, + ), + ); + } catch (error, stack) { + if (!stopped) controller.addError(error, stack); + } finally { + unsubscribe?.call(); + if (requestedError != null) controller.addError(requestedError!); + _streams.remove(stop); + finished.complete(); + // A cancellation cleanup error is already exposed by stop() or the + // error event. Do not report it again through an unobserved close future. + unawaited(controller.close().catchError((Object _) {})); + } + } + + controller = StreamController( + sync: true, + onListen: () => unawaited(produce()), + onPause: () { + demand ??= Completer(); + }, + onResume: () { + demand?.complete(); + demand = null; + }, + onCancel: stop, + ); + return controller.stream; + } +} diff --git a/lib/src/selection.dart b/lib/src/selection.dart new file mode 100644 index 00000000..76b5c5b0 --- /dev/null +++ b/lib/src/selection.dart @@ -0,0 +1,157 @@ +part of '../sql.dart'; + +typedef _Decoder = T Function(List row); + +abstract class Selection { + const Selection(); + _Decoder _bind(_SelectionPlan plan); + Selection map(R Function(T) mapper) => _Mapped(this, mapper); +} + +final class _SelectionPlan { + final bool deduplicate; + _SelectionPlan({this.deduplicate = true}); + final List> columns = []; + final List<_RelationBinding> relations = []; + final List<_Join> joins = []; + final List> required = []; + final Map, List>> guarded = {}; + Set _present = const {}; + void require(Expr expression) { + if (expression.codec.acceptsNull) return; + if (_present.isEmpty) { + required.add(expression); + } else { + (guarded[_present] ??= []).add(expression); + } + } + + _Decoder optional(TableRef table, Selection selection) { + final saved = _present; + _present = {...saved, table}; + try { + return selection._bind(this); + } finally { + _present = saved; + } + } + + final Map<_Node, int> _indices = {}; + int column(Expr expression) { + if (!deduplicate) { + columns.add(expression); + return columns.length - 1; + } + return _indices.putIfAbsent(expression._node, () { + columns.add(expression); + return columns.length - 1; + }); + } +} + +final class _Mapped(final Selection source, final R Function(T) mapper) + extends Selection { + @override + _Decoder _bind(_SelectionPlan plan) { + final decode = source._bind(plan); + return (row) => mapper(decode(row)); + } +} + +final class _Combined( + final List> sources, + final _Decoder Function(List<_Decoder>) combine, +) extends Selection { + @override + _Decoder _bind(_SelectionPlan plan) => combine( + List.generate( + sources.length, + (i) => sources[i]._bind(plan), + growable: false, + ), + ); +} + +extension Selection2 on (Selection, Selection) { + Selection map(R Function(A, B) mapper) => _Combined( + [$1, $2], + (read) => + (row) => mapper(read[0](row) as A, read[1](row) as B), + ); +} + +extension Selection3 on (Selection, Selection, Selection) { + Selection map(R Function(A, B, C) mapper) => _Combined( + [$1, $2, $3], + (read) => + (row) => + mapper(read[0](row) as A, read[1](row) as B, read[2](row) as C), + ); +} + +extension Selection4 + on (Selection, Selection, Selection, Selection) { + Selection map(R Function(A, B, C, D) mapper) => _Combined( + [$1, $2, $3, $4], + (read) => + (row) => mapper( + read[0](row) as A, + read[1](row) as B, + read[2](row) as C, + read[3](row) as D, + ), + ); +} + +extension Selection5 + on (Selection, Selection, Selection, Selection, Selection) { + Selection map(R Function(A, B, C, D, E) mapper) => _Combined( + [$1, $2, $3, $4, $5], + (read) => + (row) => mapper( + read[0](row) as A, + read[1](row) as B, + read[2](row) as C, + read[3](row) as D, + read[4](row) as E, + ), + ); +} + +extension Selection6 + on + ( + Selection, + Selection, + Selection, + Selection, + Selection, + Selection, + ) { + Selection map(R Function(A, B, C, D, E, F) mapper) => _Combined( + [$1, $2, $3, $4, $5, $6], + (read) => + (row) => mapper( + read[0](row) as A, + read[1](row) as B, + read[2](row) as C, + read[3](row) as D, + read[4](row) as E, + read[5](row) as F, + ), + ); +} + +/// Runtime field selection deliberately returns dynamic values. +Selection> fields( + Map> selected, +) { + final entries = selected.entries.toList(growable: false); + return _Combined( + [for (final e in entries) e.value], + (read) => + (row) => { + for (var i = 0; i < entries.length; i++) entries[i].key: read[i](row), + }, + ); +} diff --git a/lib/src/sql.dart b/lib/src/sql.dart new file mode 100644 index 00000000..4e4f17f2 --- /dev/null +++ b/lib/src/sql.dart @@ -0,0 +1,435 @@ +part of '../sql.dart'; + +sealed class _Node { + const _Node(); + String write(_Writer w) => w.project?.call(this) ?? writeSql(w); + String writeSql(_Writer w); +} + +_Node _unwrapStorage(_Node node) => switch (node) { + _DecimalNode(:final child) || _TemporalNode(:final child) => child, + _ => node, +}; + +final class _ColumnNode(final TableRef table, final String name) extends _Node { + @override + String writeSql(_Writer w) { + final alias = w.aliases[table]; + if (alias == null) { + throw const OrmException( + 'QUERY.SCOPE', + 'Column belongs to another query.', + ); + } + if (w.mysql && alias == 'excluded') return 'VALUES(${w.quote(name)})'; + return w.unqualified || w.unqualifiedTable == table + ? w.quote(name) + : '${w.quote(alias)}.${w.quote(name)}'; + } +} + +final class _Parameter( + final Object? value, { + final String? sqlType, + final String? storageType, +}) extends _Node { + @override + String writeSql(_Writer w) { + final parameter = w.parameter(value, storageType: storageType ?? sqlType); + if (w.mysql && (storageType ?? sqlType) == 'json') { + return w.dialect == SqlDialect.mysql + ? 'CAST($parameter AS JSON)' + : "JSON_EXTRACT($parameter, '\$')"; + } + if (w.mysql && + {'decimal', 'bigint'}.contains(storageType ?? sqlType) && + value != null) { + final decimal = Decimal.parse(value as String); + final text = decimal.toString(); + final digits = text.replaceAll('-', '').split('.'); + final scale = digits.length == 2 ? digits[1].length : 0; + final precision = digits[0].length + scale; + if (precision > 65 || scale > 30) { + throw const OrmException( + 'CAPABILITY.DECIMAL', + 'MySQL/MariaDB SQL decimals require at most 65 digits and scale 30.', + ); + } + return 'CAST($parameter AS DECIMAL(65,$scale))'; + } + // A standalone SELECT parameter has no column context in PostgreSQL and + // otherwise resolves to text, including inside a UNION operand. + if (sqlType == null || w.dialect != SqlDialect.postgres) return parameter; + final type = switch (sqlType) { + 'integer' => 'BIGINT', + 'bigint' || 'decimal' => 'NUMERIC', + 'text' => 'TEXT', + 'real' => 'DOUBLE PRECISION', + 'boolean' => 'BOOLEAN', + 'timestamp' || 'instant' => 'TIMESTAMPTZ', + 'date' => 'DATE', + 'time' => 'TIME WITHOUT TIME ZONE', + 'local_datetime' => 'TIMESTAMP WITHOUT TIME ZONE', + 'json' => 'JSONB', + 'blob' => 'BYTEA', + _ => throw OrmException( + 'QUERY.PARAMETER_TYPE', + 'No PostgreSQL parameter type for $sqlType.', + ), + }; + return 'CAST($parameter AS $type)'; + } +} + +final class _Binary(final _Node left, final String op, final _Node right) + extends _Node { + @override + String writeSql(_Writer w) => '(${left.write(w)} $op ${right.write(w)})'; +} + +final class _Unary( + final String op, + final _Node child, { + final bool postfix = false, +}) extends _Node { + @override + String writeSql(_Writer w) => + postfix ? '(${child.write(w)} $op)' : '($op ${child.write(w)})'; +} + +final class _Function( + final String name, + final List<_Node> arguments, { + final bool distinct = false, + final bool decimal = false, +}) extends _Node { + @override + String writeSql(_Writer w) { + if (decimal && w.mysql && name == 'SUM') { + throw const OrmException( + 'CAPABILITY.DECIMAL_PRECISION', + 'MySQL/MariaDB decimal sums can silently saturate in subqueries; use explicit native SQL when its precision is acceptable.', + ); + } + return '${decimal && w.dialect == SqlDialect.sqlite ? 'orm_decimal_${name.toLowerCase()}_v1' : name}(${distinct ? 'DISTINCT ' : ''}' + '${arguments.map((e) => e.write(w)).join(', ')})'; + } +} + +final class _In(final _Node expression, final List<_Node> values) + extends _Node { + @override + String writeSql(_Writer w) => values.isEmpty + ? 'FALSE' + : '(${expression.write(w)} IN (${values.map((v) => v.write(w)).join(', ')}))'; +} + +/// Trusted SQL fragments still bind embedded expressions as parameters or ASTs. +final class _Raw(final List parts, final List<_Node> values) + extends _Node { + @override + String writeSql(_Writer w) { + if (w.reads?.includeRaw == true) w.reads!.opaque = true; + final result = StringBuffer(parts.first); + for (var i = 0; i < values.length; i++) { + result + ..write(values[i].write(w)) + ..write(parts[i + 1]); + } + return result.toString(); + } +} + +final class _Writer { + final SqlDialect dialect; + final QueryContext? database; + final Map aliases; + final List parameters = []; + final Set leftJoins = {}; + final _ReadTables? reads; + final bool exactDecimal; + final bool temporal; + bool unqualified = false; + TableRef? unqualifiedTable; + String? Function(_Node)? project; + _AverageInputs? averageInputs; + _Writer( + this.dialect, + this.aliases, { + this.database, + this.reads, + this.exactDecimal = false, + this.temporal = false, + }); + bool get mysql => + dialect == SqlDialect.mysql || dialect == SqlDialect.mariadb; + String quote(String name) { + _checkSqlText(name); + return mysql + ? '`${name.replaceAll('`', '``')}`' + : '"${name.replaceAll('"', '""')}"'; + } + + String parameter(Object? value, {String? storageType}) { + // Codec storage, not string pattern guessing, determines temporal binding. + if (mysql && value is String && storageType == 'instant') { + final instant = Codecs.dateTime.decode(value).toUtc(); + if (instant.year < 1000 || instant.year > 9999) { + throw const OrmException( + 'CAPABILITY.TEMPORAL', + 'MySQL timestamps require years 1000 through 9999.', + ); + } + value = value.substring(0, value.length - 3); + } + parameters.add(switch ((dialect, value)) { + (SqlDialect.sqlite, bool v) => v ? 1 : 0, + (SqlDialect.sqlite, DateTime v) => v.toUtc().toIso8601String(), + _ => value, + }); + return dialect == SqlDialect.postgres + ? '\$${parameters.length}' + : mysql + ? '\u0001${parameters.length}\u0002' + : '?${parameters.length}'; + } + + SqlCommand finish(String sql) { + SqlCommand checked(String text, List values) { + if (database != null && + values.length > database!.capabilities.maxParameters) { + throw const OrmException( + 'QUERY.PARAMETERS', + 'SQL exceeds the parameter limit.', + ); + } + return SqlCommand(text, values); + } + + if (!mysql) return checked(sql, parameters); + // Construction may render ORDER BY before WHERE, and window staging may + // reuse a fragment. Positional protocols bind in final SQL occurrence order. + final ordered = []; + final text = sql.replaceAllMapped(RegExp('\u0001([0-9]+)\u0002'), (match) { + ordered.add(parameters[int.parse(match[1]!) - 1]); + return '?'; + }); + return checked(text, ordered); + } +} + +class Expr extends Selection { + final _Node _node; + final Codec codec; + Expr._(_Node node, this.codec) + : _node = switch (codec.sqlType) { + 'decimal' when node is! _DecimalNode => _DecimalNode(node), + 'date' || 'time' || 'local_datetime' || 'instant' + when node is! _TemporalNode => + _TemporalNode(node, codec.sqlType), + _ => node, + }; + + Expr eq(T value) => value == null + ? Expr._( + _Unary('IS NULL', _node, postfix: true), + Codecs.boolean.nullable(), + ) + : _compare( + '=', + _Parameter(codec.encode(value), storageType: codec.sqlType), + ); + Expr ne(T value) => value == null + ? Expr._( + _Unary('IS NOT NULL', _node, postfix: true), + Codecs.boolean.nullable(), + ) + : _compare( + '<>', + _Parameter(codec.encode(value), storageType: codec.sqlType), + ); + Expr equals(Expr other) => _compare('=', other._node); + Expr gt(T value) => _compare( + '>', + _Parameter(codec.encode(value), storageType: codec.sqlType), + ); + Expr gte(T value) => _compare( + '>=', + _Parameter(codec.encode(value), storageType: codec.sqlType), + ); + Expr lt(T value) => _compare( + '<', + _Parameter(codec.encode(value), storageType: codec.sqlType), + ); + Expr lte(T value) => _compare( + '<=', + _Parameter(codec.encode(value), storageType: codec.sqlType), + ); + Expr _compare(String op, _Node right) => + Expr._(_Binary(_node, op, right), Codecs.boolean.nullable()); + Expr isNull() => + Expr._(_Unary('IS NULL', _node, postfix: true), Codecs.boolean); + Expr isNotNull() => + Expr._(_Unary('IS NOT NULL', _node, postfix: true), Codecs.boolean); + Expr isIn(Iterable values) => Expr._( + _In(_node, [ + for (final value in values) + _Parameter(codec.encode(value), storageType: codec.sqlType), + ]), + Codecs.boolean.nullable(), + ); + Expr isInQuery(Query query) { + if (query._selection is! Expr) { + throw const OrmException( + 'QUERY.SCALAR', + 'IN subqueries select one SQL expression.', + ); + } + return Expr._( + _Binary(_node, 'IN', _Subquery(query)), + Codecs.boolean.nullable(), + ); + } + + Expr over({ + List> partitionBy = const [], + List orderBy = const [], + WindowFrame? frame, + }) { + final function = _unwrapStorage(_node); + if ((function is! _Function && function is! _DecimalAverage) || + !_aggregate(function)) { + throw const OrmException( + 'QUERY.WINDOW', + 'over() applies to an aggregate expression.', + ); + } + if ([ + ...partitionBy.map((e) => e._node), + ...orderBy.map((o) => o.expression._node), + ].any(_window)) { + throw const OrmException( + 'QUERY.WINDOW', + 'Window partition/order expressions cannot contain another window.', + ); + } + return Expr._( + _WindowNode( + function, + List.unmodifiable(partitionBy), + List.unmodifiable(orderBy), + frame, + ), + codec, + ); + } + + OrderTerm asc({NullOrder? nulls}) => OrderTerm._(this, false, nulls); + OrderTerm desc({NullOrder? nulls}) => OrderTerm._(this, true, nulls); + CursorTerm cursor(T value, {bool descending = false, NullOrder? nulls}) => + CursorTerm._(OrderTerm._(this, descending, nulls), codec.encode(value)); + Expr count({bool distinct = false}) => + Expr._(_Function('COUNT', [_node], distinct: distinct), Codecs.integer); + Expr min() => Expr._(_Function('MIN', [_node]), codec.nullable()); + Expr max() => Expr._(_Function('MAX', [_node]), codec.nullable()); + @override + _Decoder _bind(_SelectionPlan plan) { + plan.require(this); + final index = plan.column(this); + return (row) => codec.decode(row[index]); + } +} + +Expr value(T value, Codec codec) => + Expr._(_Parameter(codec.encode(value), sqlType: codec.sqlType), codec); + +/// [parts] are trusted SQL, [values] are expressions. Never put user input in parts. +Expr sql(List parts, List> values, Codec codec) { + if (parts.length != values.length + 1) { + throw ArgumentError('SQL needs one more text part than expression.'); + } + for (final part in parts) { + _checkSqlText(part); + } + return Expr._(_Raw(List.of(parts), [for (final v in values) v._node]), codec); +} + +void _checkSqlText(String text) { + if (text.contains('\u0001') || text.contains('\u0002')) { + throw const OrmException( + 'SQL.TEXT', + 'SQL text contains a reserved control character.', + ); + } +} + +extension Predicate on Expr { + Expr and(Expr other) => + Expr._(_Binary(_node, 'AND', other._node), Codecs.boolean.nullable()); + Expr or(Expr other) => + Expr._(_Binary(_node, 'OR', other._node), Codecs.boolean.nullable()); + Expr not() => Expr._(_Unary('NOT', _node), Codecs.boolean.nullable()); +} + +extension TextExpression on Expr { + Expr like(String pattern) => _compare('LIKE', _Parameter(pattern)); + Expr lower() => Expr._(_Function('LOWER', [_node]), Codecs.text); + Expr upper() => Expr._(_Function('UPPER', [_node]), Codecs.text); +} + +extension NumericExpression on Expr { + Expr plus(T n) => Expr._( + _Binary( + _node, + '+', + _Parameter(codec.encode(n), storageType: codec.sqlType), + ), + codec, + ); + Expr minus(T n) => Expr._( + _Binary( + _node, + '-', + _Parameter(codec.encode(n), storageType: codec.sqlType), + ), + codec, + ); + Expr times(T n) => Expr._( + _Binary( + _node, + '*', + _Parameter(codec.encode(n), storageType: codec.sqlType), + ), + codec, + ); + Expr sum() => Expr._(_Function('SUM', [_node]), codec.nullable()); + Expr average() => + Expr._(_Function('AVG', [_node]), Codecs.real.nullable()); +} + +final class OrderTerm { + final Expr expression; + final bool descending; + final NullOrder? nulls; + const OrderTerm._(this.expression, this.descending, [this.nulls]); + String _write(_Writer writer) { + final text = expression._node.write(writer); + if (writer.mysql && nulls != null) { + return '($text IS NULL) ${nulls == NullOrder.first ? 'DESC' : 'ASC'}, ' + '${expression._node.write(writer)} ${descending ? 'DESC' : 'ASC'}'; + } + return '$text$_suffix'; + } + + String get _suffix => + ' ${descending ? 'DESC' : 'ASC'}' + '${nulls == null ? '' : ' NULLS ${nulls!.name.toUpperCase()}'}'; +} + +String _projection(Expr expression, _Writer writer) { + final text = expression._node.write(writer); + // Preserve JSON null separately from SQL NULL through native driver decoders. + return writer.mysql && expression.codec.sqlType == 'json' + ? 'CAST($text AS CHAR CHARACTER SET utf8mb4)' + : text; +} diff --git a/lib/src/sql_context.dart b/lib/src/sql_context.dart new file mode 100644 index 00000000..c1882b46 --- /dev/null +++ b/lib/src/sql_context.dart @@ -0,0 +1,76 @@ +part of '../sql.dart'; + +/// Capabilities and an optional execution binding for typed query descriptions. +/// The SQL layer knows no connection pool, platform adapter or runtime session. +abstract class QueryContext { + const QueryContext(); + Capabilities get capabilities; + SqlDialect get dialect => capabilities.dialect; + bool get inTransaction => false; + bool get inSession => false; + + TableSet table(Table definition) => + TableSet(this, definition); + void registerSchema(Iterable tables) {} + + Never _unbound() => throw const OrmException( + 'QUERY.UNBOUND', + 'This query only describes SQL. Bind it to a Database to execute it.', + ); + + Future run( + Future Function(SqlConnection) action, { + AcquisitionOptions acquire = const AcquisitionOptions(), + }) => _unbound(); + Future executeOn( + SqlConnection connection, + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => _unbound(); + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + Iterable changedTables = const [], + }) => _unbound(); + Future executeCommand( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + Iterable changedTables = const [], + bool affectedOnly = false, + bool cascade = true, + }) => _unbound(); + Future atomic( + Future Function(QueryContext) action, { + AcquisitionOptions acquire = const AcquisitionOptions(), + }) => _unbound(); + Stream streamRows( + SqlCommand command, { + required int batchSize, + required ExecutionOptions options, + required Future> Function( + SqlConnection, + List>, + ExecutionOptions, + ) + decode, + }) => _unbound(); + T observeDecode(String? sql, int inputRows, T Function() action) => + action(); + void markFailed() => _unbound(); +} + +/// Builds and inspects typed SQL without constructing a driver or a database. +final class SqlBuilder extends QueryContext { + @override + final Capabilities capabilities; + const SqlBuilder.withCapabilities(this.capabilities); + SqlBuilder(SqlDialect dialect) + : capabilities = Capabilities( + dialect: dialect, + maxParameters: dialect == SqlDialect.sqlite ? 999 : 65535, + returning: + dialect == SqlDialect.sqlite || dialect == SqlDialect.postgres, + exactDecimal: true, + temporal: true, + ); +} diff --git a/lib/src/sqlite/assets_io.dart b/lib/src/sqlite/assets_io.dart new file mode 100644 index 00000000..d9374388 --- /dev/null +++ b/lib/src/sqlite/assets_io.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +import 'dart:isolate'; + +import 'package:crypto/crypto.dart'; + +import 'web_build.dart'; + +/// Copies only verified current assets. Existing older deployment assets remain. +Future copySqliteWebAssets(Directory destination) async { + final library = await Isolate.resolvePackageUri( + Uri.parse('package:orm/sqlite.dart'), + ); + if (library == null) { + throw StateError('Cannot locate the installed ORM package.'); + } + final files = [ + for (final (name, digest) in [ + (sqliteWorkerFile, sqliteWorkerSha256), + (sqliteWasmFile, sqliteWasmSha256), + ]) + (File.fromUri(library.resolve('../assets/sqlite/$name')), name, digest), + ]; + // Validate everything before writing any output. + for (final (file, _, digest) in files) { + if (!await file.exists() || + (await sha256.bind(file.openRead()).first).toString() != digest) { + throw StateError( + 'Packaged SQLite asset is missing or corrupt: ${file.path}', + ); + } + } + await destination.create(recursive: true); + for (final (file, name, _) in files) { + await file.copy('${destination.path}/$name'); + } +} diff --git a/lib/src/sqlite/decimal.dart b/lib/src/sqlite/decimal.dart new file mode 100644 index 00000000..d665748f --- /dev/null +++ b/lib/src/sqlite/decimal.dart @@ -0,0 +1,226 @@ +part of 'functions.dart'; + +void _registerDecimals(native.CommonDatabase db) { + db.createAggregateFunction( + functionName: 'orm_decimal_avg_v1', + argumentCount: const native.AllowedArgumentCount(3), + deterministic: true, + function: const _DecimalAverage(), + ); + db.createFunction( + functionName: 'orm_decimal_div_v1', + argumentCount: const native.AllowedArgumentCount(4), + deterministic: true, + function: (args) => args[0] == null || args[1] == null + ? null + : Codecs.decimal + .decode(args[0]) + .divide( + Codecs.decimal.decode(args[1]), + scale: args[2] as int, + rounding: DecimalRounding.values[args[3] as int], + ) + .toString(), + ); + db.createFunction( + functionName: 'orm_decimal_round_v1', + argumentCount: const native.AllowedArgumentCount(3), + deterministic: true, + function: (args) => args[0] == null + ? null + : Codecs.decimal + .decode(args[0]) + .rounded( + args[1] as int, + rounding: DecimalRounding.values[args[2] as int], + ) + .toString(), + ); + db.createFunction( + functionName: 'orm_decimal_cast_v1', + argumentCount: const native.AllowedArgumentCount(3), + deterministic: true, + directOnly: false, + function: (args) => args[0] == null + ? null + : Codecs.decimal + .decode(args[0]) + .constrained(args[1] as int, args[2] as int) + .toString(), + ); + db.createFunction( + functionName: 'orm_decimal_fits_v1', + argumentCount: const native.AllowedArgumentCount(3), + deterministic: true, + directOnly: false, + function: (args) => + args[0] is String && + (Decimal.tryParse(args[0] as String) + ?.fits(args[1] as int, args[2] as int) ?? + false) + ? 1 + : 0, + ); + db.createCollation( + name: 'orm_decimal_v1', + function: (a, b) { + // SQLite collation callbacks cannot signal a SQL error. Invalid external + // text gets a deterministic total order after valid decimals; typed reads + // and arithmetic reject it. Never let a parse exception escape through FFI. + if (a == b) return 0; + if (a == null) return -1; + if (b == null) return 1; + final da = Decimal.tryParse(a), db = Decimal.tryParse(b); + if (da != null && db != null) return da.compareTo(db); + if (da != null) return -1; + if (db != null) return 1; + return a.compareTo(b); + }, + ); + for (final op in ['add', 'sub', 'mul']) { + db.createFunction( + functionName: 'orm_decimal_${op}_v1', + argumentCount: const native.AllowedArgumentCount(2), + deterministic: true, + function: (args) { + if (args[0] == null || args[1] == null) return null; + final a = Codecs.decimal.decode(args[0]), + b = Codecs.decimal.decode(args[1]); + return (switch (op) { + 'add' => a + b, + 'sub' => a - b, + _ => a * b, + }).toString(); + }, + ); + } + db.createAggregateFunction( + functionName: 'orm_decimal_sum_v1', + argumentCount: const native.AllowedArgumentCount(1), + deterministic: true, + function: const _DecimalSum(), + ); +} + +final class _DecimalTotal { + // Intermediate sums can exceed the final NUMERIC range and subsequently + // cancel. Enforce the range only when SQLite requests the result. + BigInt coefficient = BigInt.zero; + int scale = 0; + int count = 0; + void add(Decimal value, int direction) { + if (count == 0) { + coefficient = value.coefficient; + scale = value.scale; + } else { + if (value.scale > scale) { + coefficient *= BigInt.from(10).pow(value.scale - scale); + scale = value.scale; + } + coefficient += + value.coefficient * + BigInt.from(direction) * + BigInt.from(10).pow(scale - value.scale); + } + count += direction; + if (count == 0) { + coefficient = BigInt.zero; + scale = 0; + } + } + + String? result() => count == 0 + ? null + : Decimal.fromBigInt(coefficient, scale: scale).toString(); +} + +final class _DecimalSum implements native.WindowFunction<_DecimalTotal> { + const _DecimalSum(); + @override + native.AggregateContext<_DecimalTotal> createContext() => + native.AggregateContext(_DecimalTotal()); + @override + void step( + native.SqliteArguments args, + native.AggregateContext<_DecimalTotal> context, + ) { + if (args[0] == null) return; + context.value.add(Codecs.decimal.decode(args[0]), 1); + } + + @override + void inverse( + native.SqliteArguments args, + native.AggregateContext<_DecimalTotal> context, + ) { + if (args[0] == null) return; + context.value.add(Codecs.decimal.decode(args[0]), -1); + } + + @override + String? value(native.AggregateContext<_DecimalTotal> context) => + context.value.result(); + @override + String? finalize(native.AggregateContext<_DecimalTotal> context) => + value(context); +} + +final class _AverageState { + final total = _DecimalTotal(); + int? scale; + DecimalRounding? rounding; + void configure(native.SqliteArguments args) { + final nextScale = args[1] as int; + final nextRounding = DecimalRounding.values[args[2] as int]; + if (scale != null && (scale != nextScale || rounding != nextRounding)) { + throw ArgumentError('Average scale and rounding must be constant.'); + } + scale = nextScale; + rounding = nextRounding; + } + + String? result() { + if (total.count == 0) return null; + final power = BigInt.from(10).pow(total.scale.abs()); + return Decimal.fromFraction( + total.coefficient * (total.scale < 0 ? power : BigInt.one), + BigInt.from(total.count) * (total.scale > 0 ? power : BigInt.one), + scale: scale!, + rounding: rounding!, + ).toString(); + } +} + +final class _DecimalAverage implements native.WindowFunction<_AverageState> { + const _DecimalAverage(); + @override + native.AggregateContext<_AverageState> createContext() => + native.AggregateContext(_AverageState()); + @override + void step( + native.SqliteArguments args, + native.AggregateContext<_AverageState> context, + ) { + context.value.configure(args); + if (args[0] != null) { + context.value.total.add(Codecs.decimal.decode(args[0]), 1); + } + } + + @override + void inverse( + native.SqliteArguments args, + native.AggregateContext<_AverageState> context, + ) { + if (args[0] != null) { + context.value.total.add(Codecs.decimal.decode(args[0]), -1); + } + } + + @override + String? value(native.AggregateContext<_AverageState> context) => + context.value.result(); + @override + String? finalize(native.AggregateContext<_AverageState> context) => + value(context); +} diff --git a/lib/src/sqlite/execution.dart b/lib/src/sqlite/execution.dart new file mode 100644 index 00000000..bb10232c --- /dev/null +++ b/lib/src/sqlite/execution.dart @@ -0,0 +1,136 @@ +import 'package:sqlite3/common.dart' as sqlite; + +import '../../driver.dart'; +import 'functions.dart'; + +int configureSqlite( + sqlite.CommonDatabase db, { + required String? journal, + required Duration busyTimeout, + bool fullSync = false, +}) { + registerSqliteFunctions(db); + db.execute('PRAGMA foreign_keys = ON'); + if (db.select('PRAGMA foreign_keys').single.values.single != 1) { + throw const OrmException( + 'DRIVER.FOREIGN_KEYS', + 'SQLite foreign keys could not be enabled.', + ); + } + db.execute('PRAGMA busy_timeout = ${busyTimeout.inMilliseconds}'); + final actual = db + .select( + journal == null + ? 'PRAGMA journal_mode' + : 'PRAGMA journal_mode = $journal', + ) + .single + .values + .single; + if (journal != null && actual != journal) { + throw OrmException( + 'DRIVER.JOURNAL', + 'Requested $journal, received $actual.', + ); + } + if (fullSync) db.execute('PRAGMA synchronous = FULL'); + for (final row in db.select('PRAGMA compile_options')) { + final option = row.values.single as String; + if (option.startsWith('MAX_VARIABLE_NUMBER=')) { + return int.parse(option.split('=').last); + } + } + return 999; +} + +/// Synchronous statement ownership shared by the native isolate and web worker. +final class SqliteExecutor { + final sqlite.CommonDatabase database; + final _cursors = {}; + int _serial = 0; + bool _closed = false; + SqliteExecutor(this.database); + + SqlResult execute(SqlCommand command) { + final statement = database.prepare(command.sql, checkNoTail: true); + try { + final rows = statement.select(sqliteParameters(command.parameters)); + return SqlResult( + [for (final row in rows) row.values.toList()], + columns: rows.columnNames, + affectedRows: statement.isReadOnly ? 0 : database.updatedRows, + ); + } finally { + statement.close(); + } + } + + int openCursor(SqlCommand command) { + final statement = database.prepare(command.sql, checkNoTail: true); + try { + if (!statement.isReadOnly) { + throw const OrmException( + 'CURSOR.READ_ONLY', + 'Streaming requires a read-only query.', + ); + } + final id = ++_serial; + _cursors[id] = _Cursor( + statement, + statement.selectCursor(sqliteParameters(command.parameters)), + ); + return id; + } catch (_) { + statement.close(); + rethrow; + } + } + + SqlResult fetch(int id, int count) { + if (count < 1) throw ArgumentError.value(count, 'count'); + final cursor = _cursors[id]; + if (cursor == null) { + throw const OrmException('CURSOR.CLOSED', 'Cursor has ended.'); + } + return cursor.fetch(count); + } + + void release(int id) => _cursors.remove(id)?.close(); + void close() { + if (_closed) return; + _closed = true; + for (final cursor in _cursors.values) { + cursor.close(); + } + _cursors.clear(); + database.close(); + } +} + +final class _Cursor( + final sqlite.CommonPreparedStatement statement, + final sqlite.IteratingCursor cursor, +) { + bool closed = false; + List columns = const []; + SqlResult fetch(int count) { + if (closed) return SqlResult(const [], columns: columns); + final rows = >[]; + while (rows.length < count) { + if (!cursor.moveNext()) { + columns = cursor.columnNames; + close(); + break; + } + rows.add(cursor.current.values.toList()); + } + columns = cursor.columnNames; + return SqlResult(rows, columns: columns); + } + + void close() { + if (closed) return; + closed = true; + statement.close(); + } +} diff --git a/lib/src/sqlite/failure.dart b/lib/src/sqlite/failure.dart new file mode 100644 index 00000000..3eeafd0c --- /dev/null +++ b/lib/src/sqlite/failure.dart @@ -0,0 +1,16 @@ +import '../../driver.dart'; + +final class SqliteFailure implements SqlFailure { + @override + bool get retryTransaction => code == 5; + @override + bool get retryCommit => code == 5; + @override + bool get commitRejected => code == 5 || code == 19; + final int code; + final int extendedCode; + final String message; + const SqliteFailure(this.code, this.extendedCode, this.message); + @override + String toString() => 'SqliteFailure($extendedCode): $message'; +} diff --git a/lib/src/sqlite/functions.dart b/lib/src/sqlite/functions.dart new file mode 100644 index 00000000..ef4d405e --- /dev/null +++ b/lib/src/sqlite/functions.dart @@ -0,0 +1,23 @@ +import 'package:sqlite3/common.dart' as native; + +import '../../driver.dart'; + +part 'decimal.dart'; +part 'temporal.dart'; + +void registerSqliteFunctions(native.CommonDatabase db) { + _registerDecimals(db); + _registerTemporals(db); +} + +List sqliteParameters(List parameters) => [ + for (final parameter in parameters) + parameter is SqlReal ? _RealParameter(parameter.value) : parameter, +]; + +final class _RealParameter(final double value) + implements native.CustomStatementParameter { + @override + void applyTo(native.CommonPreparedStatement statement, int index) => + statement.raw.bindDouble(index, value); +} diff --git a/lib/src/sqlite/native.dart b/lib/src/sqlite/native.dart new file mode 100644 index 00000000..78f84efd --- /dev/null +++ b/lib/src/sqlite/native.dart @@ -0,0 +1,41 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:ffi' as ffi; + +import 'package:sqlite3/sqlite3.dart' as native; + +import '../../driver.dart'; +import 'execution.dart'; +import 'failure.dart'; +import 'opened.dart'; +import 'options.dart'; + +part 'native_client.dart'; +part 'native_server.dart'; + +Future connectSqlite(SqliteOptions options) async { + if (options.path.isEmpty || + options.busyTimeout.isNegative || + options.name != null && options.path == ':memory:') { + throw ArgumentError( + 'A native SQLite file requires a non-empty path. ' + 'Set nativePath for SqliteOptions.persistent, and use memory() for memory storage.', + ); + } + final worker = _SqliteWorker(); + final (port, maxParameters, version) = await worker.open(options); + worker.port = port; + return ( + connection: worker, + capabilities: Capabilities( + dialect: SqlDialect.sqlite, + maxParameters: maxParameters, + streaming: true, + cancellation: worker.supportsCancellation, + exactDecimal: true, + temporal: true, + ), + version: version, + close: worker.stop, + ); +} diff --git a/lib/src/sqlite/native_client.dart b/lib/src/sqlite/native_client.dart new file mode 100644 index 00000000..51d644bc --- /dev/null +++ b/lib/src/sqlite/native_client.dart @@ -0,0 +1,222 @@ +part of 'native.dart'; + +// Resolve against the exact native asset used by package:sqlite3. Never call +// an unrelated system SQLite library with this library's database pointer. +@ffi.Native)>( + symbol: 'sqlite3_interrupt', + assetId: 'package:sqlite3/src/ffi/libsqlite3.g.dart', +) +external void _sqliteInterrupt(ffi.Pointer db); + +final class _OpenCursor(final SqlCommand command) {} + +final class _FetchCursor(final int cursor, final int count) {} + +final class _CloseCursor(final int cursor) {} + +final class _SqliteWorker implements SqlConnection { + final ReceivePort _responses = ReceivePort(); + final Completer<(SendPort, int, int)> _ready = Completer(); + final Map> _pending = {}; + late SendPort port; + Isolate? _isolate; + var _id = 0, _handle = 0; + bool _stopped = false, _busy = false; + bool _transactionActive = false; + @override + bool? get transactionActive => _stopped || _busy ? null : _transactionActive; + late final bool supportsCancellation = _resolveInterrupt(); + bool _resolveInterrupt() { + try { + return ffi.Native.addressOf< + ffi.NativeFunction)> + >(_sqliteInterrupt) + .address != + 0; + } catch (_) { + return false; + } + } + + void _fail(Object error) { + _handle = 0; + if (!_ready.isCompleted) _ready.completeError(error); + for (final pending in _pending.values) { + pending.completeError(error); + } + _pending.clear(); + } + + Future<(SendPort, int, int)> open(SqliteOptions options) async { + _responses.listen((Object? message) { + if (message case [ + 0, + SendPort port, + int maxParameters, + int version, + int handle, + ]) { + _handle = handle; + _ready.complete((port, maxParameters, version)); + } else if (message case [-1, Object error, SendPort acknowledge]) { + // The worker keeps its native connection alive until this acknowledgement. + _fail(error); + _stopped = true; + acknowledge.send(null); + _responses.close(); + } else if (message case [int id, SqlResult result, bool active]) { + _transactionActive = active; + _pending.remove(id)?.complete(result); + } else if (message case [int id, Object error, bool active]) { + _transactionActive = active; + _pending.remove(id)?.completeError(error); + } else if (message case [int id, Object error]) { + if (id == 0) { + _fail(error); + } else { + _pending.remove(id)?.completeError(error); + } + } else { + _fail( + const OrmException( + 'DRIVER.WORKER', + 'SQLite worker exited unexpectedly.', + ), + ); + } + }); + try { + _isolate = await Isolate.spawn( + _sqliteMain, + (_responses.sendPort, options), + onError: _responses.sendPort, + onExit: _responses.sendPort, + ); + return await _ready.future; + } catch (_) { + _responses.close(); + _isolate?.kill(); + rethrow; + } + } + + Future _request(Object? command) { + final id = ++_id, result = Completer(); + _pending[id] = result; + port.send((id, command)); + return result.future; + } + + Future _operate(Object command, ExecutionOptions options) async { + options.check(); + if (_stopped) { + throw const OrmException('DRIVER.CLOSED', 'SQLite worker is closed.'); + } + if (_busy) { + throw const OrmException( + 'SESSION.BUSY', + 'Await the active statement before using this connection again.', + ); + } + if ((options.cancellation != null || options.timeout != null) && + !supportsCancellation) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This SQLite build does not export its interrupt API.', + ); + } + _busy = true; + Timer? deadline, repeat; + var timedOut = false; + void interrupt() { + if (_handle != 0 && !_stopped) { + _sqliteInterrupt(ffi.Pointer.fromAddress(_handle)); + } + } + + void cancel() { + interrupt(); + // An interrupt just before sqlite3_step is a no-op. Repeat until the one + // active request finishes, without permitting another request to start. + repeat ??= Timer.periodic( + const Duration(milliseconds: 5), + (_) => interrupt(), + ); + } + + final unsubscribe = options.cancellation?.listen(cancel); + if (options.timeout != null) { + deadline = Timer(options.timeout!, () { + timedOut = true; + cancel(); + }); + } + try { + return await _request(command); + } on SqliteFailure catch (error) { + if (error.code == 9) { + throw OrmException( + timedOut ? 'OPERATION.TIMEOUT' : 'OPERATION.CANCELLED', + 'SQLite stopped the statement.', + cause: error, + ); + } + rethrow; + } finally { + deadline?.cancel(); + repeat?.cancel(); + unsubscribe?.call(); + _busy = false; + } + } + + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => _operate(command, options); + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + final result = await _operate(_OpenCursor(command), options); + return _SqliteCursor(this, result.rows.single.single as int); + } + + Future stop() async { + if (_stopped) return; + _stopped = true; + _handle = 0; + try { + await _request(null); + } finally { + _responses.close(); + _isolate?.kill(); + } + } + + @override + Future invalidate() => stop(); +} + +final class _SqliteCursor(final _SqliteWorker worker, final int id) + implements SqlCursor { + bool _closed = false; + @override + Future fetch( + int count, { + ExecutionOptions options = const ExecutionOptions(), + }) { + if (_closed) throw const OrmException('CURSOR.CLOSED', 'Cursor has ended.'); + if (count < 1) throw ArgumentError.value(count, 'count'); + return worker._operate(_FetchCursor(id, count), options); + } + + @override + Future close() async { + if (_closed) return; + _closed = true; + await worker._operate(_CloseCursor(id), const ExecutionOptions()); + } +} diff --git a/lib/src/sqlite/native_server.dart b/lib/src/sqlite/native_server.dart new file mode 100644 index 00000000..f67039bc --- /dev/null +++ b/lib/src/sqlite/native_server.dart @@ -0,0 +1,92 @@ +part of 'native.dart'; + +void _sqliteMain((SendPort, SqliteOptions) init) async { + final (response, options) = init; + native.Database? db; + final commands = ReceivePort(); + SqliteExecutor? executor; + var published = false; + try { + db = native.sqlite3.open( + options.path, + mode: options.readOnly + ? native.OpenMode.readOnly + : native.OpenMode.readWriteCreate, + ); + executor = SqliteExecutor(db); + final maxParameters = configureSqlite( + db, + journal: options.journal?.name, + busyTimeout: options.busyTimeout, + ); + response.send([ + 0, + commands.sendPort, + maxParameters, + native.sqlite3.version.versionNumber, + db.handle.address, + ]); + published = true; + await for (final message in commands) { + final (id, command) = message as (int, Object?); + if (command == null) { + executor!.close(); + executor = null; + db = null; + response.send([id, const SqlResult([]), false]); + break; + } + try { + SqlResult result; + switch (command) { + case SqlCommand(): + result = executor!.execute(command); + case _OpenCursor(): + result = SqlResult([ + [executor!.openCursor(command.command)], + ]); + case _FetchCursor(): + result = executor!.fetch(command.cursor, command.count); + case _CloseCursor(): + executor!.release(command.cursor); + result = const SqlResult([]); + default: + throw const OrmException( + 'DRIVER.WORKER', + 'Invalid SQLite worker command.', + ); + } + response.send([id, result, !db!.autocommit]); + } on native.SqliteException catch (e) { + response.send([ + id, + SqliteFailure(e.resultCode, e.extendedResultCode, e.message), + !db!.autocommit, + ]); + } catch (e) { + response.send([ + id, + e is OrmException ? e : OrmException('DRIVER.SQLITE', e.toString()), + !db!.autocommit, + ]); + } + } + } catch (e) { + final error = OrmException('DRIVER.OPEN', e.toString()); + if (published && db != null) { + final acknowledged = ReceivePort(); + response.send([-1, error, acknowledged.sendPort]); + await acknowledged.first; + acknowledged.close(); + } else { + response.send([0, error]); + } + } finally { + if (executor != null) { + executor.close(); + } else { + db?.close(); + } + commands.close(); + } +} diff --git a/lib/src/sqlite/opened.dart b/lib/src/sqlite/opened.dart new file mode 100644 index 00000000..a2e3c162 --- /dev/null +++ b/lib/src/sqlite/opened.dart @@ -0,0 +1,8 @@ +import '../../driver.dart'; + +typedef OpenedSqlite = ({ + SqlConnection connection, + Capabilities capabilities, + int version, + Future Function() close, +}); diff --git a/lib/src/sqlite/options.dart b/lib/src/sqlite/options.dart new file mode 100644 index 00000000..fa5f44f8 --- /dev/null +++ b/lib/src/sqlite/options.dart @@ -0,0 +1,73 @@ +import 'web_build.dart'; + +enum SqliteJournal { wal, delete, memory } + +final class SqliteOptions { + /// Native file path. Persistent browser databases use [name] instead. + final String path; + final String? name; + final SqliteJournal? journal; + final bool readOnly; + final Duration busyTimeout; + final SqliteWebOptions web; + + /// A native file. Web callers should use [SqliteOptions.persistent]. + const SqliteOptions.file( + this.path, { + this.journal = SqliteJournal.wal, + this.busyTimeout = const Duration(seconds: 5), + }) : name = null, + readOnly = false, + web = const SqliteWebOptions(); + + const SqliteOptions.readOnly( + this.path, { + this.busyTimeout = const Duration(seconds: 5), + }) : name = null, + journal = null, + readOnly = true, + web = const SqliteWebOptions(); + + const SqliteOptions.memory({ + this.busyTimeout = const Duration(seconds: 5), + this.web = const SqliteWebOptions(), + }) : path = ':memory:', + name = null, + journal = SqliteJournal.memory, + readOnly = false; + + /// OPFS on Web; [nativePath] on native platforms. + /// + /// Native callers must supply a non-empty file path in a directory they own. + /// The ORM does not guess a Flutter application's documents directory. + const SqliteOptions.persistent( + String this.name, { + String? nativePath, + this.journal = SqliteJournal.wal, + this.busyTimeout = const Duration(seconds: 5), + this.web = const SqliteWebOptions(), + }) : path = nativePath ?? '', + readOnly = false; +} + +/// Advanced browser asset overrides. Flutter Web needs no asset configuration. +final class SqliteWebOptions { + /// Directory containing the packaged, versioned assets. Relative to HTML base. + /// Defaults to Flutter package assets, or `orm/` in a plain Dart web app. + final Uri? assetBase; + + /// Overrides are relative to the document's HTML base, not [assetBase]. + final Uri? wasm; + final Uri? worker; + + /// Subresource integrity for the SQLite module. Override for a custom build. + final String wasmIntegrity; + final Duration openTimeout; + const SqliteWebOptions({ + this.assetBase, + this.wasm, + this.worker, + this.wasmIntegrity = sqliteWasmIntegrity, + this.openTimeout = const Duration(seconds: 15), + }); +} diff --git a/lib/src/sqlite/temporal.dart b/lib/src/sqlite/temporal.dart new file mode 100644 index 00000000..c269cd37 --- /dev/null +++ b/lib/src/sqlite/temporal.dart @@ -0,0 +1,69 @@ +part of 'functions.dart'; + +void _registerTemporals(native.CommonDatabase db) { + Object round(Object value, String kind, int digits) => switch (kind) { + 'time' => Codecs.time.decode(value).withPrecision(digits), + 'local_datetime' => + Codecs.localDateTime.decode(value).withPrecision(digits), + 'instant' => Codecs.dateTime.decode(value).withPrecision(digits), + _ => throw ArgumentError('Unknown temporal storage.'), + }; + for (final fits in [false, true]) { + db.createFunction( + functionName: fits ? 'orm_temporal_fits_v1' : 'orm_temporal_cast_v1', + argumentCount: const native.AllowedArgumentCount(3), + deterministic: true, + directOnly: false, + function: (args) { + if (args[0] == null) return null; + final kind = args[1] as String, digits = args[2] as int; + final rounded = round(args[0]!, kind, digits); + if (fits) return rounded == round(args[0]!, kind, 6) ? 1 : 0; + return rounded is DateTime + ? Codecs.dateTime.encode(rounded) + : rounded.toString(); + }, + ); + } + + db.createFunction( + functionName: 'orm_instant_v1', + argumentCount: const native.AllowedArgumentCount(1), + deterministic: true, + directOnly: false, + function: (args) => args[0] == null + ? null + : Codecs.dateTime.encode(Codecs.dateTime.decode(args[0])), + ); + void register>( + String kind, + T? Function(String) parse, + ) { + db.createCollation( + name: 'orm_${kind}_v1', + function: (a, b) { + // A collation cannot report a SQL error through FFI. Invalid external + // text sorts after valid values; typed decoding rejects it. + if (a == b) return 0; + if (a == null) return -1; + if (b == null) return 1; + final left = parse(a), right = parse(b); + if (left != null && right != null) return left.compareTo(right); + if (left != null) return -1; + if (right != null) return 1; + return a.compareTo(b); + }, + ); + } + + register('date', LocalDate.tryParse); + register('time', LocalTime.tryParse); + register('local_datetime', LocalDateTime.tryParse); + register('instant', (text) { + try { + return Codecs.dateTime.decode(text); + } on FormatException { + return null; + } + }); +} diff --git a/lib/src/sqlite/unsupported.dart b/lib/src/sqlite/unsupported.dart new file mode 100644 index 00000000..b7b52a3e --- /dev/null +++ b/lib/src/sqlite/unsupported.dart @@ -0,0 +1,5 @@ +import 'opened.dart'; +import 'options.dart'; + +Future connectSqlite(SqliteOptions options) => + throw UnsupportedError('SQLite requires a native or browser Dart runtime.'); diff --git a/lib/src/sqlite/web.dart b/lib/src/sqlite/web.dart new file mode 100644 index 00000000..cf98c8a0 --- /dev/null +++ b/lib/src/sqlite/web.dart @@ -0,0 +1,110 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:web/web.dart' as web; + +import '../../driver.dart'; +import 'failure.dart'; +import 'opened.dart'; +import 'options.dart'; +import 'web_build.dart'; +import 'web_wire.dart'; + +part 'web_client.dart'; + +Future connectSqlite(SqliteOptions options) async { + final config = options.web; + final memory = + options.name == null && options.path == ':memory:' && !options.readOnly; + if (!memory && options.name == null) { + throw const OrmException( + 'CAPABILITY.STORAGE', + 'Browser SQLite uses persistent(name) or memory(), not native file paths.', + ); + } + if (config.openTimeout <= Duration.zero || + !RegExp(r'^sha256-[A-Za-z0-9+/]{43}=$').hasMatch(config.wasmIntegrity) || + options.name != null && + !RegExp(r'^[A-Za-z0-9][A-Za-z0-9_.-]*$').hasMatch(options.name!)) { + throw ArgumentError('Invalid SQLite browser configuration.'); + } + final base = Uri.parse(web.document.baseURI); + final assetBase = base.resolveUri( + config.assetBase ?? + Uri.parse( + const bool.fromEnvironment('dart.library.ui') + ? 'assets/packages/orm/assets/sqlite/' + : 'orm/', + ), + ); + if (!assetBase.path.endsWith('/')) { + throw ArgumentError( + 'SQLite assetBase must be a directory URI ending in /.', + ); + } + final workerUri = config.worker == null + ? assetBase.resolve(sqliteWorkerFile) + : base.resolveUri(config.worker!); + final wasmUri = config.wasm == null + ? assetBase.resolve(sqliteWasmFile) + : base.resolveUri(config.wasm!); + final document = Uri.parse(web.window.location.href); + if (!{'http', 'https'}.contains(workerUri.scheme) || + workerUri.origin != document.origin || + !{'http', 'https'}.contains(wasmUri.scheme) || + config.worker?.toString() == '' || + config.wasm?.toString() == '') { + throw const OrmException( + 'DRIVER.ASSET', + 'SQLite worker must be same-origin HTTP(S); WASM must have an HTTP(S) URL.', + ); + } + final connection = _WebConnection(web.Worker(workerUri.toString().toJS)); + try { + Future> initialize() async { + final hello = await connection.request('hello'); + if (hello == null || !hello.isA()) { + throw const OrmException( + 'DRIVER.PROTOCOL', + 'Invalid SQLite worker handshake.', + ); + } + final identity = hello as JSArray; + if (identity.length != 2 || + !identity[0].equals(sqliteWebProtocol.toJS).toDart || + !identity[1].equals(sqliteWorkerBuild.toJS).toDart) { + throw const OrmException( + 'DRIVER.PROTOCOL', + 'SQLite worker and application builds differ. Deploy matching package assets.', + ); + } + return (await connection.request( + 'open', + [ + wasmUri.toString().toJS, + (memory ? 'memory' : 'opfs').toJS, + options.name?.toJS, + config.wasmIntegrity.toJS, + ].toJS, + ))! + as JSArray; + } + + final info = await initialize().timeout(config.openTimeout); + return ( + connection: connection, + capabilities: Capabilities( + dialect: SqlDialect.sqlite, + maxParameters: (info[1]! as JSNumber).toDartInt, + streaming: true, + exactDecimal: true, + temporal: true, + ), + version: (info[0]! as JSNumber).toDartInt, + close: connection.close, + ); + } catch (_) { + await connection.invalidate(); + rethrow; + } +} diff --git a/lib/src/sqlite/web_build.dart b/lib/src/sqlite/web_build.dart new file mode 100644 index 00000000..439e0486 --- /dev/null +++ b/lib/src/sqlite/web_build.dart @@ -0,0 +1,14 @@ +// Generated by tool/build_sqlite_web.dart. Do not edit. +const sqliteWebProtocol = 1; +const sqliteWorkerBuild = + '7db02584ff593d0fdefb4246b98ca454e365c59fce0a3ddf662f459385e8f5ca'; +const sqliteWorkerFile = + 'worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js'; +const sqliteWorkerSha256 = + 'cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce'; +const sqliteWasmFile = + 'sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm'; +const sqliteWasmSha256 = + '13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4'; +const sqliteWasmIntegrity = + 'sha256-E9PxHQWzm6BhinEV+0FkCl1ItjAPXT8yX1VLQr1miKQ='; diff --git a/lib/src/sqlite/web_client.dart b/lib/src/sqlite/web_client.dart new file mode 100644 index 00000000..a8928742 --- /dev/null +++ b/lib/src/sqlite/web_client.dart @@ -0,0 +1,164 @@ +part of 'web.dart'; + +final class _WebConnection implements SqlConnection { + final web.Worker worker; + final Map> _pending = {}; + int _serial = 0; + bool _closed = false, _busy = false; + bool? _active = false; + _WebConnection(this.worker) { + worker.onmessage = ((web.MessageEvent event) { + try { + final message = event.data! as JSArray; + final id = (message[0]! as JSNumber).toDartInt; + final response = _pending[id]; + if (response == null) return; + _active = (message[2] as JSBoolean?)?.toDart; + if ((message[1]! as JSBoolean).toDart) { + _pending.remove(id); + response.complete(message[3]); + } else { + final error = message[3]! as JSArray; + final code = (error[0]! as JSString).toDart; + final description = (error[1]! as JSString).toDart; + final failure = code == 'sqlite' + ? SqliteFailure( + (error[2]! as JSNumber).toDartInt, + (error[3]! as JSNumber).toDartInt, + description, + ) + : OrmException(code, description); + _pending.remove(id); + response.completeError(failure); + } + } catch (error) { + _fail(error); + } + }).toJS; + worker.onerror = ((web.Event event) { + _fail( + const OrmException( + 'DRIVER.WORKER', + 'SQLite web worker failed to load or exited with an error.', + ), + ); + }).toJS; + worker.onmessageerror = ((web.MessageEvent event) { + _fail( + const OrmException( + 'DRIVER.PROTOCOL', + 'SQLite worker message could not be cloned.', + ), + ); + }).toJS; + } + + @override + bool? get transactionActive => _closed || _busy ? null : _active; + + void _fail(Object error) { + if (_closed) return; + _closed = true; + _active = null; + worker.terminate(); + for (final response in _pending.values) { + response.completeError(error); + } + _pending.clear(); + } + + Future request(String operation, [JSAny? payload]) { + if (_closed) { + throw const OrmException('DRIVER.CLOSED', 'SQLite web worker is closed.'); + } + final id = ++_serial, response = Completer(); + _pending[id] = response; + try { + worker.postMessage([id.toJS, operation.toJS, payload].toJS); + } catch (error, stack) { + _pending.remove(id); + response.completeError(error, stack); + } + return response.future; + } + + Future _operate( + String operation, + JSAny? payload, + ExecutionOptions options, + ) async { + options.check(); + if (options.timeout != null || options.cancellation != null) { + throw const OrmException( + 'CAPABILITY.CANCEL', + 'This WASM worker cannot interrupt a running SQLite statement.', + ); + } + if (_busy) { + throw const OrmException( + 'SESSION.BUSY', + 'Await the active SQLite request.', + ); + } + _busy = true; + try { + return await request(operation, payload); + } finally { + _busy = false; + } + } + + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async => sqliteWebDartResult( + await _operate('execute', sqliteWebCommand(command), options), + ); + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + final id = await _operate('cursor', sqliteWebCommand(command), options); + return _WebCursor(this, (id! as JSNumber).toDartInt); + } + + Future close() async { + if (_closed) return; + try { + await request('close'); + } finally { + await invalidate(); + } + } + + @override + Future invalidate() async => _fail( + const OrmException('DRIVER.CLOSED', 'SQLite web worker was discarded.'), + ); +} + +final class _WebCursor(final _WebConnection connection, final int id) + implements SqlCursor { + bool _closed = false; + @override + Future fetch( + int count, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + if (_closed) throw const OrmException('CURSOR.CLOSED', 'Cursor has ended.'); + if (count < 1) throw ArgumentError.value(count, 'count'); + return sqliteWebDartResult( + await connection._operate('fetch', [id.toJS, count.toJS].toJS, options), + ); + } + + @override + Future close() async { + if (_closed) return; + _closed = true; + await connection._operate('release', id.toJS, const ExecutionOptions()); + } +} diff --git a/lib/src/sqlite/web_wire.dart b/lib/src/sqlite/web_wire.dart new file mode 100644 index 00000000..4a2cb3ec --- /dev/null +++ b/lib/src/sqlite/web_wire.dart @@ -0,0 +1,89 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +import '../../driver.dart'; + +/// Structured clone transports blobs as typed arrays and large integers as +/// tagged decimal text. It never sends Dart class instances to another worker. +JSAny? sqliteWebValue(Object? value, {bool parameter = false}) => + switch (value) { + null => null, + bool() => value.toJS, + int() + when parameter && + (value < -9007199254740991 || value > 9007199254740991) => + throw const OrmException( + 'CODEC.INTEGER', + 'Browser int transport requires a safe integer; use BigInt for wider integers or SqlReal for floating values.', + ), + SqlReal() => ['real'.toJS, value.value.toJS].toJS, + num() => value.toJS, + String() => value.toJS, + Uint8List() => value.toJS, + BigInt() => ['bigint'.toJS, value.toString().toJS].toJS, + _ => throw const OrmException( + 'CODEC.PARAMETER', + 'Unsupported SQLite wire value.', + ), + }; + +Object? sqliteWebDartValue(JSAny? value) { + if (value == null) return null; + if (value.isA()) return (value as JSString).toDart; + if (value.isA()) return (value as JSBoolean).toDart; + if (value.isA()) { + final number = (value as JSNumber).toDartDouble; + return number.isFinite && + number.abs() <= 9007199254740991 && + number == number.truncateToDouble() + ? number.toInt() + : number; + } + if (value.isA()) return (value as JSUint8Array).toDart; + if (value.isA()) { + final parts = value as JSArray; + if (parts.length == 2 && (parts[0]! as JSString).toDart == 'bigint') { + return BigInt.parse((parts[1]! as JSString).toDart); + } + if (parts.length == 2 && (parts[0]! as JSString).toDart == 'real') { + return SqlReal((parts[1]! as JSNumber).toDartDouble); + } + } + throw const OrmException('DRIVER.PROTOCOL', 'Invalid SQLite worker value.'); +} + +JSArray sqliteWebCommand(SqlCommand command) => [ + command.sql.toJS, + command.parameters + .map((v) => sqliteWebValue(v, parameter: true)) + .toList() + .toJS, +].toJS; + +SqlCommand sqliteWebDartCommand(JSAny? payload) { + final parts = payload! as JSArray; + return SqlCommand( + (parts[0]! as JSString).toDart, + (parts[1]! as JSArray).toDart.map(sqliteWebDartValue).toList(), + ); +} + +JSArray sqliteWebResult(SqlResult result) => [ + result.columns.map((c) => c.toJS).toList().toJS, + result.rows.map((row) => row.map(sqliteWebValue).toList().toJS).toList().toJS, + result.affectedRows.toJS, +].toJS; + +SqlResult sqliteWebDartResult(JSAny? payload) { + final parts = payload! as JSArray; + return SqlResult( + [ + for (final row in (parts[1]! as JSArray>).toDart) + row.toDart.map(sqliteWebDartValue).toList(), + ], + columns: [ + for (final c in (parts[0]! as JSArray).toDart) c.toDart, + ], + affectedRows: (parts[2]! as JSNumber).toDartInt, + ); +} diff --git a/lib/src/table.dart b/lib/src/table.dart new file mode 100644 index 00000000..cbc941be --- /dev/null +++ b/lib/src/table.dart @@ -0,0 +1,96 @@ +part of '../sql.dart'; + +/// A table occurrence has nominal identity, even when two row records have the +/// same structural Dart type or a query joins the same physical table twice. +final class TableRef { + final TableSchema schema; + TableRef(this.schema); +} + +abstract class Fields { + final TableRef table; + const Fields(this.table); + Field column(Column column) => Field._(table, column); + ReadField readColumn(Column column) => ReadField._(table, column); +} + +base class ReadField extends Expr { + final TableRef table; + final Column definition; + ReadField._(this.table, this.definition) + : super._(_ColumnNode(table, definition.name), definition.codec); +} + +final class Field extends ReadField { + Field._(super.table, super.definition) : super._() { + if (definition.computed != null) { + throw const OrmException( + 'COLUMN.READ_ONLY', + 'Use readColumn for a computed column.', + ); + } + } + Assignment set(T value) => + _assign(_Parameter(codec.encode(value), storageType: codec.sqlType)); + Assignment setExpression(Expr expression) => _assign(expression._node); + Assignment _assign(_Node node) => Assignment._( + this, + definition.decimalPrecision == null + ? definition.temporalPrecision == null + ? node + : _TemporalCast( + node, + definition.codec.sqlType, + definition.temporalPrecision!, + columnAssignment: true, + ) + : _DecimalCast( + node, + definition.decimalPrecision!, + definition.decimalScale ?? 0, + columnAssignment: true, + ), + ); + Assignment defaultValue() => Assignment._(this, null); +} + +extension NumericField on Field { + Assignment increment(T amount) => setExpression(plus(amount)); + Assignment decrement(T amount) => setExpression(minus(amount)); +} + +final class Table { + final TableSchema schema; + final F Function(TableRef) createFields; + final Selection Function(F) selectRow; + const Table(this.schema, this.createFields, this.selectRow); + TableAlias alias() => TableAlias._(this); +} + +sealed class Change { + const Change(); + const factory Change.keep() = _Keep; + const factory Change.set(T value) = _Set; + const factory Change.defaultValue() = _Default; +} + +final class _Keep extends Change { + const _Keep(); +} + +final class _Set extends Change { + final T value; + const _Set(this.value); +} + +final class _Default extends Change { + const _Default(); +} + +extension ChangeField on Field { + List change(Change change) => switch (change) { + _Keep() => [], + _Set(:final value) => [set(value)], + _Default() => [defaultValue()], + }; +} diff --git a/lib/src/temporal.dart b/lib/src/temporal.dart new file mode 100644 index 00000000..5c35330f --- /dev/null +++ b/lib/src/temporal.dart @@ -0,0 +1,320 @@ +part of '../values.dart'; + +const _microsecondsPerDay = 86400000000; +int _floorDiv(int a, int b) => a ~/ b - (a < 0 && a % b != 0 ? 1 : 0); +String _pad(int value, int length) => value.toString().padLeft(length, '0'); + +int _gregorianJulianDay(int year, int month, int day) { + final y = year - (month <= 2 ? 1 : 0); + final cycle = _floorDiv(y, 400); + final within = y - cycle * 400, m = month + (month > 2 ? -3 : 9); + return cycle * 146097 + + within * 365 + + within ~/ 4 - + within ~/ 100 + + (153 * m + 2) ~/ 5 + + day - + 1 + + 1721120; +} + +/// A Gregorian calendar date, without a time or timezone. +/// Year 0 is 1 BC; negative years use astronomical numbering. +final class LocalDate implements Comparable { + static const minJulianDay = 0; + static const maxJulianDay = 2147483493; + final int year, month, day; + final int julianDay; + const LocalDate._(this.year, this.month, this.day, this.julianDay); + factory LocalDate(int year, int month, int day) { + if (year < -4713 || + year > 5874897 || + month < 1 || + month > 12 || + day < 1 || + day > _monthDays(year, month)) { + throw RangeError('Invalid Gregorian date.'); + } + final ordinal = _gregorianJulianDay(year, month, day); + if (ordinal < minJulianDay || ordinal > maxJulianDay) { + throw RangeError('Date exceeds the finite PostgreSQL DATE range.'); + } + return LocalDate._(year, month, day, ordinal); + } + factory LocalDate.fromJulianDay(int value) { + if (value < minJulianDay || value > maxJulianDay) { + throw RangeError.range(value, minJulianDay, maxJulianDay, 'julianDay'); + } + // March-based Gregorian cycles (400 years = 146097 days). + final shifted = value - 1721120, cycle = _floorDiv(shifted, 146097); + final days = shifted - cycle * 146097; + final y = (days - days ~/ 1460 + days ~/ 36524 - days ~/ 146096) ~/ 365; + final dayOfYear = days - (365 * y + y ~/ 4 - y ~/ 100); + final marchMonth = (5 * dayOfYear + 2) ~/ 153; + final day = dayOfYear - (153 * marchMonth + 2) ~/ 5 + 1; + final month = marchMonth + (marchMonth < 10 ? 3 : -9); + return LocalDate._( + y + cycle * 400 + (month <= 2 ? 1 : 0), + month, + day, + value, + ); + } + factory LocalDate.parse(String text) { + final match = _syntax.firstMatch(text); + if (match == null || match.end != text.length) { + throw const FormatException( + 'Expected a Gregorian date without a timezone.', + ); + } + var year = int.parse(match[1]!); + if (match[4] != null) { + if (year <= 0) { + throw const FormatException('BC dates require a positive era year.'); + } + year = 1 - year; + } + return LocalDate(year, int.parse(match[2]!), int.parse(match[3]!)); + } + static final _syntax = RegExp( + r'^(-?\d{4,7})-(\d{2})-(\d{2})( BC)?$', + caseSensitive: false, + ); + static LocalDate? tryParse(String text) { + try { + return LocalDate.parse(text); + } on FormatException { + return null; + } on RangeError { + return null; + } + } + + static int _monthDays(int year, int month) => switch (month) { + 2 => year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) ? 29 : 28, + 4 || 6 || 9 || 11 => 30, + _ => 31, + }; + LocalDate addDays(int days) { + if (days < minJulianDay - julianDay || days > maxJulianDay - julianDay) { + throw RangeError('Date addition exceeds the supported range.'); + } + return LocalDate.fromJulianDay(julianDay + days); + } + + int daysUntil(LocalDate other) => other.julianDay - julianDay; + String get _civil => + '${_pad(year <= 0 ? 1 - year : year, 4)}-${_pad(month, 2)}-${_pad(day, 2)}'; + @override + String toString() => '$_civil${year <= 0 ? ' BC' : ''}'; + @override + int compareTo(LocalDate other) => julianDay.compareTo(other.julianDay); + @override + bool operator ==(Object other) => + other is LocalDate && julianDay == other.julianDay; + @override + int get hashCode => julianDay.hashCode; +} + +/// A wall-clock time at microsecond resolution, including the endpoint 24:00. +/// The fourth constructor argument is the full fractional second (0..999999). +final class LocalTime implements Comparable { + final int microseconds; + const LocalTime._(this.microseconds); + factory LocalTime( + int hour, [ + int minute = 0, + int second = 0, + int microsecond = 0, + ]) { + if (hour < 0 || + hour > 24 || + minute < 0 || + minute > 59 || + second < 0 || + second > 59 || + microsecond < 0 || + microsecond > 999999 || + hour == 24 && (minute != 0 || second != 0 || microsecond != 0)) { + throw RangeError('Invalid time of day.'); + } + return LocalTime._( + ((hour * 60 + minute) * 60 + second) * 1000000 + microsecond, + ); + } + factory LocalTime.fromMicroseconds(int value) { + if (value < 0 || value > _microsecondsPerDay) { + throw RangeError.range(value, 0, _microsecondsPerDay, 'microseconds'); + } + return LocalTime._(value); + } + factory LocalTime.parse(String text) { + final match = _syntax.firstMatch(text); + if (match == null || match.end != text.length) { + throw const FormatException( + 'Expected a time without a timezone, with at most six fractional digits.', + ); + } + return LocalTime( + int.parse(match[1]!), + int.parse(match[2]!), + int.parse(match[3] ?? '0'), + int.parse((match[4] ?? '').padRight(6, '0')), + ); + } + static final _syntax = RegExp( + r'^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?$', + ); + static LocalTime? tryParse(String text) { + try { + return LocalTime.parse(text); + } on FormatException { + return null; + } on RangeError { + return null; + } + } + + /// Round fractional seconds, with exact halves toward the next second. + LocalTime withPrecision(int digits) => LocalTime.fromMicroseconds( + microseconds + _temporalRoundDelta(microseconds, digits), + ); + + int get hour => microseconds ~/ 3600000000; + int get minute => microseconds ~/ 60000000 % 60; + int get second => microseconds ~/ 1000000 % 60; + int get microsecond => microseconds % 1000000; + @override + String toString() => + '${_pad(hour, 2)}:${_pad(minute, 2)}:${_pad(second, 2)}.${_pad(microsecond, 6)}'; + @override + int compareTo(LocalTime other) => microseconds.compareTo(other.microseconds); + @override + bool operator ==(Object other) => + other is LocalTime && microseconds == other.microseconds; + @override + int get hashCode => microseconds.hashCode; +} + +/// Calendar date and wall-clock time, with no timezone or implied UTC instant. +final class LocalDateTime implements Comparable { + static const maxJulianDay = 109203527; + final LocalDate date; + final LocalTime time; + const LocalDateTime._(this.date, this.time); + factory LocalDateTime(LocalDate date, LocalTime time) { + if (time.hour == 24) { + date = date.addDays(1); + time = LocalTime(0); + } + if (date.julianDay > maxJulianDay) { + throw RangeError( + 'Local timestamp exceeds the finite PostgreSQL TIMESTAMP range.', + ); + } + return LocalDateTime._(date, time); + } + factory LocalDateTime.parse(String text) { + final match = _syntax.firstMatch(text); + if (match == null || match.end != text.length) { + throw const FormatException( + 'Expected a local date and time without a timezone.', + ); + } + return LocalDateTime( + LocalDate.parse('${match[1]}${match[3] ?? ''}'), + LocalTime.parse(match[2]!), + ); + } + static final _syntax = RegExp( + r'^(-?\d{4,7}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?)( BC)?$', + caseSensitive: false, + ); + static LocalDateTime? tryParse(String text) { + try { + return LocalDateTime.parse(text); + } on FormatException { + return null; + } on RangeError { + return null; + } + } + + /// Match PostgreSQL TIMESTAMP(p): exact halves round away from 2000-01-01. + LocalDateTime withPrecision(int digits) => add( + Duration( + microseconds: _temporalRoundDelta( + time.microseconds, + digits, + beforeEpoch: date.julianDay < 2451545, + ), + ), + ); + + LocalDateTime add(Duration duration) { + final days = _floorDiv(duration.inMicroseconds, _microsecondsPerDay); + final ticks = + time.microseconds + duration.inMicroseconds % _microsecondsPerDay; + return LocalDateTime( + date.addDays(days + ticks ~/ _microsecondsPerDay), + LocalTime.fromMicroseconds(ticks % _microsecondsPerDay), + ); + } + + @override + String toString() => '${date._civil} $time${date.year <= 0 ? ' BC' : ''}'; + @override + int compareTo(LocalDateTime other) { + final day = date.compareTo(other.date); + return day == 0 ? time.compareTo(other.time) : day; + } + + @override + bool operator ==(Object other) => + other is LocalDateTime && date == other.date && time == other.time; + @override + int get hashCode => Object.hash(date, time); +} + +void _checkTemporalPrecision(int digits) { + if (digits < 0 || digits > 6) throw RangeError.range(digits, 0, 6, 'digits'); +} + +// Use day-local microseconds so the full timestamp range stays exact on JS. +int _temporalRoundDelta(int ticks, int digits, {bool beforeEpoch = false}) { + _checkTemporalPrecision(digits); + final unit = const [1000000, 100000, 10000, 1000, 100, 10, 1][digits]; + final remainder = ticks % unit; + return remainder * 2 > unit || remainder * 2 == unit && !beforeEpoch + ? unit - remainder + : -remainder; +} + +extension InstantPrecision on DateTime { + /// Round a resolved UTC instant using PostgreSQL TIMESTAMPTZ(p) rules. + DateTime withPrecision(int digits) { + final utc = _checkedInstant(this); + final rounded = LocalDateTime( + LocalDate(utc.year, utc.month, utc.day), + LocalTime( + utc.hour, + utc.minute, + utc.second, + utc.millisecond * 1000 + utc.microsecond, + ), + ).withPrecision(digits); + final date = rounded.date, time = rounded.time; + return _checkedInstant( + DateTime.utc( + date.year, + date.month, + date.day, + time.hour, + time.minute, + time.second, + time.microsecond ~/ 1000, + time.microsecond % 1000, + ), + ); + } +} diff --git a/lib/src/temporal_sql.dart b/lib/src/temporal_sql.dart new file mode 100644 index 00000000..6154f403 --- /dev/null +++ b/lib/src/temporal_sql.dart @@ -0,0 +1,79 @@ +part of '../sql.dart'; + +final class _TemporalNode(final _Node child, final String kind) extends _Node { + @override + String writeSql(_Writer w) { + if (!w.temporal) { + throw const OrmException( + 'CAPABILITY.TEMPORAL', + 'This driver does not provide temporal values.', + ); + } + final text = child.write(w); + return w.dialect == SqlDialect.sqlite + ? '($text COLLATE "orm_${kind}_v1")' + : text; + } +} + +void _checkTemporalPrecision(int digits) { + if (digits < 0 || digits > 6) throw RangeError.range(digits, 0, 6, 'digits'); +} + +final class _TemporalCast( + final _Node child, + final String kind, + final int digits, { + final bool columnAssignment = false, +}) extends _Node { + @override + String writeSql(_Writer w) { + _checkTemporalPrecision(digits); + if (!w.temporal) { + throw const OrmException( + 'CAPABILITY.TEMPORAL', + 'This driver does not provide temporal values.', + ); + } + final type = switch (kind) { + 'time' => 'TIME($digits) WITHOUT TIME ZONE', + 'local_datetime' => 'TIMESTAMP($digits) WITHOUT TIME ZONE', + 'instant' => 'TIMESTAMPTZ($digits)', + _ => throw ArgumentError('Temporal precision requires temporal storage.'), + }; + final sql = child.write(w); + if (w.mysql) { + // Storage coercion follows the declared database column. Expression + // rounding has a stronger cross-engine contract and stays explicit. + if (columnAssignment) return sql; + throw const OrmException( + 'CAPABILITY.TEMPORAL_PRECISION', + 'MySQL/MariaDB temporal rounding differs; declare column precision or use explicit SQL.', + ); + } + return w.dialect == SqlDialect.postgres + ? 'CAST($sql AS $type)' + : "orm_temporal_cast_v1($sql, '$kind', $digits)"; + } +} + +extension TimeExpression on Expr { + Expr withPrecision(int digits) { + _checkTemporalPrecision(digits); + return Expr._(_TemporalCast(_node, 'time', digits), codec); + } +} + +extension LocalDateTimeExpression on Expr { + Expr withPrecision(int digits) { + _checkTemporalPrecision(digits); + return Expr._(_TemporalCast(_node, 'local_datetime', digits), codec); + } +} + +extension InstantExpression on Expr { + Expr withPrecision(int digits) { + _checkTemporalPrecision(digits); + return Expr._(_TemporalCast(_node, 'instant', digits), codec); + } +} diff --git a/lib/src/transaction.dart b/lib/src/transaction.dart new file mode 100644 index 00000000..ebb13c9d --- /dev/null +++ b/lib/src/transaction.dart @@ -0,0 +1,219 @@ +part of '../runtime.dart'; + +/// Explicit opt-in: callback logic must be safe to repeat after rollback. +/// The budget counts every scheduled retry, including commit-only retries. +final class TransactionRetry { + /// Initial attempt plus callback replays and COMMIT-only retries combined. + final int maxAttempts; + + /// Total budget including connection acquisition, callbacks, SQL and backoff. + final Duration timeout; + + /// Initial backoff, randomized between approximately half and all this delay. + final Duration delay; + + /// Upper bound as the backoff doubles after each retry. + final Duration maxDelay; + const TransactionRetry({ + this.maxAttempts = 3, + this.timeout = const Duration(seconds: 30), + this.delay = const Duration(milliseconds: 10), + this.maxDelay = const Duration(milliseconds: 250), + }); + void _check() { + if (maxAttempts < 1 || + timeout <= Duration.zero || + delay.isNegative || + maxDelay < delay) { + throw ArgumentError( + 'Retry attempts/time must be positive; delay must be nonnegative and no greater than maxDelay.', + ); + } + } +} + +// Created only after the attempt has confirmed rollback; never for acquisition, +// uncertain COMMIT, failed cleanup or an application-caught statement failure. +final class _RetryAfterRollback(final SqlFailure error, final StackTrace stack) + implements Exception {} + +final class _RetryBudget { + final TransactionRetry policy; + final Stopwatch _clock = Stopwatch()..start(); + final math.Random _random = math.Random(); + int _attempts = 1; + int _delay; + _RetryBudget(this.policy) : _delay = policy.delay.inMicroseconds; + Duration get remaining => policy.timeout - _clock.elapsed; + Duration limit(Duration? other) { + final left = remaining; + if (left <= Duration.zero) { + throw const OrmException( + 'TRANSACTION.TIMEOUT', + 'Transaction retry time budget expired.', + ); + } + return other == null || left < other ? left : other; + } + + Future next(_TransactionControl control) async { + control.check(); + limit(null); + if (_attempts >= policy.maxAttempts) return false; + _attempts++; + // Equal jitter without multiplying a potentially large Duration by a double. + final wait = _delay ~/ 2 + (_delay ~/ 2000) * _random.nextInt(1001); + final max = policy.maxDelay.inMicroseconds; + _delay = _delay >= max ~/ 2 ? max : _delay * 2; + await control.pause(Duration(microseconds: wait)); + control.check(); + limit(null); + return true; + } +} + +final class _CancellationLink { + final token = CancellationToken(); + final List _remove = []; + _CancellationLink(Iterable sources) { + for (final source in sources) { + _remove.add(source.listen(token.cancel)); + } + } + void dispose() { + for (final remove in _remove) { + remove(); + } + } +} + +final class _TransactionControl { + final CancellationToken token = CancellationToken(); + final Duration? timeout; + final Stopwatch _clock = Stopwatch()..start(); + final Completer _expired = Completer(); + Timer? _timer; + void Function()? _unsubscribe; + OrmException? failure; + + _TransactionControl(this.timeout, CancellationToken? cancellation) { + _expired.future.ignore(); + _unsubscribe = cancellation?.listen(() => _expire(false)); + if (timeout != null) _timer = Timer(timeout!, () => _expire(true)); + } + void _expire(bool timedOut) { + if (failure != null) return; + failure = OrmException( + timedOut ? 'TRANSACTION.TIMEOUT' : 'TRANSACTION.CANCELLED', + timedOut ? 'Transaction deadline expired.' : 'Transaction cancelled.', + ); + token.cancel(); + _expired.completeError(failure!); + } + + void check() { + if (timeout != null && _clock.elapsed >= timeout!) _expire(true); + if (failure != null) throw failure!; + } + + Future race(Future Function() action) { + check(); + return Future.any([Future.sync(action), _expired.future]); + } + + Future execute( + Future Function(ExecutionOptions) action, + ExecutionOptions options, + ) async { + check(); + options.check(); + final link = + options.cancellation == null || identical(options.cancellation, token) + ? null + : _CancellationLink([token, options.cancellation!]); + try { + return await action( + ExecutionOptions( + timeout: options.timeout, + cancellation: link?.token ?? token, + ), + ); + } finally { + link?.dispose(); + } + } + + Future pause(Duration delay) async { + check(); + if (delay == Duration.zero) return; + final done = Completer(); + final timer = Timer(delay, done.complete); + final remove = token.listen(() { + if (!done.isCompleted) { + done.completeError( + failure ?? + const OrmException( + 'TRANSACTION.CANCELLED', + 'Transaction cancelled.', + ), + ); + } + }); + try { + await done.future; + } finally { + timer.cancel(); + remove(); + } + check(); + } + + void dispose() { + _clock.stop(); + _timer?.cancel(); + _unsubscribe?.call(); + } +} + +final class _TransactionConnection( + final SqlConnection inner, + final _TransactionControl control, +) implements SqlConnection { + @override + bool? get transactionActive => inner.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => control.execute((o) => inner.execute(command, options: o), options); + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async => _TransactionCursor( + await control.execute( + (o) => inner.openCursor(command, options: o), + options, + ), + control, + ); + @override + Future invalidate() => inner.invalidate(); +} + +final class _TransactionCursor( + final SqlCursor inner, + final _TransactionControl control, +) implements SqlCursor { + @override + Future fetch( + int count, { + ExecutionOptions options = const ExecutionOptions(), + }) => control.execute((o) => inner.fetch(count, options: o), options); + // Cleanup must still run after the deadline/cancellation token expires. + @override + Future close() => inner.close(); +} + +SqlConnection _unscoped(SqlConnection connection) => + connection is _TransactionConnection ? connection.inner : connection; diff --git a/lib/src/union.dart b/lib/src/union.dart new file mode 100644 index 00000000..efe2d5e0 --- /dev/null +++ b/lib/src/union.dart @@ -0,0 +1,195 @@ +part of '../sql.dart'; + +/// SQL records have no Dart mapping step. Set operations compare their stored +/// columns before any subsequent Record/DTO mapping. +final class _SqlRow( + final List> columns, + final R Function(List) assemble, +) extends Selection { + @override + _Decoder _bind(_SelectionPlan plan) { + final readers = [for (final column in columns) column._bind(plan)]; + return (row) => assemble([for (final read in readers) read(row)]); + } +} + +extension SqlRow2 on (Expr, Expr) { + Selection<(A, B)> get row => _SqlRow([$1, $2], (v) => (v[0] as A, v[1] as B)); +} + +extension SqlRow3 on (Expr, Expr, Expr) { + Selection<(A, B, C)> get row => + _SqlRow([$1, $2, $3], (v) => (v[0] as A, v[1] as B, v[2] as C)); +} + +extension SqlRow4 on (Expr, Expr, Expr, Expr) { + Selection<(A, B, C, D)> get row => _SqlRow([ + $1, + $2, + $3, + $4, + ], (v) => (v[0] as A, v[1] as B, v[2] as C, v[3] as D)); +} + +extension SqlRow5 + on (Expr, Expr, Expr, Expr, Expr) { + Selection<(A, B, C, D, E)> get row => _SqlRow([ + $1, + $2, + $3, + $4, + $5, + ], (v) => (v[0] as A, v[1] as B, v[2] as C, v[3] as D, v[4] as E)); +} + +extension SqlRow6 + on (Expr, Expr, Expr, Expr, Expr, Expr) { + Selection<(A, B, C, D, E, F)> get row => _SqlRow([ + $1, + $2, + $3, + $4, + $5, + $6, + ], (v) => (v[0] as A, v[1] as B, v[2] as C, v[3] as D, v[4] as E, v[5] as F)); +} + +extension SetQueries on Query { + /// Combines SQL scalars or `.row` projections. Both operands must use this + /// database/session and the same codecs, column types and nullability. + Query> union(Query other) => + _union(other, all: false); + + /// Keeps duplicate SQL rows. Row order requires an explicit final orderBy. + Query> unionAll(Query other) => + _union(other, all: true); + + Query> _union( + Query other, { + required bool all, + }) { + if (!identical(database, other.database)) { + throw const OrmException( + 'QUERY.UNION_SESSION', + 'UNION operands must use the same database/session.', + ); + } + final shape = _sqlRowShape(_selection); + if (shape == null || shape != _sqlRowShape(other._selection)) { + throw const OrmException( + 'QUERY.UNION_SELECTION', + 'Select SQL expressions or matching .row records. Map after UNION.', + ); + } + // SQL set columns are positional: selecting (id, id) still exports two. + final (left, decode) = _plan(deduplicate: false); + if ((database.dialect == SqlDialect.mysql || + database.dialect == SqlDialect.mariadb) && + left.columns.any((e) => e.codec.sqlType == 'decimal')) { + throw const OrmException( + 'CAPABILITY.DECIMAL_PRECISION', + 'MySQL/MariaDB decimal UNION may silently narrow mixed precision; use explicit native SQL when its precision is acceptable.', + ); + } + final (right, _) = other._plan(deduplicate: false); + if (left.columns.length != right.columns.length || + left.relations.isNotEmpty || + right.relations.isNotEmpty) { + throw const OrmException( + 'QUERY.UNION_SELECTION', + 'UNION requires matching SQL columns.', + ); + } + for (var i = 0; i < left.columns.length; i++) { + final a = left.columns[i].codec; + final b = right.columns[i].codec; + if (!a.sameStorageAs(b) || a.acceptsNull != b.acceptsNull) { + throw OrmException( + 'QUERY.UNION_CODEC', + 'UNION column ${i + 1} requires the same codec and nullability.', + ); + } + } + final table = TableRef( + TableSchema( + '_orm_union', + columns: [ + for (var i = 0; i < left.columns.length; i++) + Column( + 'c$i', + left.columns[i].codec, + nullable: left.columns[i].codec.acceptsNull, + ), + ], + ), + ); + final fields = UnionFields._(table, _fields, left); + final columns = [ + for (var i = 0; i < left.columns.length; i++) + Expr._(_ColumnNode(table, 'c$i'), left.columns[i].codec), + ]; + // A scalar remains an Expr, so scalar()/isInQuery() compose unchanged. + final Selection selection = shape == 0 + ? Expr._(columns.single._node, left.columns.single.codec as Codec) + : _ReboundSelection(decode, columns, source: _selection); + return Query._( + database, + fields, + _QueryState(table, union: _UnionSource(this, left, other, right, all)), + selection, + ); + } +} + +int? _sqlRowShape(Selection selection) => switch (selection) { + Expr() => 0, + _SqlRow(:final columns) => columns.length, + _ReboundSelection(:final source?) => _sqlRowShape(source), + _ => null, +}; + +/// References refer to the left operand's exported SQL expressions. +final class UnionFields extends Fields { + final F _original; + final _SelectionPlan _plan; + UnionFields._(super.table, this._original, this._plan); + + Expr ref(Expr Function(F) expression) { + final original = expression(_original); + final index = _plan.columns.indexWhere( + (e) => _sameSqlNode(e._node, original._node), + ); + if (index < 0) { + throw const OrmException( + 'QUERY.UNION_COLUMN', + 'UNION did not export this SQL expression.', + ); + } + if (_plan.columns[index].codec.acceptsNull && !original.codec.acceptsNull) { + throw const OrmException( + 'QUERY.NULLABILITY', + 'Reference this UNION column with a nullable expression.', + ); + } + if (!_plan.columns[index].codec.sameStorageAs(original.codec)) { + throw const OrmException( + 'QUERY.UNION_CODEC', + 'Reference the exported UNION codec.', + ); + } + return Expr._(_ColumnNode(table, 'c$index'), original.codec); + } +} + +final class _UnionSource( + final Query left, + final _SelectionPlan leftPlan, + final Query right, + final _SelectionPlan rightPlan, + final bool all, +) { + String write(_Writer w) => + 'SELECT * FROM (${left._write(w, leftPlan, aliasColumns: true)}) AS ${w.quote('_union_left')} ' + 'UNION${all ? ' ALL' : ''} ' + 'SELECT * FROM (${right._write(w, rightPlan, aliasColumns: true)}) AS ${w.quote('_union_right')}'; +} diff --git a/lib/src/watch.dart b/lib/src/watch.dart new file mode 100644 index 00000000..0e9cd356 --- /dev/null +++ b/lib/src/watch.dart @@ -0,0 +1,227 @@ +part of '../orm.dart'; + +final _changeHubs = Expando<_ChangeHub>(); + +void _mergeChanges(Map target, Map source) { + for (final entry in source.entries) { + target[entry.key] = (target[entry.key] ?? false) || entry.value; + } +} + +/// Shared by views of the same driver, without global result caching. +final class _ChangeHub { + final _schemas = Expando(); + final Map _tables = {}; + final Map> _deleteEffects = {}; + final Set<_QueryWatch> watches = {}; + bool closed = false; + + void register(Iterable schema) { + if (!closed && _schemas[schema] != true) { + _schemas[schema] = true; + for (final table in schema) { + registerTable(table); + } + } + } + + void registerTable(TableSchema table) { + if (closed || identical(_tables[table.name], table)) return; + final previous = _tables[table.name]; + if (previous != null) { + for (final fk in previous.foreignKeys) { + final effects = _deleteEffects[fk.target]; + effects?.remove(table.name); + if (effects?.isEmpty ?? false) _deleteEffects.remove(fk.target); + } + } + _tables[table.name] = table; + for (final fk in table.foreignKeys) { + if (!{'CASCADE', 'SET NULL', 'SET DEFAULT'}.contains(fk.onDelete)) { + continue; + } + final effects = _deleteEffects.putIfAbsent(fk.target, () => {}); + effects[table.name] = + (effects[table.name] ?? false) || fk.onDelete == 'CASCADE'; + } + } + + void publish(Map changes) { + if (closed || changes.isEmpty || watches.isEmpty) return; + final affected = changes.keys.toSet(); + final deletes = [ + for (final e in changes.entries) + if (e.value) e.key, + ]; + final visited = {}; + while (deletes.isNotEmpty) { + final table = deletes.removeLast(); + if (!visited.add(table)) continue; + for (final effect + in (_deleteEffects[table] ?? const {}).entries) { + affected.add(effect.key); + if (effect.value) deletes.add(effect.key); + } + } + for (final watch in watches.toList()) { + if (watch.tables.any(affected.contains)) watch.invalidate(); + } + } + + Future stop() async { + closed = true; + await Future.wait(watches.toList().map((watch) => watch.stop())); + _tables.clear(); + _deleteEffects.clear(); + } +} + +extension WatchQuery on Query { + /// Initial snapshot followed by fresh snapshots after relevant committed writes. + /// Add tables hidden inside raw SQL with [reads]. Read errors are emitted without + /// closing the stream, allowing a later invalidation to retry the query. + Stream> watch({ + Iterable reads = const [], + ExecutionOptions options = const ExecutionOptions(), + }) => _QueryWatch(this, List.unmodifiable(reads), options).controller.stream; +} + +final class _QueryWatch { + final Query query; + final List extraReads; + final ExecutionOptions options; + final Set tables = {}; + late final controller = StreamController>( + onListen: _start, + onPause: () => _paused = true, + onResume: () { + _paused = false; + _schedule(); + }, + onCancel: stop, + ); + bool _dirty = true, _paused = false, _scheduled = false, _stopped = false; + Future? _running, _stopping; + CancellationToken? _readCancellation; + void Function()? _removeCancellation; + _QueryWatch(this.query, this.extraReads, this.options); + + void _start() { + try { + final context = query.database; + if (context is! Database) { + throw const OrmException( + 'QUERY.UNBOUND', + 'Watch requires an ORM Database query.', + ); + } + final db = context; + db.checkActive(); + options.check(); + if (db.inSession) { + throw const OrmException( + 'WATCH.SESSION', + 'Watch a root database query outside leased sessions and transactions.', + ); + } + if (db._changes.closed) { + throw const OrmException('SESSION.CLOSED', 'Database is closed.'); + } + final reads = query.dependencies; + if (reads.opaque && extraReads.isEmpty) { + throw const OrmException( + 'WATCH.READS', + 'Named SQL requires explicit physical tables in watch(reads: ...).', + ); + } + for (final table in [...reads.tables, ...extraReads]) { + tables.add(table.name); + db._changes.registerTable(table); + } + // Subscribe before the initial read so concurrent commits cannot be lost. + db._changes.watches.add(this); + _removeCancellation = options.cancellation?.listen(() { + unawaited( + stop( + error: const OrmException( + 'OPERATION.CANCELLED', + 'Query watch cancelled.', + ), + ), + ); + }); + _schedule(); + } catch (error, stack) { + unawaited(stop(error: error, stack: stack)); + } + } + + void invalidate() { + _dirty = true; + _schedule(); + } + + void _schedule() { + if (_stopped || _paused || !_dirty || _scheduled || _running != null) { + return; + } + _scheduled = true; + scheduleMicrotask(() { + _scheduled = false; + if (_stopped || _paused || !_dirty) return; + final work = _pump(); + _running = work; + unawaited( + work.whenComplete(() { + _running = null; + _schedule(); + }), + ); + }); + } + + Future _pump() async { + while (!_stopped && !_paused && _dirty) { + _dirty = false; + final cancellation = query.database.capabilities.cancellation + ? CancellationToken() + : null; + _readCancellation = cancellation; + try { + final rows = await query.get( + options: ExecutionOptions( + timeout: options.timeout, + acquireTimeout: options.acquireTimeout, + cancellation: cancellation, + ), + ); + if (_paused) _dirty = true; + if (!_stopped && !_paused && !_dirty) controller.add(rows); + } catch (error, stack) { + if (!_stopped) controller.addError(error, stack); + } finally { + _readCancellation = null; + } + } + } + + Future stop({Object? error, StackTrace? stack}) { + if (_stopping case final stopping?) return stopping; + final done = Completer(); + _stopping = done.future; + _stopped = true; + if (query.database case final Database db) { + db._changes.watches.remove(this); + } + _removeCancellation?.call(); + _readCancellation?.cancel(); + unawaited(() async { + await _running; + if (error != null) controller.addError(error, stack); + // A paused listener must not hold database shutdown open. + unawaited(controller.close()); + done.complete(); + }()); + return done.future; + } +} diff --git a/lib/values.dart b/lib/values.dart new file mode 100644 index 00000000..321a55b3 --- /dev/null +++ b/lib/values.dart @@ -0,0 +1,11 @@ +/// Database value types and explicit Dart/storage codecs. No database connection required. +library; + +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:typed_data'; + +part 'src/codec.dart'; +part 'src/decimal.dart'; +part 'src/instant.dart'; +part 'src/temporal.dart'; diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 00000000..58f80886 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,5 @@ +# This repository now ships a Dart package, not the retired VitePress site. +# Keep the existing published site intact; there is no site artifact to deploy. +# https://docs.netlify.com/build/configure-builds/ignore-builds/ +[build] +ignore = "exit 0" diff --git a/pub/orm/CHANGELOG.md b/pub/orm/CHANGELOG.md deleted file mode 100644 index 991fd8e9..00000000 --- a/pub/orm/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -## 0.0.0-dev.1 - -- Initial version. diff --git a/pub/orm/README.md b/pub/orm/README.md deleted file mode 100644 index 8831761b..00000000 --- a/pub/orm/README.md +++ /dev/null @@ -1,39 +0,0 @@ - - -TODO: Put a short description of the package here that helps potential users -know whether this package might be useful for them. - -## Features - -TODO: List what your package can do. Maybe include images, gifs, or videos. - -## Getting started - -TODO: List prerequisites and provide or point to information on how to -start using the package. - -## Usage - -TODO: Include short and useful examples for package users. Add longer examples -to `/example` folder. - -```dart -const like = 'sample'; -``` - -## Additional information - -TODO: Tell users more about the package: where to find more information, how to -contribute to the package, how to file issues, what response they can expect -from the package authors, and more. diff --git a/pub/orm/analysis_options.yaml b/pub/orm/analysis_options.yaml deleted file mode 100644 index 572dd239..00000000 --- a/pub/orm/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:lints/recommended.yaml diff --git a/pub/orm/lib/config.dart b/pub/orm/lib/config.dart deleted file mode 100644 index 43a68799..00000000 --- a/pub/orm/lib/config.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:meta/meta.dart'; - -enum DatabaseProvider { sqlite, mysql, postgresql, sqlserver } - -@immutable -final class Config { - final DatabaseProvider provider; - final String output; - final String? schema; - final String? migrations; - - @literal - const Config({ - required this.provider, - required this.output, - this.schema, - this.migrations, - }); -} diff --git a/pub/orm/lib/core.dart b/pub/orm/lib/core.dart deleted file mode 100644 index 40e5cb1e..00000000 --- a/pub/orm/lib/core.dart +++ /dev/null @@ -1 +0,0 @@ -export 'src/core/sort_order.dart'; diff --git a/pub/orm/lib/main.dart b/pub/orm/lib/main.dart deleted file mode 100644 index 4079f807..00000000 --- a/pub/orm/lib/main.dart +++ /dev/null @@ -1,22 +0,0 @@ -// import 'dart:async'; - -// import 'package:analysis_server_plugin/registry.dart'; - -// // import 'src/analyzer/plugin.dart'; - -// // mixin A on Plugin { -// // @override -// // Future register(PluginRegistry registry) async { -// // await super.register(registry); -// // } -// // } - -// // class AnalysisPlugin extends Plugin with SchemaAnalyzerPlugin, A { -// // @override -// // Future register(PluginRegistry registry) async { -// // await super.register(registry); -// // // TODO: implement register -// // } -// // } - -// // final plugin = AnalysisPlugin(); diff --git a/pub/orm/lib/schema.dart b/pub/orm/lib/schema.dart deleted file mode 100644 index e9a589eb..00000000 --- a/pub/orm/lib/schema.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'src/schema/schema.dart'; -export 'src/schema/model.dart'; -export 'src/schema/relation.dart'; -export 'src/schema/map.dart'; -export 'src/schema/id.dart'; diff --git a/pub/orm/lib/src/core/sort_order.dart b/pub/orm/lib/src/core/sort_order.dart deleted file mode 100644 index d660afa4..00000000 --- a/pub/orm/lib/src/core/sort_order.dart +++ /dev/null @@ -1 +0,0 @@ -enum SortOrder { asc, desc } diff --git a/pub/orm/lib/src/schema/id.dart b/pub/orm/lib/src/schema/id.dart deleted file mode 100644 index 21d26be1..00000000 --- a/pub/orm/lib/src/schema/id.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:orm/core.dart'; - -abstract interface class _FieldID { - /// The name of the underlying primary key constraint in the database. - /// - /// > [!IMPORTANT] Not supported for MySQL or MongoDB. - String? get map; -} - -abstract interface class _MySQLFieldID { - /// Allows you to specify a maximum length for the subpart - /// of the value to be indexed. - /// - /// MySQL only. In preview in versions 3.5.0 and later, and - /// in general availability in versions 4.0.0 and later. - int? get length; -} - -abstract interface class _SQLServerFieldID { - /// Allows you to specify in what order the entries of - /// the ID are stored in the database. - /// - /// The available options are [.asc] and [.desc]. - SortOrder? get sort; - - /// Defines whether the ID is clustered or non-clustered. - /// - /// Defaults to `true` - bool get clustered; -} - -abstract interface class _ModelID { - /// The name that client will expose for the argument covering all fields. - String? get name; - - /// A list of field names. - Set? get fields; -} - -final class ID implements _FieldID, _MySQLFieldID, _SQLServerFieldID, _ModelID { - @override - final String? name; - - @override - final String? map; - - @override - final bool clustered; - - @override - final Set? fields; - - @override - final int? length; - - @override - final SortOrder? sort; - - const ID({ - this.clustered = true, - this.fields, - this.length, - this.map, - this.name, - this.sort, - }); -} - -const id = ID(); diff --git a/pub/orm/lib/src/schema/map.dart b/pub/orm/lib/src/schema/map.dart deleted file mode 100644 index 752db217..00000000 --- a/pub/orm/lib/src/schema/map.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:meta/meta.dart'; - -/// Annotation to map a model or field to a database name. -@immutable -final class Map { - final String name; - - /// Creates a mapping annotation with the given database name. - const Map(this.name); -} diff --git a/pub/orm/lib/src/schema/model.dart b/pub/orm/lib/src/schema/model.dart deleted file mode 100644 index c5aad252..00000000 --- a/pub/orm/lib/src/schema/model.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:meta/meta.dart'; -import 'package:meta/meta_meta.dart'; - -/// Marks a typedef as a model in the schema. -const model = _Model(); - -@immutable -@Target({.typedefType}) -final class _Model { - @literal - const _Model(); -} diff --git a/pub/orm/lib/src/schema/relation.dart b/pub/orm/lib/src/schema/relation.dart deleted file mode 100644 index 360a33d2..00000000 --- a/pub/orm/lib/src/schema/relation.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:meta/meta.dart'; - -/// Actions for referential integrity on update/delete. -enum ReferenceAction { - /// Do not perform any action. - noAction, - - /// Reject the update/delete if dependent rows exist. - restrict, - - /// Propagate the update/delete to dependent rows. - cascade, - - /// Set the foreign key to NULL. - setNull, - - /// Set the foreign key to its default value. - setDefault, -} - -/// Annotation describing a relation between models. -@immutable -final class Relation { - /// Defines the name of the relationship. In an m-n-relation, - /// it also determines the name of the underlying relation table. - final String? name; - - /// Defines a custom name for the foreign key in the database. - final String? map; - - /// A list of fields of the model on the other side of the relation - final Iterable references; - - /// A list of fields of the current model - final Iterable? fields; - - /// Defines the referential action to perform when a referenced - /// entry in the referenced model is being deleted. - final ReferenceAction? onDelete; - - /// Defines the referential action to perform when a referenced - /// entry in the referenced model is being updated. - final ReferenceAction? onUpdate; - - @literal - /// Creates a relation annotation describing a model relationship. - const Relation({ - required 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 deleted file mode 100644 index 5e3a1aa2..00000000 --- a/pub/orm/lib/src/schema/schema.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:meta/meta.dart'; -import 'package:meta/meta_meta.dart'; - -@immutable -@Target({.typedefType}) -final class Schema { - final String schema; - - @literal - const Schema(@mustBeConst this.schema); -} 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/pubspec.yaml b/pub/orm/pubspec.yaml deleted file mode 100644 index ab09b508..00000000 --- a/pub/orm/pubspec.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: orm -description: A starting point for Dart libraries or applications. -version: 6.0.0-dev.1 -# repository: https://github.com/my_org/my_repo - -resolution: workspace -environment: - sdk: ^3.10.1 - -dependencies: - analyzer: ^9.0.0 - analysis_server_plugin: ^0.3.4 - meta: ^1.17.0 - -dev_dependencies: - lints: ^6.0.0 - test: ^1.25.6 diff --git a/pubspec.lock b/pubspec.lock index 3c375cd5..06d19ac5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,50 +5,34 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b 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" + version: "108.0.0" analyzer: - dependency: transitive + dependency: "direct main" description: name: analyzer - sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46 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" + version: "14.4.0" args: dependency: transitive description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -57,6 +41,86 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + build: + dependency: "direct main" + description: + name: build + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" + url: "https://pub.dev" + source: hosted + version: "4.0.11" + build_config: + dependency: transitive + description: + name: build_config + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d + url: "https://pub.dev" + source: hosted + version: "1.3.3" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 + url: "https://pub.dev" + source: hosted + version: "4.1.6" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" + url: "https://pub.dev" + source: hosted + version: "2.16.1" + build_test: + dependency: "direct dev" + description: + name: build_test + sha256: "1f89d59da9565f99f735ed43c7307ad7ad4a2b10c19cb5b474e650bbe9c2d768" + url: "https://pub.dev" + source: hosted + version: "3.5.20" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" cli_config: dependency: transitive description: @@ -65,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + url: "https://pub.dev" + source: hosted + version: "2.0.0" collection: dependency: transitive description: @@ -77,42 +149,66 @@ packages: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" coverage: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.15.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.5" - dart_style: + version: "3.0.7" + csslib: dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_style: + dependency: "direct main" description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.13" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "1.1.1" frontend_server_client: dependency: transitive description: @@ -125,74 +221,122 @@ packages: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + html: + dependency: transitive + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.dev" + source: hosted + version: "0.15.7" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" lints: dependency: "direct dev" description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.20" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" mime: dependency: transitive description: name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + mysql_client_plus: + dependency: "direct main" + description: + name: mysql_client_plus + sha256: a9760c010854ed24fe46bad968871ac0e1ab491a5e1991dc40c823ce16eede82 + url: "https://pub.dev" + source: hosted + version: "0.1.3" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "9d233b6f2d9c52e1a2b5fbe70451d2c10ac674d3bb419d0ec8de14989d437c26" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "0.19.4" node_preamble: dependency: transitive description: @@ -205,42 +349,90 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + postgres: + dependency: "direct main" + description: + name: postgres + sha256: "123de5cbadc56a7e8d9fa485c780b6b56940b4081f4c74f3a5578682757c299b" + url: "https://pub.dev" + source: hosted + version: "3.5.12" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "5.0.6" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" shelf: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -261,10 +453,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" source_map_stack_trace: dependency: transitive description: @@ -277,26 +469,34 @@ packages: dependency: transitive description: name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" url: "https://pub.dev" source: hosted - version: "0.10.12" + version: "0.10.14" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: fa3fd961e83b2dca8890a7b1014ab1226831d2b5908067c0ab7f385ffb655caf + url: "https://pub.dev" + source: hosted + version: "3.6.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -305,6 +505,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" string_scanner: dependency: transitive description: @@ -322,77 +530,85 @@ packages: source: hosted version: "1.2.2" test: - dependency: transitive + dependency: "direct dev" description: name: test - sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae" + sha256: de5d145b0afff7921e5e788a880f52d7e5f3ae24068a202f6fd3b58e4ba26323 url: "https://pub.dev" source: hosted - version: "1.28.0" + version: "1.32.0" test_api: dependency: transitive description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.14" test_core: dependency: transitive description: name: test_core - sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4 + sha256: "80f3fb49087454e07e7e07c67578cfdd156c8c3a5227d8b3f47c7b2d019c2e93" url: "https://pub.dev" source: hosted - version: "0.6.14" + version: "0.6.20" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" vm_service: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.3.0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" web: - dependency: transitive + dependency: "direct main" description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: @@ -405,17 +621,9 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" - yaml_edit: - dependency: transitive - description: - name: yaml_edit - sha256: e9c1a3543d2da0db3e90270dbb1e4eebc985ee5e3ffe468d83224472b2194a5f - url: "https://pub.dev" - source: hosted - version: "2.2.1" + version: "3.1.4" sdks: - dart: ">=3.10.1 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index c910c133..024bfdba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,27 @@ -name: _ +name: orm +description: Typed relational data access for Dart, from declaration to migration. +version: 0.1.0-dev.1 publish_to: none - environment: - sdk: ^3.10.1 - -workspace: - - pub/orm - + sdk: '>=3.13.0 <4.0.0' +dependencies: + analyzer: ^14.1.0 + build: ^4.0.11 + crypto: ^3.0.7 + dart_style: ^3.1.13 + mysql_client_plus: ^0.1.3 + path: ^1.9.1 + postgres: ^3.5.12 + sqlite3: ^3.6.0 + web: ^1.1.1 dev_dependencies: - lints: ^6.0.0 + build_runner: ^2.16.1 + build_test: ^3.5.20 + lints: ^6.1.0 + test: ^1.31.2 +executables: + orm: +flutter: + assets: + - path: assets/sqlite/ + platforms: [web] diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 22a99432..00000000 --- a/renovate.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended"] -} diff --git a/research/api-correctness-audit.md b/research/api-correctness-audit.md new file mode 100644 index 00000000..38d2af86 --- /dev/null +++ b/research/api-correctness-audit.md @@ -0,0 +1,78 @@ +# Public API and correctness audit + +Scope: the `next` implementation at `6d1ba30`, followed by the changes in this +review. This is a source and behavior audit of entrypoints, generation, query +composition, decoding, writes, sessions, drivers and migration boundaries. + +## Findings and changes + +| Finding | Consequence | Correction and evidence | +| --- | --- | --- | +| Subqueries and CTEs checked dialect but not owning session | A root, expired or other database's query could silently execute inside another transaction/connection | The SQL writer carries the owning `Database`; every nested query checks identity. `query_boundary_test` exercises rejection before SQL and valid same-session composition | +| Optional selection disabled nullability validation for every joined table | One alias's presence could incorrectly justify decoding a different absent alias as required | Selection plans retain the exact presence guards of each required expression; nested guards remain composable | +| Mutation validation omitted aggregate/window predicates and assignments | Invalid typed SQL reached the database and could poison a transaction | Mutation compilation rejects invalid placement; scalar subqueries remain available | +| Empty RETURNING and joined relation RETURNING lacked a direct contract check | Invalid SQL or unrelated alias errors instead of a useful ORM error | Reject empty projections and all relationship loading in RETURNING before execution | +| A second close() returned before the first shutdown drained its lease | Awaiting shutdown did not consistently imply resource cleanup | All close callers await the same shutdown future; real SQLite/PostgreSQL tests hold and release a pending lease | +| One throwing cancellation listener blocked the remaining listeners | Running operations could miss a requested cancellation | Isolate listener failures and preserve idempotence; regression covers subsequent and already-cancelled listeners | +| Generator imposed both engines' computed-column restrictions | Valid SQLite virtual indexes and PostgreSQL stored computed primary keys could not be declared | Generation checks structure; selecting a migration engine checks native capabilities. `schema_boundary_test` executes both cases on real databases | +| Catalog import retained the same portability restriction and a separate reserved-name list | Valid native tables could not round-trip through import and generation | Preserve the inspected engine's computed keys; share Database member names across declaration, named-query and import readers. Native import → generate → verify cases cover both engines | +| Schema validation missed malformed keys, index collisions, identifier spelling and target case rules | Invalid schemas failed late; case rules were inconsistently applied | Validate names/local keys and shared table/index names; resolve SQLite case and PostgreSQL identifier length only for that target | +| Generated extension getters could shadow Database members; generated symbols could collide | Accepted declarations produced unusable getters or uncompilable clients | Reject the declaration with a generation error and retain physical names through `table:`/`ColumnName` | +| Named SQL silently accepted some storage-only annotations | Defaults/computation/precision annotations appeared to configure a query result but were discarded | Reject all storage-only metadata; named results retain codec/column-name annotations | +| Generated clients did not export their row aliases; generated schema lists were mutable | Additional model imports and mutations could break the declaration/runtime contract | Export only row aliases, keep declaration objects private to the schema import, and freeze `appSchema` | +| Generator build factories were also public through generate.dart | Two tooling entrypoints exposed the same implementation hooks | Explicit generation facade; build factories live behind builder.dart. Migration entrypoint exports its required schema primitives | +| README still suggested changing an environment variable switched migration engines | Contradicted the fixed-engine history model | One SQLite onboarding path and an explicit explanation of separate histories | + +The initial six-case SQLite reproduction passed one preservation case and failed +five boundary cases. After the runtime correction, the related SQLite query, +relation, selection and UNION suites passed 39 cases. Full final validation is +recorded below after all generation and documentation changes. + +## Decisions retained after review + +- One package. `orm.dart` contains portable runtime code; database entrypoints + re-export it. Driver/codec/manual-table interfaces are intentional advanced API, + not extra application layers. SQL AST and decoding internals remain private. +- One query belongs to one `Database` view. Queries are immutable descriptions; + get/execute/stream perform work. A transaction callback receives a borrowed view. + Helpers must receive that view; separate calls on a captured root remain separate + operations. No ambient transaction context is introduced to hide ownership. +- A Record describes data shape. Table and alias objects carry SQL identity. + Relationship selection is explicit; no lazy object graph or hidden writes. +- Current declarations and generated clients can evolve. Saved Dart migrations + retain their historical schema, selected engine, fixed checksums and imports. +- Reviewed raw SQL is an escape hatch. The ORM does not claim to parse arbitrary + raw expressions or prove their result codec, external effects or deployment safety. +- The apparent `_withConflict`/batch-row issue was not reachable through the public + API: BatchInsert has no conflict method and uses its own chunk execution path. + It is not reported as a confirmed data-loss bug or used to add a new feature. +- Record field rename limitations, platform support, retry/unknown-COMMIT rules, + watch invalidation boundaries and backfill recovery remain explicit contracts. + +## Database references + +PostgreSQL quoted identifiers are case-sensitive and normally limited to 63 bytes; +the ORM rejects oversized explicit names instead of relying on truncation. +[PostgreSQL 18 lexical structure](https://www.postgresql.org/docs/18/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS). +Computed-column support is checked per engine, including the PostgreSQL virtual +column restrictions. [PostgreSQL 18 generated columns](https://www.postgresql.org/docs/18/ddl-generated-columns.html). +The integration cases validate the actual installed SQLite and PostgreSQL behavior. + +## Validation + +The final native suite passes **872 tests in 10:41** with real SQLite and +PostgreSQL enabled. Real Chrome JS and Dart WASM each pass **20 checks**, including +new session/optional-selection/write guards. Static analysis passes. Exact logs, +runtime source digest and validation limits are in +[the final record](validation/api-audit.md). + +The first full attempt was stopped to complete the catalog-import correction. +It also exposed two existing decimal-average assertions that needed the shared +`QUERY.AGGREGATE` code, and a transient SQLite download TLS failure. Mutation +validation now uses the shared error code. Test consumers reuse the root's +content-addressed download cache through the normal hash-verifying sqlite3 hook. +The clean full rerun above supersedes that interrupted attempt. + +No Android/Apple runtime or performance capture was repeated. Earlier captures +remain historical evidence, not certification of this refactor. No push or +package publication is part of this review. diff --git a/research/benchmarks/authoring.json b/research/benchmarks/authoring.json new file mode 100644 index 00000000..e52c3fd9 --- /dev/null +++ b/research/benchmarks/authoring.json @@ -0,0 +1,1159 @@ +{ + "recordedAt": "2026-09-15T08:45:16.612800Z", + "runtimeSourceCommit": "4ef499eece6fc09c9b41f3d3f07a3bbf3d4fae01", + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "os": "Version 26.6.2 (Build 25G83)", + "smoke": false, + "scope": "Controlled User/Post/Profile/Follow declaration-frontends experiment. All forms are resolved, normalized to the same Record schema, and sent through the unchanged production generator. Class/table inputs describe schema only; arbitrary constructors/methods and nominal runtime row objects are not implemented. No database execution or runtime performance claim. One timing observation per stage, warm SDK/pub/OS caches, offline dependency resolution excluded. Physical snapshots and generated Dart must be byte-identical for each variant. Record field rename is manual; class/table schema references use a clean LSP server. Generated-client references are independently checked and repaired after regeneration.", + "harnessSha256": { + "tool/compare_authoring.dart": "94d67e12617f4c31921ffde23c87f4db43ab8b416911052ba2f4bf726df00e38", + "tool/src/authoring.dart": "8c015925af9b5dd34eac1e07f68b68d07458463a880102d5f1d8180ceb7353b2", + "tool/src/authoring_fixture.dart": "26179f126778b3d1bfef7ff3e59f0b4b67b1adc89ff02381abddede0cf02188d", + "tool/src/build_fixture.dart": "03a5d875d608fd25245b5da268d66ce4c0913ed288ac05270ae9522a68bb0ea9", + "tool/src/editor_server.dart": "00e3cf5e7675166979cdb03a9d333754ee7aeb2874d6940d5d5ab6cff43a9ff8" + }, + "outputs": { + "base": { + "schema": "import 'package:orm/schema.dart';\ntypedef User = ({\n@Id.generated() @ColumnName(\"id\") int id,\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"email\") String email,\n@ColumnName(\"nickname\") String? nickname,\n});\ntypedef Post = ({\n@Id.generated() @ColumnName(\"id\") int id,\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"author_id\") int authorId,\n@ColumnName(\"title\") String title,\n@Default.sql('false') @ColumnName(\"published\") bool published,\n@ColumnName(\"created_at\") DateTime createdAt,\n});\ntypedef Profile = ({\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"user_id\") int userId,\n@ColumnName(\"bio\") String? bio,\n});\ntypedef Follow = ({\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"follower_id\") int followerId,\n@ColumnName(\"followed_id\") int followedId,\n@ColumnName(\"created_at\") DateTime createdAt,\n});\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.email));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "clientSha256": "f4b306b028261cb1c88d2c18d5e4d1dee57e65e9e1cc5561aab52e56d5c27c65", + "snapshot": { + "format": 1, + "tables": [ + { + "name": "users", + "columns": [ + { + "name": "id", + "type": "integer", + "nullable": false, + "generated": true + }, + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "email", + "type": "text", + "nullable": false, + "generated": false + }, + { + "name": "nickname", + "type": "text", + "nullable": true, + "generated": false + } + ], + "primaryKey": [ + "id" + ], + "uniqueKeys": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "email" + ] + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "posts", + "columns": [ + { + "name": "id", + "type": "integer", + "nullable": false, + "generated": true + }, + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "author_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "title", + "type": "text", + "nullable": false, + "generated": false + }, + { + "name": "published", + "type": "boolean", + "nullable": false, + "generated": false, + "default": "false" + }, + { + "name": "created_at", + "type": "instant", + "nullable": false, + "generated": false + } + ], + "primaryKey": [ + "id" + ], + "uniqueKeys": [], + "indexes": [ + { + "name": "author_timeline", + "columns": [ + "tenant_id", + "author_id", + "created_at", + "id" + ], + "unique": false + } + ], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "author_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "RESTRICT" + } + ] + }, + { + "name": "profiles", + "columns": [ + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "user_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "bio", + "type": "text", + "nullable": true, + "generated": false + } + ], + "primaryKey": [ + "tenant_id", + "user_id" + ], + "uniqueKeys": [], + "indexes": [], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "user_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + } + ] + }, + { + "name": "follows", + "columns": [ + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "follower_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "followed_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "created_at", + "type": "instant", + "nullable": false, + "generated": false + } + ], + "primaryKey": [ + "tenant_id", + "follower_id", + "followed_id" + ], + "uniqueKeys": [], + "indexes": [], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "follower_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + }, + { + "columns": [ + "tenant_id", + "followed_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + } + ], + "checks": [ + { + "name": "distinct_users", + "sqlite": "follower_id <> followed_id", + "postgres": "follower_id <> followed_id" + } + ] + } + ] + } + }, + "field_type": { + "schema": "import 'package:orm/schema.dart';\ntypedef User = ({\n@Id.generated() @ColumnName(\"id\") int id,\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"email\") String email,\n@ColumnName(\"nickname\") String? nickname,\n});\ntypedef Post = ({\n@Id.generated() @ColumnName(\"id\") int id,\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"author_id\") int authorId,\n@ColumnName(\"title\") String title,\n@Default.sql('false') @ColumnName(\"published\") bool published,\n@ColumnName(\"created_at\") DateTime createdAt,\n});\ntypedef Profile = ({\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"user_id\") int userId,\n@ColumnName(\"bio\") int? bio,\n});\ntypedef Follow = ({\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"follower_id\") int followerId,\n@ColumnName(\"followed_id\") int followedId,\n@ColumnName(\"created_at\") DateTime createdAt,\n});\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.email));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "clientSha256": "9e71d404b664a5a686f3cd460d0ab41c9cc6fe9332412e7f896f65241f914152", + "snapshot": { + "format": 1, + "tables": [ + { + "name": "users", + "columns": [ + { + "name": "id", + "type": "integer", + "nullable": false, + "generated": true + }, + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "email", + "type": "text", + "nullable": false, + "generated": false + }, + { + "name": "nickname", + "type": "text", + "nullable": true, + "generated": false + } + ], + "primaryKey": [ + "id" + ], + "uniqueKeys": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "email" + ] + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "posts", + "columns": [ + { + "name": "id", + "type": "integer", + "nullable": false, + "generated": true + }, + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "author_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "title", + "type": "text", + "nullable": false, + "generated": false + }, + { + "name": "published", + "type": "boolean", + "nullable": false, + "generated": false, + "default": "false" + }, + { + "name": "created_at", + "type": "instant", + "nullable": false, + "generated": false + } + ], + "primaryKey": [ + "id" + ], + "uniqueKeys": [], + "indexes": [ + { + "name": "author_timeline", + "columns": [ + "tenant_id", + "author_id", + "created_at", + "id" + ], + "unique": false + } + ], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "author_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "RESTRICT" + } + ] + }, + { + "name": "profiles", + "columns": [ + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "user_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "bio", + "type": "integer", + "nullable": true, + "generated": false + } + ], + "primaryKey": [ + "tenant_id", + "user_id" + ], + "uniqueKeys": [], + "indexes": [], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "user_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + } + ] + }, + { + "name": "follows", + "columns": [ + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "follower_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "followed_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "created_at", + "type": "instant", + "nullable": false, + "generated": false + } + ], + "primaryKey": [ + "tenant_id", + "follower_id", + "followed_id" + ], + "uniqueKeys": [], + "indexes": [], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "follower_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + }, + { + "columns": [ + "tenant_id", + "followed_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + } + ], + "checks": [ + { + "name": "distinct_users", + "sqlite": "follower_id <> followed_id", + "postgres": "follower_id <> followed_id" + } + ] + } + ] + } + }, + "rename": { + "schema": "import 'package:orm/schema.dart';\ntypedef User = ({\n@Id.generated() @ColumnName(\"id\") int id,\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"email\") String contactEmail,\n@ColumnName(\"nickname\") String? nickname,\n});\ntypedef Post = ({\n@Id.generated() @ColumnName(\"id\") int id,\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"author_id\") int authorId,\n@ColumnName(\"title\") String title,\n@Default.sql('false') @ColumnName(\"published\") bool published,\n@ColumnName(\"created_at\") DateTime createdAt,\n});\ntypedef Profile = ({\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"user_id\") int userId,\n@ColumnName(\"bio\") String? bio,\n});\ntypedef Follow = ({\n@ColumnName(\"tenant_id\") int tenantId,\n@ColumnName(\"follower_id\") int followerId,\n@ColumnName(\"followed_id\") int followedId,\n@ColumnName(\"created_at\") DateTime createdAt,\n});\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.contactEmail));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "clientSha256": "421b7bf6338dd8e2116e3acaa1c0b887f1ee8c077e79e0009a13d4734c6c9adc", + "snapshot": { + "format": 1, + "tables": [ + { + "name": "users", + "columns": [ + { + "name": "id", + "type": "integer", + "nullable": false, + "generated": true + }, + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "email", + "type": "text", + "nullable": false, + "generated": false + }, + { + "name": "nickname", + "type": "text", + "nullable": true, + "generated": false + } + ], + "primaryKey": [ + "id" + ], + "uniqueKeys": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "email" + ] + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "posts", + "columns": [ + { + "name": "id", + "type": "integer", + "nullable": false, + "generated": true + }, + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "author_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "title", + "type": "text", + "nullable": false, + "generated": false + }, + { + "name": "published", + "type": "boolean", + "nullable": false, + "generated": false, + "default": "false" + }, + { + "name": "created_at", + "type": "instant", + "nullable": false, + "generated": false + } + ], + "primaryKey": [ + "id" + ], + "uniqueKeys": [], + "indexes": [ + { + "name": "author_timeline", + "columns": [ + "tenant_id", + "author_id", + "created_at", + "id" + ], + "unique": false + } + ], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "author_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "RESTRICT" + } + ] + }, + { + "name": "profiles", + "columns": [ + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "user_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "bio", + "type": "text", + "nullable": true, + "generated": false + } + ], + "primaryKey": [ + "tenant_id", + "user_id" + ], + "uniqueKeys": [], + "indexes": [], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "user_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + } + ] + }, + { + "name": "follows", + "columns": [ + { + "name": "tenant_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "follower_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "followed_id", + "type": "integer", + "nullable": false, + "generated": false + }, + { + "name": "created_at", + "type": "instant", + "nullable": false, + "generated": false + } + ], + "primaryKey": [ + "tenant_id", + "follower_id", + "followed_id" + ], + "uniqueKeys": [], + "indexes": [], + "foreignKeys": [ + { + "columns": [ + "tenant_id", + "follower_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + }, + { + "columns": [ + "tenant_id", + "followed_id" + ], + "target": "users", + "targetColumns": [ + "tenant_id", + "id" + ], + "onDelete": "CASCADE" + } + ], + "checks": [ + { + "name": "distinct_users", + "sqlite": "follower_id <> followed_id", + "postgres": "follower_id <> followed_id" + } + ] + } + ] + } + } + }, + "results": [ + { + "form": "record", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\n\ntypedef User = ({\n @Id.generated() int id,\n int tenantId,\n @ColumnName('email') String email,\n String? nickname,\n});\ntypedef Post = ({\n @Id.generated() int id,\n int tenantId,\n int authorId,\n String title,\n @Default.sql('false') bool published,\n DateTime createdAt,\n});\ntypedef Profile = ({int tenantId, int userId, String? bio});\ntypedef Follow = ({\n int tenantId,\n int followerId,\n int followedId,\n DateTime createdAt,\n});\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.email));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "base": { + "sourceBytes": 1843, + "sourceLines": 62, + "modelDeclarationBytes": 487, + "modelDeclarationLines": 24, + "normalizeMicros": 1401839, + "generationMicros": 510759, + "schemaSha256": "cd7afdcdbf23cf25963555e46af817e1be7914e08bb0c96033290976c2dbe7c9", + "clientSha256": "f4b306b028261cb1c88d2c18d5e4d1dee57e65e9e1cc5561aab52e56d5c27c65", + "snapshotSha256": "8fdeb6639ee4f6bc1358d356f305095de9462b6a14b8c4fdd2bde8ca626afa85" + }, + "initialAnalyzeMs": 467, + "fieldTypeEdit": { + "sourceBytes": 1840, + "sourceLines": 62, + "modelDeclarationBytes": 484, + "modelDeclarationLines": 24, + "normalizeMicros": 345208, + "generationMicros": 352162, + "schemaSha256": "a9f4abceec75636c58c9e81edbdaea637aad35f9c201fa814d509e4f8ec043fa", + "clientSha256": "9e71d404b664a5a686f3cd460d0ab41c9cc6fe9332412e7f896f65241f914152", + "snapshotSha256": "4b410fc14cf017b777c36062aa71b38bd6620e6869bd3a1b22397775cb0a7866", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\n\ntypedef User = ({\n @Id.generated() int id,\n int tenantId,\n @ColumnName('email') String email,\n String? nickname,\n});\ntypedef Post = ({\n @Id.generated() int id,\n int tenantId,\n int authorId,\n String title,\n @Default.sql('false') bool published,\n DateTime createdAt,\n});\ntypedef Profile = ({int tenantId, int userId, int? bio});\ntypedef Follow = ({\n int tenantId,\n int followerId,\n int followedId,\n DateTime createdAt,\n});\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.email));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "staleConsumerDiagnostics": [ + "ERROR|COMPILE_TIME_ERROR|INVALID_ASSIGNMENT|/privatefixture/lib/client.dart|14|97|5|A value of type 'List<({int? bio, String email, List following, List posts})>' can't be assigned to a variable of type 'List<({String? bio, String email, List following, List posts})>'." + ] + }, + "fieldRename": { + "sourceBytes": 1857, + "sourceLines": 62, + "modelDeclarationBytes": 494, + "modelDeclarationLines": 24, + "normalizeMicros": 315184, + "generationMicros": 282625, + "schemaSha256": "6efc4a760f465a87b05ada8a66d38a9262c21730ae586d6caaaa24418abf1943", + "clientSha256": "421b7bf6338dd8e2116e3acaa1c0b887f1ee8c077e79e0009a13d4734c6c9adc", + "snapshotSha256": "8fdeb6639ee4f6bc1358d356f305095de9462b6a14b8c4fdd2bde8ca626afa85", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\n\ntypedef User = ({\n @Id.generated() int id,\n int tenantId,\n @ColumnName('email') String contactEmail,\n String? nickname,\n});\ntypedef Post = ({\n @Id.generated() int id,\n int tenantId,\n int authorId,\n String title,\n @Default.sql('false') bool published,\n DateTime createdAt,\n});\ntypedef Profile = ({int tenantId, int userId, String? bio});\ntypedef Follow = ({\n int tenantId,\n int followerId,\n int followedId,\n DateTime createdAt,\n});\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.contactEmail));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "method": "manual", + "lspMicros": 2358, + "prepare": null, + "edit": null, + "staleConsumerDiagnostics": [ + "ERROR|COMPILE_TIME_ERROR|MISSING_REQUIRED_ARGUMENT|/privatefixture/lib/client.dart|5|31|6|The named parameter 'contactEmail' is required, but there's no corresponding argument.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_NAMED_PARAMETER|/privatefixture/lib/client.dart|5|51|5|The named parameter 'email' isn't defined.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|6|29|5|The getter 'email' isn't defined for the type 'User'.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|9|7|5|The getter 'email' isn't defined for the type 'UsersFields'.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|12|58|5|The getter 'email' isn't defined for the type 'UsersFields'.", + "ERROR|COMPILE_TIME_ERROR|INVALID_ASSIGNMENT|/privatefixture/lib/client.dart|14|97|5|A value of type 'List<({dynamic bio, dynamic email, dynamic following, dynamic posts})>' can't be assigned to a variable of type 'List<({String? bio, String email, List following, List posts})>'." + ], + "physicalSnapshotUnchanged": true + }, + "errors": [ + { + "case": "unknown_field", + "phase": "dart", + "code": "undefined_getter", + "message": "The getter 'absent' isn't defined for the type 'User'.", + "offset": 793, + "length": 6, + "line": 30, + "column": 54, + "sourceLine": "final userEmail = users.unique((u) => (u.tenantId, u.absent));" + }, + { + "case": "computed_selector", + "phase": "generator", + "code": "computed_selector", + "message": "Key selectors accept direct fields or positional records of fields only. (offset 2217)", + "offset": 1879, + "length": 1, + "line": 64, + "column": 36, + "sourceLine": "final invalid = users.index((u) => 0);" + }, + { + "case": "duplicate_key_field", + "phase": "generator", + "code": "duplicate_key_field", + "message": "A key cannot repeat a field. (offset 2224)", + "offset": 1886, + "length": 4, + "line": 64, + "column": 43, + "sourceLine": "final invalid = users.index((u) => (u.id, u.id));" + }, + { + "case": "non_unique_target", + "phase": "generator", + "code": "non_unique_target", + "message": "Foreign key target must be a primary or unique key in the same column order. (offset 1267)", + "offset": 928, + "length": 5, + "line": 34, + "column": 16, + "sourceLine": "final author = posts" + } + ], + "finalAnalyzeMs": 465 + }, + { + "form": "primary", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\n\nfinal class User(\n @Id.generated() final int id,\n final int tenantId,\n @ColumnName('email') final String email,\n final String? nickname,\n);\nfinal class Post(\n @Id.generated() final int id,\n final int tenantId,\n final int authorId,\n final String title,\n @Default.sql('false') final bool published,\n final DateTime createdAt,\n);\nfinal class Profile(final int tenantId, final int userId, final String? bio);\nfinal class Follow(\n final int tenantId,\n final int followerId,\n final int followedId,\n final DateTime createdAt,\n);\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.email));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "base": { + "sourceBytes": 1941, + "sourceLines": 62, + "modelDeclarationBytes": 585, + "modelDeclarationLines": 24, + "normalizeMicros": 240968, + "generationMicros": 256922, + "schemaSha256": "cd7afdcdbf23cf25963555e46af817e1be7914e08bb0c96033290976c2dbe7c9", + "clientSha256": "f4b306b028261cb1c88d2c18d5e4d1dee57e65e9e1cc5561aab52e56d5c27c65", + "snapshotSha256": "8fdeb6639ee4f6bc1358d356f305095de9462b6a14b8c4fdd2bde8ca626afa85" + }, + "initialAnalyzeMs": 465, + "fieldTypeEdit": { + "sourceBytes": 1938, + "sourceLines": 62, + "modelDeclarationBytes": 582, + "modelDeclarationLines": 24, + "normalizeMicros": 234572, + "generationMicros": 248713, + "schemaSha256": "a9f4abceec75636c58c9e81edbdaea637aad35f9c201fa814d509e4f8ec043fa", + "clientSha256": "9e71d404b664a5a686f3cd460d0ab41c9cc6fe9332412e7f896f65241f914152", + "snapshotSha256": "4b410fc14cf017b777c36062aa71b38bd6620e6869bd3a1b22397775cb0a7866", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\n\nfinal class User(\n @Id.generated() final int id,\n final int tenantId,\n @ColumnName('email') final String email,\n final String? nickname,\n);\nfinal class Post(\n @Id.generated() final int id,\n final int tenantId,\n final int authorId,\n final String title,\n @Default.sql('false') final bool published,\n final DateTime createdAt,\n);\nfinal class Profile(final int tenantId, final int userId, final int? bio);\nfinal class Follow(\n final int tenantId,\n final int followerId,\n final int followedId,\n final DateTime createdAt,\n);\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.email));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "staleConsumerDiagnostics": [ + "ERROR|COMPILE_TIME_ERROR|INVALID_ASSIGNMENT|/privatefixture/lib/client.dart|14|97|5|A value of type 'List<({int? bio, String email, List following, List posts})>' can't be assigned to a variable of type 'List<({String? bio, String email, List following, List posts})>'." + ] + }, + "fieldRename": { + "sourceBytes": 1955, + "sourceLines": 62, + "modelDeclarationBytes": 592, + "modelDeclarationLines": 24, + "normalizeMicros": 225761, + "generationMicros": 229178, + "schemaSha256": "6efc4a760f465a87b05ada8a66d38a9262c21730ae586d6caaaa24418abf1943", + "clientSha256": "421b7bf6338dd8e2116e3acaa1c0b887f1ee8c077e79e0009a13d4734c6c9adc", + "snapshotSha256": "8fdeb6639ee4f6bc1358d356f305095de9462b6a14b8c4fdd2bde8ca626afa85", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\n\nfinal class User(\n @Id.generated() final int id,\n final int tenantId,\n @ColumnName('email') final String contactEmail,\n final String? nickname,\n);\nfinal class Post(\n @Id.generated() final int id,\n final int tenantId,\n final int authorId,\n final String title,\n @Default.sql('false') final bool published,\n final DateTime createdAt,\n);\nfinal class Profile(final int tenantId, final int userId, final String? bio);\nfinal class Follow(\n final int tenantId,\n final int followerId,\n final int followedId,\n final DateTime createdAt,\n);\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.contactEmail));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "method": "lsp", + "lspMicros": 321910, + "prepare": { + "placeholder": "email", + "range": { + "end": { + "character": 41, + "line": 6 + }, + "start": { + "character": 36, + "line": 6 + } + } + }, + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "contactEmail", + "range": { + "end": { + "character": 41, + "line": 6 + }, + "start": { + "character": 36, + "line": 6 + } + } + }, + { + "newText": "contactEmail", + "range": { + "end": { + "character": 58, + "line": 29 + }, + "start": { + "character": 53, + "line": 29 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/author.dart", + "version": 1 + } + } + ] + }, + "staleConsumerDiagnostics": [ + "ERROR|COMPILE_TIME_ERROR|MISSING_REQUIRED_ARGUMENT|/privatefixture/lib/client.dart|5|31|6|The named parameter 'contactEmail' is required, but there's no corresponding argument.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_NAMED_PARAMETER|/privatefixture/lib/client.dart|5|51|5|The named parameter 'email' isn't defined.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|6|29|5|The getter 'email' isn't defined for the type 'User'.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|9|7|5|The getter 'email' isn't defined for the type 'UsersFields'.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|12|58|5|The getter 'email' isn't defined for the type 'UsersFields'.", + "ERROR|COMPILE_TIME_ERROR|INVALID_ASSIGNMENT|/privatefixture/lib/client.dart|14|97|5|A value of type 'List<({dynamic bio, dynamic email, dynamic following, dynamic posts})>' can't be assigned to a variable of type 'List<({String? bio, String email, List following, List posts})>'." + ], + "physicalSnapshotUnchanged": true + }, + "errors": [ + { + "case": "unknown_field", + "phase": "dart", + "code": "undefined_getter", + "message": "The getter 'absent' isn't defined for the type 'User'.", + "offset": 891, + "length": 6, + "line": 30, + "column": 54, + "sourceLine": "final userEmail = users.unique((u) => (u.tenantId, u.absent));" + }, + { + "case": "computed_selector", + "phase": "generator", + "code": "computed_selector", + "message": "Key selectors accept direct fields or positional records of fields only. (offset 2217)", + "offset": 1977, + "length": 1, + "line": 64, + "column": 36, + "sourceLine": "final invalid = users.index((u) => 0);" + }, + { + "case": "duplicate_key_field", + "phase": "generator", + "code": "duplicate_key_field", + "message": "A key cannot repeat a field. (offset 2224)", + "offset": 1984, + "length": 4, + "line": 64, + "column": 43, + "sourceLine": "final invalid = users.index((u) => (u.id, u.id));" + }, + { + "case": "non_unique_target", + "phase": "generator", + "code": "non_unique_target", + "message": "Foreign key target must be a primary or unique key in the same column order. (offset 1267)", + "offset": 1026, + "length": 5, + "line": 34, + "column": 16, + "sourceLine": "final author = posts" + }, + { + "case": "constructor_logic", + "phase": "authoring", + "code": "unsupported_class", + "message": "Use an unmodified primary constructor with declaring fields only.", + "offset": 49, + "length": 193, + "line": 4, + "column": 1, + "sourceLine": "final class User(" + } + ], + "finalAnalyzeMs": 464 + }, + { + "form": "table", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\nimport 'package:orm/orm.dart';\n\nfinal class User extends Fields {\n User(super.table);\n @Id.generated()\n late final id = column(Column('id', Codecs.integer));\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final email = column(Column('email', Codecs.text));\n late final nickname = column(\n Column('nickname', Codecs.text.nullable(), nullable: true),\n );\n}\n\nfinal class Post extends Fields {\n Post(super.table);\n @Id.generated()\n late final id = column(Column('id', Codecs.integer));\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final authorId = column(Column('author_id', Codecs.integer));\n late final title = column(Column('title', Codecs.text));\n @Default.sql('false')\n late final published = column(Column('published', Codecs.boolean));\n late final createdAt = column(Column('created_at', Codecs.dateTime));\n}\n\nfinal class Profile extends Fields {\n Profile(super.table);\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final userId = column(Column('user_id', Codecs.integer));\n late final bio = column(\n Column('bio', Codecs.text.nullable(), nullable: true),\n );\n}\n\nfinal class Follow extends Fields {\n Follow(super.table);\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final followerId = column(Column('follower_id', Codecs.integer));\n late final followedId = column(Column('followed_id', Codecs.integer));\n late final createdAt = column(Column('created_at', Codecs.dateTime));\n}\n\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.email));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "base": { + "sourceBytes": 2930, + "sourceLines": 82, + "modelDeclarationBytes": 1574, + "modelDeclarationLines": 43, + "normalizeMicros": 239366, + "generationMicros": 242599, + "schemaSha256": "cd7afdcdbf23cf25963555e46af817e1be7914e08bb0c96033290976c2dbe7c9", + "clientSha256": "f4b306b028261cb1c88d2c18d5e4d1dee57e65e9e1cc5561aab52e56d5c27c65", + "snapshotSha256": "8fdeb6639ee4f6bc1358d356f305095de9462b6a14b8c4fdd2bde8ca626afa85" + }, + "initialAnalyzeMs": 465, + "fieldTypeEdit": { + "sourceBytes": 2933, + "sourceLines": 82, + "modelDeclarationBytes": 1577, + "modelDeclarationLines": 43, + "normalizeMicros": 224609, + "generationMicros": 241511, + "schemaSha256": "a9f4abceec75636c58c9e81edbdaea637aad35f9c201fa814d509e4f8ec043fa", + "clientSha256": "9e71d404b664a5a686f3cd460d0ab41c9cc6fe9332412e7f896f65241f914152", + "snapshotSha256": "4b410fc14cf017b777c36062aa71b38bd6620e6869bd3a1b22397775cb0a7866", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\nimport 'package:orm/orm.dart';\n\nfinal class User extends Fields {\n User(super.table);\n @Id.generated()\n late final id = column(Column('id', Codecs.integer));\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final email = column(Column('email', Codecs.text));\n late final nickname = column(\n Column('nickname', Codecs.text.nullable(), nullable: true),\n );\n}\n\nfinal class Post extends Fields {\n Post(super.table);\n @Id.generated()\n late final id = column(Column('id', Codecs.integer));\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final authorId = column(Column('author_id', Codecs.integer));\n late final title = column(Column('title', Codecs.text));\n @Default.sql('false')\n late final published = column(Column('published', Codecs.boolean));\n late final createdAt = column(Column('created_at', Codecs.dateTime));\n}\n\nfinal class Profile extends Fields {\n Profile(super.table);\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final userId = column(Column('user_id', Codecs.integer));\n late final bio = column(\n Column('bio', Codecs.integer.nullable(), nullable: true),\n );\n}\n\nfinal class Follow extends Fields {\n Follow(super.table);\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final followerId = column(Column('follower_id', Codecs.integer));\n late final followedId = column(Column('followed_id', Codecs.integer));\n late final createdAt = column(Column('created_at', Codecs.dateTime));\n}\n\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.email));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "staleConsumerDiagnostics": [ + "ERROR|COMPILE_TIME_ERROR|INVALID_ASSIGNMENT|/privatefixture/lib/client.dart|14|97|5|A value of type 'List<({int? bio, String email, List following, List posts})>' can't be assigned to a variable of type 'List<({String? bio, String email, List following, List posts})>'." + ] + }, + "fieldRename": { + "sourceBytes": 2944, + "sourceLines": 82, + "modelDeclarationBytes": 1581, + "modelDeclarationLines": 43, + "normalizeMicros": 224387, + "generationMicros": 237466, + "schemaSha256": "6efc4a760f465a87b05ada8a66d38a9262c21730ae586d6caaaa24418abf1943", + "clientSha256": "421b7bf6338dd8e2116e3acaa1c0b887f1ee8c077e79e0009a13d4734c6c9adc", + "snapshotSha256": "8fdeb6639ee4f6bc1358d356f305095de9462b6a14b8c4fdd2bde8ca626afa85", + "source": "// @dart=3.13\nimport 'package:orm/schema.dart';\nimport 'package:orm/orm.dart';\n\nfinal class User extends Fields {\n User(super.table);\n @Id.generated()\n late final id = column(Column('id', Codecs.integer));\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final contactEmail = column(Column('email', Codecs.text));\n late final nickname = column(\n Column('nickname', Codecs.text.nullable(), nullable: true),\n );\n}\n\nfinal class Post extends Fields {\n Post(super.table);\n @Id.generated()\n late final id = column(Column('id', Codecs.integer));\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final authorId = column(Column('author_id', Codecs.integer));\n late final title = column(Column('title', Codecs.text));\n @Default.sql('false')\n late final published = column(Column('published', Codecs.boolean));\n late final createdAt = column(Column('created_at', Codecs.dateTime));\n}\n\nfinal class Profile extends Fields {\n Profile(super.table);\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final userId = column(Column('user_id', Codecs.integer));\n late final bio = column(\n Column('bio', Codecs.text.nullable(), nullable: true),\n );\n}\n\nfinal class Follow extends Fields {\n Follow(super.table);\n late final tenantId = column(Column('tenant_id', Codecs.integer));\n late final followerId = column(Column('follower_id', Codecs.integer));\n late final followedId = column(Column('followed_id', Codecs.integer));\n late final createdAt = column(Column('created_at', Codecs.dateTime));\n}\n\nfinal users = entity(table: 'users');\nfinal posts = entity(table: 'posts');\nfinal profiles = entity(table: 'profiles');\nfinal follows = entity(table: 'follows');\nfinal userIdentity = users.unique((u) => (u.tenantId, u.id));\nfinal userEmail = users.unique((u) => (u.tenantId, u.contactEmail));\nfinal profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId));\nfinal followIdentity = follows.primaryKey(\n (f) => (f.tenantId, f.followerId, f.followedId),\n);\nfinal author = posts\n .key((p) => (p.tenantId, p.authorId))\n .references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts');\nfinal profileOwner = profiles\n .key((p) => (p.tenantId, p.userId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'profile',\n onDelete: .cascade,\n );\nfinal follower = follows\n .key((f) => (f.tenantId, f.followerId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'following',\n onDelete: .cascade,\n );\nfinal followed = follows\n .key((f) => (f.tenantId, f.followedId))\n .references(\n users.key((u) => (u.tenantId, u.id)),\n inverse: 'followers',\n onDelete: .cascade,\n );\nfinal authorTimeline = posts.index(\n (p) => (p.tenantId, p.authorId, p.createdAt, p.id),\n);\nfinal distinctUsers = follows.check('follower_id <> followed_id');\n", + "method": "lsp", + "lspMicros": 313547, + "prepare": { + "placeholder": "email", + "range": { + "end": { + "character": 18, + "line": 9 + }, + "start": { + "character": 13, + "line": 9 + } + } + }, + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "contactEmail", + "range": { + "end": { + "character": 18, + "line": 9 + }, + "start": { + "character": 13, + "line": 9 + } + } + }, + { + "newText": "contactEmail", + "range": { + "end": { + "character": 58, + "line": 49 + }, + "start": { + "character": 53, + "line": 49 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/author.dart", + "version": 1 + } + } + ] + }, + "staleConsumerDiagnostics": [ + "ERROR|COMPILE_TIME_ERROR|MISSING_REQUIRED_ARGUMENT|/privatefixture/lib/client.dart|5|31|6|The named parameter 'contactEmail' is required, but there's no corresponding argument.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_NAMED_PARAMETER|/privatefixture/lib/client.dart|5|51|5|The named parameter 'email' isn't defined.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|6|29|5|The getter 'email' isn't defined for the type 'User'.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|9|7|5|The getter 'email' isn't defined for the type 'UsersFields'.", + "ERROR|COMPILE_TIME_ERROR|UNDEFINED_GETTER|/privatefixture/lib/client.dart|12|58|5|The getter 'email' isn't defined for the type 'UsersFields'.", + "ERROR|COMPILE_TIME_ERROR|INVALID_ASSIGNMENT|/privatefixture/lib/client.dart|14|97|5|A value of type 'List<({dynamic bio, dynamic email, dynamic following, dynamic posts})>' can't be assigned to a variable of type 'List<({String? bio, String email, List following, List posts})>'." + ], + "physicalSnapshotUnchanged": true + }, + "errors": [ + { + "case": "unknown_field", + "phase": "dart", + "code": "undefined_getter", + "message": "The getter 'absent' isn't defined for the type 'User'.", + "offset": 1880, + "length": 6, + "line": 50, + "column": 54, + "sourceLine": "final userEmail = users.unique((u) => (u.tenantId, u.absent));" + }, + { + "case": "computed_selector", + "phase": "generator", + "code": "computed_selector", + "message": "Key selectors accept direct fields or positional records of fields only. (offset 2217)", + "offset": 2966, + "length": 1, + "line": 84, + "column": 36, + "sourceLine": "final invalid = users.index((u) => 0);" + }, + { + "case": "duplicate_key_field", + "phase": "generator", + "code": "duplicate_key_field", + "message": "A key cannot repeat a field. (offset 2224)", + "offset": 2973, + "length": 4, + "line": 84, + "column": 43, + "sourceLine": "final invalid = users.index((u) => (u.id, u.id));" + }, + { + "case": "non_unique_target", + "phase": "generator", + "code": "non_unique_target", + "message": "Foreign key target must be a primary or unique key in the same column order. (offset 1267)", + "offset": 2015, + "length": 5, + "line": 54, + "column": 16, + "sourceLine": "final author = posts" + }, + { + "case": "custom_codec", + "phase": "authoring", + "code": "unsupported_codec", + "message": "Use the matching built-in codec and explicit SQL nullability; custom conversions cannot be discarded.", + "offset": 392, + "length": 46, + "line": 12, + "column": 24, + "sourceLine": " Column('nickname', Codecs.text.map((v) => v, (v) => v).nullable(), nullable: true)," + } + ], + "finalAnalyzeMs": 465 + } + ] +} diff --git a/research/benchmarks/decimal-average.json b/research/benchmarks/decimal-average.json new file mode 100644 index 00000000..7b58d58f --- /dev/null +++ b/research/benchmarks/decimal-average.json @@ -0,0 +1,166 @@ +{ + "recordedAt": "2026-09-15T01:00:42.064966Z", + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "os": "Version 26.6.2 (Build 25G83)", + "warmupRuns": 1, + "measuredRuns": 3, + "scope": "Single-client end-to-end query fetch and Decimal decoding; 10000 integer-valued rows; not a concurrency or percentile benchmark.", + "results": [ + { + "backend": "sqlite", + "version": "3.51.0", + "case": "read", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 11.392, + 10.143, + 9.747 + ], + "sqlBytes": 70 + }, + { + "backend": "sqlite", + "version": "3.51.0", + "case": "average_half_even_scale_2", + "inputRows": 10000, + "rows": 1, + "elapsedMs": [ + 8.821, + 8.78, + 8.599 + ], + "sqlBytes": 125 + }, + { + "backend": "sqlite", + "version": "3.51.0", + "case": "window_average_half_even_scale_2", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 28.88, + 29.125, + 30.176 + ], + "sqlBytes": 204 + }, + { + "backend": "sqlite", + "version": "3.51.0", + "case": "divide_half_even_scale_2", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 32.28, + 30.836, + 30.717 + ], + "sqlBytes": 154 + }, + { + "backend": "sqlite", + "version": "3.51.0", + "case": "round_half_even_scale_minus_1", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 22.987, + 22.628, + 23.365 + ], + "sqlBytes": 126 + }, + { + "backend": "sqlite", + "version": "3.51.0", + "case": "window_divide_half_even_scale_2", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 52.056, + 51.782, + 53.221 + ], + "sqlBytes": 280 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "read", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 25.067, + 24.24, + 25.092 + ], + "sqlBytes": 43 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "average_half_even_scale_2", + "inputRows": 10000, + "rows": 1, + "elapsedMs": [ + 2.751, + 2.666, + 2.739 + ], + "sqlBytes": 2282 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "window_average_half_even_scale_2", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 46.994, + 47.254, + 49.207 + ], + "sqlBytes": 2637 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "divide_half_even_scale_2", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 34.445, + 34.518, + 34.378 + ], + "sqlBytes": 1597 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "round_half_even_scale_minus_1", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 25.926, + 26.494, + 25.991 + ], + "sqlBytes": 1149 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "window_divide_half_even_scale_2", + "inputRows": 10000, + "rows": 10000, + "elapsedMs": [ + 38.94, + 38.582, + 38.361 + ], + "sqlBytes": 1773 + } + ] +} diff --git a/research/benchmarks/decimal-division.json b/research/benchmarks/decimal-division.json new file mode 100644 index 00000000..835ca903 --- /dev/null +++ b/research/benchmarks/decimal-division.json @@ -0,0 +1,106 @@ +{ + "recordedAt": "2026-09-15T00:38:41.193708Z", + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "os": "Version 26.6.2 (Build 25G83)", + "warmupRuns": 1, + "measuredRuns": 3, + "scope": "Single-client end-to-end query fetch and Decimal decoding; 10000 integer-valued rows; not a concurrency or percentile benchmark.", + "results": [ + { + "backend": "sqlite", + "version": "3.51.0", + "case": "read", + "rows": 10000, + "elapsedMs": [ + 11.068, + 10.129, + 9.57 + ], + "sqlBytes": 70 + }, + { + "backend": "sqlite", + "version": "3.51.0", + "case": "divide_half_even_scale_2", + "rows": 10000, + "elapsedMs": [ + 30.672, + 30.095, + 29.656 + ], + "sqlBytes": 154 + }, + { + "backend": "sqlite", + "version": "3.51.0", + "case": "round_half_even_scale_minus_1", + "rows": 10000, + "elapsedMs": [ + 21.219, + 22.125, + 21.644 + ], + "sqlBytes": 126 + }, + { + "backend": "sqlite", + "version": "3.51.0", + "case": "window_divide_half_even_scale_2", + "rows": 10000, + "elapsedMs": [ + 46.842, + 48.727, + 48.175 + ], + "sqlBytes": 280 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "read", + "rows": 10000, + "elapsedMs": [ + 21.922, + 23.661, + 22.724 + ], + "sqlBytes": 43 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "divide_half_even_scale_2", + "rows": 10000, + "elapsedMs": [ + 32.665, + 32.626, + 32.521 + ], + "sqlBytes": 1597 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "round_half_even_scale_minus_1", + "rows": 10000, + "elapsedMs": [ + 25.258, + 25.168, + 25.245 + ], + "sqlBytes": 1149 + }, + { + "backend": "postgres", + "version": "18.4 (Homebrew)", + "case": "window_divide_half_even_scale_2", + "rows": 10000, + "elapsedMs": [ + 36.849, + 36.591, + 37.124 + ], + "sqlBytes": 1773 + } + ] +} diff --git a/research/benchmarks/editor.json b/research/benchmarks/editor.json new file mode 100644 index 00000000..d9f2610e --- /dev/null +++ b/research/benchmarks/editor.json @@ -0,0 +1,2801 @@ +{ + "recordedAt": "2026-09-15T08:19:35.236903Z", + "runtimeSourceCommit": "7eaab3bf83a4cb183132ff1ba669e7fb4c4b5c25", + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "os": "Version 26.6.2 (Build 25G83)", + "cpu": "Apple M3 Max", + "smoke": false, + "samplesPerWarmProbe": 20, + "harnessSha256": { + "tool/benchmark_editor.dart": "88bbe4fe83ac6579e778e86f5b1c3bcb0d04c5db1e4046de0a88952b0180130a", + "tool/src/editor_server.dart": "00e3cf5e7675166979cdb03a9d333754ee7aeb2874d6940d5d5ab6cff43a9ff8", + "tool/src/build_fixture.dart": "03a5d875d608fd25245b5da268d66ce4c0913ed288ac05270ae9522a68bb0ea9" + }, + "scope": "Direct stdio LSP client against the installed Dart Analysis Server, isolated consumer packages and real generated APIs; no database. Includes client transport/JSON costs, excludes GUI/plugin rendering and dependency download. Each scale uses one fresh server for completion/diagnostics and another for symbol renames. Startup and initial project analysis are measured separately from completion. First completion follows a fresh diagnostic for the incomplete source and is not a cold OS/cache measurement. Warm probes use unchanged documents after two unrecorded warmups. Diagnostic probes serialize changes and await a fresh notification; this SDK omits document versions. Final CLI analysis verifies the edited sources. Symbol-form probes compare language rename behavior only, not three ORM authoring frontends or identical generated APIs from each form.", + "limits": [ + "During harness development, primary-constructor field rename after the error/repair sequence stalled for 60 seconds in the same server. A separate clean server completes the symbol probes; this report does not establish reliable mixed-session rename after diagnostic recovery." + ], + "results": [ + { + "models": 10, + "sourceBytes": 1608, + "generatedBytes": 22953, + "initializeMicros": 34025, + "projectReadyMicros": 310640, + "completions": [ + { + "probe": "table_entries", + "analysisReadyMicros": 6287, + "firstRequestMicros": 2650, + "warm": { + "samplesMicros": [ + 1047, + 1117, + 1035, + 945, + 927, + 821, + 829, + 2734, + 884, + 838, + 809, + 772, + 772, + 809, + 776, + 765, + 720, + 709, + 716, + 705 + ], + "p50Micros": 809, + "p95Micros": 1117 + }, + "response": { + "isIncomplete": false, + "itemCount": 31, + "items": [ + { + "label": "rows0", + "kind": 10, + "detail": "Rows0TableSet", + "labelDetails": { + "detail": " Rows0TableSet" + }, + "textEdit": { + "newText": "rows0", + "range": { + "end": { + "character": 51, + "line": 3 + }, + "start": { + "character": 51, + "line": 3 + } + } + } + }, + { + "label": "rows9", + "kind": 10, + "detail": "Rows9TableSet", + "labelDetails": { + "detail": " Rows9TableSet" + }, + "textEdit": { + "newText": "rows9", + "range": { + "end": { + "character": 51, + "line": 3 + }, + "start": { + "character": 51, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "rows0", + "rows9" + ], + "forbiddenLabels": [] + }, + { + "probe": "schema_record_fields", + "analysisReadyMicros": 7617, + "firstRequestMicros": 777, + "warm": { + "samplesMicros": [ + 545, + 492, + 439, + 461, + 438, + 443, + 424, + 419, + 458, + 463, + 452, + 511, + 442, + 462, + 408, + 439, + 426, + 409, + 403, + 398 + ], + "p50Micros": 439, + "p95Micros": 511 + }, + "response": { + "isIncomplete": false, + "itemCount": 8, + "items": [ + { + "label": "id", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 39, + "line": 25 + }, + "start": { + "character": 39, + "line": 25 + } + } + } + }, + { + "label": "score", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "score", + "range": { + "end": { + "character": 39, + "line": 25 + }, + "start": { + "character": 39, + "line": 25 + } + } + } + }, + { + "label": "status", + "kind": 6, + "detail": "Status", + "labelDetails": { + "detail": " Status" + }, + "textEdit": { + "newText": "status", + "range": { + "end": { + "character": 39, + "line": 25 + }, + "start": { + "character": 39, + "line": 25 + } + } + } + }, + { + "label": "title", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 39, + "line": 25 + }, + "start": { + "character": 39, + "line": 25 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title", + "score", + "status" + ], + "forbiddenLabels": [] + }, + { + "probe": "query_fields", + "analysisReadyMicros": 10510, + "firstRequestMicros": 1792, + "warm": { + "samplesMicros": [ + 780, + 749, + 1701, + 604, + 541, + 617, + 533, + 564, + 458, + 439, + 480, + 484, + 485, + 457, + 464, + 446, + 435, + 442, + 453, + 1126 + ], + "p50Micros": 484, + "p95Micros": 1126 + }, + "response": { + "isIncomplete": false, + "itemCount": 11, + "items": [ + { + "label": "id", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "title", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "score", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "score", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "status", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "status", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title", + "score", + "status" + ], + "forbiddenLabels": [] + }, + { + "probe": "create_arguments", + "analysisReadyMicros": 4884, + "firstRequestMicros": 455, + "warm": { + "samplesMicros": [ + 307, + 262, + 293, + 272, + 233, + 225, + 246, + 216, + 247, + 247, + 227, + 219, + 259, + 243, + 218, + 235, + 266, + 238, + 213, + 203 + ], + "p50Micros": 238, + "p95Micros": 293 + }, + "response": { + "isIncomplete": false, + "itemCount": 5, + "items": [ + { + "label": "title:", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title: ", + "range": { + "end": { + "character": 70, + "line": 3 + }, + "start": { + "character": 70, + "line": 3 + } + } + } + }, + { + "label": "status:", + "kind": 6, + "detail": "Status", + "labelDetails": { + "detail": " Status" + }, + "textEdit": { + "newText": "status: ", + "range": { + "end": { + "character": 70, + "line": 3 + }, + "start": { + "character": 70, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "title:", + "status:" + ], + "forbiddenLabels": [] + }, + { + "probe": "projected_record_fields", + "analysisReadyMicros": 5305, + "firstRequestMicros": 640, + "warm": { + "samplesMicros": [ + 518, + 539, + 514, + 505, + 553, + 515, + 1620, + 590, + 491, + 500, + 517, + 504, + 512, + 521, + 505, + 541, + 547, + 537, + 520, + 535 + ], + "p50Micros": 518, + "p95Micros": 590 + }, + "response": { + "isIncomplete": false, + "itemCount": 6, + "items": [ + { + "label": "id", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 180, + "line": 3 + }, + "start": { + "character": 180, + "line": 3 + } + } + } + }, + { + "label": "title", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 180, + "line": 3 + }, + "start": { + "character": 180, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title" + ], + "forbiddenLabels": [ + "score", + "status" + ] + } + ], + "diagnostics": [ + { + "probe": "create_type", + "micros": 5058, + "version": 5, + "expectedCode": "argument_type_not_assignable", + "expectedRange": { + "start": { + "line": 3, + "character": 77 + }, + "end": { + "line": 3, + "character": 80 + } + }, + "diagnostics": [ + { + "code": "argument_type_not_assignable", + "message": "The argument type 'int' can't be assigned to the parameter type 'String'. ", + "range": { + "end": { + "character": 80, + "line": 3 + }, + "start": { + "character": 77, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + } + ] + }, + { + "probe": "predicate_type", + "micros": 5817, + "version": 6, + "expectedCode": "argument_type_not_assignable", + "expectedRange": { + "start": { + "line": 3, + "character": 81 + }, + "end": { + "line": 3, + "character": 88 + } + }, + "diagnostics": [ + { + "code": "argument_type_not_assignable", + "message": "The argument type 'String' can't be assigned to the parameter type 'int'. ", + "range": { + "end": { + "character": 88, + "line": 3 + }, + "start": { + "character": 81, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + }, + { + "probe": "selected_result", + "micros": 4517, + "version": 7, + "expectedCode": "invalid_assignment", + "expectedRange": { + "start": { + "line": 3, + "character": 124 + }, + "end": { + "line": 3, + "character": 134 + } + }, + "diagnostics": [ + { + "code": "invalid_assignment", + "message": "A value of type 'String' can't be assigned to a variable of type 'int'.\nTry changing the type of the variable, or casting the right-hand type to 'int'.", + "range": { + "end": { + "character": 134, + "line": 3 + }, + "start": { + "character": 124, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + }, + { + "probe": "missing_selected_field", + "micros": 56852, + "version": 8, + "expectedCode": "undefined_getter", + "expectedRange": { + "start": { + "line": 3, + "character": 169 + }, + "end": { + "line": 3, + "character": 174 + } + }, + "diagnostics": [ + { + "code": "undefined_getter", + "message": "The getter 'score' isn't defined for the type '({int id, String title})'.\nTry importing the library that defines 'score', correcting the name to the name of an existing getter, or defining a getter or field named 'score'.", + "range": { + "end": { + "character": 174, + "line": 3 + }, + "start": { + "character": 169, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + } + ], + "repairMicros": 10153, + "renameServerStartupMicros": 270113, + "symbolRenames": [ + { + "probe": "record_field", + "status": "unavailable", + "micros": 10803, + "prepare": null, + "error": null, + "workspaceEdit": null, + "applied": null + }, + { + "probe": "primary_constructor_field", + "status": "applied", + "micros": 639112, + "prepare": { + "placeholder": "name", + "range": { + "end": { + "character": 53, + "line": 3 + }, + "start": { + "character": 49, + "line": 3 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { + "character": 53, + "line": 3 + }, + "start": { + "character": 49, + "line": 3 + } + } + }, + { + "newText": "displayName", + "range": { + "end": { + "character": 46, + "line": 12 + }, + "start": { + "character": 42, + "line": 12 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 1 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 2, + "result": "import 'package:orm/orm.dart';\ntypedef RecordUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final name = column(Column('name', Codecs.text));\n}\nString recordLabel(RecordUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRecordUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.name;\n" + } + } + }, + { + "probe": "table_field", + "status": "applied", + "micros": 33684, + "prepare": { + "placeholder": "name", + "range": { + "end": { + "character": 17, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { + "character": 17, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + } + }, + { + "newText": "displayName", + "range": { + "end": { + "character": 54, + "line": 13 + }, + "start": { + "character": 50, + "line": 13 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 2 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 2, + "result": "import 'package:orm/orm.dart';\ntypedef RecordUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final displayName = column(Column('name', Codecs.text));\n}\nString recordLabel(RecordUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRecordUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.displayName;\n" + } + } + }, + { + "probe": "record_alias", + "status": "applied", + "micros": 4803, + "prepare": { + "placeholder": "RecordUser", + "range": { + "end": { + "character": 18, + "line": 1 + }, + "start": { + "character": 8, + "line": 1 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 18, + "line": 1 + }, + "start": { + "character": 8, + "line": 1 + } + } + }, + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 29, + "line": 9 + }, + "start": { + "character": 19, + "line": 9 + } + } + }, + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 10, + "line": 11 + }, + "start": { + "character": 0, + "line": 11 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 3 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 3, + "result": "import 'package:orm/orm.dart';\ntypedef RenamedUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final displayName = column(Column('name', Codecs.text));\n}\nString recordLabel(RenamedUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRenamedUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.displayName;\n" + } + } + } + ], + "ormRule": { + "dartAnalysisMilliseconds": 95, + "dartAnalysisOutput": "Analyzing schema.dart...\nNo issues found!\n\n", + "generatorError": "Key selectors accept direct fields or positional records of fields only. (offset 1644)" + }, + "finalAnalyzeMilliseconds": 462 + }, + { + "models": 100, + "sourceBytes": 15918, + "generatedBytes": 230943, + "initializeMicros": 31152, + "projectReadyMicros": 487323, + "completions": [ + { + "probe": "table_entries", + "analysisReadyMicros": 9256, + "firstRequestMicros": 2120, + "warm": { + "samplesMicros": [ + 1545, + 1497, + 1498, + 2812, + 1473, + 1452, + 1465, + 1438, + 1427, + 1551, + 1566, + 1441, + 1462, + 1444, + 1453, + 3166, + 2472, + 2217, + 2212, + 2330 + ], + "p50Micros": 1497, + "p95Micros": 2812 + }, + "response": { + "isIncomplete": false, + "itemCount": 121, + "items": [ + { + "label": "rows0", + "kind": 10, + "detail": "Rows0TableSet", + "labelDetails": { + "detail": " Rows0TableSet" + }, + "textEdit": { + "newText": "rows0", + "range": { + "end": { + "character": 51, + "line": 3 + }, + "start": { + "character": 51, + "line": 3 + } + } + } + }, + { + "label": "rows99", + "kind": 10, + "detail": "Rows99TableSet", + "labelDetails": { + "detail": " Rows99TableSet" + }, + "textEdit": { + "newText": "rows99", + "range": { + "end": { + "character": 51, + "line": 3 + }, + "start": { + "character": 51, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "rows0", + "rows99" + ], + "forbiddenLabels": [] + }, + { + "probe": "schema_record_fields", + "analysisReadyMicros": 26581, + "firstRequestMicros": 663, + "warm": { + "samplesMicros": [ + 458, + 414, + 413, + 473, + 447, + 422, + 414, + 418, + 424, + 449, + 471, + 421, + 428, + 435, + 2102, + 492, + 418, + 410, + 453, + 442 + ], + "p50Micros": 428, + "p95Micros": 492 + }, + "response": { + "isIncomplete": false, + "itemCount": 8, + "items": [ + { + "label": "id", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 39, + "line": 205 + }, + "start": { + "character": 39, + "line": 205 + } + } + } + }, + { + "label": "score", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "score", + "range": { + "end": { + "character": 39, + "line": 205 + }, + "start": { + "character": 39, + "line": 205 + } + } + } + }, + { + "label": "status", + "kind": 6, + "detail": "Status", + "labelDetails": { + "detail": " Status" + }, + "textEdit": { + "newText": "status", + "range": { + "end": { + "character": 39, + "line": 205 + }, + "start": { + "character": 39, + "line": 205 + } + } + } + }, + { + "label": "title", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 39, + "line": 205 + }, + "start": { + "character": 39, + "line": 205 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title", + "score", + "status" + ], + "forbiddenLabels": [] + }, + { + "probe": "query_fields", + "analysisReadyMicros": 17499, + "firstRequestMicros": 4209, + "warm": { + "samplesMicros": [ + 559, + 510, + 541, + 493, + 473, + 468, + 536, + 504, + 470, + 485, + 476, + 464, + 460, + 447, + 1978, + 513, + 484, + 468, + 486, + 469 + ], + "p50Micros": 484, + "p95Micros": 559 + }, + "response": { + "isIncomplete": false, + "itemCount": 11, + "items": [ + { + "label": "id", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "title", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "score", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "score", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "status", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "status", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title", + "score", + "status" + ], + "forbiddenLabels": [] + }, + { + "probe": "create_arguments", + "analysisReadyMicros": 6619, + "firstRequestMicros": 444, + "warm": { + "samplesMicros": [ + 255, + 289, + 263, + 266, + 243, + 232, + 235, + 221, + 227, + 205, + 217, + 217, + 208, + 250, + 257, + 252, + 389, + 315, + 271, + 258 + ], + "p50Micros": 250, + "p95Micros": 315 + }, + "response": { + "isIncomplete": false, + "itemCount": 5, + "items": [ + { + "label": "title:", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title: ", + "range": { + "end": { + "character": 70, + "line": 3 + }, + "start": { + "character": 70, + "line": 3 + } + } + } + }, + { + "label": "status:", + "kind": 6, + "detail": "Status", + "labelDetails": { + "detail": " Status" + }, + "textEdit": { + "newText": "status: ", + "range": { + "end": { + "character": 70, + "line": 3 + }, + "start": { + "character": 70, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "title:", + "status:" + ], + "forbiddenLabels": [] + }, + { + "probe": "projected_record_fields", + "analysisReadyMicros": 7882, + "firstRequestMicros": 671, + "warm": { + "samplesMicros": [ + 600, + 570, + 584, + 582, + 570, + 568, + 1273, + 596, + 564, + 583, + 565, + 567, + 558, + 564, + 561, + 556, + 568, + 554, + 583, + 532 + ], + "p50Micros": 568, + "p95Micros": 600 + }, + "response": { + "isIncomplete": false, + "itemCount": 6, + "items": [ + { + "label": "id", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 180, + "line": 3 + }, + "start": { + "character": 180, + "line": 3 + } + } + } + }, + { + "label": "title", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 180, + "line": 3 + }, + "start": { + "character": 180, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title" + ], + "forbiddenLabels": [ + "score", + "status" + ] + } + ], + "diagnostics": [ + { + "probe": "create_type", + "micros": 9978, + "version": 5, + "expectedCode": "argument_type_not_assignable", + "expectedRange": { + "start": { + "line": 3, + "character": 77 + }, + "end": { + "line": 3, + "character": 80 + } + }, + "diagnostics": [ + { + "code": "argument_type_not_assignable", + "message": "The argument type 'int' can't be assigned to the parameter type 'String'. ", + "range": { + "end": { + "character": 80, + "line": 3 + }, + "start": { + "character": 77, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + } + ] + }, + { + "probe": "predicate_type", + "micros": 6571, + "version": 6, + "expectedCode": "argument_type_not_assignable", + "expectedRange": { + "start": { + "line": 3, + "character": 81 + }, + "end": { + "line": 3, + "character": 88 + } + }, + "diagnostics": [ + { + "code": "argument_type_not_assignable", + "message": "The argument type 'String' can't be assigned to the parameter type 'int'. ", + "range": { + "end": { + "character": 88, + "line": 3 + }, + "start": { + "character": 81, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + }, + { + "probe": "selected_result", + "micros": 7602, + "version": 7, + "expectedCode": "invalid_assignment", + "expectedRange": { + "start": { + "line": 3, + "character": 124 + }, + "end": { + "line": 3, + "character": 134 + } + }, + "diagnostics": [ + { + "code": "invalid_assignment", + "message": "A value of type 'String' can't be assigned to a variable of type 'int'.\nTry changing the type of the variable, or casting the right-hand type to 'int'.", + "range": { + "end": { + "character": 134, + "line": 3 + }, + "start": { + "character": 124, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + }, + { + "probe": "missing_selected_field", + "micros": 9357, + "version": 8, + "expectedCode": "undefined_getter", + "expectedRange": { + "start": { + "line": 3, + "character": 169 + }, + "end": { + "line": 3, + "character": 174 + } + }, + "diagnostics": [ + { + "code": "undefined_getter", + "message": "The getter 'score' isn't defined for the type '({int id, String title})'.\nTry importing the library that defines 'score', correcting the name to the name of an existing getter, or defining a getter or field named 'score'.", + "range": { + "end": { + "character": 174, + "line": 3 + }, + "start": { + "character": 169, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + } + ], + "repairMicros": 9399, + "renameServerStartupMicros": 268255, + "symbolRenames": [ + { + "probe": "record_field", + "status": "unavailable", + "micros": 14962, + "prepare": null, + "error": null, + "workspaceEdit": null, + "applied": null + }, + { + "probe": "primary_constructor_field", + "status": "applied", + "micros": 590193, + "prepare": { + "placeholder": "name", + "range": { + "end": { + "character": 53, + "line": 3 + }, + "start": { + "character": 49, + "line": 3 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { + "character": 53, + "line": 3 + }, + "start": { + "character": 49, + "line": 3 + } + } + }, + { + "newText": "displayName", + "range": { + "end": { + "character": 46, + "line": 12 + }, + "start": { + "character": 42, + "line": 12 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 1 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 2, + "result": "import 'package:orm/orm.dart';\ntypedef RecordUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final name = column(Column('name', Codecs.text));\n}\nString recordLabel(RecordUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRecordUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.name;\n" + } + } + }, + { + "probe": "table_field", + "status": "applied", + "micros": 41126, + "prepare": { + "placeholder": "name", + "range": { + "end": { + "character": 17, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { + "character": 17, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + } + }, + { + "newText": "displayName", + "range": { + "end": { + "character": 54, + "line": 13 + }, + "start": { + "character": 50, + "line": 13 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 2 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 2, + "result": "import 'package:orm/orm.dart';\ntypedef RecordUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final displayName = column(Column('name', Codecs.text));\n}\nString recordLabel(RecordUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRecordUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.displayName;\n" + } + } + }, + { + "probe": "record_alias", + "status": "applied", + "micros": 4995, + "prepare": { + "placeholder": "RecordUser", + "range": { + "end": { + "character": 18, + "line": 1 + }, + "start": { + "character": 8, + "line": 1 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 18, + "line": 1 + }, + "start": { + "character": 8, + "line": 1 + } + } + }, + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 29, + "line": 9 + }, + "start": { + "character": 19, + "line": 9 + } + } + }, + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 10, + "line": 11 + }, + "start": { + "character": 0, + "line": 11 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 3 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 3, + "result": "import 'package:orm/orm.dart';\ntypedef RenamedUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final displayName = column(Column('name', Codecs.text));\n}\nString recordLabel(RenamedUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRenamedUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.displayName;\n" + } + } + } + ], + "ormRule": { + "dartAnalysisMilliseconds": 104, + "dartAnalysisOutput": "Analyzing schema.dart...\nNo issues found!\n\n", + "generatorError": "Key selectors accept direct fields or positional records of fields only. (offset 15954)" + }, + "finalAnalyzeMilliseconds": 512 + }, + { + "models": 1000, + "sourceBytes": 162618, + "generatedBytes": 2343243, + "initializeMicros": 32252, + "projectReadyMicros": 3404717, + "completions": [ + { + "probe": "table_entries", + "analysisReadyMicros": 42685, + "firstRequestMicros": 10672, + "warm": { + "samplesMicros": [ + 11593, + 9543, + 12990, + 17094, + 11936, + 10767, + 9421, + 9570, + 15206, + 18331, + 10945, + 9720, + 9613, + 11572, + 13042, + 10145, + 12720, + 9240, + 12030, + 14791 + ], + "p50Micros": 11572, + "p95Micros": 17094 + }, + "response": { + "isIncomplete": false, + "itemCount": 1021, + "items": [ + { + "label": "rows0", + "kind": 10, + "detail": "Rows0TableSet", + "labelDetails": { + "detail": " Rows0TableSet" + }, + "textEdit": { + "newText": "rows0", + "range": { + "end": { + "character": 51, + "line": 3 + }, + "start": { + "character": 51, + "line": 3 + } + } + } + }, + { + "label": "rows999", + "kind": 10, + "detail": "Rows999TableSet", + "labelDetails": { + "detail": " Rows999TableSet" + }, + "textEdit": { + "newText": "rows999", + "range": { + "end": { + "character": 51, + "line": 3 + }, + "start": { + "character": 51, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "rows0", + "rows999" + ], + "forbiddenLabels": [] + }, + { + "probe": "schema_record_fields", + "analysisReadyMicros": 165844, + "firstRequestMicros": 3371, + "warm": { + "samplesMicros": [ + 722, + 753, + 723, + 691, + 744, + 773, + 788, + 704, + 730, + 692, + 691, + 6504, + 730, + 663, + 653, + 698, + 705, + 650, + 705, + 698 + ], + "p50Micros": 705, + "p95Micros": 788 + }, + "response": { + "isIncomplete": false, + "itemCount": 8, + "items": [ + { + "label": "id", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 39, + "line": 2005 + }, + "start": { + "character": 39, + "line": 2005 + } + } + } + }, + { + "label": "score", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "score", + "range": { + "end": { + "character": 39, + "line": 2005 + }, + "start": { + "character": 39, + "line": 2005 + } + } + } + }, + { + "label": "status", + "kind": 6, + "detail": "Status", + "labelDetails": { + "detail": " Status" + }, + "textEdit": { + "newText": "status", + "range": { + "end": { + "character": 39, + "line": 2005 + }, + "start": { + "character": 39, + "line": 2005 + } + } + } + }, + { + "label": "title", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 39, + "line": 2005 + }, + "start": { + "character": 39, + "line": 2005 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title", + "score", + "status" + ], + "forbiddenLabels": [] + }, + { + "probe": "query_fields", + "analysisReadyMicros": 128488, + "firstRequestMicros": 4109, + "warm": { + "samplesMicros": [ + 850, + 797, + 787, + 835, + 766, + 822, + 741, + 1606, + 767, + 797, + 764, + 765, + 829, + 800, + 746, + 780, + 807, + 755, + 755, + 753 + ], + "p50Micros": 780, + "p95Micros": 850 + }, + "response": { + "isIncomplete": false, + "itemCount": 11, + "items": [ + { + "label": "id", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "title", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "score", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "score", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "status", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "status", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title", + "score", + "status" + ], + "forbiddenLabels": [] + }, + { + "probe": "create_arguments", + "analysisReadyMicros": 38272, + "firstRequestMicros": 1763, + "warm": { + "samplesMicros": [ + 249, + 237, + 215, + 238, + 217, + 213, + 196, + 307, + 224, + 217, + 387, + 277, + 233, + 214, + 231, + 230, + 223, + 218, + 232, + 226 + ], + "p50Micros": 226, + "p95Micros": 307 + }, + "response": { + "isIncomplete": false, + "itemCount": 5, + "items": [ + { + "label": "title:", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title: ", + "range": { + "end": { + "character": 70, + "line": 3 + }, + "start": { + "character": 70, + "line": 3 + } + } + } + }, + { + "label": "status:", + "kind": 6, + "detail": "Status", + "labelDetails": { + "detail": " Status" + }, + "textEdit": { + "newText": "status: ", + "range": { + "end": { + "character": 70, + "line": 3 + }, + "start": { + "character": 70, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "title:", + "status:" + ], + "forbiddenLabels": [] + }, + { + "probe": "projected_record_fields", + "analysisReadyMicros": 50153, + "firstRequestMicros": 3097, + "warm": { + "samplesMicros": [ + 2649, + 2863, + 1972, + 1514, + 4880, + 1330, + 1294, + 1336, + 1330, + 1331, + 1337, + 1291, + 1142, + 1676, + 1098, + 1097, + 1105, + 1107, + 1100, + 1129 + ], + "p50Micros": 1330, + "p95Micros": 2863 + }, + "response": { + "isIncomplete": false, + "itemCount": 6, + "items": [ + { + "label": "id", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 180, + "line": 3 + }, + "start": { + "character": 180, + "line": 3 + } + } + } + }, + { + "label": "title", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 180, + "line": 3 + }, + "start": { + "character": 180, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title" + ], + "forbiddenLabels": [ + "score", + "status" + ] + } + ], + "diagnostics": [ + { + "probe": "create_type", + "micros": 40469, + "version": 5, + "expectedCode": "argument_type_not_assignable", + "expectedRange": { + "start": { + "line": 3, + "character": 77 + }, + "end": { + "line": 3, + "character": 80 + } + }, + "diagnostics": [ + { + "code": "argument_type_not_assignable", + "message": "The argument type 'int' can't be assigned to the parameter type 'String'. ", + "range": { + "end": { + "character": 80, + "line": 3 + }, + "start": { + "character": 77, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + } + ] + }, + { + "probe": "predicate_type", + "micros": 47620, + "version": 6, + "expectedCode": "argument_type_not_assignable", + "expectedRange": { + "start": { + "line": 3, + "character": 81 + }, + "end": { + "line": 3, + "character": 88 + } + }, + "diagnostics": [ + { + "code": "argument_type_not_assignable", + "message": "The argument type 'String' can't be assigned to the parameter type 'int'. ", + "range": { + "end": { + "character": 88, + "line": 3 + }, + "start": { + "character": 81, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + }, + { + "probe": "selected_result", + "micros": 60539, + "version": 7, + "expectedCode": "invalid_assignment", + "expectedRange": { + "start": { + "line": 3, + "character": 124 + }, + "end": { + "line": 3, + "character": 134 + } + }, + "diagnostics": [ + { + "code": "invalid_assignment", + "message": "A value of type 'String' can't be assigned to a variable of type 'int'.\nTry changing the type of the variable, or casting the right-hand type to 'int'.", + "range": { + "end": { + "character": 134, + "line": 3 + }, + "start": { + "character": 124, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + }, + { + "probe": "missing_selected_field", + "micros": 45383, + "version": 8, + "expectedCode": "undefined_getter", + "expectedRange": { + "start": { + "line": 3, + "character": 169 + }, + "end": { + "line": 3, + "character": 174 + } + }, + "diagnostics": [ + { + "code": "undefined_getter", + "message": "The getter 'score' isn't defined for the type '({int id, String title})'.\nTry importing the library that defines 'score', correcting the name to the name of an existing getter, or defining a getter or field named 'score'.", + "range": { + "end": { + "character": 174, + "line": 3 + }, + "start": { + "character": 169, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + } + ], + "repairMicros": 52492, + "renameServerStartupMicros": 306917, + "symbolRenames": [ + { + "probe": "record_field", + "status": "unavailable", + "micros": 11100, + "prepare": null, + "error": null, + "workspaceEdit": null, + "applied": null + }, + { + "probe": "primary_constructor_field", + "status": "applied", + "micros": 788454, + "prepare": { + "placeholder": "name", + "range": { + "end": { + "character": 53, + "line": 3 + }, + "start": { + "character": 49, + "line": 3 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { + "character": 53, + "line": 3 + }, + "start": { + "character": 49, + "line": 3 + } + } + }, + { + "newText": "displayName", + "range": { + "end": { + "character": 46, + "line": 12 + }, + "start": { + "character": 42, + "line": 12 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 1 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 2, + "result": "import 'package:orm/orm.dart';\ntypedef RecordUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final name = column(Column('name', Codecs.text));\n}\nString recordLabel(RecordUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRecordUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.name;\n" + } + } + }, + { + "probe": "table_field", + "status": "applied", + "micros": 22850, + "prepare": { + "placeholder": "name", + "range": { + "end": { + "character": 17, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { + "character": 17, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + } + }, + { + "newText": "displayName", + "range": { + "end": { + "character": 54, + "line": 13 + }, + "start": { + "character": 50, + "line": 13 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 2 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 2, + "result": "import 'package:orm/orm.dart';\ntypedef RecordUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final displayName = column(Column('name', Codecs.text));\n}\nString recordLabel(RecordUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRecordUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.displayName;\n" + } + } + }, + { + "probe": "record_alias", + "status": "applied", + "micros": 4802, + "prepare": { + "placeholder": "RecordUser", + "range": { + "end": { + "character": 18, + "line": 1 + }, + "start": { + "character": 8, + "line": 1 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 18, + "line": 1 + }, + "start": { + "character": 8, + "line": 1 + } + } + }, + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 29, + "line": 9 + }, + "start": { + "character": 19, + "line": 9 + } + } + }, + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 10, + "line": 11 + }, + "start": { + "character": 0, + "line": 11 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 3 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 3, + "result": "import 'package:orm/orm.dart';\ntypedef RenamedUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final displayName = column(Column('name', Codecs.text));\n}\nString recordLabel(RenamedUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRenamedUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.displayName;\n" + } + } + } + ], + "ormRule": { + "dartAnalysisMilliseconds": 180, + "dartAnalysisOutput": "Analyzing schema.dart...\nNo issues found!\n\n", + "generatorError": "Key selectors accept direct fields or positional records of fields only. (offset 162654)" + }, + "finalAnalyzeMilliseconds": 3540 + } + ] +} diff --git a/research/benchmarks/generation.json b/research/benchmarks/generation.json new file mode 100644 index 00000000..defa9439 --- /dev/null +++ b/research/benchmarks/generation.json @@ -0,0 +1,58 @@ +{ + "recorded_at_utc": "2026-09-14T20:20:37.160791Z", + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "os": "Version 26.6.2 (Build 25G83)", + "cpu": "Apple M3 Max", + "logical_processors": 14, + "packages": { + "build_runner": "2.16.1", + "build": "4.0.11", + "dart_style": "3.1.13", + "analyzer": "14.4.0" + }, + "method": "One sample per case in a fresh consumer package; dependency download excluded; shared SDK/pub/native caches warm. Timings include process startup or filesystem watch detection as applicable. No p50/p95 claim.", + "results": [ + { + "models": 10, + "builder_mode": "aot", + "schema_bytes": 1622, + "generated_dart_bytes": 23308, + "cold_build_ms": 16201, + "no_change_build_ms": 566, + "watch_start_ms": 801, + "watch_field_edit_ms": 517, + "watch_import_edit_ms": 396, + "unrelated_edit_triggered_build": false, + "unrelated_observation_ms": 1000, + "analyze_ms": 480 + }, + { + "models": 100, + "builder_mode": "aot", + "schema_bytes": 15932, + "generated_dart_bytes": 231298, + "cold_build_ms": 16261, + "no_change_build_ms": 567, + "watch_start_ms": 775, + "watch_field_edit_ms": 647, + "watch_import_edit_ms": 658, + "unrelated_edit_triggered_build": false, + "unrelated_observation_ms": 1000, + "analyze_ms": 495 + }, + { + "models": 1000, + "builder_mode": "aot", + "schema_bytes": 162632, + "generated_dart_bytes": 2343598, + "cold_build_ms": 17955, + "no_change_build_ms": 581, + "watch_start_ms": 787, + "watch_field_edit_ms": 2169, + "watch_import_edit_ms": 2220, + "unrelated_edit_triggered_build": false, + "unrelated_observation_ms": 1000, + "analyze_ms": 3480 + } + ] +} diff --git a/research/benchmarks/runtime-selection.json b/research/benchmarks/runtime-selection.json new file mode 100644 index 00000000..a81758b7 --- /dev/null +++ b/research/benchmarks/runtime-selection.json @@ -0,0 +1,17884 @@ +{ + "recordedAt": "2026-09-15T07:11:37.302015Z", + "sourceCommit": "d0a8c9a3c5c2bbd8feb99007d7723cd81a890ea5", + "harnessSha256": { + "tool/benchmark_runtime.dart": "4f7de193a9814bf571c35d672c71b0e04182b3de184a234f8979651c1d7bac10", + "tool/src/runtime_cases.dart": "fbd025c22388055452994d476d2ca1118bf123537d7c5f09a35c887d8887608c", + "tool/src/runtime_relay.dart": "cb6eb51792a6993c0143eea08f9f5bb9a6ebb31a3da32821047371709cde44cc", + "tool/src/runtime_vm.dart": "9a1f4f87be3371f9cee2d9a093def47cdf4679764d69ac219ba218a3a0931ca5" + }, + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "os": "Version 26.6.2 (Build 25G83)", + "cpu": "Apple M3 Max", + "samplesPerLane": 200, + "warmupPairs": 20, + "smoke": false, + "scope": "Warm native JIT, reused query objects; raw uses the same public Driver and precompiled identical SQL with manual same-codec mapping. ORM includes compilation. Default hooks and allocation tracing are disabled in timing. Loopback relay has no loss or bandwidth limit; delayed scenario is controlled TCP latency, not a remote-server measurement. PostgreSQL protocol bytes exclude TCP/IP headers. SQLite byte counts are UTF-8 JSON of driver rows, not IPC wire bytes. Heap fields are live isolate-group memory; traces count selected classes only, not total allocated bytes.", + "fixture": { + "users": 100, + "postsPerUser": 10, + "selectedPostsPerUser": 3, + "nullableNicknames": true, + "sqlite": "temporary file, WAL, warm cache", + "postgresPool": 4, + "concurrentClients": 8 + }, + "results": [ + { + "scenario": "sqlite_wal", + "environment": { + "backend": "sqlite_wal", + "databaseVersion": "3.53.4", + "pid": 5121 + }, + "oneWayDelayMs": 0, + "selectOneMicros": { + "samplesMicros": [ + 422, + 50, + 44, + 40, + 40, + 41, + 34, + 31, + 33, + 31 + ], + "p50Micros": 40, + "p95Micros": 422, + "minMicros": 31, + "maxMicros": 422, + "wallMicros": 766, + "operationsPerSecond": 13054.830287206267 + }, + "cases": [ + { + "case": "full_rows", + "latency": { + "raw": { + "samplesMicros": [ + 273, + 172, + 170, + 170, + 166, + 180, + 174, + 175, + 186, + 208, + 190, + 369, + 177, + 196, + 173, + 213, + 187, + 165, + 158, + 161, + 172, + 164, + 159, + 164, + 166, + 185, + 184, + 134, + 146, + 144, + 128, + 133, + 127, + 136, + 169, + 141, + 135, + 129, + 138, + 135, + 134, + 151, + 151, + 173, + 142, + 129, + 137, + 132, + 144, + 133, + 419, + 248, + 167, + 147, + 144, + 127, + 121, + 218, + 142, + 127, + 137, + 121, + 121, + 148, + 216, + 144, + 133, + 129, + 162, + 125, + 167, + 115, + 105, + 151, + 186, + 139, + 127, + 115, + 111, + 126, + 111, + 118, + 105, + 143, + 122, + 112, + 103, + 110, + 103, + 178, + 134, + 111, + 106, + 110, + 108, + 115, + 127, + 121, + 149, + 115, + 114, + 335, + 170, + 130, + 185, + 118, + 127, + 120, + 111, + 103, + 101, + 111, + 115, + 109, + 115, + 114, + 114, + 115, + 122, + 110, + 114, + 107, + 117, + 111, + 105, + 120, + 103, + 109, + 113, + 122, + 150, + 232, + 222, + 115, + 120, + 211, + 132, + 111, + 137, + 126, + 119, + 117, + 118, + 106, + 126, + 109, + 119, + 114, + 109, + 115, + 110, + 130, + 116, + 121, + 113, + 110, + 108, + 153, + 129, + 125, + 118, + 113, + 113, + 115, + 195, + 115, + 117, + 132, + 125, + 118, + 110, + 116, + 108, + 117, + 108, + 117, + 109, + 110, + 116, + 134, + 131, + 141, + 120, + 142, + 114, + 119, + 118, + 173, + 161, + 115, + 114, + 134, + 111, + 123, + 127, + 118, + 109, + 129, + 112, + 124 + ], + "p50Micros": 127, + "p95Micros": 211, + "minMicros": 101, + "maxMicros": 419, + "wallMicros": 27941, + "operationsPerSecond": 7157.939944883862 + }, + "orm": { + "samplesMicros": [ + 353, + 243, + 209, + 202, + 230, + 213, + 225, + 216, + 224, + 267, + 260, + 212, + 231, + 261, + 288, + 294, + 224, + 227, + 297, + 214, + 231, + 247, + 232, + 222, + 231, + 310, + 202, + 192, + 197, + 199, + 176, + 200, + 180, + 184, + 204, + 191, + 171, + 180, + 183, + 172, + 185, + 538, + 210, + 218, + 186, + 170, + 179, + 173, + 179, + 169, + 190, + 182, + 256, + 227, + 194, + 181, + 191, + 325, + 227, + 205, + 186, + 170, + 192, + 183, + 204, + 193, + 161, + 156, + 245, + 171, + 2889, + 175, + 276, + 191, + 194, + 187, + 173, + 157, + 152, + 210, + 169, + 151, + 154, + 159, + 289, + 168, + 156, + 147, + 150, + 162, + 161, + 148, + 158, + 150, + 152, + 164, + 152, + 147, + 155, + 148, + 168, + 199, + 230, + 191, + 167, + 160, + 211, + 164, + 148, + 143, + 148, + 155, + 142, + 165, + 146, + 158, + 146, + 142, + 156, + 161, + 166, + 148, + 157, + 141, + 177, + 195, + 142, + 148, + 164, + 145, + 199, + 195, + 215, + 165, + 155, + 166, + 188, + 172, + 153, + 161, + 196, + 163, + 165, + 166, + 164, + 161, + 167, + 165, + 154, + 151, + 175, + 184, + 160, + 162, + 162, + 169, + 158, + 173, + 181, + 157, + 174, + 147, + 144, + 250, + 190, + 166, + 166, + 240, + 161, + 158, + 151, + 150, + 153, + 150, + 149, + 148, + 150, + 146, + 153, + 153, + 154, + 145, + 169, + 187, + 167, + 154, + 163, + 153, + 167, + 156, + 179, + 178, + 178, + 168, + 155, + 149, + 161, + 167, + 245, + 160 + ], + "p50Micros": 170, + "p95Micros": 267, + "minMicros": 141, + "maxMicros": 2889, + "wallMicros": 39723, + "operationsPerSecond": 5034.866450167409 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 183, + 324, + 463, + 589, + 736, + 852, + 973, + 1086, + 1020, + 1008, + 1000, + 993, + 961, + 960, + 962, + 962, + 952, + 948, + 933, + 932, + 930, + 930, + 935, + 943, + 1090, + 1156, + 1162, + 1159, + 1172, + 1169, + 1195, + 1215, + 1081, + 1014, + 1021, + 1055, + 1067, + 1076, + 1048, + 1025, + 1013, + 1017, + 1009, + 1013, + 1025, + 1033, + 1032, + 1037, + 1038, + 1036, + 1026, + 982, + 938, + 919, + 901, + 888, + 881, + 874, + 893, + 892, + 892, + 893, + 893, + 993, + 1003, + 1012, + 1002, + 1000, + 996, + 993, + 989, + 885, + 873, + 910, + 904, + 906, + 908, + 913, + 956, + 1008, + 1074, + 1027, + 1026, + 1058, + 1061, + 1058, + 1017, + 968, + 908, + 911, + 912, + 989, + 1032, + 1041, + 1047, + 1051, + 1070, + 1204, + 1256, + 1156, + 1126, + 1138, + 1229, + 1227, + 1208, + 1070, + 1011, + 999, + 986, + 963, + 863, + 863, + 883, + 887, + 906, + 915, + 914, + 923, + 923, + 925, + 904, + 900, + 889, + 882, + 885, + 880, + 991, + 991, + 992, + 992, + 991, + 992, + 991, + 994, + 887, + 886, + 887, + 910, + 904, + 898, + 890, + 884, + 893, + 892, + 915, + 918, + 934, + 948, + 960, + 984, + 978, + 972, + 941, + 925, + 936, + 931, + 926, + 917, + 934, + 957, + 984, + 978, + 965, + 966, + 971, + 968, + 988, + 1105, + 1145, + 1177, + 1191, + 1196, + 1193, + 1200, + 1167, + 1037, + 978, + 949, + 936, + 925, + 929, + 914, + 914, + 924, + 924, + 922, + 924, + 923, + 923, + 926, + 1024, + 1044, + 1053, + 1120, + 1130, + 1139, + 1143, + 1134, + 1031, + 992 + ], + "p50Micros": 973, + "p95Micros": 1191, + "minMicros": 183, + "maxMicros": 1256, + "wallMicros": 25151, + "operationsPerSecond": 7951.970100592422 + }, + "orm": { + "samplesMicros": [ + 436, + 564, + 744, + 890, + 1090, + 1239, + 1404, + 1613, + 1393, + 1389, + 1352, + 1440, + 1404, + 1433, + 1415, + 1376, + 1398, + 1441, + 1452, + 1375, + 1357, + 1328, + 1327, + 1326, + 1271, + 1223, + 1202, + 1297, + 1301, + 1340, + 1347, + 1327, + 1332, + 1349, + 1357, + 1258, + 1327, + 1297, + 1294, + 1289, + 1289, + 1366, + 1379, + 1367, + 1289, + 1269, + 1352, + 1352, + 1355, + 1273, + 1270, + 1259, + 1276, + 1316, + 1235, + 1354, + 1417, + 1428, + 1462, + 1485, + 1494, + 1460, + 1462, + 1344, + 1302, + 1282, + 1269, + 1275, + 1259, + 1248, + 1231, + 1220, + 1201, + 1286, + 1401, + 1503, + 1543, + 1576, + 1577, + 1581, + 1586, + 1511, + 1394, + 1284, + 1256, + 1249, + 1252, + 1265, + 1242, + 1260, + 1241, + 1243, + 1241, + 1241, + 1245, + 1231, + 1234, + 1242, + 1273, + 1267, + 1253, + 1230, + 1235, + 1253, + 1366, + 1306, + 1283, + 1285, + 1294, + 1300, + 1294, + 1278, + 1172, + 1203, + 1292, + 1296, + 1347, + 1377, + 1407, + 1429, + 1440, + 1421, + 1342, + 1345, + 1309, + 1272, + 1247, + 1244, + 1235, + 1248, + 1228, + 1207, + 1170, + 1169, + 1163, + 1156, + 1151, + 1147, + 1142, + 1144, + 1152, + 1164, + 1161, + 1149, + 1150, + 1134, + 1118, + 1129, + 1122, + 1115, + 1121, + 1123, + 1118, + 1125, + 1146, + 1138, + 1148, + 1155, + 1149, + 1150, + 1152, + 1150, + 1282, + 1274, + 1292, + 1288, + 1297, + 1293, + 1299, + 1311, + 1256, + 1267, + 1276, + 1293, + 1291, + 1304, + 1292, + 1297, + 1217, + 1219, + 1191, + 1181, + 1174, + 1164, + 1173, + 1156, + 1154, + 1150, + 1147, + 1137, + 1143, + 1144, + 1142, + 1135, + 1170, + 1203, + 1232, + 1241, + 1242, + 1212 + ], + "p50Micros": 1270, + "p95Micros": 1462, + "minMicros": 436, + "maxMicros": 1613, + "wallMicros": 32250, + "operationsPerSecond": 6201.550387596899 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 29, + 162, + 278, + 389, + 497, + 605, + 712, + 820, + 766, + 763, + 809, + 827, + 839, + 850, + 1006, + 1098, + 1091, + 1066, + 1500, + 1521, + 1735, + 1626, + 1645, + 1665, + 1684, + 1248, + 1269, + 1072, + 1035, + 959, + 961, + 1030, + 1003, + 980, + 1034, + 1044, + 1113, + 1100, + 1013, + 1013, + 1030, + 943, + 911, + 831, + 813, + 807, + 793, + 778, + 776, + 775, + 767, + 766, + 764, + 760, + 757, + 761, + 768, + 771, + 774, + 779, + 782, + 784, + 771, + 763 + ], + "p50Micros": 827, + "p95Micros": 1645, + "minMicros": 29, + "maxMicros": 1735, + "wallMicros": 59081 + }, + "sqlMicros": [ + 117, + 108, + 103, + 101, + 100, + 99, + 99, + 99, + 104, + 148, + 117, + 108, + 107, + 252, + 181, + 110, + 113, + 100, + 130, + 324, + 160, + 195, + 131, + 125, + 116, + 150, + 129, + 122, + 118, + 131, + 192, + 116, + 101, + 186, + 129, + 186, + 117, + 105, + 111, + 115, + 104, + 99, + 108, + 100, + 99, + 101, + 100, + 101, + 98, + 100, + 99, + 99, + 99, + 99, + 106, + 107, + 105, + 103, + 103, + 101, + 101, + 97, + 97, + 98 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 41, + 285, + 449, + 603, + 942, + 1139, + 1275, + 1415, + 1260, + 1238, + 1251, + 1040, + 1030, + 1347, + 1505, + 2128, + 2136, + 2124, + 2124, + 2100, + 1759, + 1609, + 992, + 965, + 951, + 949, + 939, + 946, + 953, + 960, + 968, + 971, + 968, + 971, + 957, + 933, + 927, + 915, + 912, + 905, + 1158, + 1161, + 1164, + 1191, + 2280, + 2313, + 2340, + 2171, + 2451, + 2600, + 1570, + 1610, + 1670, + 1670, + 1609, + 1449, + 1348, + 1291, + 1267, + 1185, + 1163, + 1096, + 968, + 926 + ], + "p50Micros": 1163, + "p95Micros": 2313, + "minMicros": 41, + "maxMicros": 2600, + "wallMicros": 83533 + }, + "sqlMicros": [ + 129, + 132, + 115, + 309, + 159, + 111, + 110, + 115, + 116, + 127, + 112, + 133, + 430, + 250, + 723, + 128, + 118, + 115, + 112, + 111, + 111, + 111, + 107, + 109, + 110, + 105, + 117, + 122, + 111, + 114, + 108, + 109, + 107, + 106, + 106, + 107, + 105, + 105, + 104, + 104, + 110, + 110, + 133, + 138, + 127, + 119, + 185, + 376, + 242, + 157, + 143, + 195, + 123, + 135, + 221, + 134, + 132, + 115, + 110, + 103, + 101, + 99, + 99, + 110 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 35 + ], + "sqlMicros": [ + 178 + ], + "ormDecodeMicros": [] + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 15 + ], + "sqlMicros": [ + 156 + ], + "ormDecodeMicros": [ + 59 + ] + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1367474824613, + "endMicros": 1367474825696, + "retainedRootRows": 100, + "rssBefore": 308183040, + "rssAfter": 308920320, + "processLifetimeMaxRss": 486506496 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14620096, + "heapCapacity": 27852800, + "externalUsage": 80 + }, + "gcTimestamp": "1789456291448", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 109, + "bytesCurrent": 5104 + }, + { + "name": "_Map", + "instancesCurrent": 14, + "bytesCurrent": 896 + }, + { + "name": "_List", + "instancesCurrent": 40142, + "bytesCurrent": 3105328 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1130, + "bytesCurrent": 36160 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14682416, + "heapCapacity": 25542656, + "externalUsage": 80 + }, + "gcTimestamp": "1789456291611", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 209, + "bytesCurrent": 9904 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40267, + "bytesCurrent": 3115072 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1232, + "bytesCurrent": 39424 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 343, + "counts": { + "_List": 34, + "_Record": 303, + "_GrowableList": 6 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 300 + }, + { + "function": "_allocateData", + "samples": 18 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_instanceOf", + "samples": 6 + }, + { + "function": "_worker", + "samples": 3 + }, + { + "function": "_request", + "samples": 3 + }, + { + "function": "_GrowableList._withData", + "samples": 3 + }, + { + "function": "_List", + "samples": 1 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1367475017206, + "endMicros": 1367475018633, + "retainedRootRows": 100, + "rssBefore": 315097088, + "rssAfter": 315932672, + "processLifetimeMaxRss": 486506496 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14665648, + "heapCapacity": 25542656, + "externalUsage": 80 + }, + "gcTimestamp": "1789456291648", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 109, + "bytesCurrent": 5104 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40163, + "bytesCurrent": 3106464 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1128, + "bytesCurrent": 36096 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14682576, + "heapCapacity": 26066944, + "externalUsage": 80 + }, + "gcTimestamp": "1789456291808", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 209, + "bytesCurrent": 9904 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40267, + "bytesCurrent": 3115072 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1232, + "bytesCurrent": 39424 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 663, + "counts": { + "_List": 204, + "_SelectionPlan": 3, + "_GrowableList": 129, + "_Map": 18, + "_Record": 309 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 300 + }, + { + "function": "_GrowableList._withData", + "samples": 120 + }, + { + "function": "_allocateData", + "samples": 48 + }, + { + "function": "quote", + "samples": 48 + }, + { + "function": "_write", + "samples": 33 + }, + { + "function": "writeSql", + "samples": 21 + }, + { + "function": "_List", + "samples": 20 + }, + { + "function": "_instanceOf", + "samples": 12 + }, + { + "function": "Map._fromLiteral", + "samples": 12 + }, + { + "function": "_addPart", + "samples": 12 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_plan", + "samples": 6 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "two_columns", + "latency": { + "raw": { + "samplesMicros": [ + 164, + 58, + 55, + 54, + 53, + 52, + 50, + 53, + 55, + 53, + 52, + 51, + 49, + 51, + 50, + 54, + 53, + 52, + 52, + 49, + 48, + 52, + 54, + 52, + 52, + 54, + 54, + 54, + 53, + 54, + 57, + 55, + 56, + 158, + 105, + 68, + 76, + 96, + 61, + 56, + 54, + 58, + 59, + 219, + 71, + 82, + 71, + 60, + 71, + 176, + 86, + 124, + 86, + 73, + 87, + 109, + 80, + 119, + 57, + 50, + 50, + 50, + 49, + 55, + 149, + 131, + 246, + 68, + 62, + 52, + 51, + 53, + 50, + 57, + 52, + 63, + 52, + 63, + 58, + 63, + 65, + 56, + 55, + 60, + 60, + 52, + 56, + 66, + 56, + 61, + 106, + 73, + 53, + 61, + 69, + 64, + 60, + 75, + 66, + 61, + 57, + 93, + 53, + 50, + 51, + 68, + 52, + 57, + 63, + 64, + 102, + 73, + 81, + 56, + 53, + 57, + 72, + 64, + 54, + 79, + 120, + 77, + 58, + 59, + 63, + 59, + 61, + 50, + 50, + 51, + 64, + 61, + 54, + 59, + 56, + 63, + 63, + 75, + 65, + 63, + 64, + 61, + 63, + 63, + 63, + 67, + 66, + 63, + 57, + 67, + 62, + 53, + 56, + 49, + 50, + 53, + 53, + 53, + 53, + 60, + 63, + 53, + 63, + 57, + 69, + 62, + 64, + 64, + 133, + 54, + 54, + 50, + 48, + 50, + 49, + 49, + 48, + 48, + 48, + 48, + 49, + 48, + 49, + 48, + 70, + 65, + 69, + 63, + 58, + 50, + 51, + 52, + 55, + 50, + 52, + 50, + 51, + 66, + 67, + 53 + ], + "p50Micros": 57, + "p95Micros": 119, + "minMicros": 48, + "maxMicros": 246, + "wallMicros": 13052, + "operationsPerSecond": 15323.322096230462 + }, + "orm": { + "samplesMicros": [ + 129, + 87, + 80, + 74, + 70, + 68, + 68, + 68, + 72, + 71, + 68, + 70, + 68, + 68, + 67, + 71, + 70, + 69, + 69, + 66, + 66, + 69, + 70, + 71, + 71, + 70, + 71, + 69, + 70, + 71, + 73, + 70, + 81, + 1110, + 151, + 127, + 894, + 1271, + 143, + 79, + 82, + 78, + 92, + 144, + 105, + 89, + 80, + 79, + 180, + 200, + 104, + 164, + 106, + 118, + 118, + 85, + 146, + 99, + 84, + 65, + 65, + 66, + 62, + 72, + 194, + 276, + 164, + 101, + 80, + 80, + 71, + 71, + 73, + 75, + 84, + 76, + 66, + 75, + 80, + 75, + 82, + 82, + 72, + 80, + 66, + 69, + 64, + 196, + 77, + 131, + 143, + 90, + 107, + 76, + 76, + 80, + 69, + 96, + 83, + 75, + 71, + 79, + 68, + 65, + 99, + 88, + 81, + 66, + 84, + 76, + 97, + 94, + 86, + 69, + 78, + 67, + 104, + 92, + 68, + 75, + 107, + 85, + 78, + 86, + 73, + 79, + 72, + 64, + 67, + 67, + 64, + 73, + 67, + 68, + 75, + 79, + 76, + 72, + 76, + 70, + 78, + 75, + 79, + 74, + 184, + 98, + 72, + 85, + 78, + 78, + 71, + 75, + 68, + 73, + 70, + 81, + 67, + 74, + 77, + 77, + 67, + 77, + 72, + 81, + 67, + 80, + 105, + 85, + 108, + 73, + 64, + 62, + 59, + 60, + 59, + 59, + 58, + 59, + 59, + 59, + 59, + 59, + 58, + 58, + 248, + 94, + 75, + 75, + 68, + 73, + 72, + 76, + 70, + 67, + 74, + 64, + 65, + 74, + 77, + 65 + ], + "p50Micros": 75, + "p95Micros": 164, + "minMicros": 58, + "maxMicros": 1271, + "wallMicros": 19831, + "operationsPerSecond": 10085.2201099289 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 214, + 339, + 436, + 514, + 647, + 705, + 764, + 825, + 682, + 613, + 584, + 566, + 492, + 496, + 497, + 516, + 526, + 526, + 532, + 546, + 567, + 574, + 577, + 564, + 564, + 566, + 564, + 584, + 580, + 590, + 597, + 701, + 785, + 791, + 787, + 765, + 752, + 733, + 717, + 666, + 594, + 588, + 592, + 577, + 572, + 607, + 613, + 581, + 571, + 606, + 601, + 611, + 679, + 756, + 771, + 736, + 723, + 739, + 743, + 729, + 666, + 548, + 528, + 525, + 529, + 466, + 458, + 456, + 446, + 442, + 455, + 475, + 471, + 477, + 475, + 474, + 471, + 481, + 463, + 444, + 448, + 453, + 451, + 453, + 472, + 501, + 501, + 501, + 497, + 489, + 491, + 510, + 502, + 475, + 487, + 496, + 499, + 506, + 507, + 487, + 486, + 487, + 505, + 509, + 510, + 513, + 516, + 539, + 549, + 539, + 517, + 504, + 494, + 530, + 536, + 524, + 514, + 522, + 526, + 526, + 536, + 512, + 523, + 527, + 630, + 649, + 657, + 668, + 668, + 648, + 630, + 618, + 503, + 485, + 468, + 456, + 446, + 442, + 430, + 433, + 438, + 426, + 424, + 426, + 427, + 423, + 432, + 425, + 422, + 428, + 435, + 440, + 439, + 441, + 432, + 433, + 433, + 429, + 435, + 438, + 438, + 443, + 454, + 459, + 460, + 474, + 523, + 531, + 572, + 573, + 583, + 590, + 589, + 576, + 521, + 513, + 474, + 472, + 460, + 447, + 451, + 468, + 478, + 492, + 495, + 500, + 509, + 525, + 533, + 517, + 504, + 498, + 547, + 551, + 540, + 531, + 533, + 538, + 541, + 529 + ], + "p50Micros": 514, + "p95Micros": 739, + "minMicros": 214, + "maxMicros": 825, + "wallMicros": 13601, + "operationsPerSecond": 14704.801117564884 + }, + "orm": { + "samplesMicros": [ + 162, + 235, + 318, + 397, + 471, + 545, + 616, + 686, + 650, + 660, + 658, + 677, + 677, + 685, + 708, + 757, + 727, + 719, + 712, + 685, + 693, + 712, + 693, + 641, + 632, + 614, + 604, + 598, + 581, + 546, + 534, + 531, + 530, + 531, + 531, + 530, + 530, + 529, + 529, + 529, + 529, + 591, + 591, + 594, + 604, + 629, + 630, + 640, + 646, + 587, + 586, + 588, + 590, + 576, + 586, + 591, + 598, + 605, + 606, + 612, + 630, + 623, + 640, + 628, + 676, + 664, + 665, + 653, + 620, + 614, + 592, + 589, + 532, + 539, + 533, + 550, + 565, + 576, + 569, + 642, + 733, + 772, + 807, + 799, + 795, + 793, + 793, + 724, + 664, + 643, + 649, + 658, + 661, + 657, + 670, + 663, + 626, + 600, + 575, + 821, + 859, + 861, + 859, + 859, + 863, + 864, + 859, + 607, + 553, + 544, + 531, + 533, + 539, + 547, + 554, + 548, + 553, + 561, + 566, + 573, + 574, + 577, + 568, + 574, + 579, + 581, + 590, + 593, + 601, + 593, + 600, + 600, + 605, + 603, + 603, + 598, + 581, + 614, + 626, + 628, + 634, + 669, + 668, + 677, + 685, + 695, + 709, + 710, + 736, + 826, + 831, + 826, + 832, + 795, + 765, + 757, + 715, + 585, + 571, + 570, + 561, + 547, + 535, + 526, + 530, + 566, + 574, + 570, + 584, + 593, + 602, + 603, + 600, + 570, + 571, + 567, + 567, + 567, + 570, + 577, + 581, + 579, + 670, + 693, + 684, + 690, + 719, + 703, + 703, + 696, + 596, + 576, + 587, + 582, + 554, + 599, + 606, + 615, + 619, + 612 + ], + "p50Micros": 603, + "p95Micros": 826, + "minMicros": 162, + "maxMicros": 864, + "wallMicros": 15914, + "operationsPerSecond": 12567.550584391101 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 25, + 97, + 149, + 201, + 251, + 301, + 350, + 399, + 357, + 354, + 360, + 365, + 370, + 386, + 392, + 390, + 390, + 382, + 383, + 396, + 378, + 381, + 384, + 384, + 383, + 381, + 374, + 379, + 379, + 381, + 384, + 395, + 393, + 387, + 384, + 383, + 381, + 380, + 447, + 448, + 447, + 446, + 444, + 443, + 442, + 366, + 364, + 364, + 363, + 363, + 364, + 364, + 364, + 365, + 364, + 365, + 364, + 363, + 362, + 362, + 360, + 360, + 358, + 358 + ], + "p50Micros": 374, + "p95Micros": 446, + "minMicros": 25, + "maxMicros": 448, + "wallMicros": 23199 + }, + "sqlMicros": [ + 68, + 49, + 47, + 46, + 46, + 45, + 46, + 49, + 46, + 54, + 51, + 51, + 60, + 51, + 47, + 46, + 45, + 52, + 63, + 47, + 51, + 50, + 46, + 45, + 51, + 56, + 52, + 50, + 51, + 50, + 56, + 49, + 49, + 49, + 49, + 49, + 49, + 122, + 50, + 48, + 48, + 48, + 48, + 47, + 47, + 48, + 48, + 47, + 48, + 49, + 47, + 48, + 48, + 47, + 48, + 48, + 48, + 47, + 47, + 46, + 47, + 47, + 47, + 119 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 22, + 107, + 190, + 277, + 356, + 425, + 494, + 562, + 519, + 500, + 494, + 485, + 481, + 478, + 480, + 474, + 469, + 450, + 471, + 468, + 482, + 494, + 496, + 495, + 477, + 466, + 460, + 437, + 430, + 441, + 441, + 442, + 442, + 452, + 459, + 457, + 476, + 527, + 705, + 746, + 765, + 850, + 873, + 794, + 842, + 678, + 658, + 662, + 589, + 622, + 614, + 687, + 691, + 687, + 714, + 720, + 685, + 685, + 641, + 644, + 640, + 573, + 553, + 520 + ], + "p50Micros": 494, + "p95Micros": 794, + "minMicros": 22, + "maxMicros": 873, + "wallMicros": 34414 + }, + "sqlMicros": [ + 65, + 64, + 68, + 64, + 53, + 52, + 52, + 52, + 52, + 60, + 58, + 50, + 51, + 54, + 48, + 48, + 55, + 59, + 47, + 58, + 60, + 49, + 47, + 47, + 47, + 48, + 47, + 51, + 57, + 47, + 47, + 47, + 57, + 53, + 53, + 74, + 87, + 219, + 72, + 74, + 137, + 68, + 73, + 63, + 61, + 58, + 73, + 63, + 96, + 67, + 131, + 64, + 53, + 109, + 63, + 77, + 65, + 88, + 60, + 48, + 46, + 52, + 45, + 44 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 9 + ], + "sqlMicros": [ + 54 + ], + "ormDecodeMicros": [] + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 11 + ], + "sqlMicros": [ + 74 + ], + "ormDecodeMicros": [ + 6 + ] + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1367475218464, + "endMicros": 1367475219170, + "retainedRootRows": 100, + "rssBefore": 324108288, + "rssAfter": 324747264, + "processLifetimeMaxRss": 486506496 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14601120, + "heapCapacity": 21872640, + "externalUsage": 80 + }, + "gcTimestamp": "1789456291849", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 109, + "bytesCurrent": 3504 + }, + { + "name": "_Map", + "instancesCurrent": 14, + "bytesCurrent": 896 + }, + { + "name": "_List", + "instancesCurrent": 40187, + "bytesCurrent": 3104800 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1130, + "bytesCurrent": 36160 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14618736, + "heapCapacity": 21872640, + "externalUsage": 80 + }, + "gcTimestamp": "1789456291997", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 209, + "bytesCurrent": 6704 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40291, + "bytesCurrent": 3111760 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1232, + "bytesCurrent": 39424 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 340, + "counts": { + "_List": 31, + "_Record": 303, + "_GrowableList": 6 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 300 + }, + { + "function": "_allocateData", + "samples": 18 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_instanceOf", + "samples": 6 + }, + { + "function": "_request", + "samples": 3 + }, + { + "function": "_GrowableList._withData", + "samples": 3 + }, + { + "function": "_worker", + "samples": 1 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1367475403349, + "endMicros": 1367475404314, + "retainedRootRows": 100, + "rssBefore": 334610432, + "rssAfter": 335331328, + "processLifetimeMaxRss": 486506496 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14606416, + "heapCapacity": 21872640, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292031", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 109, + "bytesCurrent": 3504 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40190, + "bytesCurrent": 3104992 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1128, + "bytesCurrent": 36096 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14620080, + "heapCapacity": 21872640, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292183", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 209, + "bytesCurrent": 6704 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40294, + "bytesCurrent": 3111968 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1232, + "bytesCurrent": 39424 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 612, + "counts": { + "_List": 171, + "_SelectionPlan": 3, + "_GrowableList": 111, + "_Map": 18, + "_Record": 309 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 300 + }, + { + "function": "_allocateData", + "samples": 129 + }, + { + "function": "quote", + "samples": 36 + }, + { + "function": "_write", + "samples": 27 + }, + { + "function": "_List", + "samples": 20 + }, + { + "function": "writeSql", + "samples": 15 + }, + { + "function": "_instanceOf", + "samples": 12 + }, + { + "function": "Map._fromLiteral", + "samples": 12 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_addPart", + "samples": 12 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_plan", + "samples": 6 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "three_posts", + "latency": { + "raw": { + "samplesMicros": [ + 917, + 908, + 842, + 872, + 768, + 802, + 806, + 836, + 847, + 920, + 835, + 944, + 951, + 796, + 790, + 836, + 952, + 845, + 805, + 839, + 910, + 812, + 821, + 786, + 803, + 1109, + 871, + 786, + 781, + 921, + 885, + 807, + 786, + 872, + 806, + 774, + 767, + 818, + 888, + 855, + 801, + 779, + 775, + 859, + 840, + 823, + 819, + 821, + 960, + 788, + 818, + 804, + 807, + 897, + 809, + 796, + 789, + 831, + 874, + 829, + 793, + 832, + 809, + 854, + 799, + 786, + 781, + 1002, + 902, + 793, + 785, + 835, + 850, + 788, + 795, + 782, + 864, + 844, + 773, + 790, + 789, + 815, + 851, + 813, + 803, + 923, + 807, + 812, + 817, + 782, + 790, + 935, + 879, + 763, + 763, + 821, + 806, + 768, + 804, + 775, + 821, + 874, + 766, + 775, + 771, + 752, + 846, + 807, + 804, + 790, + 806, + 1097, + 811, + 808, + 905, + 937, + 791, + 787, + 776, + 788, + 895, + 771, + 780, + 892, + 821, + 765, + 745, + 784, + 760, + 827, + 819, + 789, + 769, + 754, + 767, + 826, + 794, + 777, + 771, + 947, + 915, + 747, + 981, + 781, + 856, + 742, + 773, + 759, + 771, + 818, + 863, + 841, + 792, + 810, + 854, + 785, + 802, + 813, + 806, + 850, + 814, + 790, + 753, + 806, + 829, + 802, + 783, + 789, + 812, + 857, + 779, + 766, + 736, + 770, + 862, + 783, + 770, + 845, + 789, + 794, + 904, + 737, + 827, + 822, + 793, + 768, + 766, + 791, + 793, + 845, + 792, + 764, + 772, + 816, + 820, + 769, + 758, + 786, + 871, + 804 + ], + "p50Micros": 806, + "p95Micros": 935, + "minMicros": 736, + "maxMicros": 1109, + "wallMicros": 164112, + "operationsPerSecond": 1218.6799259042605 + }, + "orm": { + "samplesMicros": [ + 1011, + 1355, + 1104, + 1082, + 974, + 948, + 1064, + 1164, + 1138, + 977, + 1143, + 994, + 973, + 912, + 1016, + 996, + 979, + 961, + 987, + 969, + 1001, + 1025, + 911, + 889, + 922, + 1151, + 898, + 861, + 910, + 998, + 921, + 919, + 892, + 917, + 1006, + 999, + 1099, + 922, + 1000, + 1063, + 913, + 871, + 889, + 1029, + 1037, + 912, + 848, + 839, + 1044, + 929, + 876, + 887, + 913, + 1012, + 870, + 937, + 916, + 1024, + 915, + 912, + 877, + 872, + 900, + 917, + 933, + 887, + 979, + 948, + 894, + 896, + 879, + 891, + 954, + 947, + 863, + 862, + 927, + 1059, + 936, + 897, + 898, + 901, + 948, + 904, + 876, + 865, + 1108, + 884, + 861, + 871, + 891, + 873, + 922, + 844, + 860, + 885, + 1071, + 962, + 867, + 858, + 886, + 991, + 871, + 872, + 981, + 912, + 958, + 907, + 863, + 859, + 924, + 1254, + 1016, + 1129, + 939, + 971, + 854, + 871, + 865, + 888, + 956, + 891, + 867, + 861, + 984, + 899, + 896, + 857, + 870, + 908, + 902, + 847, + 896, + 938, + 902, + 915, + 886, + 851, + 896, + 906, + 967, + 907, + 900, + 872, + 935, + 909, + 850, + 836, + 901, + 899, + 881, + 858, + 832, + 845, + 934, + 944, + 856, + 874, + 883, + 1132, + 915, + 949, + 826, + 886, + 974, + 894, + 866, + 853, + 1019, + 971, + 999, + 896, + 898, + 1006, + 898, + 887, + 851, + 864, + 904, + 925, + 908, + 809, + 884, + 987, + 891, + 888, + 852, + 872, + 918, + 925, + 843, + 836, + 866, + 928, + 887, + 897, + 853, + 864, + 931, + 991 + ], + "p50Micros": 907, + "p95Micros": 1099, + "minMicros": 809, + "maxMicros": 1355, + "wallMicros": 185956, + "operationsPerSecond": 1075.5232420572609 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 779, + 1528, + 2267, + 2995, + 3803, + 4597, + 5415, + 6299, + 6344, + 6375, + 6427, + 6471, + 6446, + 6434, + 6416, + 6321, + 6464, + 6541, + 6537, + 6521, + 6538, + 6704, + 6721, + 6709, + 6544, + 6510, + 6615, + 6757, + 6775, + 6574, + 6569, + 6635, + 6586, + 6528, + 6377, + 6283, + 6257, + 6475, + 6508, + 6472, + 6514, + 6514, + 6531, + 6662, + 6648, + 6421, + 6350, + 6486, + 6506, + 6498, + 6522, + 6377, + 6372, + 6411, + 6416, + 6266, + 6266, + 6328, + 6408, + 6453, + 6413, + 6414, + 6395, + 6366, + 6335, + 6292, + 6222, + 6221, + 6454, + 6515, + 6597, + 6716, + 6765, + 6747, + 6716, + 6690, + 6543, + 6542, + 6557, + 6546, + 6523, + 6530, + 6531, + 6552, + 6539, + 6464, + 6428, + 6326, + 6461, + 6607, + 6637, + 6621, + 6628, + 6655, + 6634, + 6656, + 6523, + 6495, + 6699, + 6730, + 6743, + 6777, + 6782, + 6767, + 6768, + 6666, + 6495, + 6464, + 6475, + 6541, + 6587, + 6597, + 6587, + 6587, + 6547, + 6524, + 6522, + 6550, + 6565, + 6595, + 6586, + 6599, + 6620, + 6632, + 6599, + 6468, + 6411, + 6540, + 6644, + 6694, + 6689, + 6670, + 6680, + 6683, + 6666, + 6502, + 6458, + 6434, + 6554, + 6634, + 6907, + 6928, + 6895, + 6874, + 6792, + 6735, + 6616, + 6545, + 6308, + 6384, + 6370, + 6400, + 6448, + 6446, + 6408, + 6384, + 6311, + 6208, + 6250, + 6289, + 6291, + 6330, + 6343, + 6363, + 6344, + 6323, + 6297, + 6245, + 6204, + 6208, + 6275, + 6330, + 6344, + 6365, + 6370, + 6351, + 6341, + 6293, + 6237, + 6185, + 6321, + 6437, + 6506, + 6484, + 6517, + 6539, + 6529, + 6521, + 6414, + 6324, + 6381, + 6507, + 6541, + 6557, + 6573, + 6567, + 6534, + 6503, + 6412, + 6340 + ], + "p50Micros": 6506, + "p95Micros": 6765, + "minMicros": 779, + "maxMicros": 6928, + "wallMicros": 162331, + "operationsPerSecond": 1232.0505633551202 + }, + "orm": { + "samplesMicros": [ + 1246, + 2177, + 3078, + 3954, + 5054, + 5896, + 6696, + 7536, + 7180, + 7124, + 7179, + 7206, + 6943, + 6961, + 6998, + 7017, + 6979, + 6965, + 6862, + 6866, + 6939, + 6948, + 6984, + 7341, + 7367, + 7340, + 7342, + 7281, + 7331, + 7453, + 7521, + 7199, + 7181, + 7153, + 7160, + 7181, + 7092, + 7123, + 7162, + 7211, + 7195, + 7202, + 7212, + 7218, + 7237, + 7101, + 6997, + 7151, + 7193, + 7170, + 7112, + 7036, + 6987, + 6938, + 6912, + 6684, + 6705, + 6817, + 6969, + 7013, + 7125, + 7138, + 7129, + 7119, + 7104, + 7098, + 7177, + 7249, + 7194, + 7182, + 7195, + 7167, + 7124, + 7076, + 6882, + 6906, + 6950, + 6992, + 7005, + 7037, + 7074, + 7077, + 7112, + 7051, + 7077, + 7471, + 7469, + 7433, + 7367, + 7342, + 7284, + 7238, + 7119, + 6700, + 6873, + 6931, + 6985, + 6960, + 6972, + 6949, + 6946, + 6984, + 6824, + 6851, + 6883, + 6918, + 6891, + 6898, + 6895, + 6845, + 6827, + 6766, + 6741, + 6840, + 6997, + 7074, + 7077, + 7098, + 7094, + 7089, + 7095, + 7021, + 6968, + 6982, + 7050, + 7051, + 7049, + 7044, + 7016, + 7005, + 6963, + 6963, + 7074, + 7139, + 7122, + 7108, + 7114, + 7132, + 7092, + 7008, + 6796, + 6735, + 6983, + 6994, + 6965, + 6958, + 6987, + 7019, + 7208, + 7207, + 6986, + 7048, + 7145, + 7169, + 7160, + 7148, + 7004, + 7038, + 7045, + 6972, + 7010, + 7126, + 7184, + 7171, + 7163, + 7083, + 7002, + 7000, + 6912, + 6784, + 6985, + 7204, + 7233, + 7267, + 7333, + 7353, + 7326, + 7335, + 7082, + 7063, + 7155, + 7184, + 7152, + 7116, + 7122, + 7078, + 7097, + 6942, + 6973, + 7032, + 7077, + 7119, + 7133, + 7128, + 7090, + 7111, + 6993, + 6906, + 6949, + 7013 + ], + "p50Micros": 7074, + "p95Micros": 7342, + "minMicros": 1246, + "maxMicros": 7536, + "wallMicros": 177342, + "operationsPerSecond": 1127.7644325653257 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 19, + 859, + 1663, + 2419, + 3212, + 4006, + 4839, + 5639, + 5668, + 5734, + 5754, + 5981, + 6011, + 6024, + 6033, + 6246, + 6258, + 6330, + 6219, + 6350, + 6336, + 6347, + 6318, + 6243, + 6161, + 6031, + 5864, + 5867, + 5934, + 5714, + 5694, + 5734, + 5720, + 5705, + 5639, + 5537, + 5478, + 5460, + 5613, + 5662, + 5696, + 5724, + 5703, + 5664, + 5669, + 5480, + 5465, + 5464, + 5485, + 5525, + 5567, + 5575, + 5593, + 5564, + 5521, + 5505, + 5487, + 5461, + 5464, + 5509, + 5554, + 5584, + 5555, + 5554 + ], + "p50Micros": 5639, + "p95Micros": 6330, + "minMicros": 19, + "maxMicros": 6350, + "wallMicros": 345689 + }, + "sqlMicros": [ + 59, + 716, + 48, + 665, + 52, + 650, + 48, + 692, + 49, + 692, + 48, + 732, + 48, + 698, + 48, + 791, + 51, + 765, + 48, + 673, + 48, + 922, + 47, + 713, + 61, + 729, + 56, + 699, + 51, + 997, + 80, + 737, + 51, + 735, + 65, + 797, + 108, + 785, + 54, + 716, + 49, + 716, + 315, + 706, + 55, + 703, + 47, + 685, + 50, + 700, + 50, + 704, + 47, + 746, + 52, + 798, + 60, + 751, + 49, + 695, + 51, + 718, + 49, + 689, + 49, + 691, + 48, + 684, + 49, + 707, + 57, + 717, + 42, + 688, + 50, + 875, + 57, + 728, + 53, + 721, + 50, + 707, + 49, + 685, + 49, + 687, + 47, + 690, + 47, + 690, + 48, + 716, + 48, + 724, + 44, + 735, + 50, + 721, + 55, + 722, + 48, + 695, + 47, + 707, + 47, + 693, + 47, + 684, + 47, + 716, + 51, + 704, + 50, + 702, + 49, + 701, + 48, + 748, + 47, + 741, + 49, + 714, + 46, + 689, + 49, + 705, + 47, + 685 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 10, + 901, + 1799, + 2719, + 3676, + 4665, + 5638, + 6503, + 6466, + 6408, + 6330, + 6167, + 6091, + 5984, + 6142, + 6206, + 6253, + 6269, + 6269, + 6219, + 6210, + 6036, + 5987, + 6065, + 6136, + 6216, + 6263, + 6275, + 6261, + 6232, + 6101, + 6058, + 6125, + 6263, + 6363, + 6415, + 6386, + 6379, + 6335, + 6159, + 6006, + 5908, + 6008, + 6150, + 6182, + 6160, + 6135, + 6108, + 6103, + 5942, + 5859, + 5872, + 6183, + 6301, + 6332, + 6356, + 6361, + 6345, + 6313, + 6061, + 6011, + 6169, + 6260, + 6259 + ], + "p50Micros": 6167, + "p95Micros": 6408, + "minMicros": 10, + "maxMicros": 6503, + "wallMicros": 372364 + }, + "sqlMicros": [ + 52, + 700, + 65, + 712, + 58, + 753, + 51, + 729, + 90, + 776, + 112, + 733, + 54, + 706, + 51, + 715, + 50, + 694, + 49, + 691, + 50, + 692, + 48, + 732, + 50, + 704, + 49, + 872, + 60, + 760, + 51, + 739, + 51, + 709, + 48, + 693, + 48, + 691, + 48, + 693, + 48, + 712, + 49, + 725, + 48, + 820, + 53, + 751, + 58, + 759, + 58, + 725, + 52, + 707, + 50, + 694, + 51, + 693, + 50, + 692, + 52, + 722, + 53, + 819, + 67, + 810, + 73, + 762, + 74, + 696, + 50, + 658, + 48, + 689, + 49, + 687, + 48, + 685, + 47, + 712, + 48, + 697, + 47, + 846, + 66, + 756, + 65, + 689, + 52, + 657, + 58, + 652, + 47, + 687, + 48, + 698, + 48, + 698, + 49, + 714, + 48, + 720, + 50, + 966, + 58, + 754, + 49, + 713, + 56, + 714, + 49, + 704, + 48, + 699, + 48, + 699, + 47, + 730, + 53, + 711, + 47, + 871, + 63, + 776, + 68, + 664, + 57, + 667 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43, ?44, ?45, ?46, ?47, ?48, ?49, ?50, ?51, ?52, ?53, ?54, ?55, ?56, ?57, ?58, ?59, ?60, ?61, ?62, ?63, ?64, ?65, ?66, ?67, ?68, ?69, ?70, ?71, ?72, ?73, ?74, ?75, ?76, ?77, ?78, ?79, ?80, ?81, ?82, ?83, ?84, ?85, ?86, ?87, ?88, ?89, ?90, ?91, ?92, ?93, ?94, ?95, ?96, ?97, ?98, ?99, ?100))) AS \"orm_partition\" WHERE \"c3\" > ?101 AND \"c3\" <= ?102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 8 + ], + "sqlMicros": [ + 63, + 661 + ], + "ormDecodeMicros": [] + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43, ?44, ?45, ?46, ?47, ?48, ?49, ?50, ?51, ?52, ?53, ?54, ?55, ?56, ?57, ?58, ?59, ?60, ?61, ?62, ?63, ?64, ?65, ?66, ?67, ?68, ?69, ?70, ?71, ?72, ?73, ?74, ?75, ?76, ?77, ?78, ?79, ?80, ?81, ?82, ?83, ?84, ?85, ?86, ?87, ?88, ?89, ?90, ?91, ?92, ?93, ?94, ?95, ?96, ?97, ?98, ?99, ?100))) AS \"orm_partition\" WHERE \"c3\" > ?101 AND \"c3\" <= ?102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 6 + ], + "sqlMicros": [ + 69, + 690 + ], + "ormDecodeMicros": [ + 42, + 5 + ] + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1367475648529, + "endMicros": 1367475652655, + "retainedRootRows": 100, + "rssBefore": 343605248, + "rssAfter": 346456064, + "processLifetimeMaxRss": 486506496 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14827712, + "heapCapacity": 25018368, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292274", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 408, + "bytesCurrent": 14672 + }, + { + "name": "_Map", + "instancesCurrent": 13, + "bytesCurrent": 832 + }, + { + "name": "_List", + "instancesCurrent": 40088, + "bytesCurrent": 3121312 + }, + { + "name": "_GrowableList", + "instancesCurrent": 926, + "bytesCurrent": 29632 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14952928, + "heapCapacity": 26591232, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292452", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 808, + "bytesCurrent": 29072 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40388, + "bytesCurrent": 3140416 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1228, + "bytesCurrent": 39296 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 2505, + "counts": { + "_List": 681, + "_Record": 1506, + "_GrowableList": 315, + "_Map": 3 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 1200 + }, + { + "function": "_allocateData", + "samples": 642 + }, + { + "function": "_List.of", + "samples": 303 + }, + { + "function": "current", + "samples": 300 + }, + { + "function": "_init", + "samples": 20 + }, + { + "function": "_sendInternal", + "samples": 18 + }, + { + "function": "_instanceOf", + "samples": 6 + }, + { + "function": "_request", + "samples": 6 + }, + { + "function": "_sliceInternal", + "samples": 3 + }, + { + "function": "_slice", + "samples": 3 + }, + { + "function": "Map._fromLiteral", + "samples": 3 + }, + { + "function": "_worker", + "samples": 1 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1367475857524, + "endMicros": 1367475862890, + "retainedRootRows": 100, + "rssBefore": 355336192, + "rssAfter": 360349696, + "processLifetimeMaxRss": 486506496 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14827424, + "heapCapacity": 26591232, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292485", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 408, + "bytesCurrent": 14672 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40084, + "bytesCurrent": 3120768 + }, + { + "name": "_GrowableList", + "instancesCurrent": 924, + "bytesCurrent": 29568 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14954368, + "heapCapacity": 20299776, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292701", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 808, + "bytesCurrent": 29072 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40389, + "bytesCurrent": 3140448 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1228, + "bytesCurrent": 39296 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 6819, + "counts": { + "_List": 3267, + "_SelectionPlan": 12, + "_GrowableList": 1953, + "_Map": 60, + "_Record": 1527 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 2100 + }, + { + "function": "_allocateData", + "samples": 2088 + }, + { + "function": "parameter", + "samples": 636 + }, + { + "function": "_List", + "samples": 355 + }, + { + "function": "_List.of", + "samples": 309 + }, + { + "function": "load", + "samples": 303 + }, + { + "function": "add", + "samples": 300 + }, + { + "function": "quote", + "samples": 180 + }, + { + "function": "_write", + "samples": 135 + }, + { + "function": "_addPart", + "samples": 96 + }, + { + "function": "writeSql", + "samples": 81 + }, + { + "function": "_compile", + "samples": 48 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "aggregate", + "latency": { + "raw": { + "samplesMicros": [ + 32, + 35, + 39, + 56, + 37, + 30, + 35, + 35, + 36, + 34, + 38, + 35, + 35, + 65, + 35, + 35, + 36, + 72, + 41, + 35, + 31, + 36, + 45, + 37, + 38, + 35, + 35, + 34, + 37, + 35, + 35, + 35, + 35, + 37, + 36, + 35, + 36, + 36, + 40, + 34, + 36, + 35, + 38, + 34, + 36, + 34, + 37, + 35, + 36, + 35, + 37, + 34, + 36, + 36, + 35, + 34, + 34, + 31, + 39, + 35, + 37, + 35, + 35, + 35, + 34, + 34, + 38, + 36, + 34, + 34, + 35, + 46, + 29, + 35, + 37, + 37, + 39, + 44, + 37, + 35, + 36, + 35, + 36, + 35, + 35, + 35, + 37, + 37, + 37, + 46, + 48, + 31, + 36, + 47, + 37, + 32, + 36, + 32, + 30, + 37, + 35, + 31, + 46, + 32, + 59, + 37, + 39, + 40, + 36, + 156, + 34, + 35, + 35, + 34, + 35, + 32, + 38, + 38, + 29, + 35, + 39, + 34, + 36, + 48, + 38, + 35, + 42, + 35, + 35, + 35, + 38, + 36, + 37, + 35, + 36, + 35, + 38, + 35, + 38, + 35, + 42, + 37, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 37, + 36, + 36, + 36, + 44, + 37, + 36, + 36, + 36, + 37, + 37, + 63, + 45, + 37, + 37, + 35, + 34, + 41, + 36, + 35, + 44, + 35, + 35, + 41, + 38, + 34, + 35, + 35, + 40, + 34, + 37, + 35, + 40, + 45, + 36, + 35, + 38, + 32, + 37, + 33, + 36, + 31, + 37, + 47, + 47, + 40, + 44, + 35, + 41, + 38 + ], + "p50Micros": 36, + "p95Micros": 47, + "minMicros": 29, + "maxMicros": 156, + "wallMicros": 7555, + "operationsPerSecond": 26472.534745201854 + }, + "orm": { + "samplesMicros": [ + 48, + 40, + 39, + 52, + 55, + 48, + 41, + 39, + 42, + 43, + 41, + 39, + 51, + 45, + 48, + 44, + 40, + 43, + 56, + 44, + 41, + 40, + 53, + 40, + 50, + 39, + 39, + 39, + 41, + 42, + 40, + 39, + 39, + 39, + 40, + 39, + 39, + 39, + 39, + 39, + 40, + 39, + 41, + 40, + 40, + 39, + 40, + 39, + 39, + 39, + 40, + 38, + 40, + 39, + 40, + 39, + 40, + 48, + 45, + 77, + 45, + 44, + 39, + 38, + 40, + 38, + 40, + 39, + 46, + 38, + 40, + 38, + 40, + 38, + 41, + 39, + 45, + 40, + 49, + 40, + 40, + 39, + 40, + 39, + 40, + 39, + 47, + 45, + 45, + 47, + 48, + 38, + 58, + 46, + 55, + 49, + 48, + 43, + 47, + 44, + 53, + 46, + 47, + 53, + 59, + 53, + 50, + 46, + 50, + 46, + 52, + 40, + 39, + 39, + 41, + 47, + 43, + 46, + 39, + 39, + 45, + 44, + 39, + 38, + 61, + 41, + 41, + 39, + 39, + 38, + 41, + 39, + 40, + 38, + 39, + 38, + 39, + 39, + 39, + 38, + 47, + 41, + 41, + 40, + 41, + 41, + 41, + 41, + 41, + 40, + 41, + 41, + 40, + 40, + 58, + 43, + 41, + 41, + 51, + 42, + 46, + 43, + 51, + 48, + 45, + 40, + 40, + 39, + 41, + 39, + 46, + 40, + 43, + 39, + 40, + 39, + 39, + 38, + 45, + 42, + 40, + 39, + 47, + 71, + 47, + 39, + 42, + 46, + 44, + 39, + 38, + 39, + 48, + 41, + 43, + 39, + 45, + 42, + 55, + 50 + ], + "p50Micros": 41, + "p95Micros": 53, + "minMicros": 38, + "maxMicros": 77, + "wallMicros": 8602, + "operationsPerSecond": 23250.40688212044 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 56, + 98, + 134, + 186, + 229, + 301, + 341, + 379, + 373, + 364, + 363, + 343, + 353, + 308, + 315, + 313, + 302, + 304, + 308, + 311, + 296, + 298, + 296, + 296, + 293, + 295, + 294, + 295, + 290, + 291, + 290, + 291, + 294, + 293, + 293, + 294, + 298, + 298, + 298, + 299, + 298, + 298, + 295, + 294, + 299, + 300, + 307, + 305, + 305, + 304, + 307, + 307, + 303, + 301, + 295, + 296, + 297, + 297, + 297, + 298, + 297, + 297, + 297, + 296, + 296, + 296, + 296, + 294, + 294, + 295, + 295, + 294, + 296, + 297, + 297, + 298, + 300, + 301, + 301, + 301, + 299, + 299, + 299, + 298, + 296, + 294, + 294, + 294, + 294, + 294, + 302, + 319, + 317, + 318, + 318, + 316, + 317, + 317, + 309, + 292, + 295, + 294, + 294, + 296, + 295, + 293, + 294, + 293, + 293, + 293, + 293, + 293, + 293, + 295, + 298, + 298, + 292, + 292, + 293, + 309, + 309, + 310, + 306, + 306, + 313, + 313, + 325, + 314, + 333, + 354, + 350, + 365, + 362, + 364, + 354, + 346, + 329, + 307, + 310, + 297, + 300, + 294, + 293, + 301, + 338, + 385, + 424, + 475, + 514, + 547, + 554, + 585, + 547, + 498, + 504, + 481, + 448, + 477, + 471, + 478, + 502, + 514, + 468, + 465, + 519, + 479, + 486, + 456, + 445, + 437, + 435, + 407, + 343, + 324, + 309, + 294, + 283, + 280, + 277, + 280, + 278, + 277, + 276, + 278, + 290, + 289, + 294, + 295, + 301, + 304, + 302, + 296, + 286, + 283, + 279, + 285, + 284, + 281, + 295, + 301 + ], + "p50Micros": 298, + "p95Micros": 486, + "minMicros": 56, + "maxMicros": 585, + "wallMicros": 8253, + "operationsPerSecond": 24233.61201987156 + }, + "orm": { + "samplesMicros": [ + 96, + 147, + 191, + 230, + 272, + 308, + 349, + 387, + 338, + 338, + 337, + 335, + 334, + 344, + 347, + 351, + 351, + 335, + 330, + 335, + 333, + 327, + 320, + 316, + 316, + 315, + 314, + 315, + 315, + 314, + 314, + 314, + 314, + 323, + 326, + 331, + 337, + 346, + 354, + 361, + 382, + 383, + 428, + 427, + 425, + 420, + 411, + 403, + 383, + 373, + 329, + 320, + 315, + 311, + 311, + 311, + 311, + 310, + 307, + 308, + 307, + 307, + 306, + 306, + 307, + 313, + 316, + 318, + 320, + 322, + 324, + 326, + 328, + 324, + 322, + 323, + 322, + 323, + 324, + 324, + 324, + 325, + 326, + 327, + 329, + 328, + 328, + 328, + 332, + 332, + 332, + 336, + 342, + 344, + 348, + 375, + 383, + 391, + 398, + 400, + 400, + 401, + 398, + 371, + 366, + 367, + 363, + 363, + 361, + 362, + 359, + 364, + 359, + 348, + 342, + 338, + 335, + 337, + 340, + 334, + 334, + 338, + 340, + 338, + 338, + 333, + 435, + 444, + 445, + 447, + 447, + 448, + 452, + 455, + 354, + 345, + 344, + 345, + 345, + 346, + 341, + 340, + 340, + 340, + 344, + 347, + 347, + 345, + 344, + 341, + 339, + 341, + 336, + 334, + 366, + 417, + 424, + 427, + 429, + 478, + 534, + 539, + 504, + 449, + 438, + 431, + 426, + 385, + 329, + 322, + 321, + 321, + 321, + 323, + 326, + 325, + 323, + 317, + 321, + 326, + 326, + 324, + 352, + 342, + 343, + 346, + 347, + 349, + 352, + 359, + 338, + 341, + 341, + 341, + 338, + 334, + 333, + 328, + 321, + 307 + ], + "p50Micros": 338, + "p95Micros": 445, + "minMicros": 96, + "maxMicros": 539, + "wallMicros": 8851, + "operationsPerSecond": 22596.316800361543 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 12, + 62, + 101, + 150, + 187, + 227, + 266, + 302, + 281, + 282, + 268, + 271, + 269, + 269, + 270, + 266, + 266, + 264, + 265, + 268, + 272, + 276, + 275, + 277, + 280, + 280, + 278, + 273, + 268, + 268, + 267, + 267, + 267, + 269, + 269, + 270, + 270, + 270, + 268, + 267, + 265, + 264, + 264, + 263, + 263, + 263, + 264, + 260, + 253, + 246, + 239, + 232, + 225, + 218, + 217, + 216, + 233, + 242, + 249, + 256, + 262, + 268, + 275, + 262 + ], + "p50Micros": 266, + "p95Micros": 280, + "minMicros": 12, + "maxMicros": 302, + "wallMicros": 15976 + }, + "sqlMicros": [ + 47, + 38, + 46, + 35, + 38, + 37, + 34, + 42, + 37, + 36, + 36, + 36, + 36, + 36, + 37, + 36, + 34, + 37, + 39, + 41, + 41, + 36, + 38, + 37, + 38, + 36, + 36, + 36, + 36, + 36, + 37, + 38, + 38, + 37, + 36, + 36, + 37, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 37, + 32, + 30, + 30, + 30, + 29, + 30, + 30, + 30, + 29, + 45, + 38, + 36, + 36, + 36, + 36, + 36, + 36, + 36 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 8, + 61, + 102, + 143, + 184, + 224, + 264, + 304, + 276, + 276, + 275, + 274, + 274, + 275, + 279, + 279, + 280, + 280, + 286, + 287, + 288, + 284, + 283, + 283, + 283, + 277, + 279, + 279, + 283, + 288, + 291, + 296, + 300, + 296, + 295, + 290, + 288, + 285, + 283, + 288, + 289, + 290, + 290, + 289, + 288, + 279, + 276, + 276, + 276, + 276, + 275, + 275, + 277, + 278, + 278, + 278, + 278, + 278, + 277, + 271, + 266, + 261, + 256, + 255 + ], + "p50Micros": 278, + "p95Micros": 296, + "minMicros": 8, + "maxMicros": 304, + "wallMicros": 17002 + }, + "sqlMicros": [ + 38, + 31, + 31, + 32, + 31, + 31, + 31, + 32, + 31, + 31, + 31, + 31, + 31, + 36, + 32, + 32, + 32, + 37, + 33, + 33, + 32, + 31, + 32, + 31, + 31, + 35, + 32, + 36, + 37, + 36, + 36, + 35, + 31, + 31, + 31, + 33, + 33, + 33, + 34, + 31, + 31, + 32, + 32, + 32, + 32, + 31, + 31, + 31, + 31, + 32, + 32, + 33, + 31, + 32, + 32, + 31, + 31, + 32, + 29, + 28, + 28, + 28, + 31, + 32 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 8 + ], + "sqlMicros": [ + 56 + ], + "ormDecodeMicros": [] + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 6 + ], + "sqlMicros": [ + 45 + ], + "ormDecodeMicros": [ + 5 + ] + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1367476108419, + "endMicros": 1367476108861, + "retainedRootRows": 10, + "rssBefore": 370704384, + "rssAfter": 370802688, + "processLifetimeMaxRss": 486506496 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14736176, + "heapCapacity": 20824064, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292737", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 18, + "bytesCurrent": 592 + }, + { + "name": "_Map", + "instancesCurrent": 13, + "bytesCurrent": 832 + }, + { + "name": "_List", + "instancesCurrent": 40099, + "bytesCurrent": 3121264 + }, + { + "name": "_GrowableList", + "instancesCurrent": 926, + "bytesCurrent": 29632 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14736592, + "heapCapacity": 20824064, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292891", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 28, + "bytesCurrent": 912 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40107, + "bytesCurrent": 3121520 + }, + { + "name": "_GrowableList", + "instancesCurrent": 938, + "bytesCurrent": 30016 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 62, + "counts": { + "_List": 23, + "_Record": 33, + "_GrowableList": 6 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 30 + }, + { + "function": "_allocateData", + "samples": 12 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_instanceOf", + "samples": 6 + }, + { + "function": "_request", + "samples": 3 + }, + { + "function": "_init", + "samples": 1 + }, + { + "function": "_worker", + "samples": 1 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1367476288958, + "endMicros": 1367476289760, + "retainedRootRows": 10, + "rssBefore": 378912768, + "rssAfter": 379125760, + "processLifetimeMaxRss": 486506496 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14734864, + "heapCapacity": 20824064, + "externalUsage": 80 + }, + "gcTimestamp": "1789456292920", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 18, + "bytesCurrent": 592 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40093, + "bytesCurrent": 3120656 + }, + { + "name": "_GrowableList", + "instancesCurrent": 924, + "bytesCurrent": 29568 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14736752, + "heapCapacity": 20824064, + "externalUsage": 80 + }, + "gcTimestamp": "1789456293069", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 28, + "bytesCurrent": 912 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40107, + "bytesCurrent": 3121520 + }, + { + "name": "_GrowableList", + "instancesCurrent": 938, + "bytesCurrent": 30016 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 371, + "counts": { + "_List": 188, + "_SelectionPlan": 3, + "_GrowableList": 120, + "_Map": 18, + "_Record": 42 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 123 + }, + { + "function": "quote", + "samples": 42 + }, + { + "function": "_write", + "samples": 30 + }, + { + "function": "", + "samples": 30 + }, + { + "function": "_addPart", + "samples": 24 + }, + { + "function": "writeSql", + "samples": 21 + }, + { + "function": "_List", + "samples": 15 + }, + { + "function": "_instanceOf", + "samples": 12 + }, + { + "function": "Map._fromLiteral", + "samples": 12 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_init", + "samples": 10 + }, + { + "function": "_sendInternal", + "samples": 9 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + } + ] + }, + { + "scenario": "postgres_loopback", + "environment": { + "backend": "postgres_loopback", + "databaseVersion": "18.4 (Homebrew)", + "pid": 5140 + }, + "oneWayDelayMs": 0, + "echoRoundTripMicros": { + "samplesMicros": [ + 175, + 164, + 193, + 159, + 165, + 161, + 393, + 169, + 185, + 175 + ], + "p50Micros": 169, + "p95Micros": 393, + "minMicros": 159, + "maxMicros": 393, + "wallMicros": 1939 + }, + "selectOneMicros": { + "samplesMicros": [ + 1188, + 816, + 716, + 712, + 723, + 674, + 746, + 735, + 888, + 840 + ], + "p50Micros": 735, + "p95Micros": 1188, + "minMicros": 674, + "maxMicros": 1188, + "wallMicros": 8038, + "operationsPerSecond": 1244.0905697934809 + }, + "cases": [ + { + "case": "full_rows", + "latency": { + "raw": { + "samplesMicros": [ + 1038, + 2245, + 1032, + 1238, + 1019, + 1079, + 1000, + 1021, + 1149, + 991, + 1035, + 1133, + 1114, + 947, + 812, + 868, + 904, + 928, + 923, + 952, + 970, + 1269, + 978, + 1015, + 1215, + 1001, + 937, + 1420, + 1234, + 949, + 928, + 986, + 1153, + 905, + 892, + 1220, + 1039, + 920, + 1001, + 1024, + 1114, + 1606, + 1049, + 949, + 1231, + 893, + 864, + 1203, + 992, + 856, + 890, + 933, + 1097, + 873, + 841, + 973, + 1225, + 888, + 832, + 1033, + 939, + 928, + 931, + 959, + 1105, + 935, + 874, + 901, + 954, + 960, + 937, + 868, + 861, + 975, + 961, + 884, + 844, + 1168, + 990, + 950, + 1007, + 1032, + 923, + 857, + 868, + 1099, + 1018, + 854, + 958, + 893, + 1212, + 888, + 882, + 912, + 939, + 1012, + 830, + 849, + 881, + 969, + 861, + 877, + 899, + 925, + 905, + 799, + 1021, + 1012, + 926, + 985, + 944, + 987, + 944, + 874, + 799, + 874, + 1011, + 877, + 818, + 854, + 952, + 885, + 869, + 807, + 902, + 899, + 832, + 843, + 1065, + 880, + 899, + 824, + 796, + 816, + 815, + 884, + 896, + 815, + 830, + 1030, + 902, + 822, + 872, + 1388, + 1066, + 962, + 956, + 914, + 861, + 804, + 833, + 881, + 917, + 871, + 798, + 885, + 866, + 878, + 838, + 800, + 950, + 958, + 857, + 838, + 805, + 894, + 1060, + 856, + 770, + 880, + 923, + 861, + 834, + 819, + 868, + 938, + 1110, + 813, + 903, + 1047, + 898, + 786, + 907, + 829, + 922, + 934, + 851, + 862, + 854, + 891, + 933, + 799, + 823, + 883, + 846, + 772, + 799, + 885, + 887, + 858 + ], + "p50Micros": 907, + "p95Micros": 1215, + "minMicros": 770, + "maxMicros": 2245, + "wallMicros": 189681, + "operationsPerSecond": 1054.401864182496 + }, + "orm": { + "samplesMicros": [ + 1168, + 1288, + 1092, + 1064, + 1202, + 1366, + 1064, + 1048, + 1151, + 1139, + 1092, + 1018, + 1098, + 955, + 871, + 856, + 1015, + 1128, + 985, + 965, + 1096, + 1055, + 1023, + 1032, + 1225, + 1432, + 1008, + 1228, + 1186, + 1072, + 1097, + 1215, + 1118, + 1014, + 1041, + 1072, + 1094, + 1085, + 973, + 983, + 1052, + 986, + 1390, + 1074, + 1116, + 1072, + 980, + 992, + 1083, + 944, + 940, + 945, + 1034, + 967, + 892, + 1026, + 1189, + 985, + 935, + 940, + 1138, + 974, + 971, + 1008, + 1043, + 1118, + 985, + 914, + 1079, + 1149, + 995, + 899, + 1008, + 1087, + 959, + 920, + 979, + 961, + 1077, + 991, + 1034, + 1136, + 997, + 964, + 978, + 1004, + 1082, + 951, + 1015, + 1021, + 1117, + 1009, + 929, + 920, + 1145, + 1103, + 873, + 888, + 1406, + 1183, + 900, + 882, + 1035, + 1039, + 948, + 875, + 967, + 956, + 1010, + 972, + 967, + 951, + 933, + 1013, + 898, + 943, + 1052, + 945, + 881, + 860, + 1010, + 941, + 898, + 911, + 891, + 981, + 857, + 852, + 938, + 851, + 893, + 926, + 852, + 815, + 1118, + 1050, + 885, + 843, + 988, + 987, + 1028, + 922, + 1023, + 961, + 1055, + 995, + 971, + 889, + 1071, + 1043, + 872, + 975, + 1051, + 1342, + 878, + 860, + 1163, + 983, + 842, + 857, + 942, + 966, + 937, + 873, + 913, + 968, + 948, + 891, + 836, + 854, + 1009, + 975, + 868, + 857, + 1003, + 942, + 936, + 896, + 979, + 938, + 966, + 924, + 869, + 936, + 1060, + 927, + 927, + 914, + 965, + 995, + 863, + 824, + 934, + 996, + 905, + 885, + 878, + 908, + 1032, + 965 + ], + "p50Micros": 979, + "p95Micros": 1189, + "minMicros": 815, + "maxMicros": 1432, + "wallMicros": 199665, + "operationsPerSecond": 1001.6778103323066 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 5307, + 6868, + 6951, + 7029, + 7121, + 8799, + 8859, + 8887, + 3657, + 3840, + 3824, + 3778, + 3714, + 4131, + 4160, + 4268, + 4365, + 4305, + 4319, + 4337, + 4345, + 4070, + 3967, + 3945, + 3928, + 3996, + 3995, + 3986, + 3987, + 4029, + 4077, + 4071, + 4183, + 4409, + 4470, + 4472, + 4489, + 4606, + 4635, + 4560, + 4689, + 4574, + 4534, + 4541, + 4534, + 4440, + 4375, + 4344, + 4327, + 4020, + 4171, + 4079, + 4063, + 4054, + 4053, + 4051, + 4050, + 3910, + 3910, + 3907, + 3916, + 4065, + 4075, + 4037, + 4128, + 4209, + 4214, + 4219, + 4228, + 4143, + 4088, + 4239, + 4182, + 4162, + 4153, + 4160, + 4155, + 4279, + 4144, + 4328, + 4273, + 4451, + 4456, + 4449, + 4446, + 4282, + 4293, + 4300, + 4313, + 4146, + 4134, + 4081, + 4174, + 4149, + 4157, + 4151, + 4138, + 4312, + 4327, + 4330, + 4325, + 4097, + 4070, + 4073, + 4075, + 4103, + 4109, + 4108, + 4115, + 4365, + 4382, + 4383, + 4413, + 4238, + 4228, + 4228, + 4220, + 4237, + 4226, + 4229, + 4197, + 4009, + 4013, + 4008, + 4014, + 4005, + 3961, + 3971, + 4118, + 4188, + 4172, + 4173, + 4174, + 3939, + 3923, + 3867, + 3962, + 3914, + 3923, + 3928, + 3929, + 4016, + 4040, + 4110, + 4121, + 4282, + 4292, + 4447, + 4335, + 4322, + 4305, + 4236, + 4224, + 4007, + 4015, + 4024, + 4031, + 4298, + 4320, + 4323, + 4531, + 4527, + 4678, + 4609, + 4604, + 4601, + 4619, + 4617, + 4615, + 4362, + 4356, + 4306, + 4401, + 4253, + 4188, + 4286, + 4233, + 4302, + 4289, + 4237, + 4334, + 4137, + 4135, + 4132, + 4136, + 4145, + 4147, + 4101, + 4197, + 4207, + 4128, + 4228, + 4330, + 4213, + 4322, + 4252, + 4241, + 3653, + 3596, + 3577 + ], + "p50Micros": 4197, + "p95Micros": 4635, + "minMicros": 3577, + "maxMicros": 8887, + "wallMicros": 109456, + "operationsPerSecond": 1827.2182429469376 + }, + "orm": { + "samplesMicros": [ + 2424, + 2526, + 2623, + 2719, + 4735, + 4900, + 5018, + 5114, + 4632, + 4614, + 4595, + 4575, + 4393, + 4345, + 4198, + 4365, + 4245, + 4290, + 4306, + 4314, + 4526, + 4398, + 4587, + 4760, + 4647, + 4694, + 4635, + 4887, + 5282, + 5281, + 5261, + 5296, + 6022, + 5696, + 6105, + 6156, + 5200, + 5672, + 5617, + 5599, + 4822, + 4768, + 4735, + 4626, + 4506, + 4565, + 4513, + 4683, + 4856, + 4710, + 4956, + 4967, + 4700, + 4976, + 4793, + 4788, + 4627, + 4706, + 5055, + 4860, + 4998, + 5248, + 5058, + 5232, + 4593, + 4614, + 4617, + 5133, + 4449, + 4737, + 4609, + 4608, + 4620, + 4379, + 4570, + 4761, + 4585, + 4875, + 4719, + 4720, + 4868, + 5133, + 4917, + 5076, + 4799, + 4816, + 4820, + 4810, + 4883, + 5244, + 5124, + 5145, + 4857, + 5130, + 4869, + 5022, + 4352, + 4690, + 4438, + 4446, + 4746, + 4632, + 4887, + 5067, + 4619, + 4827, + 4719, + 4710, + 4351, + 4315, + 4302, + 4177, + 4674, + 4734, + 4664, + 4923, + 4902, + 5200, + 5108, + 5060, + 4442, + 4442, + 4783, + 4430, + 4407, + 4345, + 4243, + 4465, + 4805, + 4742, + 4914, + 4846, + 6295, + 6907, + 6821, + 6820, + 6865, + 6782, + 6779, + 7049, + 5053, + 4672, + 4637, + 4585, + 4184, + 4157, + 4182, + 4173, + 4115, + 4134, + 4142, + 4142, + 4421, + 4507, + 4827, + 4640, + 4917, + 4942, + 5350, + 5223, + 4849, + 4751, + 4718, + 4689, + 4463, + 4447, + 4342, + 4238, + 4374, + 4444, + 4390, + 4566, + 4453, + 4441, + 4453, + 4453, + 4524, + 4459, + 4444, + 4455, + 4380, + 4442, + 4471, + 4569, + 4520, + 4740, + 4641, + 4615, + 4437, + 4376, + 4330, + 4227, + 3897, + 3949, + 3960, + 3962, + 4463, + 4418, + 4374, + 4341 + ], + "p50Micros": 4647, + "p95Micros": 6022, + "minMicros": 2424, + "maxMicros": 7049, + "wallMicros": 119678, + "operationsPerSecond": 1671.1509216397333 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 113, + 117, + 117, + 119, + 2059, + 2118, + 2175, + 2228, + 2302, + 2283, + 2265, + 2267, + 2039, + 2060, + 2070, + 2076, + 2721, + 2728, + 2723, + 2730, + 2036, + 2010, + 2011, + 2007, + 2778, + 2775, + 2768, + 2771, + 3294, + 3274, + 3276, + 3283, + 2114, + 2100, + 2095, + 2094, + 4477, + 4488, + 4540, + 4602, + 2748, + 2724, + 2723, + 2639, + 2241, + 2131, + 2150, + 2141, + 2125, + 2170, + 2182, + 2214, + 2438, + 2388, + 2351, + 2345, + 2388, + 2343, + 2327, + 2303, + 2014, + 2010, + 2011, + 2008 + ], + "p50Micros": 2241, + "p95Micros": 4477, + "minMicros": 113, + "maxMicros": 4602, + "wallMicros": 151217 + }, + "sqlMicros": [ + 1903, + 1991, + 2074, + 2099, + 2244, + 2242, + 2228, + 2222, + 1963, + 1959, + 2072, + 2031, + 2569, + 2615, + 2819, + 2683, + 1875, + 2048, + 1963, + 1956, + 2698, + 2718, + 2723, + 2724, + 3141, + 3308, + 3238, + 3237, + 1910, + 2118, + 2108, + 2046, + 4378, + 4444, + 4455, + 4534, + 2371, + 2666, + 2514, + 2898, + 2107, + 2020, + 2164, + 2096, + 2028, + 2080, + 2103, + 2138, + 2214, + 2236, + 2524, + 2289, + 2238, + 2383, + 2290, + 2255, + 1945, + 1970, + 1976, + 1970, + 2425, + 2404, + 2384, + 2378 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 106, + 573, + 599, + 615, + 3002, + 3138, + 3221, + 3350, + 2359, + 2293, + 2294, + 2244, + 2104, + 2110, + 2107, + 2091, + 2271, + 2271, + 2254, + 2253, + 2302, + 2261, + 2268, + 2281, + 1980, + 1970, + 1956, + 1935, + 2051, + 2050, + 2100, + 2138, + 2267, + 2299, + 2290, + 2268, + 2115, + 2067, + 2073, + 2063, + 1972, + 1967, + 1933, + 1920, + 2088, + 2091, + 2078, + 2092, + 2062, + 2333, + 2364, + 2443, + 2677, + 2351, + 2319, + 2240, + 2011, + 2003, + 2003, + 2049, + 1989, + 1984, + 1981, + 1940 + ], + "p50Micros": 2100, + "p95Micros": 3002, + "minMicros": 106, + "maxMicros": 3350, + "wallMicros": 134879 + }, + "sqlMicros": [ + 2313, + 2513, + 2665, + 2659, + 2110, + 2329, + 2217, + 2174, + 2003, + 2034, + 2033, + 2028, + 2157, + 2193, + 2192, + 2189, + 2106, + 2286, + 2207, + 2215, + 1896, + 1902, + 1889, + 1873, + 1966, + 1991, + 2000, + 2066, + 2158, + 2223, + 2202, + 2191, + 2004, + 1997, + 2009, + 2000, + 1892, + 1897, + 1875, + 1860, + 2009, + 2022, + 2021, + 2035, + 1880, + 2256, + 2306, + 2378, + 2190, + 2401, + 2257, + 2173, + 1928, + 1941, + 1942, + 1956, + 1913, + 1930, + 1924, + 1877, + 2127, + 2105, + 2079, + 2076 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 38 + ], + "sqlMicros": [ + 898 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 237, + "received": 27784 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 32 + ], + "sqlMicros": [ + 896 + ], + "ormDecodeMicros": [ + 12 + ], + "postgresProtocolBytes": { + "sent": 237, + "received": 27784 + } + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1367478259746, + "endMicros": 1367478264702, + "retainedRootRows": 100, + "rssBefore": 304562176, + "rssAfter": 305807360, + "processLifetimeMaxRss": 484982784 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16899520, + "heapCapacity": 36765696, + "externalUsage": 272 + }, + "gcTimestamp": "1789456294883", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 106, + "bytesCurrent": 4992 + }, + { + "name": "_Map", + "instancesCurrent": 25, + "bytesCurrent": 1600 + }, + { + "name": "_List", + "instancesCurrent": 46791, + "bytesCurrent": 3592272 + }, + { + "name": "_GrowableList", + "instancesCurrent": 922, + "bytesCurrent": 29504 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16956176, + "heapCapacity": 22921216, + "externalUsage": 272 + }, + "gcTimestamp": "1789456295099", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 206, + "bytesCurrent": 9792 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46815, + "bytesCurrent": 3595696 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 3694, + "counts": { + "_List": 2389, + "_GrowableList": 993, + "_Record": 312 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 1983 + }, + { + "function": "", + "samples": 603 + }, + { + "function": "wait", + "samples": 303 + }, + { + "function": "DataRowMessage.parse", + "samples": 300 + }, + { + "function": "handleMessage", + "samples": 300 + }, + { + "function": "_List", + "samples": 114 + }, + { + "function": "current", + "samples": 48 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 19 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_worker", + "samples": 3 + }, + { + "function": "_prepare", + "samples": 3 + }, + { + "function": "bindParameters", + "samples": 3 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1367478508292, + "endMicros": 1367478512415, + "retainedRootRows": 100, + "rssBefore": 329531392, + "rssAfter": 330874880, + "processLifetimeMaxRss": 484982784 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16910128, + "heapCapacity": 22921216, + "externalUsage": 272 + }, + "gcTimestamp": "1789456295134", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 106, + "bytesCurrent": 4992 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46814, + "bytesCurrent": 3594656 + }, + { + "name": "_GrowableList", + "instancesCurrent": 920, + "bytesCurrent": 29440 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16962624, + "heapCapacity": 26591232, + "externalUsage": 272 + }, + "gcTimestamp": "1789456295355", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 206, + "bytesCurrent": 9792 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46821, + "bytesCurrent": 3596672 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 4034, + "counts": { + "_List": 2561, + "_GrowableList": 1134, + "_SelectionPlan": 3, + "_Map": 18, + "_Record": 318 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 2139 + }, + { + "function": "", + "samples": 603 + }, + { + "function": "wait", + "samples": 303 + }, + { + "function": "DataRowMessage.parse", + "samples": 300 + }, + { + "function": "handleMessage", + "samples": 300 + }, + { + "function": "_List", + "samples": 139 + }, + { + "function": "current", + "samples": 48 + }, + { + "function": "quote", + "samples": 48 + }, + { + "function": "_GrowableList._withData", + "samples": 33 + }, + { + "function": "_write", + "samples": 33 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 24 + }, + { + "function": "writeSql", + "samples": 21 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "two_columns", + "latency": { + "raw": { + "samplesMicros": [ + 861, + 799, + 801, + 688, + 690, + 836, + 832, + 814, + 680, + 728, + 775, + 788, + 719, + 669, + 668, + 751, + 723, + 858, + 741, + 678, + 573, + 567, + 577, + 723, + 680, + 578, + 574, + 572, + 625, + 811, + 700, + 554, + 534, + 668, + 569, + 668, + 687, + 594, + 582, + 542, + 542, + 547, + 618, + 614, + 547, + 547, + 547, + 580, + 586, + 644, + 618, + 558, + 527, + 522, + 600, + 597, + 749, + 564, + 577, + 523, + 513, + 603, + 556, + 726, + 596, + 537, + 537, + 533, + 577, + 709, + 666, + 519, + 549, + 532, + 519, + 583, + 532, + 558, + 519, + 817, + 564, + 539, + 536, + 596, + 602, + 543, + 529, + 570, + 536, + 506, + 629, + 558, + 530, + 513, + 538, + 538, + 542, + 755, + 701, + 552, + 503, + 497, + 515, + 537, + 573, + 671, + 550, + 526, + 501, + 525, + 569, + 763, + 661, + 497, + 541, + 607, + 533, + 497, + 577, + 554, + 597, + 516, + 539, + 540, + 527, + 602, + 757, + 532, + 514, + 506, + 514, + 553, + 522, + 616, + 595, + 541, + 531, + 523, + 553, + 523, + 648, + 665, + 583, + 523, + 602, + 531, + 556, + 590, + 669, + 499, + 515, + 498, + 568, + 543, + 544, + 644, + 586, + 537, + 500, + 495, + 504, + 522, + 569, + 517, + 572, + 493, + 508, + 551, + 522, + 641, + 600, + 549, + 541, + 484, + 503, + 512, + 524, + 806, + 615, + 502, + 521, + 488, + 507, + 517, + 552, + 531, + 529, + 521, + 583, + 485, + 490, + 509, + 708, + 534, + 507, + 538, + 516, + 517, + 532, + 676 + ], + "p50Micros": 553, + "p95Micros": 788, + "minMicros": 484, + "maxMicros": 861, + "wallMicros": 117532, + "operationsPerSecond": 1701.6642276146072 + }, + "orm": { + "samplesMicros": [ + 974, + 910, + 742, + 739, + 838, + 861, + 827, + 779, + 797, + 699, + 799, + 848, + 819, + 728, + 699, + 771, + 824, + 884, + 764, + 725, + 565, + 565, + 677, + 926, + 656, + 593, + 572, + 572, + 618, + 703, + 643, + 584, + 587, + 615, + 569, + 585, + 712, + 725, + 642, + 613, + 618, + 639, + 809, + 623, + 590, + 592, + 607, + 633, + 704, + 833, + 691, + 581, + 562, + 554, + 604, + 595, + 797, + 638, + 546, + 621, + 549, + 592, + 668, + 718, + 645, + 580, + 580, + 568, + 591, + 572, + 702, + 599, + 556, + 529, + 616, + 598, + 639, + 740, + 574, + 658, + 963, + 616, + 595, + 735, + 662, + 657, + 543, + 554, + 577, + 643, + 869, + 776, + 645, + 588, + 566, + 658, + 563, + 628, + 591, + 550, + 620, + 544, + 582, + 581, + 696, + 701, + 582, + 590, + 527, + 595, + 613, + 615, + 603, + 554, + 563, + 550, + 625, + 553, + 661, + 612, + 564, + 618, + 556, + 608, + 546, + 559, + 674, + 580, + 545, + 525, + 546, + 623, + 566, + 614, + 580, + 565, + 600, + 574, + 575, + 565, + 645, + 632, + 588, + 559, + 549, + 539, + 599, + 560, + 591, + 534, + 564, + 544, + 540, + 548, + 588, + 689, + 591, + 615, + 540, + 515, + 537, + 562, + 760, + 603, + 588, + 545, + 544, + 544, + 571, + 561, + 675, + 597, + 624, + 551, + 527, + 542, + 564, + 615, + 577, + 521, + 512, + 524, + 544, + 633, + 634, + 608, + 531, + 548, + 526, + 531, + 553, + 557, + 771, + 776, + 579, + 570, + 530, + 537, + 580, + 729 + ], + "p50Micros": 593, + "p95Micros": 827, + "minMicros": 512, + "maxMicros": 974, + "wallMicros": 125300, + "operationsPerSecond": 1596.1691939345571 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 1472, + 1523, + 1606, + 1669, + 2883, + 2940, + 2972, + 3017, + 2640, + 2651, + 2670, + 2645, + 2534, + 2682, + 2614, + 2603, + 2978, + 2952, + 2897, + 2892, + 3102, + 3099, + 3218, + 3216, + 3238, + 3362, + 3300, + 3309, + 3212, + 3226, + 3228, + 3215, + 3144, + 3149, + 3149, + 3195, + 3659, + 3853, + 3688, + 3653, + 3602, + 3731, + 3586, + 3735, + 3641, + 3642, + 3658, + 3661, + 3524, + 3449, + 3553, + 3473, + 3452, + 3569, + 3497, + 3507, + 3676, + 3713, + 3676, + 3775, + 2983, + 2984, + 3028, + 3040, + 3633, + 3615, + 3562, + 3668, + 3745, + 3763, + 3731, + 3723, + 3746, + 3924, + 3925, + 3899, + 3950, + 3885, + 3985, + 4000, + 3202, + 3139, + 3117, + 3311, + 4424, + 4429, + 4440, + 4359, + 4382, + 4379, + 4378, + 4368, + 4263, + 4203, + 4297, + 4234, + 4129, + 4136, + 4143, + 4971, + 5902, + 5989, + 6044, + 6155, + 6820, + 6840, + 5959, + 6882, + 3837, + 3749, + 3661, + 3629, + 3012, + 3155, + 3059, + 3057, + 2991, + 3000, + 3005, + 3008, + 3677, + 3702, + 3752, + 3767, + 4015, + 3979, + 4074, + 4028, + 3111, + 3092, + 2981, + 3079, + 2742, + 2720, + 2722, + 2713, + 2538, + 2538, + 2496, + 2571, + 2489, + 2486, + 2620, + 2520, + 2773, + 2805, + 2850, + 3037, + 2865, + 2886, + 2896, + 2899, + 2687, + 2805, + 2691, + 2678, + 2589, + 2709, + 2635, + 2632, + 2484, + 2494, + 2504, + 2507, + 2673, + 2668, + 2639, + 2715, + 2849, + 2860, + 2810, + 2893, + 3069, + 3084, + 3080, + 3078, + 3070, + 3194, + 3125, + 3121, + 2893, + 2901, + 2867, + 2945, + 2837, + 2983, + 2901, + 2983, + 2775, + 2908, + 2838, + 2830, + 3201, + 3205, + 3153, + 3237, + 3295, + 3383, + 3301, + 3298 + ], + "p50Micros": 3149, + "p95Micros": 4429, + "minMicros": 1472, + "maxMicros": 6882, + "wallMicros": 84664, + "operationsPerSecond": 2362.2791269016348 + }, + "orm": { + "samplesMicros": [ + 1375, + 1459, + 1521, + 1573, + 2824, + 2898, + 2947, + 2993, + 2932, + 2825, + 2944, + 2875, + 3112, + 3093, + 3122, + 3315, + 3278, + 3500, + 3499, + 3443, + 3210, + 3181, + 3155, + 3116, + 2928, + 2905, + 2893, + 2894, + 2836, + 2841, + 2861, + 2884, + 2955, + 2905, + 2947, + 3195, + 3241, + 3261, + 3410, + 3338, + 3340, + 3247, + 3420, + 3240, + 3078, + 3043, + 3035, + 3024, + 2861, + 2807, + 2939, + 2867, + 2970, + 2911, + 2837, + 2945, + 2958, + 2963, + 2978, + 2997, + 2863, + 2854, + 2863, + 2869, + 3005, + 3016, + 3009, + 2987, + 2988, + 3013, + 3013, + 3013, + 3039, + 2925, + 3042, + 2975, + 2959, + 2889, + 2896, + 3073, + 2853, + 2882, + 2891, + 2895, + 3351, + 3423, + 3441, + 3452, + 3331, + 3316, + 3321, + 3319, + 3030, + 2969, + 2953, + 2955, + 2979, + 2964, + 2963, + 2964, + 2908, + 3126, + 2968, + 3101, + 3206, + 3353, + 3268, + 3263, + 3175, + 3118, + 3039, + 3158, + 2850, + 2846, + 2806, + 2903, + 2666, + 2684, + 2685, + 2686, + 2668, + 2631, + 2774, + 2745, + 3179, + 3201, + 3195, + 3233, + 3252, + 3274, + 3262, + 3231, + 2971, + 3172, + 3072, + 3062, + 3064, + 3024, + 3027, + 3034, + 2884, + 2860, + 2779, + 2917, + 3065, + 3127, + 3071, + 3193, + 3165, + 3172, + 3359, + 3214, + 3059, + 2999, + 2986, + 2974, + 2935, + 2980, + 2987, + 2995, + 3290, + 3346, + 3367, + 3383, + 3940, + 3890, + 4021, + 3980, + 3657, + 3875, + 3660, + 3797, + 3086, + 3285, + 3079, + 3203, + 2940, + 2939, + 2929, + 2910, + 2726, + 2721, + 2718, + 2718, + 2772, + 2794, + 2803, + 2813, + 3060, + 3128, + 3151, + 3172, + 3343, + 3322, + 3259, + 3376, + 3164, + 3048, + 2913, + 3006 + ], + "p50Micros": 3013, + "p95Micros": 3452, + "minMicros": 1375, + "maxMicros": 4021, + "wallMicros": 76859, + "operationsPerSecond": 2602.167605615478 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 53, + 53, + 54, + 55, + 1188, + 1229, + 1274, + 1313, + 1428, + 1419, + 1408, + 1409, + 1401, + 1389, + 1387, + 1377, + 1591, + 1585, + 1586, + 1595, + 1307, + 1287, + 1293, + 1307, + 1339, + 1333, + 1341, + 1357, + 1461, + 1446, + 1432, + 1417, + 1405, + 1389, + 1381, + 1395, + 1683, + 1684, + 1687, + 1700, + 1518, + 1497, + 1494, + 1483, + 1299, + 1287, + 1282, + 1281, + 1371, + 1368, + 1363, + 1365, + 1328, + 1335, + 1343, + 1356, + 1323, + 1288, + 1270, + 1337, + 1372, + 1412, + 1441, + 1389 + ], + "p50Micros": 1371, + "p95Micros": 1683, + "minMicros": 53, + "maxMicros": 1700, + "wallMicros": 84240 + }, + "sqlMicros": [ + 1125, + 1169, + 1209, + 1248, + 1370, + 1390, + 1376, + 1376, + 1337, + 1365, + 1320, + 1393, + 1520, + 1528, + 1532, + 1627, + 1237, + 1257, + 1231, + 1309, + 1265, + 1284, + 1305, + 1306, + 1412, + 1413, + 1410, + 1384, + 1351, + 1356, + 1356, + 1352, + 1575, + 1704, + 1666, + 1662, + 1455, + 1462, + 1466, + 1453, + 1250, + 1247, + 1254, + 1253, + 1326, + 1335, + 1329, + 1329, + 1258, + 1261, + 1356, + 1321, + 1264, + 1205, + 1291, + 1223, + 1275, + 1326, + 1472, + 1339, + 1291, + 1250, + 1418, + 1247 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 68, + 108, + 120, + 128, + 1428, + 1484, + 1532, + 1578, + 1334, + 1335, + 1345, + 1354, + 1439, + 1440, + 1529, + 1564, + 1659, + 1668, + 1576, + 1549, + 1563, + 1548, + 1736, + 1771, + 1931, + 1900, + 1737, + 1670, + 1324, + 1320, + 1324, + 1370, + 1730, + 1736, + 1730, + 1736, + 1430, + 1394, + 1382, + 1377, + 1534, + 1604, + 1628, + 1715, + 1903, + 1803, + 1779, + 1713, + 1471, + 1463, + 1448, + 1452, + 1414, + 1398, + 1398, + 1459, + 1479, + 1466, + 1463, + 1412, + 1501, + 1502, + 1501, + 1499 + ], + "p50Micros": 1484, + "p95Micros": 1803, + "minMicros": 68, + "maxMicros": 1931, + "wallMicros": 92952 + }, + "sqlMicros": [ + 1286, + 1378, + 1401, + 1437, + 1258, + 1284, + 1301, + 1307, + 1364, + 1377, + 1389, + 1504, + 1560, + 1363, + 1507, + 1729, + 1454, + 1499, + 1634, + 1718, + 1834, + 1770, + 1642, + 1613, + 1177, + 1216, + 1389, + 1276, + 1649, + 1687, + 1693, + 1612, + 1331, + 1340, + 1345, + 1329, + 1357, + 1589, + 1533, + 1695, + 1656, + 1853, + 1599, + 1746, + 1387, + 1410, + 1404, + 1383, + 1269, + 1412, + 1300, + 1460, + 1342, + 1475, + 1417, + 1356, + 1441, + 1402, + 1514, + 1457, + 1557, + 1664, + 1571, + 1510 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 34 + ], + "sqlMicros": [ + 588 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 212, + "received": 4693 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 32 + ], + "sqlMicros": [ + 561 + ], + "ormDecodeMicros": [ + 7 + ], + "postgresProtocolBytes": { + "sent": 212, + "received": 4693 + } + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1367478798643, + "endMicros": 1367478801421, + "retainedRootRows": 100, + "rssBefore": 344834048, + "rssAfter": 345718784, + "processLifetimeMaxRss": 484982784 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16914944, + "heapCapacity": 29212672, + "externalUsage": 272 + }, + "gcTimestamp": "1789456295424", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 106, + "bytesCurrent": 3392 + }, + { + "name": "_Map", + "instancesCurrent": 25, + "bytesCurrent": 1600 + }, + { + "name": "_List", + "instancesCurrent": 46869, + "bytesCurrent": 3601888 + }, + { + "name": "_GrowableList", + "instancesCurrent": 922, + "bytesCurrent": 29504 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16922704, + "heapCapacity": 23445504, + "externalUsage": 272 + }, + "gcTimestamp": "1789456295637", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 206, + "bytesCurrent": 6592 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46864, + "bytesCurrent": 3602320 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 2665, + "counts": { + "_List": 1372, + "_GrowableList": 987, + "_Record": 306 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 1062 + }, + { + "function": "", + "samples": 603 + }, + { + "function": "wait", + "samples": 303 + }, + { + "function": "DataRowMessage.parse", + "samples": 300 + }, + { + "function": "handleMessage", + "samples": 300 + }, + { + "function": "current", + "samples": 42 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 18 + }, + { + "function": "_List", + "samples": 12 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_List.filled", + "samples": 3 + }, + { + "function": "_prepare", + "samples": 3 + }, + { + "function": "bindParameters", + "samples": 3 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1367479042645, + "endMicros": 1367479045697, + "retainedRootRows": 100, + "rssBefore": 357728256, + "rssAfter": 358711296, + "processLifetimeMaxRss": 484982784 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16913632, + "heapCapacity": 23445504, + "externalUsage": 272 + }, + "gcTimestamp": "1789456295670", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 106, + "bytesCurrent": 3392 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46863, + "bytesCurrent": 3601280 + }, + { + "name": "_GrowableList", + "instancesCurrent": 920, + "bytesCurrent": 29440 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16922704, + "heapCapacity": 25542656, + "externalUsage": 272 + }, + "gcTimestamp": "1789456295883", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 206, + "bytesCurrent": 6592 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46864, + "bytesCurrent": 3602320 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 2944, + "counts": { + "_List": 1507, + "_GrowableList": 1104, + "_SelectionPlan": 3, + "_Map": 18, + "_Record": 312 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 1185 + }, + { + "function": "", + "samples": 603 + }, + { + "function": "wait", + "samples": 303 + }, + { + "function": "DataRowMessage.parse", + "samples": 300 + }, + { + "function": "handleMessage", + "samples": 300 + }, + { + "function": "current", + "samples": 42 + }, + { + "function": "_List", + "samples": 37 + }, + { + "function": "quote", + "samples": 36 + }, + { + "function": "_GrowableList._withData", + "samples": 33 + }, + { + "function": "_write", + "samples": 27 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 18 + }, + { + "function": "writeSql", + "samples": 15 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "three_posts", + "latency": { + "raw": { + "samplesMicros": [ + 2580, + 2216, + 2080, + 1982, + 2082, + 2048, + 2081, + 2100, + 1842, + 1830, + 1815, + 1866, + 1869, + 1909, + 2080, + 1826, + 1857, + 1916, + 1980, + 1991, + 1873, + 1874, + 2073, + 1856, + 1829, + 1696, + 1834, + 1788, + 1898, + 1727, + 1876, + 1724, + 1918, + 1763, + 1892, + 1773, + 1723, + 1825, + 1747, + 1781, + 1727, + 1888, + 1736, + 1814, + 1804, + 1801, + 1867, + 1753, + 1803, + 1733, + 1652, + 2066, + 1775, + 2064, + 1801, + 1965, + 1705, + 2064, + 1759, + 2096, + 1782, + 2116, + 1806, + 2397, + 1857, + 2083, + 1860, + 2193, + 1753, + 2061, + 1779, + 2232, + 1848, + 2346, + 1864, + 2164, + 1963, + 2078, + 2012, + 2009, + 1873, + 1957, + 2103, + 2010, + 2061, + 2382, + 2078, + 2177, + 1933, + 2284, + 1871, + 2026, + 2009, + 2119, + 1999, + 1943, + 2132, + 1954, + 2068, + 2007, + 2279, + 2037, + 1985, + 2158, + 2072, + 1840, + 2043, + 2071, + 2326, + 2059, + 2498, + 1824, + 2199, + 1898, + 2351, + 1852, + 2237, + 1859, + 2229, + 2003, + 2237, + 1948, + 2101, + 1882, + 2142, + 1886, + 2209, + 1942, + 2162, + 1806, + 2029, + 1852, + 2022, + 1782, + 1968, + 1773, + 1792, + 1781, + 1811, + 1705, + 1831, + 1823, + 1905, + 1712, + 1837, + 1859, + 2050, + 1789, + 1941, + 1803, + 1811, + 1793, + 1884, + 1850, + 1739, + 1823, + 1818, + 1797, + 2081, + 1820, + 1839, + 2015, + 2030, + 1864, + 1760, + 1964, + 1782, + 1962, + 1811, + 1841, + 1685, + 1890, + 1855, + 2021, + 1735, + 2102, + 1814, + 2139, + 1706, + 2252, + 1805, + 2340, + 1832, + 2243, + 1979, + 2341, + 1928, + 1842, + 2243, + 1981, + 2117, + 1927, + 2093, + 1725, + 2056, + 1863, + 1966, + 1918, + 2001, + 2023 + ], + "p50Micros": 1909, + "p95Micros": 2279, + "minMicros": 1652, + "maxMicros": 2580, + "wallMicros": 390388, + "operationsPerSecond": 512.3108292263082 + }, + "orm": { + "samplesMicros": [ + 2988, + 3055, + 2393, + 2114, + 2566, + 2044, + 5168, + 4399, + 2263, + 2344, + 2234, + 2236, + 2146, + 2388, + 2332, + 2512, + 2282, + 2347, + 2440, + 2437, + 2480, + 2275, + 2498, + 2226, + 2478, + 2189, + 2262, + 2759, + 2453, + 2103, + 2340, + 2155, + 2271, + 2409, + 2296, + 2126, + 2439, + 2231, + 2287, + 2203, + 2298, + 2289, + 2305, + 2591, + 2233, + 2457, + 2120, + 2179, + 2000, + 2215, + 2052, + 2263, + 2114, + 2366, + 2211, + 2462, + 2036, + 2351, + 1951, + 2261, + 2002, + 2364, + 1917, + 2218, + 2067, + 2306, + 1990, + 2327, + 1999, + 2084, + 1945, + 2046, + 1972, + 2007, + 1960, + 2020, + 1944, + 2009, + 1977, + 2031, + 1798, + 1979, + 2031, + 1931, + 2391, + 2317, + 2268, + 2247, + 2048, + 2067, + 1873, + 1956, + 2111, + 2382, + 1879, + 2017, + 1910, + 1962, + 2009, + 2023, + 2046, + 1877, + 2194, + 1943, + 2052, + 1784, + 2256, + 1887, + 2054, + 2267, + 2090, + 1895, + 2088, + 1915, + 1997, + 1890, + 2194, + 1856, + 2162, + 2151, + 2300, + 1849, + 2299, + 1905, + 2228, + 1879, + 2224, + 2168, + 1974, + 1857, + 2325, + 1940, + 2220, + 1862, + 2507, + 1914, + 2497, + 2048, + 2345, + 2016, + 2309, + 2442, + 2274, + 2169, + 2119, + 2408, + 2343, + 2233, + 2455, + 2154, + 2345, + 2145, + 2372, + 2084, + 2086, + 2377, + 2050, + 2330, + 2180, + 2204, + 2037, + 2449, + 2102, + 2300, + 2100, + 2379, + 2255, + 2439, + 2161, + 2127, + 2056, + 2400, + 1921, + 2305, + 1881, + 2275, + 1905, + 2207, + 1922, + 1954, + 1960, + 1840, + 1917, + 1884, + 2078, + 1936, + 1863, + 1905, + 1945, + 1967, + 1923, + 1925, + 1825, + 1845, + 2259, + 1861, + 2418, + 1952, + 2505, + 1905 + ], + "p50Micros": 2154, + "p95Micros": 2498, + "minMicros": 1784, + "maxMicros": 5168, + "wallMicros": 437228, + "operationsPerSecond": 457.42724619649243 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 5170, + 5260, + 5342, + 5410, + 10566, + 10698, + 10778, + 10841, + 10505, + 10687, + 10636, + 10963, + 10854, + 10690, + 10924, + 10912, + 10598, + 10538, + 10715, + 10691, + 12190, + 12409, + 12209, + 12387, + 16372, + 16457, + 16400, + 16665, + 14040, + 14104, + 14360, + 14176, + 11502, + 11380, + 11177, + 11388, + 11452, + 11430, + 11470, + 11755, + 10522, + 10757, + 10565, + 10742, + 10510, + 10399, + 10702, + 10618, + 10468, + 10413, + 10379, + 10353, + 10460, + 10979, + 10532, + 10948, + 10450, + 10717, + 10732, + 10819, + 11184, + 11213, + 11247, + 11293, + 11333, + 10946, + 11318, + 11249, + 10927, + 11227, + 11020, + 10966, + 10812, + 10847, + 10866, + 10881, + 10773, + 10983, + 11222, + 11023, + 11126, + 11104, + 11371, + 11337, + 10916, + 11124, + 10879, + 11051, + 10969, + 10967, + 10943, + 10947, + 10820, + 10997, + 10904, + 10896, + 10593, + 10920, + 10677, + 10602, + 10409, + 10996, + 10908, + 10906, + 10776, + 11134, + 11116, + 11181, + 10923, + 10564, + 10534, + 10699, + 11220, + 11117, + 10845, + 11092, + 10800, + 11131, + 10975, + 11246, + 11098, + 11078, + 11081, + 11069, + 10897, + 11204, + 10741, + 11004, + 10549, + 10602, + 10639, + 10674, + 10463, + 10716, + 10966, + 10793, + 10966, + 10896, + 10847, + 10794, + 10647, + 10495, + 10486, + 10849, + 10947, + 11222, + 11154, + 11173, + 11163, + 11216, + 11613, + 11408, + 10469, + 10778, + 10617, + 10640, + 11011, + 11334, + 11598, + 11887, + 11743, + 11753, + 11744, + 11733, + 11057, + 11393, + 11227, + 11207, + 10567, + 10559, + 10919, + 10681, + 10999, + 10946, + 10903, + 10863, + 10920, + 10985, + 11412, + 11237, + 10819, + 10858, + 11143, + 10967, + 11185, + 11545, + 11130, + 11371, + 11116, + 11085, + 10984, + 11151, + 10495, + 10826, + 10552, + 10771, + 10368, + 10512, + 10409, + 10385 + ], + "p50Micros": 10943, + "p95Micros": 12209, + "minMicros": 5170, + "maxMicros": 16665, + "wallMicros": 278425, + "operationsPerSecond": 718.3262997216485 + }, + "orm": { + "samplesMicros": [ + 5783, + 6133, + 6334, + 6556, + 11591, + 11776, + 11916, + 12320, + 11385, + 11332, + 11999, + 11347, + 11827, + 11873, + 11463, + 11979, + 11547, + 11881, + 11704, + 11686, + 11028, + 11414, + 11164, + 11168, + 11185, + 11196, + 11189, + 11192, + 10836, + 11116, + 11399, + 11111, + 11313, + 11655, + 11492, + 11488, + 11722, + 11834, + 11874, + 11982, + 15511, + 15935, + 15788, + 15817, + 15822, + 16246, + 15936, + 15838, + 11579, + 11990, + 11720, + 11703, + 11201, + 11059, + 11315, + 11165, + 11595, + 11624, + 11622, + 11633, + 11599, + 12108, + 12113, + 12027, + 11558, + 11850, + 11736, + 12288, + 12126, + 11943, + 12229, + 12071, + 11763, + 12230, + 12183, + 12054, + 11373, + 11332, + 11335, + 11324, + 11338, + 11336, + 11338, + 11324, + 11585, + 11665, + 11709, + 11749, + 11546, + 11531, + 11530, + 11531, + 11751, + 11655, + 11584, + 11545, + 11399, + 11684, + 11415, + 11652, + 11070, + 11400, + 11286, + 11300, + 10968, + 11237, + 11089, + 11079, + 10854, + 10865, + 10899, + 10921, + 10819, + 11102, + 10991, + 10985, + 11298, + 11631, + 11418, + 12065, + 11791, + 11803, + 11792, + 11790, + 11187, + 11526, + 11392, + 11402, + 10693, + 10985, + 11001, + 11231, + 11138, + 11151, + 11143, + 11126, + 11196, + 11516, + 11284, + 11314, + 11301, + 11787, + 11465, + 11815, + 11313, + 11939, + 11470, + 11437, + 11180, + 11497, + 11814, + 11604, + 11360, + 11742, + 11573, + 11579, + 11503, + 11821, + 12138, + 11734, + 11620, + 12001, + 11782, + 11766, + 11275, + 11584, + 11414, + 11395, + 10901, + 11232, + 11038, + 11033, + 11101, + 11196, + 11263, + 11307, + 11180, + 11488, + 11364, + 11379, + 11467, + 11363, + 11289, + 11251, + 11197, + 11553, + 11265, + 11506, + 11350, + 11364, + 11394, + 11397, + 11480, + 11526, + 11432, + 11712, + 11578, + 11509, + 11356, + 11573 + ], + "p50Micros": 11470, + "p95Micros": 12230, + "minMicros": 5783, + "maxMicros": 16246, + "wallMicros": 291966, + "operationsPerSecond": 685.0112684353658 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 66, + 64, + 64, + 65, + 5150, + 5371, + 5534, + 5692, + 5994, + 5841, + 5741, + 5669, + 5040, + 5077, + 5140, + 5213, + 5990, + 5926, + 5857, + 5816, + 6108, + 6186, + 6300, + 6375, + 5693, + 5555, + 5449, + 5364, + 5025, + 5065, + 5079, + 5092, + 5203, + 5124, + 5119, + 5099, + 5178, + 5143, + 5111, + 5101, + 4902, + 4884, + 4891, + 4899, + 5301, + 5260, + 5249, + 5233, + 5107, + 5203, + 5274, + 5358, + 5604, + 5489, + 5431, + 5381, + 5469, + 5495, + 5506, + 5518, + 5048, + 5406, + 5384, + 5351 + ], + "p50Micros": 5274, + "p95Micros": 6108, + "minMicros": 64, + "maxMicros": 6375, + "wallMicros": 324322 + }, + "sqlMicros": [ + 1226, + 1296, + 1335, + 1361, + 3616, + 3810, + 3974, + 4188, + 1923, + 1752, + 1618, + 1488, + 3870, + 4028, + 4051, + 4096, + 1426, + 1574, + 1340, + 1467, + 3311, + 3457, + 3580, + 3679, + 1634, + 1835, + 1579, + 1450, + 4060, + 4148, + 4200, + 4254, + 1753, + 1716, + 1674, + 1602, + 4069, + 4349, + 4475, + 4609, + 1837, + 1733, + 1587, + 1454, + 3620, + 3785, + 3767, + 3813, + 1503, + 1449, + 1377, + 1314, + 3328, + 3554, + 3602, + 3689, + 1538, + 1705, + 1508, + 1430, + 3360, + 3480, + 3504, + 3616, + 1548, + 1472, + 1404, + 1353, + 3520, + 3606, + 3648, + 3693, + 1354, + 1309, + 1476, + 1287, + 3347, + 3429, + 3490, + 3542, + 1530, + 1657, + 1526, + 1571, + 3569, + 3675, + 3581, + 3608, + 1422, + 1418, + 1401, + 1383, + 3476, + 3603, + 3799, + 3842, + 1764, + 1643, + 1535, + 1423, + 3685, + 3765, + 3892, + 3883, + 1600, + 1573, + 1762, + 1570, + 3595, + 3700, + 3888, + 3849, + 1590, + 1506, + 1347, + 1475, + 3236, + 3477, + 3866, + 3949, + 2137, + 1690, + 1646, + 1615, + 3733, + 3896, + 3939, + 4035 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 85, + 127, + 134, + 140, + 5330, + 5477, + 5655, + 5766, + 5599, + 5636, + 5573, + 5584, + 5739, + 5700, + 5719, + 5727, + 5805, + 5733, + 5700, + 5677, + 5483, + 5559, + 5621, + 5684, + 6001, + 5981, + 5959, + 5948, + 5707, + 5602, + 5569, + 5533, + 5950, + 5934, + 5907, + 5920, + 5223, + 5227, + 5257, + 5270, + 5890, + 5843, + 5831, + 5832, + 5849, + 5850, + 5846, + 5833, + 5544, + 5529, + 5519, + 5542, + 5288, + 5271, + 5269, + 5251, + 5642, + 5627, + 5649, + 5687, + 6056, + 6144, + 6230, + 6229 + ], + "p50Micros": 5655, + "p95Micros": 6056, + "minMicros": 85, + "maxMicros": 6230, + "wallMicros": 341492 + }, + "sqlMicros": [ + 1317, + 1452, + 1582, + 1709, + 3504, + 3601, + 3973, + 3839, + 1785, + 1567, + 1808, + 1569, + 3471, + 3773, + 3796, + 3862, + 1620, + 1893, + 1563, + 1753, + 3610, + 3765, + 3860, + 3944, + 1385, + 1941, + 1850, + 1799, + 3706, + 3705, + 3908, + 3856, + 1797, + 1784, + 1736, + 1691, + 3396, + 3561, + 3682, + 3800, + 1694, + 1978, + 1659, + 1549, + 3835, + 4000, + 4116, + 4210, + 1529, + 1993, + 1862, + 1615, + 3457, + 3712, + 3695, + 3754, + 1556, + 1786, + 1592, + 1541, + 3957, + 4067, + 4312, + 4218, + 1585, + 1552, + 1506, + 1436, + 3265, + 3559, + 3645, + 3670, + 1589, + 1412, + 1618, + 1972, + 3863, + 4084, + 4064, + 4097, + 1951, + 1968, + 1937, + 1874, + 3522, + 3806, + 3759, + 3788, + 1445, + 1661, + 1478, + 1425, + 3730, + 3850, + 3901, + 3952, + 1353, + 1667, + 1607, + 1432, + 3456, + 3562, + 3627, + 3667, + 1652, + 1619, + 1580, + 1532, + 3735, + 3840, + 3857, + 4043, + 1812, + 1787, + 1728, + 1653, + 3944, + 4127, + 4300, + 4396, + 1501, + 1488, + 2138, + 1955, + 3755, + 3772, + 3783, + 3804 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100))) AS \"orm_partition\" WHERE \"c3\" > $101 AND \"c3\" <= $102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 48 + ], + "sqlMicros": [ + 725, + 1579 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 2427, + "received": 56982 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100))) AS \"orm_partition\" WHERE \"c3\" > $101 AND \"c3\" <= $102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 42 + ], + "sqlMicros": [ + 788, + 1413 + ], + "ormDecodeMicros": [ + 55, + 8 + ], + "postgresProtocolBytes": { + "sent": 2427, + "received": 56982 + } + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1367479417669, + "endMicros": 1367479426227, + "retainedRootRows": 100, + "rssBefore": 370655232, + "rssAfter": 374652928, + "processLifetimeMaxRss": 484982784 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17196128, + "heapCapacity": 27639808, + "externalUsage": 272 + }, + "gcTimestamp": "1789456296041", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 406, + "bytesCurrent": 14592 + }, + { + "name": "_Map", + "instancesCurrent": 25, + "bytesCurrent": 1600 + }, + { + "name": "_List", + "instancesCurrent": 47020, + "bytesCurrent": 3635696 + }, + { + "name": "_GrowableList", + "instancesCurrent": 922, + "bytesCurrent": 29504 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17297552, + "heapCapacity": 28164096, + "externalUsage": 272 + }, + "gcTimestamp": "1789456296355", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 806, + "bytesCurrent": 28992 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47022, + "bytesCurrent": 3637024 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 11766, + "counts": { + "_List": 6165, + "_GrowableList": 4083, + "_Record": 1515, + "_Map": 3 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 4623 + }, + { + "function": "", + "samples": 2406 + }, + { + "function": "wait", + "samples": 1206 + }, + { + "function": "DataRowMessage.parse", + "samples": 1200 + }, + { + "function": "handleMessage", + "samples": 1200 + }, + { + "function": "current", + "samples": 378 + }, + { + "function": "bindParameters", + "samples": 309 + }, + { + "function": "_List._ofGrowableList", + "samples": 303 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 47 + }, + { + "function": "_GrowableList._withData", + "samples": 21 + }, + { + "function": "_List", + "samples": 18 + }, + { + "function": "_init", + "samples": 18 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1367479760648, + "endMicros": 1367479771631, + "retainedRootRows": 100, + "rssBefore": 395100160, + "rssAfter": 400801792, + "processLifetimeMaxRss": 484982784 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17200480, + "heapCapacity": 28688384, + "externalUsage": 272 + }, + "gcTimestamp": "1789456296391", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 406, + "bytesCurrent": 14592 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47021, + "bytesCurrent": 3635984 + }, + { + "name": "_GrowableList", + "instancesCurrent": 920, + "bytesCurrent": 29440 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17299712, + "heapCapacity": 23969792, + "externalUsage": 272 + }, + "gcTimestamp": "1789456296732", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 806, + "bytesCurrent": 28992 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47025, + "bytesCurrent": 3637248 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 16149, + "counts": { + "_List": 8768, + "_GrowableList": 5773, + "_SelectionPlan": 12, + "_Map": 60, + "_Record": 1536 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 6158 + }, + { + "function": "", + "samples": 3306 + }, + { + "function": "wait", + "samples": 1206 + }, + { + "function": "DataRowMessage.parse", + "samples": 1200 + }, + { + "function": "handleMessage", + "samples": 1200 + }, + { + "function": "parameter", + "samples": 636 + }, + { + "function": "_List", + "samples": 375 + }, + { + "function": "_List._ofGrowableList", + "samples": 309 + }, + { + "function": "bindParameters", + "samples": 309 + }, + { + "function": "load", + "samples": 303 + }, + { + "function": "[]", + "samples": 300 + }, + { + "function": "quote", + "samples": 180 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "aggregate", + "latency": { + "raw": { + "samplesMicros": [ + 591, + 576, + 593, + 547, + 564, + 616, + 543, + 456, + 380, + 359, + 362, + 444, + 388, + 465, + 529, + 372, + 341, + 345, + 327, + 334, + 350, + 339, + 377, + 334, + 458, + 385, + 354, + 338, + 366, + 343, + 333, + 328, + 331, + 350, + 354, + 447, + 406, + 347, + 339, + 361, + 346, + 337, + 329, + 360, + 348, + 346, + 340, + 365, + 440, + 360, + 351, + 350, + 332, + 356, + 433, + 388, + 341, + 462, + 452, + 356, + 369, + 348, + 345, + 336, + 346, + 362, + 356, + 337, + 504, + 453, + 378, + 354, + 360, + 342, + 338, + 339, + 346, + 379, + 368, + 469, + 519, + 366, + 344, + 359, + 333, + 349, + 423, + 346, + 343, + 413, + 455, + 328, + 354, + 371, + 363, + 369, + 356, + 361, + 371, + 358, + 374, + 358, + 366, + 349, + 343, + 332, + 340, + 345, + 337, + 378, + 360, + 482, + 424, + 413, + 342, + 355, + 354, + 345, + 351, + 334, + 353, + 367, + 354, + 470, + 435, + 361, + 351, + 352, + 329, + 340, + 331, + 351, + 350, + 341, + 413, + 369, + 357, + 349, + 353, + 335, + 391, + 342, + 337, + 366, + 353, + 391, + 380, + 345, + 341, + 351, + 340, + 338, + 333, + 344, + 343, + 357, + 331, + 422, + 377, + 365, + 363, + 340, + 356, + 332, + 327, + 350, + 346, + 410, + 470, + 361, + 363, + 351, + 338, + 336, + 342, + 342, + 353, + 341, + 342, + 453, + 397, + 347, + 346, + 358, + 339, + 334, + 338, + 328, + 346, + 334, + 326, + 389, + 370, + 350, + 337, + 340, + 334, + 325, + 326, + 350 + ], + "p50Micros": 353, + "p95Micros": 482, + "minMicros": 325, + "maxMicros": 616, + "wallMicros": 74494, + "operationsPerSecond": 2684.7799822804523 + }, + "orm": { + "samplesMicros": [ + 570, + 615, + 572, + 555, + 699, + 667, + 595, + 550, + 375, + 370, + 411, + 412, + 376, + 510, + 444, + 392, + 369, + 380, + 365, + 376, + 373, + 347, + 390, + 365, + 503, + 388, + 416, + 379, + 375, + 359, + 360, + 358, + 351, + 360, + 371, + 364, + 430, + 386, + 356, + 378, + 359, + 358, + 364, + 360, + 368, + 373, + 451, + 465, + 418, + 395, + 366, + 373, + 365, + 377, + 379, + 446, + 356, + 450, + 471, + 413, + 392, + 398, + 372, + 375, + 374, + 388, + 389, + 350, + 464, + 384, + 393, + 348, + 367, + 375, + 353, + 351, + 369, + 381, + 387, + 458, + 612, + 454, + 398, + 396, + 367, + 388, + 373, + 371, + 387, + 382, + 410, + 386, + 383, + 354, + 442, + 380, + 380, + 359, + 382, + 380, + 401, + 400, + 443, + 410, + 376, + 364, + 346, + 359, + 355, + 363, + 354, + 379, + 444, + 381, + 365, + 354, + 353, + 357, + 364, + 351, + 381, + 378, + 458, + 476, + 434, + 378, + 378, + 378, + 350, + 354, + 353, + 354, + 372, + 364, + 388, + 445, + 374, + 360, + 352, + 342, + 381, + 355, + 378, + 376, + 384, + 456, + 386, + 372, + 374, + 368, + 350, + 356, + 348, + 347, + 351, + 369, + 436, + 521, + 408, + 410, + 384, + 356, + 363, + 374, + 361, + 378, + 364, + 481, + 488, + 397, + 352, + 331, + 357, + 335, + 352, + 348, + 371, + 374, + 368, + 450, + 398, + 379, + 357, + 375, + 372, + 348, + 348, + 347, + 360, + 366, + 431, + 429, + 381, + 418, + 388, + 358, + 362, + 336, + 351, + 352 + ], + "p50Micros": 375, + "p95Micros": 510, + "minMicros": 331, + "maxMicros": 699, + "wallMicros": 78725, + "operationsPerSecond": 2540.489044140997 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 583, + 690, + 756, + 784, + 1203, + 1246, + 1281, + 1382, + 1311, + 1234, + 1210, + 1221, + 1378, + 1371, + 1357, + 1276, + 1330, + 1324, + 1263, + 1329, + 1274, + 1293, + 1286, + 1283, + 1380, + 1384, + 1381, + 1379, + 1377, + 1371, + 1373, + 1374, + 1320, + 1308, + 1366, + 1356, + 1321, + 1306, + 1307, + 1313, + 1249, + 1204, + 1262, + 1217, + 1210, + 1215, + 1195, + 1248, + 1301, + 1273, + 1321, + 1295, + 1286, + 1358, + 1316, + 1314, + 1347, + 1377, + 1388, + 1407, + 1511, + 1502, + 1501, + 1500, + 1536, + 1665, + 1599, + 1589, + 1502, + 1482, + 1526, + 1504, + 1313, + 1289, + 1293, + 1295, + 1148, + 1227, + 1255, + 1333, + 1175, + 1258, + 1264, + 1272, + 1202, + 1168, + 1150, + 1223, + 1087, + 1065, + 1058, + 1144, + 1040, + 1102, + 1147, + 1065, + 1034, + 1166, + 1232, + 1158, + 1237, + 1115, + 1269, + 1288, + 1158, + 1282, + 1174, + 1267, + 1243, + 1302, + 1296, + 1512, + 1605, + 1521, + 1665, + 1666, + 1624, + 1554, + 1470, + 1298, + 1265, + 1268, + 1167, + 1237, + 1292, + 1335, + 1351, + 1371, + 1433, + 1450, + 1349, + 1390, + 1320, + 1396, + 1264, + 1360, + 1346, + 1319, + 1418, + 1365, + 1384, + 1262, + 1444, + 1492, + 1310, + 1309, + 1357, + 1279, + 1076, + 1251, + 1117, + 1014, + 1010, + 1240, + 1099, + 1259, + 1229, + 1318, + 1343, + 1324, + 1248, + 1207, + 1324, + 1104, + 1332, + 1389, + 1516, + 1415, + 1563, + 1569, + 1435, + 1421, + 1336, + 1202, + 1030, + 1035, + 1008, + 1052, + 1059, + 1129, + 1032, + 1064, + 1056, + 1042, + 1015, + 1017, + 1009, + 1000, + 998, + 1035, + 1018, + 1068, + 1034, + 1046, + 1129, + 1073, + 1056, + 1058, + 1063, + 1009 + ], + "p50Micros": 1283, + "p95Micros": 1536, + "minMicros": 583, + "maxMicros": 1666, + "wallMicros": 32033, + "operationsPerSecond": 6243.561327381139 + }, + "orm": { + "samplesMicros": [ + 675, + 713, + 760, + 793, + 1319, + 1400, + 1456, + 1492, + 1283, + 1398, + 1357, + 1485, + 1372, + 1352, + 1358, + 1408, + 1483, + 1413, + 1422, + 1384, + 1678, + 1608, + 1705, + 1596, + 1695, + 1661, + 1646, + 1635, + 1490, + 1488, + 1446, + 1524, + 1506, + 1504, + 1500, + 1490, + 1431, + 1430, + 1388, + 1466, + 1325, + 1330, + 1325, + 1321, + 1317, + 1357, + 1370, + 1402, + 1263, + 1416, + 1367, + 1386, + 1244, + 1242, + 1228, + 1294, + 1262, + 1273, + 1283, + 1378, + 1459, + 1394, + 1482, + 1543, + 1539, + 1490, + 1471, + 1589, + 1556, + 1551, + 1537, + 1547, + 1646, + 1669, + 1668, + 1538, + 1540, + 1532, + 1538, + 1368, + 1282, + 1264, + 1268, + 1170, + 1135, + 1269, + 1273, + 1253, + 1208, + 1304, + 1302, + 1300, + 1246, + 1231, + 1241, + 1235, + 1221, + 1162, + 1164, + 1264, + 1228, + 1176, + 1161, + 1249, + 1212, + 1294, + 1322, + 1265, + 1403, + 1369, + 1388, + 1301, + 1396, + 1292, + 1268, + 1221, + 1259, + 1246, + 1389, + 1374, + 1327, + 1409, + 1429, + 1468, + 1350, + 1272, + 1278, + 1312, + 1197, + 1167, + 1172, + 1149, + 1204, + 1153, + 1149, + 1199, + 1307, + 1153, + 1266, + 1189, + 1161, + 1195, + 1208, + 1115, + 1174, + 1268, + 1282, + 1290, + 1272, + 1441, + 1449, + 1455, + 1305, + 1457, + 1473, + 1466, + 1438, + 1463, + 1445, + 1438, + 1360, + 1408, + 1641, + 1575, + 1320, + 1524, + 1602, + 1769, + 1574, + 1532, + 1417, + 1463, + 1541, + 1344, + 1479, + 1328, + 1427, + 1400, + 1615, + 1608, + 1566, + 1526, + 1771, + 1769, + 1653, + 1543, + 1535, + 1613, + 1520, + 1417, + 1214, + 1417, + 1467, + 1378, + 1370, + 1452, + 1488, + 1380, + 1382, + 1220 + ], + "p50Micros": 1380, + "p95Micros": 1646, + "minMicros": 675, + "maxMicros": 1771, + "wallMicros": 35008, + "operationsPerSecond": 5712.979890310786 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 59, + 59, + 62, + 63, + 675, + 739, + 770, + 787, + 851, + 833, + 827, + 846, + 813, + 769, + 761, + 759, + 677, + 674, + 668, + 668, + 686, + 682, + 683, + 691, + 658, + 655, + 652, + 642, + 568, + 572, + 572, + 574, + 440, + 515, + 598, + 616, + 673, + 586, + 551, + 695, + 715, + 696, + 656, + 595, + 634, + 647, + 652, + 555, + 776, + 774, + 760, + 762, + 760, + 787, + 798, + 814, + 685, + 739, + 758, + 756, + 705, + 674, + 707, + 703 + ], + "p50Micros": 682, + "p95Micros": 827, + "minMicros": 59, + "maxMicros": 851, + "wallMicros": 41777 + }, + "sqlMicros": [ + 613, + 670, + 710, + 747, + 723, + 745, + 911, + 841, + 763, + 746, + 742, + 726, + 618, + 685, + 652, + 647, + 627, + 693, + 666, + 666, + 632, + 637, + 634, + 623, + 544, + 530, + 580, + 555, + 410, + 496, + 547, + 625, + 631, + 567, + 524, + 674, + 684, + 680, + 635, + 568, + 612, + 615, + 630, + 529, + 750, + 742, + 739, + 735, + 729, + 734, + 802, + 791, + 597, + 606, + 831, + 774, + 683, + 643, + 688, + 686, + 602, + 546, + 531, + 504 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 53, + 89, + 95, + 101, + 683, + 724, + 762, + 794, + 678, + 678, + 669, + 671, + 699, + 701, + 704, + 707, + 655, + 638, + 637, + 643, + 589, + 615, + 616, + 613, + 576, + 668, + 677, + 693, + 696, + 778, + 795, + 781, + 759, + 671, + 636, + 648, + 635, + 677, + 722, + 719, + 858, + 724, + 676, + 693, + 697, + 655, + 649, + 630, + 707, + 714, + 710, + 697, + 674, + 666, + 665, + 664, + 677, + 673, + 672, + 666, + 652, + 676, + 682, + 698 + ], + "p50Micros": 676, + "p95Micros": 781, + "minMicros": 53, + "maxMicros": 858, + "wallMicros": 41420 + }, + "sqlMicros": [ + 590, + 629, + 662, + 691, + 635, + 644, + 642, + 641, + 652, + 671, + 677, + 675, + 610, + 570, + 649, + 612, + 552, + 544, + 629, + 585, + 537, + 633, + 636, + 665, + 645, + 739, + 764, + 754, + 702, + 629, + 609, + 619, + 598, + 631, + 643, + 731, + 794, + 672, + 642, + 660, + 585, + 673, + 611, + 599, + 666, + 683, + 678, + 670, + 587, + 675, + 601, + 673, + 633, + 646, + 640, + 643, + 604, + 621, + 694, + 672, + 628, + 587, + 564, + 536 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 25 + ], + "sqlMicros": [ + 328 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 244, + "received": 413 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 24 + ], + "sqlMicros": [ + 353 + ], + "ormDecodeMicros": [ + 1 + ], + "postgresProtocolBytes": { + "sent": 244, + "received": 413 + } + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1367480167582, + "endMicros": 1367480169351, + "retainedRootRows": 10, + "rssBefore": 364445696, + "rssAfter": 364642304, + "processLifetimeMaxRss": 484982784 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17112512, + "heapCapacity": 22396928, + "externalUsage": 272 + }, + "gcTimestamp": "1789456296794", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 16, + "bytesCurrent": 512 + }, + { + "name": "_Map", + "instancesCurrent": 25, + "bytesCurrent": 1600 + }, + { + "name": "_List", + "instancesCurrent": 47038, + "bytesCurrent": 3636560 + }, + { + "name": "_GrowableList", + "instancesCurrent": 922, + "bytesCurrent": 29504 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17111696, + "heapCapacity": 22396928, + "externalUsage": 272 + }, + "gcTimestamp": "1789456296987", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 26, + "bytesCurrent": 832 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47033, + "bytesCurrent": 3636096 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 487, + "counts": { + "_List": 274, + "_GrowableList": 177, + "_Record": 36 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 234 + }, + { + "function": "", + "samples": 63 + }, + { + "function": "current", + "samples": 42 + }, + { + "function": "wait", + "samples": 33 + }, + { + "function": "DataRowMessage.parse", + "samples": 30 + }, + { + "function": "handleMessage", + "samples": 30 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 18 + }, + { + "function": "_List", + "samples": 9 + }, + { + "function": "_GrowableList._withData", + "samples": 9 + }, + { + "function": "_GrowableList._ofEfficientLengthIterable", + "samples": 6 + }, + { + "function": "_List.filled", + "samples": 3 + }, + { + "function": "_prepare", + "samples": 3 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1367480394044, + "endMicros": 1367480396502, + "retainedRootRows": 10, + "rssBefore": 373932032, + "rssAfter": 374210560, + "processLifetimeMaxRss": 484982784 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17111200, + "heapCapacity": 22396928, + "externalUsage": 272 + }, + "gcTimestamp": "1789456297020", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 16, + "bytesCurrent": 512 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47032, + "bytesCurrent": 3635952 + }, + { + "name": "_GrowableList", + "instancesCurrent": 920, + "bytesCurrent": 29440 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17122720, + "heapCapacity": 23445504, + "externalUsage": 272 + }, + "gcTimestamp": "1789456297224", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 26, + "bytesCurrent": 832 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47036, + "bytesCurrent": 3637904 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 806, + "counts": { + "_List": 437, + "_GrowableList": 303, + "_SelectionPlan": 3, + "_Map": 18, + "_Record": 45 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 366 + }, + { + "function": "", + "samples": 63 + }, + { + "function": "current", + "samples": 42 + }, + { + "function": "quote", + "samples": 42 + }, + { + "function": "_GrowableList._withData", + "samples": 33 + }, + { + "function": "wait", + "samples": 33 + }, + { + "function": "_write", + "samples": 30 + }, + { + "function": "DataRowMessage.parse", + "samples": 30 + }, + { + "function": "handleMessage", + "samples": 30 + }, + { + "function": "_List", + "samples": 25 + }, + { + "function": "writeSql", + "samples": 21 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 18 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + } + ] + }, + { + "scenario": "postgres_delay_20ms", + "environment": { + "backend": "postgres_delay_20ms", + "databaseVersion": "18.4 (Homebrew)", + "pid": 5186 + }, + "oneWayDelayMs": 10, + "echoRoundTripMicros": { + "samplesMicros": [ + 24442, + 24545, + 23766, + 24729, + 24725, + 24852, + 24477, + 24819, + 25129, + 24860 + ], + "p50Micros": 24725, + "p95Micros": 25129, + "minMicros": 23766, + "maxMicros": 25129, + "wallMicros": 246344 + }, + "selectOneMicros": { + "samplesMicros": [ + 77574, + 75453, + 75477, + 74005, + 74697, + 74833, + 74584, + 75129, + 75531, + 75403 + ], + "p50Micros": 75129, + "p95Micros": 77574, + "minMicros": 74005, + "maxMicros": 77574, + "wallMicros": 752686, + "operationsPerSecond": 13.28575262460043 + }, + "cases": [ + { + "case": "full_rows", + "latency": { + "raw": { + "samplesMicros": [ + 77194, + 80261, + 77152, + 76208, + 73663, + 75031, + 77315, + 76417, + 74503, + 76444, + 75343, + 74414, + 74517, + 77633, + 77800, + 77544, + 77922, + 75523, + 76456, + 75724, + 76745, + 76337, + 76649, + 76771, + 74783, + 76587, + 75441, + 76730, + 74830, + 75338, + 76552, + 76157, + 77255, + 75888, + 74830, + 75284, + 77326, + 76119, + 75491, + 77412, + 77190, + 74671, + 74917, + 76574, + 76340, + 77780, + 77274, + 76129, + 74664, + 75034, + 77144, + 77002, + 77502, + 75804, + 76842, + 75243, + 74154, + 75968, + 75849, + 76648, + 76233, + 74795, + 76335, + 75114, + 75494, + 78799, + 77169, + 75524, + 77878, + 75277, + 76430, + 75314, + 76447, + 76253, + 76521, + 76695, + 76438, + 74912, + 74684, + 76884, + 76500, + 76270, + 76902, + 76027, + 77010, + 75024, + 75522, + 74151, + 74880, + 75479, + 76533, + 76713, + 76735, + 74307, + 75332, + 76523, + 76413, + 76168, + 77124, + 76000, + 75845, + 76301, + 76747, + 76888, + 78126, + 76432, + 77602, + 73972, + 74473, + 75286, + 75264, + 76294, + 76604, + 77883, + 77848, + 74982, + 74345, + 76724, + 75445, + 75504, + 75397, + 75298, + 75557, + 74107, + 76636, + 75427, + 77367, + 74754, + 75575, + 76273, + 75739, + 76548, + 77041, + 76293, + 76325, + 75806, + 77868, + 75190, + 74029, + 75663, + 76543, + 76680, + 75900, + 78068, + 75476, + 75027, + 74264, + 75316, + 77635, + 80965, + 76255, + 75508, + 75887, + 75099, + 76321, + 78108, + 75990, + 75555, + 74998, + 74572, + 75068, + 76068, + 75213, + 76818, + 76865, + 75373, + 77333, + 72790, + 74155, + 75545, + 76360, + 77764, + 77689, + 77068, + 76366, + 74396, + 74883, + 75177, + 75504, + 76021, + 75814, + 76009, + 74539, + 72945, + 74208, + 77141, + 76343, + 76846, + 74725, + 76678, + 75037, + 75685, + 74753, + 74624, + 75411, + 75335, + 75555, + 74307, + 73671, + 77138 + ], + "p50Micros": 76021, + "p95Micros": 77848, + "minMicros": 72790, + "maxMicros": 80965, + "wallMicros": 15201972, + "operationsPerSecond": 13.15618789457052 + }, + "orm": { + "samplesMicros": [ + 77042, + 79142, + 78950, + 77662, + 75725, + 77273, + 76004, + 75297, + 75449, + 76473, + 77802, + 76339, + 76121, + 76012, + 76880, + 77617, + 77933, + 76420, + 75371, + 74574, + 76891, + 76158, + 76117, + 78359, + 77108, + 77115, + 75186, + 74906, + 77804, + 78788, + 77050, + 80318, + 76520, + 76837, + 75011, + 76555, + 77168, + 78033, + 77294, + 77639, + 75939, + 75682, + 76475, + 78530, + 75995, + 78444, + 76981, + 76335, + 75150, + 74643, + 76220, + 77222, + 77432, + 78311, + 77615, + 77410, + 74130, + 75976, + 78021, + 77211, + 76327, + 76949, + 76498, + 75491, + 76896, + 76847, + 77726, + 77267, + 77729, + 76527, + 76755, + 75495, + 76798, + 76032, + 75743, + 78146, + 76727, + 74266, + 74718, + 76784, + 78670, + 76409, + 77264, + 76517, + 76289, + 75652, + 75269, + 74741, + 74918, + 74330, + 76778, + 77122, + 75685, + 75324, + 75366, + 77532, + 76433, + 78126, + 78941, + 76944, + 75400, + 76001, + 76194, + 77068, + 77887, + 77216, + 76298, + 76275, + 74206, + 75791, + 75983, + 77742, + 76686, + 78263, + 77249, + 75247, + 76196, + 77133, + 78232, + 78655, + 76765, + 77601, + 75748, + 75187, + 75471, + 75437, + 75960, + 76280, + 77888, + 77090, + 73350, + 74025, + 76442, + 76232, + 76969, + 76242, + 75059, + 76048, + 75800, + 75349, + 76521, + 76980, + 77526, + 75377, + 76234, + 74452, + 75822, + 76643, + 79230, + 76238, + 76280, + 76918, + 74989, + 74729, + 77477, + 75631, + 77093, + 76739, + 75817, + 76383, + 75123, + 74349, + 77401, + 74808, + 77545, + 74595, + 75080, + 74629, + 75289, + 75123, + 77476, + 76898, + 79933, + 77035, + 75109, + 75027, + 76042, + 76505, + 75697, + 76514, + 75745, + 74923, + 74297, + 74875, + 75278, + 74016, + 75957, + 76740, + 76120, + 75430, + 74770, + 75564, + 77193, + 76735, + 75606, + 75438, + 76031, + 75207, + 74768, + 75689 + ], + "p50Micros": 76327, + "p95Micros": 78444, + "minMicros": 73350, + "maxMicros": 80318, + "wallMicros": 15277000, + "operationsPerSecond": 13.091575571119984 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 86313, + 86747, + 86797, + 86930, + 164153, + 164627, + 164788, + 164936, + 157768, + 157442, + 157731, + 158021, + 157929, + 158390, + 159160, + 158898, + 158161, + 158238, + 158241, + 158299, + 159050, + 159076, + 159085, + 159045, + 158672, + 158972, + 159754, + 159292, + 159266, + 159253, + 159122, + 159402, + 157965, + 157913, + 158778, + 157882, + 154725, + 154756, + 155315, + 154809, + 152579, + 153012, + 152660, + 152824, + 151376, + 151440, + 151454, + 151476, + 149817, + 149836, + 149860, + 149908, + 152498, + 153162, + 152954, + 153430, + 156653, + 157368, + 157313, + 157375, + 157920, + 158450, + 158899, + 158230, + 158321, + 159097, + 158997, + 158842, + 157669, + 158762, + 158481, + 158735, + 158722, + 159283, + 159752, + 159472, + 158422, + 158805, + 159454, + 158816, + 157390, + 157743, + 158075, + 157609, + 156441, + 156369, + 156398, + 156783, + 155219, + 155697, + 156061, + 155844, + 155138, + 155033, + 154974, + 154922, + 152468, + 152991, + 153409, + 152968, + 151799, + 152085, + 152476, + 152427, + 153176, + 153639, + 153559, + 153721, + 154364, + 154901, + 154760, + 154714, + 155542, + 155939, + 156191, + 156556, + 155933, + 156001, + 156015, + 156715, + 155757, + 156678, + 156525, + 156330, + 157333, + 157657, + 157759, + 158383, + 157404, + 157529, + 157542, + 158391, + 158484, + 158013, + 158428, + 158811, + 157105, + 157793, + 157444, + 157802, + 156485, + 156198, + 156574, + 156277, + 157225, + 158024, + 157731, + 157749, + 156635, + 156569, + 156514, + 156482, + 152188, + 152044, + 152903, + 152334, + 149936, + 150042, + 150434, + 150126, + 151239, + 151372, + 151629, + 151873, + 153303, + 153737, + 153688, + 154183, + 155414, + 155910, + 156242, + 156037, + 156485, + 156323, + 156564, + 156459, + 155263, + 155714, + 155695, + 156202, + 156679, + 156402, + 156819, + 156843, + 157311, + 157858, + 157583, + 157815, + 158656, + 158698, + 158564, + 158845, + 158103, + 158472, + 158899, + 158429, + 156803, + 157039, + 156823, + 157515 + ], + "p50Micros": 156678, + "p95Micros": 159283, + "minMicros": 86313, + "maxMicros": 164936, + "wallMicros": 3913504, + "operationsPerSecond": 51.10509660907463 + }, + "orm": { + "samplesMicros": [ + 78272, + 78819, + 78985, + 79292, + 156065, + 156492, + 156679, + 156947, + 155291, + 154686, + 155092, + 155415, + 151506, + 151959, + 151657, + 152635, + 149445, + 149546, + 150315, + 150173, + 149326, + 149925, + 150162, + 150672, + 152138, + 152539, + 152609, + 153365, + 154659, + 154951, + 155986, + 155849, + 152390, + 153504, + 153033, + 153047, + 154613, + 155696, + 155114, + 155560, + 160138, + 159974, + 159890, + 159916, + 160205, + 161264, + 160348, + 160778, + 158929, + 159619, + 159337, + 160412, + 157737, + 158349, + 158933, + 158312, + 157215, + 157567, + 158474, + 157810, + 156434, + 157722, + 157790, + 158575, + 156816, + 157740, + 158447, + 157813, + 154705, + 156214, + 154985, + 155579, + 151475, + 151096, + 151081, + 151514, + 150414, + 150899, + 150799, + 151524, + 153069, + 153206, + 153523, + 154098, + 154181, + 154994, + 154791, + 154882, + 154089, + 154679, + 155497, + 155446, + 157331, + 158589, + 160112, + 160181, + 161475, + 162860, + 162388, + 162793, + 161627, + 158637, + 160386, + 159611, + 159058, + 158876, + 159259, + 159123, + 158020, + 158849, + 158519, + 160057, + 157768, + 157363, + 157764, + 158152, + 156918, + 158070, + 157284, + 158116, + 157849, + 157896, + 158295, + 159090, + 156245, + 156370, + 157434, + 157222, + 152762, + 153247, + 152970, + 153946, + 151849, + 152524, + 152569, + 153237, + 153326, + 154016, + 154665, + 154612, + 156492, + 157619, + 157607, + 158522, + 158848, + 160144, + 160744, + 161743, + 156141, + 156004, + 156258, + 156274, + 154939, + 155949, + 157909, + 155776, + 160111, + 159926, + 159537, + 160045, + 164228, + 165311, + 165327, + 166397, + 162031, + 163980, + 164039, + 165275, + 158914, + 158578, + 158578, + 159289, + 160864, + 158788, + 159787, + 161068, + 158876, + 159362, + 160164, + 161327, + 159465, + 160433, + 161101, + 160537, + 156770, + 157606, + 159296, + 158686, + 153581, + 153289, + 153097, + 153329, + 149380, + 150630, + 150515, + 150502, + 151387, + 151244, + 151208, + 151182 + ], + "p50Micros": 156816, + "p95Micros": 162031, + "minMicros": 78272, + "maxMicros": 166397, + "wallMicros": 3914576, + "operationsPerSecond": 51.091101565022626 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 127, + 136, + 138, + 141, + 73492, + 73655, + 73751, + 73941, + 75899, + 75802, + 76011, + 76095, + 77723, + 77657, + 77697, + 77626, + 77888, + 77864, + 77753, + 77675, + 78071, + 78256, + 78266, + 78290, + 78899, + 78487, + 78438, + 78421, + 79718, + 79704, + 79722, + 79782, + 79268, + 79054, + 79116, + 79112, + 77864, + 77958, + 78021, + 77990, + 79196, + 78934, + 78830, + 79104, + 78626, + 78419, + 78368, + 78213, + 76858, + 76846, + 76871, + 76896, + 75602, + 75499, + 75562, + 75626, + 75117, + 75023, + 74962, + 74909, + 77050, + 76878, + 76851, + 76827 + ], + "p50Micros": 77697, + "p95Micros": 79704, + "minMicros": 127, + "maxMicros": 79782, + "wallMicros": 4642575 + }, + "sqlMicros": [ + 73128, + 73491, + 73627, + 73672, + 75054, + 75557, + 76002, + 76235, + 76917, + 76987, + 77917, + 78000, + 77379, + 77133, + 77600, + 78263, + 77454, + 78346, + 77970, + 78307, + 77654, + 78146, + 78968, + 78648, + 79279, + 79253, + 79741, + 79767, + 78657, + 78779, + 79356, + 78978, + 77410, + 77763, + 77568, + 77987, + 78186, + 79177, + 78479, + 79146, + 77944, + 78557, + 78423, + 77886, + 76592, + 76566, + 76780, + 77001, + 75143, + 75129, + 75450, + 75766, + 74777, + 74698, + 74656, + 75153, + 76815, + 76673, + 76644, + 77006, + 76287, + 76802, + 76614, + 76825 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 218, + 525, + 581, + 614, + 77296, + 77568, + 77716, + 77875, + 77003, + 76957, + 76994, + 77020, + 76628, + 76444, + 76355, + 76317, + 76974, + 77033, + 77114, + 77156, + 77869, + 77685, + 77671, + 77660, + 78437, + 78483, + 78499, + 78611, + 77749, + 77720, + 77853, + 77884, + 80398, + 80254, + 80098, + 79938, + 78381, + 78122, + 78142, + 78171, + 78034, + 77686, + 77586, + 77525, + 75794, + 75784, + 75783, + 75762, + 76811, + 76710, + 76724, + 76912, + 77708, + 77836, + 77867, + 77794, + 77981, + 77816, + 77831, + 77992, + 79149, + 79470, + 79498, + 79351 + ], + "p50Micros": 77686, + "p95Micros": 79938, + "minMicros": 218, + "maxMicros": 80398, + "wallMicros": 4665417 + }, + "sqlMicros": [ + 76468, + 77074, + 77138, + 77239, + 76122, + 76789, + 77203, + 76854, + 76231, + 76146, + 76086, + 76568, + 76526, + 76774, + 77076, + 77011, + 77391, + 77553, + 77512, + 77506, + 77458, + 78280, + 78707, + 78619, + 76678, + 77788, + 77766, + 77675, + 79246, + 79427, + 79901, + 80758, + 77107, + 78174, + 77803, + 78321, + 77516, + 77577, + 77296, + 77593, + 75408, + 75650, + 75554, + 75804, + 76155, + 76784, + 76739, + 76769, + 76609, + 77821, + 77847, + 77906, + 77009, + 77246, + 78156, + 78116, + 78731, + 78974, + 78978, + 79536, + 79464, + 78584, + 78845, + 79173 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 152 + ], + "sqlMicros": [ + 78114 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 237, + "received": 27784 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 140 + ], + "sqlMicros": [ + 75166 + ], + "ormDecodeMicros": [ + 89 + ], + "postgresProtocolBytes": { + "sent": 237, + "received": 27784 + } + } + } + }, + { + "case": "two_columns", + "latency": { + "raw": { + "samplesMicros": [ + 75156, + 75234, + 76824, + 80889, + 82298, + 82085, + 76196, + 81333, + 82328, + 81400, + 78701, + 76769, + 80065, + 79285, + 79787, + 80610, + 80605, + 80435, + 79987, + 78053, + 78683, + 80067, + 80623, + 80774, + 79094, + 81918, + 80469, + 80319, + 79953, + 80821, + 79935, + 82693, + 83650, + 80122, + 81162, + 79389, + 80356, + 82147, + 83039, + 83834, + 83235, + 74470, + 77185, + 81546, + 80126, + 83178, + 78279, + 81715, + 79742, + 80020, + 76747, + 81207, + 81660, + 78920, + 77997, + 79233, + 79879, + 78457, + 81795, + 85055, + 81634, + 80304, + 77592, + 79142, + 80535, + 80605, + 83626, + 78478, + 77223, + 78860, + 76509, + 80532, + 81861, + 82202, + 82480, + 81044, + 82216, + 79705, + 79877, + 78811, + 79902, + 82558, + 80781, + 80051, + 76008, + 79342, + 81535, + 79334, + 83691, + 82678, + 80274, + 80126, + 80766, + 80163, + 79497, + 82296, + 81083, + 81530, + 78593, + 81748, + 81716, + 83324, + 81536, + 80236, + 80844, + 80038, + 78542, + 79132, + 83453, + 82488, + 83366, + 80508, + 77994, + 79742, + 80604, + 83877, + 80554, + 80249, + 81577, + 79817, + 78545, + 81150, + 77874, + 80786, + 78847, + 78552, + 77891, + 81995, + 79005, + 83596, + 79168, + 79496, + 79267, + 77881, + 78235, + 84431, + 82614, + 79497, + 81394, + 81141, + 80066, + 77299, + 80600, + 81152, + 81877, + 79183, + 82293, + 79993, + 79501, + 81403, + 79881, + 78530, + 80530, + 82288, + 77020, + 79723, + 78225, + 81054, + 82556, + 81915, + 83169, + 78698, + 78241, + 78507, + 80503, + 80715, + 80546, + 78270, + 81428, + 78051, + 80962, + 82519, + 81451, + 83314, + 81791, + 83050, + 79535, + 80488, + 80641, + 81808, + 80607, + 82798, + 78415, + 74648, + 79868, + 80941, + 80273, + 82308, + 80379, + 82309, + 79007, + 76230, + 76137, + 79996, + 80053, + 81612, + 81634, + 79898, + 77946, + 80810 + ], + "p50Micros": 80379, + "p95Micros": 83324, + "minMicros": 74470, + "maxMicros": 85055, + "wallMicros": 16058333, + "operationsPerSecond": 12.454592889560828 + }, + "orm": { + "samplesMicros": [ + 75320, + 74721, + 82200, + 82095, + 83502, + 81114, + 76742, + 81719, + 82888, + 79375, + 80708, + 81048, + 80877, + 79643, + 80291, + 80800, + 80355, + 83357, + 80848, + 81198, + 79921, + 80887, + 77685, + 79515, + 78157, + 79443, + 81315, + 81443, + 82297, + 79308, + 81715, + 80972, + 81084, + 84287, + 78138, + 77960, + 81075, + 81798, + 84384, + 83657, + 81833, + 79558, + 81261, + 83464, + 82783, + 79292, + 81784, + 79406, + 78675, + 79299, + 81978, + 81897, + 82181, + 82754, + 78476, + 78987, + 77141, + 79429, + 83147, + 82169, + 82715, + 78779, + 79826, + 78351, + 80902, + 81839, + 80581, + 80614, + 79572, + 79942, + 81974, + 79106, + 81350, + 76461, + 82741, + 83162, + 79846, + 79177, + 80457, + 79746, + 80831, + 80447, + 79494, + 77058, + 79235, + 79301, + 81559, + 82506, + 81792, + 79537, + 77660, + 78312, + 80965, + 80595, + 81050, + 82206, + 82535, + 81238, + 80139, + 78555, + 79965, + 84405, + 83261, + 82175, + 77726, + 77905, + 81010, + 84125, + 77930, + 80297, + 81077, + 83621, + 79688, + 76097, + 82900, + 78577, + 84810, + 85339, + 78717, + 78439, + 81407, + 80108, + 81790, + 79419, + 80440, + 78757, + 78598, + 77695, + 79913, + 78944, + 82473, + 79753, + 80988, + 79787, + 79728, + 79911, + 84633, + 83137, + 78301, + 80692, + 83522, + 81216, + 81955, + 80825, + 86869, + 78143, + 83222, + 80147, + 81112, + 78828, + 84421, + 82442, + 81985, + 78774, + 81213, + 76422, + 82002, + 83015, + 79451, + 81741, + 77386, + 82194, + 79192, + 78228, + 81779, + 82297, + 81006, + 82129, + 77312, + 79739, + 79070, + 80066, + 80513, + 77284, + 80265, + 82333, + 76687, + 76098, + 78780, + 80930, + 83257, + 79693, + 79127, + 80969, + 77223, + 80378, + 81610, + 83712, + 79130, + 80780, + 79645, + 81354, + 82068, + 81673, + 78995, + 82519, + 80291, + 81692, + 73289, + 78564 + ], + "p50Micros": 80614, + "p95Micros": 83657, + "minMicros": 73289, + "maxMicros": 86869, + "wallMicros": 16102612, + "operationsPerSecond": 12.420345221011349 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 76857, + 76964, + 77063, + 77141, + 158883, + 159210, + 159368, + 159481, + 163466, + 163694, + 163752, + 164236, + 161647, + 161468, + 161740, + 161619, + 162647, + 163291, + 163013, + 163019, + 163175, + 162950, + 163443, + 163537, + 164247, + 164313, + 164351, + 165171, + 163742, + 163459, + 163446, + 164102, + 164577, + 165392, + 165279, + 165181, + 161760, + 162149, + 161805, + 162554, + 155119, + 154876, + 155045, + 155231, + 156081, + 156191, + 156620, + 156524, + 158597, + 158938, + 158972, + 159333, + 159791, + 160390, + 160308, + 160300, + 161930, + 162276, + 162727, + 162654, + 166553, + 167193, + 166951, + 167147, + 162932, + 163111, + 163764, + 163665, + 164150, + 164053, + 164391, + 164442, + 167120, + 167960, + 167807, + 167771, + 166761, + 167520, + 167375, + 167216, + 167056, + 167491, + 168038, + 167924, + 165582, + 165569, + 165830, + 166429, + 169104, + 169569, + 170171, + 170010, + 165505, + 165867, + 166242, + 165814, + 158248, + 159030, + 158664, + 158867, + 160076, + 160747, + 160518, + 160752, + 162630, + 162927, + 163185, + 162967, + 161300, + 162094, + 162000, + 161995, + 163298, + 163693, + 163771, + 163894, + 169165, + 168899, + 169177, + 169449, + 168160, + 168228, + 168778, + 169526, + 165428, + 166101, + 165907, + 165709, + 164152, + 164094, + 164820, + 164496, + 163544, + 163579, + 163630, + 163652, + 165910, + 166056, + 166222, + 166782, + 166735, + 167280, + 166985, + 167377, + 163331, + 164477, + 164126, + 163769, + 163539, + 164588, + 164392, + 164264, + 160777, + 160230, + 160726, + 160642, + 158145, + 157850, + 158085, + 158310, + 160444, + 160378, + 160438, + 160712, + 162229, + 162836, + 162816, + 162709, + 163161, + 163574, + 163440, + 164095, + 164229, + 164521, + 164633, + 165546, + 164910, + 165685, + 165388, + 165624, + 166298, + 166248, + 166776, + 166414, + 166902, + 167492, + 167449, + 167366, + 166757, + 166233, + 166759, + 167522, + 163928, + 163995, + 164709, + 164548, + 164049, + 162207, + 163518, + 163809 + ], + "p50Micros": 163769, + "p95Micros": 168228, + "minMicros": 76857, + "maxMicros": 170171, + "wallMicros": 4091151, + "operationsPerSecond": 48.88599809686809 + }, + "orm": { + "samplesMicros": [ + 81842, + 81975, + 82064, + 82149, + 158079, + 158259, + 158331, + 158415, + 154517, + 154828, + 154754, + 155236, + 154780, + 155234, + 155537, + 155391, + 159625, + 160842, + 161190, + 161618, + 162530, + 163188, + 163660, + 163403, + 159654, + 161623, + 159824, + 159762, + 162764, + 162785, + 163135, + 163885, + 165789, + 166876, + 166626, + 166453, + 168859, + 169417, + 169756, + 170297, + 166101, + 166246, + 167234, + 166741, + 165226, + 166431, + 166077, + 166418, + 166311, + 166436, + 166930, + 167553, + 161584, + 162970, + 162688, + 162655, + 162859, + 163467, + 163406, + 163936, + 159943, + 159799, + 160765, + 160139, + 157563, + 157480, + 157410, + 157599, + 159503, + 160070, + 159818, + 160109, + 158145, + 158083, + 158141, + 158502, + 157469, + 157348, + 157614, + 157891, + 160491, + 161041, + 161518, + 161107, + 161143, + 161842, + 162569, + 162414, + 163040, + 163915, + 165593, + 165667, + 168152, + 168409, + 169072, + 169843, + 167938, + 170670, + 169049, + 170284, + 169119, + 170003, + 170741, + 170034, + 167379, + 167303, + 167301, + 167305, + 169186, + 169250, + 170276, + 169269, + 166005, + 166738, + 166147, + 166392, + 159462, + 159815, + 160352, + 159847, + 159740, + 160394, + 160071, + 160072, + 155560, + 155772, + 155842, + 156608, + 156333, + 156520, + 156791, + 157384, + 164740, + 164633, + 164928, + 165231, + 167087, + 167150, + 167174, + 167185, + 164698, + 164206, + 164512, + 164810, + 165565, + 165292, + 165359, + 165972, + 165233, + 166530, + 166292, + 166588, + 165045, + 164935, + 165704, + 165728, + 164299, + 165265, + 166211, + 165288, + 163423, + 163246, + 163650, + 164841, + 163747, + 164394, + 164811, + 166168, + 164830, + 165607, + 165037, + 165434, + 162644, + 163152, + 163746, + 162562, + 159375, + 159957, + 159631, + 159835, + 160553, + 161395, + 161127, + 161361, + 165223, + 166702, + 166708, + 167186, + 170741, + 171786, + 171848, + 172194, + 169890, + 169835, + 171401, + 169825, + 163441, + 161833, + 162927, + 162487 + ], + "p50Micros": 163650, + "p95Micros": 170034, + "minMicros": 81842, + "maxMicros": 172194, + "wallMicros": 4086091, + "operationsPerSecond": 48.946535943521575 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 300, + 225, + 217, + 219, + 81041, + 81206, + 81320, + 81448, + 83210, + 83165, + 83190, + 83210, + 82944, + 82770, + 82707, + 82672, + 81429, + 81445, + 81478, + 81510, + 83035, + 82929, + 82915, + 82918, + 85119, + 85236, + 85241, + 85248, + 79818, + 79484, + 79419, + 79406, + 77318, + 77230, + 77195, + 77154, + 80027, + 80130, + 80191, + 80253, + 80364, + 80285, + 80318, + 80389, + 81603, + 81384, + 81360, + 81321, + 83394, + 83456, + 83488, + 83522, + 82290, + 82111, + 82144, + 82165, + 82429, + 82356, + 82304, + 82233, + 80667, + 80594, + 80570, + 80576 + ], + "p50Micros": 81445, + "p95Micros": 85119, + "minMicros": 217, + "maxMicros": 85248, + "wallMicros": 4897295 + }, + "sqlMicros": [ + 80736, + 80939, + 81052, + 81182, + 82707, + 83223, + 82942, + 83213, + 82218, + 82704, + 82996, + 82725, + 81129, + 81148, + 81382, + 81584, + 82307, + 82703, + 83145, + 83068, + 84493, + 85235, + 85062, + 85396, + 79218, + 79151, + 79835, + 79426, + 76875, + 77305, + 77164, + 77287, + 79776, + 79986, + 80175, + 80175, + 80009, + 79963, + 80181, + 80494, + 81116, + 80984, + 81625, + 81369, + 82687, + 83206, + 83496, + 83770, + 81959, + 81686, + 82171, + 82196, + 81633, + 82059, + 82393, + 82668, + 80186, + 80514, + 80792, + 80495, + 83205, + 82998, + 82930, + 83212 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 181, + 288, + 313, + 333, + 81638, + 81845, + 81939, + 82023, + 80508, + 80443, + 80458, + 80544, + 79915, + 79845, + 79817, + 79740, + 76560, + 76517, + 76573, + 76607, + 77364, + 77268, + 77188, + 77142, + 77574, + 77582, + 77597, + 77604, + 80219, + 80232, + 80281, + 80327, + 80847, + 81090, + 81188, + 81211, + 83143, + 82992, + 82784, + 83038, + 83898, + 83795, + 83918, + 83716, + 82586, + 82525, + 82410, + 82332, + 83100, + 82997, + 82980, + 82984, + 83470, + 83402, + 83586, + 83604, + 80752, + 80736, + 80715, + 80740, + 81738, + 81532, + 81335, + 81212 + ], + "p50Micros": 80752, + "p95Micros": 83716, + "minMicros": 181, + "maxMicros": 83918, + "wallMicros": 4852821 + }, + "sqlMicros": [ + 81201, + 81460, + 81586, + 81771, + 79955, + 80485, + 80478, + 80394, + 79184, + 79635, + 79847, + 80093, + 76138, + 76251, + 76643, + 76612, + 77115, + 77198, + 77004, + 77175, + 77158, + 77584, + 77444, + 77675, + 79664, + 80251, + 80102, + 80407, + 80260, + 81010, + 81069, + 81097, + 81876, + 83152, + 82633, + 83230, + 83482, + 83095, + 83600, + 84171, + 81422, + 82552, + 82235, + 82681, + 82277, + 82852, + 82713, + 83359, + 82847, + 83006, + 83451, + 83986, + 79697, + 80763, + 80315, + 81033, + 80578, + 81144, + 81778, + 81628, + 83113, + 83512, + 83452, + 83367 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 124 + ], + "sqlMicros": [ + 79432 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 212, + "received": 4693 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 139 + ], + "sqlMicros": [ + 79956 + ], + "ormDecodeMicros": [ + 52 + ], + "postgresProtocolBytes": { + "sent": 212, + "received": 4693 + } + } + } + }, + { + "case": "three_posts", + "latency": { + "raw": { + "samplesMicros": [ + 165355, + 159259, + 160315, + 162987, + 157265, + 161559, + 164362, + 160835, + 161545, + 168928, + 164460, + 160586, + 158818, + 162371, + 165588, + 158386, + 161258, + 164810, + 167170, + 164626, + 168199, + 158637, + 159533, + 163989, + 161947, + 161577, + 162399, + 161686, + 160679, + 158822, + 166138, + 168534, + 160677, + 168513, + 165310, + 165984, + 159498, + 164457, + 164821, + 156529, + 163769, + 160351, + 159467, + 162411, + 162332, + 164515, + 161481, + 163949, + 165078, + 165133, + 161284, + 161503, + 160647, + 160956, + 159497, + 165387, + 158532, + 163540, + 163156, + 165538, + 166277, + 161533, + 159100, + 164334, + 160795, + 168446, + 163755, + 160581, + 159386, + 158793, + 164450, + 160145, + 163393, + 165957, + 160090, + 158871, + 162096, + 162400, + 151152, + 165604, + 160723, + 155992, + 160405, + 161479, + 164544, + 160399, + 165791, + 166643, + 163770, + 165240, + 162550, + 161043, + 162557, + 161332, + 161234, + 158110, + 160998, + 161610, + 155327, + 159468, + 165043, + 162332, + 162103, + 164247, + 166067, + 162015, + 159159, + 159892, + 162375, + 161687, + 158485, + 167496, + 160308, + 162847, + 164031, + 162917, + 163990, + 161247, + 162926, + 166041, + 161902, + 165720, + 162940, + 160493, + 162023, + 159385, + 163250, + 159780, + 162776, + 162404, + 165580, + 162353, + 161852, + 164038, + 158669, + 162120, + 164595, + 156223, + 157619, + 162154, + 166362, + 160748, + 163899, + 163829, + 164811, + 163605, + 161503, + 162314, + 157723, + 160860, + 161420, + 156525, + 159383, + 161289, + 165883, + 160707, + 166227, + 162958, + 160600, + 160439, + 164520, + 162980, + 160854, + 165339, + 163696, + 162835, + 158565, + 157994, + 165240, + 158581, + 158379, + 164153, + 160452, + 161161, + 158281, + 160114, + 158306, + 165215, + 162818, + 163128, + 161674, + 162280, + 164138, + 157913, + 158599, + 164502, + 164024, + 158763, + 162318, + 163924, + 161490, + 162800, + 158918, + 160724, + 155931, + 161997, + 167824, + 159087, + 162879, + 165827 + ], + "p50Micros": 162096, + "p95Micros": 166277, + "minMicros": 151152, + "maxMicros": 168928, + "wallMicros": 32423703, + "operationsPerSecond": 6.1683269181191305 + }, + "orm": { + "samplesMicros": [ + 158963, + 158877, + 163296, + 164490, + 158368, + 162033, + 167987, + 166779, + 158531, + 161134, + 166773, + 165586, + 158784, + 164172, + 160992, + 160587, + 164863, + 166335, + 161980, + 160194, + 164606, + 164690, + 162345, + 163421, + 165591, + 164636, + 161312, + 161564, + 162583, + 161033, + 162359, + 168931, + 161073, + 159653, + 164658, + 168228, + 158881, + 162209, + 163708, + 161069, + 163570, + 163142, + 157970, + 162077, + 166069, + 164428, + 158002, + 163666, + 163814, + 164500, + 160990, + 153709, + 166111, + 160415, + 169103, + 167784, + 161829, + 156268, + 157418, + 165429, + 153859, + 162762, + 164066, + 163176, + 156498, + 162284, + 166037, + 163856, + 161545, + 165624, + 161354, + 159398, + 164989, + 163524, + 155831, + 156628, + 163887, + 162095, + 163942, + 164211, + 162113, + 162769, + 164309, + 168485, + 163780, + 160211, + 166651, + 166292, + 161578, + 161489, + 166747, + 163504, + 162649, + 161435, + 164385, + 161916, + 167290, + 166111, + 162310, + 159957, + 166471, + 164873, + 160245, + 156450, + 172877, + 164003, + 157906, + 162270, + 159396, + 160451, + 164356, + 166144, + 164564, + 160422, + 163559, + 164688, + 159573, + 157051, + 162575, + 161503, + 159521, + 169535, + 165828, + 167464, + 163472, + 163216, + 160606, + 159553, + 163831, + 162996, + 161945, + 161313, + 165726, + 166298, + 163297, + 160632, + 160230, + 161553, + 164482, + 164547, + 162121, + 158380, + 162396, + 162058, + 154843, + 161647, + 164470, + 161354, + 161555, + 164029, + 155643, + 156583, + 165344, + 167366, + 165488, + 157505, + 161374, + 159775, + 161039, + 158503, + 165802, + 165407, + 156947, + 164001, + 160697, + 162975, + 165617, + 161738, + 163459, + 157736, + 164121, + 166723, + 156272, + 160686, + 161788, + 164904, + 161509, + 160065, + 163469, + 161739, + 160339, + 165634, + 168834, + 161943, + 158335, + 167021, + 160755, + 157045, + 160742, + 163284, + 157754, + 161488, + 166142, + 162949, + 160795, + 164907, + 163645, + 161848, + 163174, + 162642 + ], + "p50Micros": 162396, + "p95Micros": 167366, + "minMicros": 153709, + "maxMicros": 172877, + "wallMicros": 32500941, + "operationsPerSecond": 6.153667981490136 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 171921, + 172154, + 172048, + 172525, + 340960, + 341162, + 341394, + 341569, + 332616, + 332449, + 332799, + 333480, + 331934, + 332484, + 332103, + 332768, + 338617, + 338515, + 338872, + 339452, + 335734, + 335013, + 335892, + 335924, + 341653, + 342379, + 342444, + 343014, + 346453, + 346435, + 346523, + 346553, + 333479, + 333443, + 334261, + 334066, + 326660, + 328076, + 327792, + 328297, + 336001, + 336402, + 336671, + 336561, + 341759, + 343460, + 343663, + 343784, + 341203, + 341143, + 341175, + 341839, + 338315, + 338421, + 340173, + 341551, + 333765, + 333420, + 333898, + 333575, + 329448, + 328622, + 329336, + 329177, + 331019, + 331224, + 331166, + 331906, + 335477, + 335488, + 335411, + 335807, + 337417, + 338672, + 338581, + 339762, + 338467, + 338043, + 338785, + 338838, + 341066, + 342881, + 342981, + 343858, + 332863, + 333618, + 333472, + 334173, + 322344, + 323230, + 324201, + 325272, + 334203, + 334160, + 334153, + 334896, + 337188, + 337413, + 337443, + 338402, + 338499, + 338431, + 338899, + 338632, + 339764, + 339791, + 340602, + 341526, + 341968, + 342168, + 342225, + 343579, + 337769, + 336926, + 336832, + 337792, + 327527, + 327305, + 326796, + 327363, + 332623, + 333230, + 333357, + 334040, + 342643, + 342316, + 343065, + 343116, + 341293, + 341461, + 341585, + 341647, + 340578, + 341524, + 341472, + 342456, + 342529, + 343380, + 342789, + 342570, + 331958, + 333092, + 332890, + 332803, + 327799, + 327671, + 327952, + 328744, + 330553, + 331132, + 331102, + 332052, + 335257, + 335731, + 336499, + 336549, + 341526, + 341343, + 342731, + 342403, + 334802, + 336329, + 336337, + 336325, + 332016, + 332863, + 331918, + 332539, + 328742, + 328198, + 328085, + 328958, + 326495, + 327747, + 327301, + 327794, + 333843, + 333812, + 334799, + 334529, + 331198, + 331858, + 332511, + 332184, + 333661, + 333754, + 334451, + 335540, + 341422, + 342024, + 341760, + 342715, + 342333, + 342553, + 344323, + 343922, + 330099, + 329370, + 329675, + 329959 + ], + "p50Micros": 335734, + "p95Micros": 343460, + "minMicros": 171921, + "maxMicros": 346553, + "wallMicros": 8399807, + "operationsPerSecond": 23.81007087424747 + }, + "orm": { + "samplesMicros": [ + 162115, + 162551, + 162866, + 163184, + 327421, + 328030, + 328402, + 328754, + 334668, + 336104, + 336048, + 335823, + 337668, + 339815, + 339267, + 339969, + 338618, + 340331, + 340003, + 340077, + 334117, + 333718, + 335505, + 334464, + 324076, + 323357, + 324093, + 326137, + 329353, + 329748, + 330569, + 331461, + 339296, + 340367, + 341254, + 340842, + 340329, + 340445, + 341790, + 342785, + 336281, + 337512, + 337123, + 336576, + 328874, + 328552, + 330336, + 329557, + 326361, + 326829, + 327316, + 326843, + 327506, + 327043, + 327676, + 328271, + 335307, + 335536, + 336520, + 336628, + 344246, + 346055, + 345173, + 346035, + 342974, + 342811, + 343484, + 343057, + 340788, + 340730, + 342551, + 341704, + 343880, + 343966, + 345457, + 345181, + 331680, + 333274, + 332556, + 333118, + 323756, + 324796, + 324828, + 325806, + 333303, + 333537, + 334270, + 334400, + 340522, + 341344, + 342073, + 341343, + 340336, + 340929, + 340977, + 342916, + 337811, + 339000, + 338739, + 338440, + 339478, + 339940, + 340880, + 342102, + 333209, + 333081, + 333024, + 332858, + 328310, + 328312, + 329952, + 329236, + 338233, + 339056, + 339143, + 339879, + 341764, + 341962, + 343231, + 342465, + 346000, + 345501, + 346078, + 346515, + 347785, + 347901, + 349156, + 348102, + 334506, + 335384, + 336236, + 335705, + 327497, + 327641, + 327727, + 329098, + 329118, + 330131, + 329950, + 331352, + 335602, + 336743, + 336583, + 337970, + 341141, + 342484, + 341405, + 342226, + 346640, + 345658, + 346795, + 346663, + 348978, + 348668, + 349444, + 350888, + 334342, + 335562, + 334490, + 335348, + 324643, + 324408, + 325950, + 325381, + 334430, + 334616, + 334601, + 335197, + 343687, + 343573, + 345001, + 344595, + 342350, + 343247, + 342724, + 342544, + 332461, + 334052, + 333818, + 333821, + 333927, + 335238, + 335044, + 334963, + 337898, + 337803, + 337390, + 337926, + 330767, + 331607, + 331713, + 332548, + 330773, + 331377, + 331926, + 332472, + 335461, + 335954, + 335646, + 336914 + ], + "p50Micros": 336048, + "p95Micros": 346640, + "minMicros": 162115, + "maxMicros": 350888, + "wallMicros": 8411642, + "operationsPerSecond": 23.77657061486925 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 149, + 144, + 147, + 149, + 164655, + 165036, + 165367, + 165707, + 165374, + 165062, + 164870, + 164750, + 164739, + 165070, + 165239, + 165265, + 159660, + 159239, + 158967, + 158825, + 167888, + 168063, + 168320, + 168540, + 171381, + 171291, + 171276, + 171455, + 171309, + 171186, + 171114, + 170874, + 169406, + 169279, + 169229, + 169208, + 170623, + 170456, + 170411, + 170376, + 164821, + 164782, + 164748, + 164732, + 165654, + 165480, + 165473, + 165451, + 165891, + 166003, + 166076, + 166170, + 168940, + 168980, + 168896, + 168885, + 172291, + 172247, + 172327, + 172334, + 170506, + 170403, + 170407, + 170604 + ], + "p50Micros": 166170, + "p95Micros": 172247, + "minMicros": 144, + "maxMicros": 172334, + "wallMicros": 10052200 + }, + "sqlMicros": [ + 79623, + 79841, + 80034, + 80129, + 83962, + 84704, + 84788, + 85579, + 81497, + 82019, + 82435, + 82900, + 82218, + 82556, + 82839, + 82678, + 82991, + 82628, + 83071, + 82998, + 81054, + 81421, + 82008, + 82523, + 78210, + 78911, + 79510, + 78684, + 79862, + 80063, + 80258, + 80458, + 80271, + 80614, + 80787, + 80977, + 86452, + 87197, + 87383, + 87586, + 82855, + 83350, + 84152, + 83829, + 86673, + 87266, + 87463, + 88087, + 81920, + 82761, + 81596, + 82503, + 87863, + 88471, + 88927, + 89086, + 81792, + 82350, + 82709, + 83122, + 86192, + 86374, + 86496, + 86795, + 81446, + 82404, + 81953, + 82262, + 87999, + 88042, + 88263, + 88539, + 81915, + 81955, + 82280, + 82750, + 82295, + 82018, + 82281, + 82571, + 81057, + 81724, + 81549, + 81415, + 83672, + 83900, + 83904, + 84232, + 80979, + 80689, + 81086, + 81010, + 84406, + 84589, + 84928, + 85367, + 82286, + 82692, + 83046, + 82362, + 85662, + 86035, + 86166, + 86601, + 82577, + 83008, + 83251, + 83688, + 87799, + 88672, + 89063, + 89489, + 81034, + 82098, + 81802, + 81610, + 88017, + 88329, + 88844, + 88810, + 80348, + 80135, + 79481, + 79977, + 85001, + 85298, + 85827, + 86000 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 170, + 228, + 248, + 264, + 159743, + 160079, + 160309, + 160528, + 163872, + 163842, + 163856, + 163861, + 166556, + 166882, + 166910, + 166971, + 175931, + 175448, + 175436, + 175416, + 167676, + 167663, + 167822, + 167905, + 171077, + 170947, + 170797, + 170701, + 167765, + 168000, + 168074, + 168226, + 164460, + 163916, + 163688, + 163438, + 166115, + 166257, + 166370, + 166489, + 165020, + 164856, + 164798, + 164767, + 166338, + 166413, + 166469, + 166523, + 173283, + 173204, + 173175, + 173192, + 167073, + 167042, + 167104, + 167140, + 170550, + 170305, + 170109, + 169988, + 161044, + 161025, + 161083, + 161080 + ], + "p50Micros": 166523, + "p95Micros": 175416, + "minMicros": 170, + "maxMicros": 175931, + "wallMicros": 10025517 + }, + "sqlMicros": [ + 79050, + 79344, + 79418, + 79589, + 79610, + 80253, + 80652, + 80639, + 78985, + 79447, + 79967, + 79303, + 83336, + 84229, + 84058, + 84468, + 81020, + 81097, + 80773, + 81135, + 84116, + 85334, + 85927, + 85841, + 84401, + 85312, + 86180, + 85356, + 88792, + 89954, + 90042, + 89990, + 80907, + 80707, + 81144, + 81929, + 85110, + 85810, + 86630, + 86781, + 81492, + 80768, + 81685, + 81452, + 88589, + 89242, + 88948, + 89382, + 80037, + 80986, + 80254, + 80919, + 85876, + 86590, + 87520, + 87631, + 81040, + 80684, + 80436, + 80105, + 82573, + 83130, + 83110, + 83172, + 81294, + 81380, + 81788, + 81428, + 84083, + 84300, + 84680, + 84755, + 81732, + 81634, + 82055, + 82772, + 82080, + 82681, + 82322, + 82672, + 78795, + 79481, + 79431, + 79088, + 86152, + 86534, + 87233, + 87334, + 81046, + 81851, + 82697, + 82596, + 89741, + 90018, + 89884, + 92253, + 80757, + 81355, + 81776, + 81085, + 84358, + 85455, + 85599, + 85793, + 82959, + 83522, + 83362, + 83967, + 86556, + 85943, + 85826, + 86631, + 79552, + 79425, + 79749, + 80179, + 80685, + 80974, + 81079, + 81204, + 79173, + 79191, + 78883, + 79224, + 83254, + 83728, + 84082, + 83980 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100))) AS \"orm_partition\" WHERE \"c3\" > $101 AND \"c3\" <= $102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 108 + ], + "sqlMicros": [ + 81587, + 78399 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 2427, + "received": 56982 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100))) AS \"orm_partition\" WHERE \"c3\" > $101 AND \"c3\" <= $102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 219 + ], + "sqlMicros": [ + 80707, + 79474 + ], + "ormDecodeMicros": [ + 242, + 35 + ], + "postgresProtocolBytes": { + "sent": 2427, + "received": 56982 + } + } + } + }, + { + "case": "aggregate", + "latency": { + "raw": { + "samplesMicros": [ + 80667, + 81805, + 80150, + 81642, + 80319, + 80232, + 79796, + 81041, + 80448, + 81614, + 81631, + 81380, + 77320, + 80992, + 81816, + 81472, + 78546, + 78850, + 79904, + 78725, + 79034, + 78514, + 80670, + 80041, + 80591, + 77262, + 77059, + 81030, + 81270, + 78705, + 77952, + 81445, + 77231, + 78432, + 76360, + 78464, + 78683, + 79614, + 78958, + 75740, + 78681, + 75270, + 79350, + 79804, + 77389, + 81679, + 80677, + 78757, + 75917, + 76021, + 79799, + 79950, + 75395, + 80829, + 81403, + 79392, + 75292, + 79282, + 81393, + 76651, + 77358, + 77021, + 78951, + 80078, + 77447, + 81642, + 80675, + 78418, + 78716, + 77635, + 78648, + 80324, + 80484, + 77566, + 80938, + 81064, + 76476, + 79455, + 80528, + 77826, + 79763, + 79771, + 80792, + 77034, + 79911, + 80714, + 80539, + 78639, + 78691, + 80023, + 78462, + 77604, + 77242, + 77854, + 82138, + 80478, + 79325, + 80052, + 77887, + 79144, + 81630, + 78916, + 80850, + 77446, + 79175, + 80168, + 76323, + 78925, + 79578, + 77190, + 79604, + 77480, + 79398, + 77270, + 78612, + 82798, + 79536, + 78929, + 80269, + 77541, + 80613, + 78506, + 80972, + 75882, + 77111, + 80284, + 81612, + 76814, + 78583, + 79048, + 79215, + 77888, + 79683, + 79674, + 80365, + 80508, + 81859, + 80724, + 79563, + 81684, + 80449, + 78589, + 80028, + 81341, + 80018, + 78710, + 80556, + 79229, + 77494, + 82335, + 79062, + 78775, + 81310, + 80942, + 76870, + 77965, + 81224, + 78646, + 80321, + 78324, + 79962, + 81715, + 78598, + 78100, + 79853, + 82146, + 79013, + 79676, + 80907, + 80244, + 79600, + 79760, + 81672, + 80233, + 80602, + 80879, + 79950, + 77592, + 79000, + 79982, + 81689, + 76775, + 79227, + 78061, + 78005, + 81538, + 79039, + 80636, + 81355, + 81258, + 80129, + 79760, + 77693, + 80203, + 80553, + 80419, + 76862, + 77563, + 80266, + 80069 + ], + "p50Micros": 79614, + "p95Micros": 81679, + "minMicros": 75270, + "maxMicros": 82798, + "wallMicros": 15880647, + "operationsPerSecond": 12.593945322252928 + }, + "orm": { + "samplesMicros": [ + 82103, + 80564, + 79523, + 81554, + 79025, + 78134, + 79356, + 80423, + 79917, + 81572, + 83845, + 80283, + 77627, + 81916, + 82529, + 79493, + 81797, + 78299, + 78352, + 77071, + 76092, + 79386, + 79281, + 81836, + 79972, + 77580, + 76781, + 78782, + 78045, + 80864, + 80386, + 81561, + 79740, + 79686, + 81109, + 78844, + 81543, + 80145, + 79481, + 81619, + 78406, + 80347, + 76415, + 76922, + 79314, + 79052, + 79958, + 79276, + 75352, + 78235, + 78105, + 77147, + 78661, + 76825, + 74425, + 75449, + 80333, + 76113, + 80813, + 79432, + 79769, + 76049, + 76480, + 77360, + 80341, + 80267, + 80800, + 76250, + 80458, + 79271, + 79679, + 77608, + 79219, + 80124, + 82018, + 80873, + 79813, + 78799, + 80284, + 78158, + 79781, + 80110, + 79279, + 81531, + 75444, + 77223, + 79694, + 81340, + 79980, + 83314, + 81532, + 78367, + 80739, + 83586, + 80079, + 78634, + 81316, + 80628, + 77601, + 81227, + 82029, + 77172, + 78521, + 80124, + 80671, + 80932, + 81267, + 80127, + 82333, + 79266, + 79884, + 78674, + 74549, + 77189, + 77154, + 80959, + 78972, + 79927, + 83036, + 78275, + 79793, + 81195, + 78913, + 80260, + 82294, + 80204, + 79313, + 78633, + 82169, + 78272, + 81504, + 82396, + 81158, + 80587, + 78645, + 80758, + 80570, + 77259, + 81767, + 79420, + 79244, + 77046, + 80767, + 80134, + 79988, + 81173, + 79836, + 80296, + 79197, + 81143, + 77851, + 79982, + 79135, + 81218, + 79444, + 78035, + 80538, + 79665, + 79843, + 78378, + 80229, + 79896, + 78539, + 75052, + 80261, + 79922, + 80273, + 79251, + 82811, + 79960, + 80294, + 82965, + 81369, + 80089, + 82250, + 82137, + 78011, + 76235, + 81852, + 80038, + 81778, + 78069, + 83442, + 79466, + 78675, + 79971, + 79120, + 79312, + 81629, + 79227, + 80047, + 78088, + 78401, + 77640, + 81337, + 80931, + 78912, + 83182, + 79638, + 76501 + ], + "p50Micros": 79836, + "p95Micros": 82333, + "minMicros": 74425, + "maxMicros": 83845, + "wallMicros": 15927605, + "operationsPerSecond": 12.556815666887772 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 81950, + 82480, + 82537, + 82595, + 165523, + 165658, + 165710, + 165763, + 165823, + 166152, + 166658, + 166119, + 162103, + 162193, + 162203, + 162219, + 159322, + 159256, + 159511, + 159489, + 160498, + 161016, + 160783, + 160916, + 160303, + 160381, + 160394, + 160910, + 162064, + 162461, + 162755, + 163059, + 163252, + 163493, + 163616, + 163853, + 160887, + 160313, + 160737, + 160552, + 158741, + 158919, + 159144, + 158904, + 156431, + 156625, + 156599, + 156875, + 155547, + 155668, + 155828, + 156019, + 162539, + 163047, + 163151, + 163511, + 166714, + 167259, + 167193, + 167196, + 163329, + 163692, + 163936, + 164391, + 162136, + 162240, + 162720, + 162534, + 159686, + 160037, + 159813, + 159798, + 162016, + 162461, + 162383, + 162316, + 161405, + 161802, + 161648, + 161762, + 158465, + 158730, + 159093, + 158956, + 159301, + 159792, + 159721, + 159675, + 157150, + 157583, + 157450, + 157376, + 158096, + 158313, + 158765, + 158391, + 159395, + 159420, + 159576, + 159775, + 156029, + 156338, + 156272, + 156727, + 156724, + 157078, + 157222, + 157129, + 160522, + 160400, + 160418, + 160583, + 160509, + 160383, + 160402, + 160599, + 158923, + 159281, + 159152, + 159165, + 159139, + 159431, + 159849, + 159549, + 159848, + 160373, + 160788, + 160683, + 161373, + 161665, + 162242, + 161970, + 160991, + 161916, + 161414, + 161614, + 157172, + 157502, + 157492, + 157867, + 159365, + 159484, + 159940, + 159766, + 162873, + 163107, + 163422, + 163232, + 162298, + 162403, + 162766, + 162620, + 159887, + 159662, + 159619, + 159550, + 157574, + 157917, + 157926, + 158194, + 157070, + 157118, + 157153, + 157294, + 156937, + 157096, + 157467, + 157173, + 161399, + 161837, + 161743, + 161671, + 164958, + 165008, + 165349, + 165064, + 162639, + 162700, + 163104, + 162904, + 158679, + 158759, + 159078, + 158972, + 159413, + 160100, + 160646, + 160647, + 164384, + 164448, + 164608, + 164968, + 164132, + 165135, + 164212, + 164517, + 159946, + 159692, + 159816, + 159692 + ], + "p50Micros": 160383, + "p95Micros": 165658, + "minMicros": 81950, + "maxMicros": 167259, + "wallMicros": 4018563, + "operationsPerSecond": 49.7690343538225 + }, + "orm": { + "samplesMicros": [ + 82677, + 83011, + 83150, + 83301, + 163291, + 163568, + 163623, + 163659, + 159037, + 158735, + 158693, + 158947, + 156716, + 156867, + 156888, + 157324, + 158504, + 158673, + 159016, + 158893, + 160001, + 160214, + 160261, + 160291, + 156637, + 158527, + 158975, + 158669, + 155885, + 156559, + 156023, + 156202, + 156128, + 156766, + 158963, + 157287, + 158289, + 159334, + 159245, + 159265, + 160914, + 160969, + 160893, + 160895, + 162804, + 164306, + 163719, + 164010, + 166270, + 166089, + 166122, + 166122, + 164341, + 164016, + 163944, + 163901, + 166268, + 166026, + 166011, + 166401, + 167303, + 167677, + 167847, + 168184, + 161170, + 161860, + 161591, + 161822, + 156594, + 156803, + 157225, + 156881, + 156101, + 156477, + 156879, + 156753, + 156498, + 157422, + 157250, + 157267, + 156157, + 156386, + 156625, + 156948, + 158870, + 159802, + 159322, + 159322, + 162672, + 163454, + 163885, + 164469, + 162779, + 163209, + 163863, + 163531, + 162419, + 163703, + 164975, + 163391, + 160917, + 161671, + 161245, + 161448, + 159951, + 160126, + 160805, + 160521, + 160862, + 160761, + 161078, + 160998, + 160341, + 161301, + 160909, + 161145, + 163280, + 163969, + 164657, + 164150, + 162576, + 162969, + 162927, + 163373, + 160133, + 160115, + 160797, + 160131, + 159050, + 159122, + 159163, + 159362, + 161812, + 162289, + 162017, + 162151, + 161932, + 162374, + 162397, + 162897, + 162103, + 162228, + 162659, + 162337, + 162901, + 163177, + 163765, + 163606, + 163675, + 164264, + 164315, + 164445, + 162483, + 163320, + 163068, + 162942, + 160604, + 161205, + 160707, + 161738, + 159842, + 160673, + 160393, + 160609, + 157806, + 158333, + 158057, + 158230, + 160603, + 161210, + 161767, + 162432, + 160817, + 161086, + 161667, + 161387, + 155509, + 154768, + 154990, + 154693, + 152290, + 152534, + 153070, + 152432, + 153050, + 153475, + 153513, + 153624, + 157782, + 158170, + 158559, + 158547, + 162042, + 161844, + 161986, + 162301, + 161917, + 161645, + 162350, + 161896 + ], + "p50Micros": 160893, + "p95Micros": 166026, + "minMicros": 82677, + "maxMicros": 168184, + "wallMicros": 4016613, + "operationsPerSecond": 49.793196407022535 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 143, + 131, + 133, + 135, + 81174, + 81489, + 81566, + 81687, + 79372, + 79034, + 78994, + 78970, + 80562, + 80496, + 80545, + 80575, + 81570, + 81561, + 81526, + 81577, + 79609, + 79252, + 79183, + 79134, + 78777, + 78702, + 78695, + 78703, + 82270, + 82184, + 82196, + 82206, + 79830, + 79730, + 79702, + 79689, + 76564, + 76451, + 76429, + 76408, + 78026, + 77979, + 77978, + 77985, + 77020, + 76974, + 76979, + 76993, + 80126, + 80098, + 80111, + 80139, + 81118, + 81094, + 81142, + 81181, + 81684, + 81602, + 81561, + 81544, + 81271, + 81383, + 81407, + 81467 + ], + "p50Micros": 79830, + "p95Micros": 82184, + "minMicros": 131, + "maxMicros": 82270, + "wallMicros": 4793816 + }, + "sqlMicros": [ + 80846, + 81327, + 81469, + 81507, + 78792, + 78891, + 78831, + 79095, + 79891, + 80499, + 80559, + 80585, + 80884, + 81227, + 81890, + 81601, + 79257, + 79160, + 79002, + 79211, + 78316, + 78769, + 78728, + 78644, + 81814, + 82260, + 82219, + 82145, + 79289, + 79685, + 79660, + 79933, + 76207, + 76297, + 76566, + 76503, + 77780, + 78024, + 77916, + 77995, + 76691, + 76939, + 77073, + 77001, + 79775, + 80055, + 80078, + 80278, + 80912, + 80897, + 81146, + 81189, + 81096, + 81675, + 81508, + 81699, + 80791, + 81211, + 81574, + 81564, + 81683, + 82201, + 81705, + 81994 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 184, + 280, + 302, + 319, + 82689, + 82948, + 83097, + 83252, + 81109, + 80999, + 80939, + 80919, + 82539, + 82240, + 82268, + 82332, + 84221, + 84183, + 84163, + 84149, + 81572, + 81415, + 81392, + 81366, + 82883, + 82829, + 82808, + 82806, + 77615, + 77613, + 77574, + 77556, + 80648, + 80189, + 80164, + 80160, + 78923, + 78926, + 78943, + 78956, + 79512, + 79475, + 79480, + 79503, + 80244, + 80194, + 80191, + 80200, + 81303, + 81385, + 81432, + 81480, + 81431, + 81347, + 81509, + 81540, + 83715, + 83632, + 83512, + 83418, + 81030, + 80929, + 80905, + 80885 + ], + "p50Micros": 81109, + "p95Micros": 84149, + "minMicros": 184, + "maxMicros": 84221, + "wallMicros": 4875722 + }, + "sqlMicros": [ + 82240, + 82614, + 82858, + 82869, + 80220, + 80761, + 81184, + 80767, + 81762, + 81962, + 82194, + 82561, + 83301, + 84093, + 84372, + 84217, + 80851, + 81320, + 81625, + 81263, + 82501, + 82551, + 82847, + 82824, + 76809, + 77546, + 77766, + 77506, + 79964, + 80076, + 80074, + 80333, + 78584, + 78951, + 78953, + 78913, + 79033, + 79406, + 79592, + 79505, + 79722, + 80130, + 80142, + 80394, + 80704, + 81397, + 81370, + 81557, + 81051, + 81111, + 81350, + 81439, + 82722, + 83546, + 83357, + 83923, + 80369, + 80989, + 80848, + 81032, + 79666, + 79486, + 79423, + 79623 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 55 + ], + "sqlMicros": [ + 79175 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 244, + "received": 413 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 126 + ], + "sqlMicros": [ + 82060 + ], + "ormDecodeMicros": [ + 9 + ], + "postgresProtocolBytes": { + "sent": 244, + "received": 413 + } + } + } + } + ] + } + ] +} diff --git a/research/benchmarks/runtime.json b/research/benchmarks/runtime.json new file mode 100644 index 00000000..5c63d6e9 --- /dev/null +++ b/research/benchmarks/runtime.json @@ -0,0 +1,17876 @@ +{ + "recordedAt": "2026-09-15T06:41:48.294527Z", + "sourceCommit": "94d24fed7b008f2444830f9088e73ee830aa092a", + "harnessSha256": { + "tool/benchmark_runtime.dart": "4613bf4cd128018db02d907c5cf34d77c4235bc6f010c9534e92594431912366", + "tool/src/runtime_cases.dart": "fbd025c22388055452994d476d2ca1118bf123537d7c5f09a35c887d8887608c", + "tool/src/runtime_relay.dart": "cb6eb51792a6993c0143eea08f9f5bb9a6ebb31a3da32821047371709cde44cc", + "tool/src/runtime_vm.dart": "9a1f4f87be3371f9cee2d9a093def47cdf4679764d69ac219ba218a3a0931ca5" + }, + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "os": "Version 26.6.2 (Build 25G83)", + "cpu": "Apple M3 Max", + "samplesPerLane": 200, + "warmupPairs": 20, + "smoke": false, + "scope": "Warm native JIT, reused query objects; raw uses the same public Driver and precompiled identical SQL with manual same-codec mapping. ORM includes compilation. Default hooks and allocation tracing are disabled in timing. Loopback relay has no loss or bandwidth limit; delayed scenario is controlled TCP latency, not a remote-server measurement. PostgreSQL protocol bytes exclude TCP/IP headers. SQLite byte counts are UTF-8 JSON of driver rows, not IPC wire bytes. Heap fields are live isolate-group memory; traces count selected classes only, not total allocated bytes.", + "fixture": { + "users": 100, + "postsPerUser": 10, + "selectedPostsPerUser": 3, + "nullableNicknames": true, + "sqlite": "temporary file, WAL, warm cache", + "postgresPool": 4, + "concurrentClients": 8 + }, + "results": [ + { + "scenario": "sqlite_wal", + "environment": { + "backend": "sqlite_wal", + "databaseVersion": "3.53.4", + "pid": 94368 + }, + "oneWayDelayMs": 0, + "selectOneMicros": { + "samplesMicros": [ + 401, + 59, + 45, + 40, + 36, + 36, + 37, + 40, + 34, + 36 + ], + "p50Micros": 37, + "p95Micros": 401, + "minMicros": 34, + "maxMicros": 401, + "wallMicros": 764, + "operationsPerSecond": 13089.005235602095 + }, + "cases": [ + { + "case": "full_rows", + "latency": { + "raw": { + "samplesMicros": [ + 229, + 169, + 171, + 173, + 167, + 176, + 167, + 264, + 173, + 171, + 166, + 158, + 148, + 157, + 146, + 162, + 158, + 155, + 153, + 158, + 159, + 169, + 182, + 167, + 156, + 159, + 131, + 136, + 132, + 127, + 125, + 128, + 124, + 130, + 124, + 127, + 125, + 137, + 130, + 126, + 132, + 126, + 128, + 126, + 124, + 127, + 123, + 128, + 123, + 130, + 129, + 269, + 150, + 146, + 140, + 121, + 155, + 116, + 111, + 115, + 110, + 113, + 106, + 112, + 188, + 114, + 207, + 121, + 151, + 121, + 175, + 117, + 107, + 111, + 101, + 110, + 105, + 112, + 104, + 114, + 105, + 113, + 116, + 120, + 114, + 109, + 101, + 109, + 102, + 131, + 116, + 115, + 104, + 111, + 101, + 163, + 106, + 108, + 142, + 110, + 101, + 107, + 166, + 111, + 103, + 109, + 101, + 109, + 100, + 102, + 100, + 108, + 100, + 109, + 101, + 108, + 100, + 108, + 100, + 109, + 101, + 109, + 101, + 109, + 101, + 123, + 107, + 111, + 102, + 109, + 101, + 110, + 194, + 116, + 105, + 111, + 102, + 110, + 99, + 110, + 108, + 107, + 104, + 109, + 128, + 112, + 187, + 111, + 101, + 110, + 100, + 115, + 103, + 113, + 99, + 105, + 100, + 102, + 101, + 109, + 113, + 109, + 117, + 102, + 216, + 112, + 102, + 110, + 102, + 112, + 102, + 108, + 100, + 107, + 98, + 106, + 101, + 102, + 100, + 109, + 99, + 101, + 100, + 118, + 103, + 109, + 100, + 110, + 155, + 100, + 99, + 108, + 99, + 108, + 98, + 109, + 107, + 102, + 101, + 108 + ], + "p50Micros": 111, + "p95Micros": 175, + "minMicros": 98, + "maxMicros": 269, + "wallMicros": 24698, + "operationsPerSecond": 8097.821685966475 + }, + "orm": { + "samplesMicros": [ + 254, + 234, + 212, + 215, + 216, + 209, + 212, + 204, + 219, + 231, + 206, + 192, + 192, + 189, + 197, + 198, + 224, + 288, + 287, + 205, + 226, + 242, + 236, + 233, + 224, + 296, + 205, + 197, + 200, + 188, + 185, + 186, + 184, + 186, + 188, + 180, + 458, + 203, + 190, + 188, + 186, + 182, + 194, + 179, + 183, + 180, + 182, + 179, + 183, + 180, + 440, + 186, + 250, + 226, + 202, + 185, + 180, + 165, + 163, + 154, + 160, + 155, + 161, + 155, + 166, + 162, + 171, + 165, + 216, + 194, + 2614, + 166, + 160, + 152, + 152, + 147, + 154, + 147, + 153, + 194, + 154, + 168, + 220, + 189, + 163, + 150, + 153, + 147, + 151, + 190, + 162, + 171, + 153, + 154, + 149, + 143, + 148, + 146, + 175, + 148, + 159, + 166, + 164, + 149, + 150, + 142, + 176, + 149, + 147, + 143, + 144, + 143, + 148, + 147, + 144, + 143, + 144, + 141, + 143, + 139, + 143, + 143, + 143, + 141, + 180, + 231, + 161, + 149, + 149, + 143, + 149, + 162, + 187, + 156, + 160, + 148, + 144, + 140, + 146, + 138, + 205, + 155, + 146, + 170, + 176, + 160, + 140, + 137, + 143, + 134, + 229, + 156, + 145, + 138, + 355, + 208, + 152, + 139, + 162, + 161, + 167, + 163, + 153, + 267, + 160, + 146, + 142, + 135, + 135, + 138, + 142, + 135, + 139, + 135, + 137, + 131, + 143, + 137, + 143, + 131, + 135, + 136, + 140, + 223, + 198, + 137, + 139, + 130, + 138, + 134, + 138, + 129, + 138, + 129, + 134, + 130, + 139, + 141, + 135, + 133 + ], + "p50Micros": 160, + "p95Micros": 242, + "minMicros": 129, + "maxMicros": 2614, + "wallMicros": 37076, + "operationsPerSecond": 5394.325169921243 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 152, + 256, + 359, + 461, + 649, + 755, + 854, + 952, + 905, + 897, + 893, + 888, + 799, + 790, + 790, + 791, + 791, + 793, + 807, + 810, + 812, + 812, + 812, + 812, + 813, + 812, + 797, + 797, + 795, + 795, + 797, + 939, + 944, + 942, + 940, + 938, + 939, + 939, + 936, + 792, + 788, + 790, + 793, + 844, + 846, + 922, + 946, + 951, + 953, + 952, + 951, + 900, + 898, + 822, + 798, + 792, + 807, + 813, + 901, + 907, + 905, + 904, + 908, + 930, + 929, + 935, + 856, + 865, + 875, + 943, + 943, + 923, + 906, + 901, + 892, + 877, + 867, + 801, + 866, + 880, + 980, + 986, + 993, + 1070, + 1077, + 1078, + 1009, + 993, + 892, + 877, + 871, + 972, + 991, + 998, + 1014, + 1030, + 1046, + 1066, + 1082, + 924, + 918, + 1079, + 1083, + 1073, + 1068, + 1051, + 1040, + 1022, + 1001, + 828, + 808, + 801, + 789, + 794, + 847, + 848, + 848, + 849, + 852, + 851, + 851, + 847, + 788, + 784, + 784, + 783, + 780, + 779, + 780, + 776, + 773, + 775, + 775, + 774, + 775, + 776, + 776, + 777, + 778, + 776, + 775, + 776, + 775, + 776, + 777, + 778, + 779, + 780, + 785, + 808, + 878, + 883, + 883, + 882, + 880, + 970, + 979, + 970, + 919, + 940, + 947, + 947, + 947, + 855, + 844, + 832, + 812, + 787, + 783, + 783, + 783, + 783, + 782, + 780, + 786, + 786, + 781, + 780, + 785, + 785, + 785, + 791, + 783, + 784, + 785, + 786, + 782, + 782, + 788, + 786, + 786, + 788, + 787, + 813, + 814, + 812, + 806, + 800, + 800, + 793 + ], + "p50Micros": 814, + "p95Micros": 1046, + "minMicros": 152, + "maxMicros": 1083, + "wallMicros": 21823, + "operationsPerSecond": 9164.642808046556 + }, + "orm": { + "samplesMicros": [ + 315, + 432, + 582, + 727, + 881, + 1021, + 1170, + 1362, + 1249, + 1261, + 1390, + 1424, + 1424, + 1432, + 1430, + 1389, + 1374, + 1356, + 1210, + 1171, + 1153, + 1142, + 1134, + 1121, + 1123, + 1121, + 1124, + 1197, + 1195, + 1208, + 1202, + 1218, + 1212, + 1214, + 1217, + 1145, + 1240, + 1240, + 1267, + 1264, + 1280, + 1374, + 1372, + 1382, + 1290, + 1282, + 1257, + 1243, + 1231, + 1134, + 1127, + 1111, + 1109, + 1108, + 1126, + 1199, + 1241, + 1263, + 1290, + 1299, + 1303, + 1306, + 1391, + 1316, + 1275, + 1248, + 1224, + 1213, + 1208, + 1195, + 1089, + 1084, + 1074, + 1076, + 1128, + 1129, + 1151, + 1152, + 1160, + 1159, + 1168, + 1162, + 1109, + 1105, + 1082, + 1079, + 1071, + 1070, + 1064, + 1064, + 1059, + 1065, + 1063, + 1064, + 1075, + 1079, + 1083, + 1119, + 1120, + 1118, + 1118, + 1119, + 1105, + 1102, + 1098, + 1062, + 1060, + 1058, + 1057, + 1054, + 1055, + 1065, + 1078, + 1157, + 1219, + 1221, + 1241, + 1257, + 1296, + 1281, + 1298, + 1233, + 1175, + 1187, + 1179, + 1173, + 1133, + 1139, + 1110, + 1094, + 1096, + 1082, + 1076, + 1065, + 1067, + 1072, + 1070, + 1088, + 1082, + 1075, + 1064, + 1061, + 1069, + 1064, + 1090, + 1080, + 1093, + 1099, + 1111, + 1118, + 1112, + 1109, + 1089, + 1085, + 1075, + 1069, + 1064, + 1056, + 1052, + 1053, + 1040, + 1035, + 1028, + 1031, + 1036, + 1044, + 1148, + 1162, + 1171, + 1184, + 1194, + 1199, + 1192, + 1194, + 1091, + 1080, + 1078, + 1067, + 1081, + 1073, + 1078, + 1073, + 1077, + 1073, + 1073, + 1077, + 1067, + 1072, + 1066, + 1066, + 1064, + 1062, + 1062, + 1054, + 1050, + 1096, + 1097, + 1095, + 1089, + 1064 + ], + "p50Micros": 1111, + "p95Micros": 1372, + "minMicros": 315, + "maxMicros": 1432, + "wallMicros": 28905, + "operationsPerSecond": 6919.2181283514965 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 22, + 162, + 277, + 392, + 508, + 623, + 738, + 853, + 805, + 806, + 807, + 807, + 808, + 808, + 840, + 844, + 845, + 840, + 1196, + 1203, + 1201, + 1169, + 1167, + 1168, + 1173, + 817, + 813, + 811, + 811, + 810, + 809, + 808, + 809, + 815, + 815, + 823, + 823, + 871, + 936, + 923, + 899, + 884, + 862, + 847, + 784, + 704, + 701, + 699, + 699, + 702, + 701, + 722, + 737, + 753, + 769, + 788, + 806, + 822, + 818, + 822, + 825, + 824, + 815, + 814 + ], + "p50Micros": 811, + "p95Micros": 1173, + "minMicros": 22, + "maxMicros": 1203, + "wallMicros": 51353 + }, + "sqlMicros": [ + 124, + 104, + 105, + 105, + 105, + 105, + 104, + 105, + 104, + 106, + 105, + 106, + 105, + 133, + 105, + 106, + 101, + 461, + 112, + 107, + 106, + 105, + 105, + 106, + 105, + 109, + 104, + 105, + 105, + 105, + 104, + 106, + 115, + 105, + 112, + 105, + 149, + 169, + 95, + 92, + 90, + 91, + 90, + 92, + 90, + 90, + 90, + 91, + 94, + 90, + 110, + 105, + 106, + 105, + 109, + 110, + 107, + 106, + 109, + 109, + 104, + 104, + 105, + 107 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 23, + 198, + 359, + 540, + 708, + 856, + 1015, + 1158, + 1070, + 1057, + 1014, + 993, + 979, + 1178, + 1174, + 1176, + 1167, + 1176, + 1169, + 1171, + 956, + 947, + 941, + 938, + 935, + 938, + 939, + 940, + 936, + 938, + 937, + 952, + 983, + 991, + 1014, + 1020, + 1014, + 1014, + 984, + 964, + 944, + 930, + 926, + 1191, + 1252, + 2248, + 2292, + 2327, + 2340, + 2097, + 2088, + 1044, + 1027, + 983, + 958, + 941, + 943, + 934, + 928, + 918, + 913, + 903, + 901, + 894 + ], + "p50Micros": 964, + "p95Micros": 2248, + "minMicros": 23, + "maxMicros": 2340, + "wallMicros": 68384 + }, + "sqlMicros": [ + 123, + 115, + 136, + 127, + 112, + 121, + 108, + 103, + 109, + 102, + 107, + 103, + 324, + 113, + 105, + 102, + 110, + 102, + 104, + 102, + 105, + 102, + 104, + 104, + 103, + 106, + 103, + 103, + 103, + 107, + 117, + 126, + 112, + 121, + 109, + 99, + 104, + 102, + 103, + 104, + 103, + 103, + 119, + 140, + 121, + 139, + 127, + 112, + 101, + 107, + 102, + 107, + 102, + 107, + 103, + 104, + 102, + 99, + 101, + 99, + 101, + 100, + 99, + 99 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 11 + ], + "sqlMicros": [ + 132 + ], + "ormDecodeMicros": [] + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 13 + ], + "sqlMicros": [ + 136 + ], + "ormDecodeMicros": [ + 52 + ] + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1365686504708, + "endMicros": 1365686505876, + "retainedRootRows": 100, + "rssBefore": 308232192, + "rssAfter": 308969472, + "processLifetimeMaxRss": 531660800 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14600544, + "heapCapacity": 28377088, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503076", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 109, + "bytesCurrent": 5104 + }, + { + "name": "_Map", + "instancesCurrent": 14, + "bytesCurrent": 896 + }, + { + "name": "_List", + "instancesCurrent": 40105, + "bytesCurrent": 3101440 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1130, + "bytesCurrent": 36160 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14662864, + "heapCapacity": 26066944, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503220", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 209, + "bytesCurrent": 9904 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40230, + "bytesCurrent": 3111184 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1232, + "bytesCurrent": 39424 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 342, + "counts": { + "_List": 33, + "_Record": 303, + "_GrowableList": 6 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 300 + }, + { + "function": "_allocateData", + "samples": 21 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_instanceOf", + "samples": 6 + }, + { + "function": "_request", + "samples": 3 + }, + { + "function": "_worker", + "samples": 2 + }, + { + "function": "_List", + "samples": 1 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1365686675414, + "endMicros": 1365686676658, + "retainedRootRows": 100, + "rssBefore": 315424768, + "rssAfter": 316555264, + "processLifetimeMaxRss": 531660800 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14646096, + "heapCapacity": 26066944, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503251", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 109, + "bytesCurrent": 5104 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40126, + "bytesCurrent": 3102576 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1128, + "bytesCurrent": 36096 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14663024, + "heapCapacity": 26591232, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503391", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 209, + "bytesCurrent": 9904 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40230, + "bytesCurrent": 3111184 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1232, + "bytesCurrent": 39424 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 1569, + "counts": { + "_List": 807, + "_SelectionPlan": 3, + "_GrowableList": 432, + "_Map": 18, + "_Record": 309 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 765 + }, + { + "function": "", + "samples": 600 + }, + { + "function": "quote", + "samples": 48 + }, + { + "function": "_write", + "samples": 33 + }, + { + "function": "writeSql", + "samples": 21 + }, + { + "function": "_List", + "samples": 17 + }, + { + "function": "_instanceOf", + "samples": 12 + }, + { + "function": "Map._fromLiteral", + "samples": 12 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_addPart", + "samples": 12 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_plan", + "samples": 6 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "two_columns", + "latency": { + "raw": { + "samplesMicros": [ + 65, + 64, + 65, + 64, + 65, + 64, + 64, + 64, + 65, + 65, + 64, + 64, + 65, + 64, + 71, + 64, + 65, + 64, + 65, + 64, + 65, + 58, + 58, + 53, + 52, + 64, + 56, + 55, + 55, + 69, + 66, + 59, + 69, + 65, + 65, + 64, + 64, + 63, + 65, + 64, + 67, + 64, + 65, + 64, + 64, + 64, + 65, + 63, + 65, + 64, + 64, + 662, + 67, + 65, + 67, + 66, + 67, + 136, + 192, + 69, + 68, + 65, + 70, + 99, + 72, + 70, + 69, + 66, + 66, + 64, + 65, + 118, + 109, + 69, + 67, + 165, + 82, + 83, + 68, + 99, + 111, + 57, + 53, + 54, + 60, + 64, + 65, + 113, + 81, + 55, + 55, + 64, + 64, + 53, + 51, + 50, + 51, + 50, + 52, + 50, + 50, + 83, + 51, + 50, + 51, + 51, + 50, + 49, + 49, + 49, + 50, + 55, + 51, + 51, + 51, + 51, + 52, + 51, + 50, + 89, + 71, + 55, + 52, + 51, + 50, + 50, + 50, + 51, + 61, + 137, + 68, + 52, + 50, + 54, + 60, + 50, + 51, + 49, + 50, + 50, + 50, + 49, + 49, + 49, + 50, + 49, + 51, + 49, + 50, + 49, + 50, + 50, + 49, + 49, + 50, + 49, + 49, + 49, + 51, + 50, + 53, + 50, + 51, + 50, + 50, + 50, + 50, + 50, + 51, + 50, + 49, + 51, + 50, + 50, + 52, + 49, + 50, + 49, + 50, + 50, + 51, + 68, + 64, + 65, + 64, + 50, + 56, + 51, + 50, + 49, + 55, + 50, + 51, + 83, + 51, + 63, + 57, + 51, + 51, + 50 + ], + "p50Micros": 55, + "p95Micros": 99, + "minMicros": 49, + "maxMicros": 662, + "wallMicros": 12883, + "operationsPerSecond": 15524.33439416285 + }, + "orm": { + "samplesMicros": [ + 80, + 82, + 79, + 82, + 80, + 82, + 79, + 82, + 78, + 82, + 80, + 81, + 78, + 81, + 80, + 83, + 79, + 82, + 85, + 82, + 75, + 76, + 81, + 71, + 715, + 89, + 87, + 74, + 91, + 83, + 88, + 87, + 84, + 84, + 87, + 84, + 83, + 81, + 84, + 81, + 87, + 82, + 88, + 83, + 83, + 81, + 83, + 81, + 85, + 83, + 84, + 82, + 696, + 246, + 84, + 85, + 80, + 88, + 112, + 89, + 83, + 82, + 158, + 183, + 94, + 189, + 92, + 87, + 77, + 75, + 81, + 172, + 97, + 154, + 107, + 88, + 85, + 139, + 108, + 86, + 108, + 87, + 75, + 78, + 77, + 75, + 80, + 73, + 80, + 95, + 67, + 79, + 111, + 76, + 66, + 64, + 65, + 72, + 66, + 64, + 66, + 77, + 64, + 63, + 66, + 68, + 65, + 64, + 65, + 72, + 87, + 86, + 69, + 72, + 67, + 67, + 65, + 65, + 75, + 68, + 87, + 79, + 70, + 74, + 65, + 64, + 65, + 72, + 71, + 67, + 87, + 76, + 66, + 66, + 73, + 65, + 63, + 64, + 64, + 63, + 66, + 65, + 64, + 64, + 64, + 64, + 65, + 64, + 64, + 64, + 64, + 64, + 64, + 71, + 64, + 63, + 65, + 64, + 64, + 65, + 65, + 65, + 66, + 64, + 65, + 65, + 64, + 72, + 66, + 65, + 65, + 66, + 64, + 64, + 66, + 64, + 63, + 66, + 65, + 72, + 145, + 100, + 77, + 73, + 127, + 73, + 65, + 66, + 66, + 65, + 66, + 66, + 66, + 72, + 69, + 77, + 64, + 65, + 65, + 64 + ], + "p50Micros": 75, + "p95Micros": 127, + "minMicros": 63, + "maxMicros": 715, + "wallMicros": 17106, + "operationsPerSecond": 11691.804045364199 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 212, + 276, + 352, + 413, + 471, + 528, + 588, + 646, + 506, + 498, + 488, + 487, + 488, + 491, + 492, + 490, + 489, + 490, + 485, + 491, + 541, + 581, + 600, + 617, + 630, + 641, + 653, + 659, + 619, + 668, + 684, + 678, + 689, + 698, + 683, + 663, + 648, + 553, + 512, + 500, + 474, + 449, + 447, + 449, + 449, + 462, + 461, + 463, + 465, + 477, + 488, + 563, + 578, + 622, + 632, + 638, + 679, + 664, + 648, + 571, + 554, + 495, + 483, + 470, + 422, + 422, + 424, + 424, + 423, + 423, + 422, + 424, + 424, + 424, + 424, + 424, + 424, + 424, + 425, + 425, + 424, + 500, + 498, + 498, + 504, + 545, + 558, + 561, + 558, + 483, + 491, + 489, + 483, + 446, + 437, + 435, + 439, + 436, + 430, + 432, + 431, + 427, + 423, + 425, + 425, + 434, + 444, + 453, + 463, + 472, + 482, + 483, + 491, + 528, + 522, + 513, + 503, + 494, + 483, + 480, + 469, + 428, + 423, + 423, + 423, + 422, + 422, + 422, + 423, + 422, + 423, + 424, + 421, + 423, + 424, + 425, + 425, + 422, + 419, + 418, + 417, + 416, + 414, + 414, + 414, + 417, + 420, + 420, + 423, + 423, + 423, + 423, + 422, + 422, + 422, + 432, + 431, + 430, + 432, + 432, + 433, + 433, + 433, + 422, + 424, + 425, + 424, + 424, + 424, + 424, + 423, + 424, + 424, + 429, + 429, + 426, + 429, + 430, + 432, + 433, + 431, + 424, + 425, + 435, + 443, + 447, + 447, + 447, + 459, + 479, + 488, + 515, + 501, + 496, + 497, + 495, + 484, + 467, + 457, + 417 + ], + "p50Micros": 446, + "p95Micros": 653, + "minMicros": 212, + "maxMicros": 698, + "wallMicros": 12102, + "operationsPerSecond": 16526.194017517766 + }, + "orm": { + "samplesMicros": [ + 112, + 188, + 264, + 341, + 428, + 585, + 667, + 745, + 744, + 763, + 773, + 778, + 772, + 699, + 698, + 726, + 703, + 686, + 671, + 670, + 668, + 695, + 686, + 664, + 663, + 645, + 638, + 631, + 623, + 578, + 572, + 553, + 553, + 558, + 556, + 545, + 538, + 534, + 532, + 531, + 535, + 545, + 562, + 565, + 566, + 578, + 583, + 582, + 571, + 559, + 540, + 537, + 533, + 520, + 514, + 511, + 508, + 510, + 522, + 533, + 544, + 563, + 575, + 587, + 597, + 603, + 641, + 640, + 629, + 609, + 596, + 590, + 579, + 568, + 518, + 520, + 531, + 543, + 554, + 559, + 569, + 618, + 633, + 621, + 615, + 604, + 593, + 582, + 571, + 524, + 507, + 506, + 501, + 502, + 503, + 502, + 503, + 507, + 507, + 506, + 506, + 570, + 587, + 586, + 585, + 580, + 580, + 580, + 580, + 516, + 498, + 505, + 507, + 507, + 507, + 506, + 506, + 506, + 506, + 500, + 500, + 500, + 500, + 500, + 500, + 500, + 500, + 499, + 500, + 584, + 593, + 595, + 600, + 599, + 599, + 598, + 598, + 514, + 508, + 506, + 505, + 535, + 536, + 536, + 534, + 533, + 531, + 532, + 528, + 498, + 498, + 498, + 498, + 515, + 526, + 526, + 527, + 527, + 527, + 527, + 527, + 510, + 502, + 501, + 519, + 562, + 572, + 574, + 577, + 576, + 576, + 575, + 557, + 514, + 504, + 518, + 527, + 526, + 526, + 525, + 525, + 525, + 525, + 509, + 499, + 500, + 501, + 501, + 501, + 501, + 500, + 500, + 500, + 499, + 502, + 541, + 554, + 564, + 564, + 605 + ], + "p50Micros": 535, + "p95Micros": 695, + "minMicros": 112, + "maxMicros": 778, + "wallMicros": 14125, + "operationsPerSecond": 14159.29203539823 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 19, + 83, + 137, + 187, + 250, + 305, + 356, + 407, + 374, + 370, + 370, + 353, + 354, + 352, + 352, + 353, + 353, + 353, + 354, + 350, + 349, + 348, + 348, + 348, + 348, + 352, + 366, + 381, + 394, + 407, + 409, + 426, + 434, + 433, + 432, + 433, + 518, + 528, + 516, + 503, + 489, + 476, + 463, + 360, + 350, + 347, + 347, + 348, + 348, + 347, + 348, + 348, + 347, + 347, + 347, + 346, + 346, + 346, + 345, + 345, + 344, + 343, + 343, + 342 + ], + "p50Micros": 350, + "p95Micros": 503, + "minMicros": 19, + "maxMicros": 528, + "wallMicros": 23017 + }, + "sqlMicros": [ + 61, + 49, + 46, + 58, + 50, + 47, + 46, + 45, + 45, + 45, + 45, + 49, + 45, + 46, + 46, + 45, + 45, + 46, + 46, + 45, + 45, + 46, + 45, + 45, + 50, + 59, + 59, + 58, + 59, + 47, + 61, + 59, + 58, + 58, + 58, + 144, + 57, + 49, + 45, + 45, + 45, + 45, + 45, + 45, + 46, + 45, + 45, + 45, + 45, + 45, + 46, + 45, + 45, + 44, + 45, + 45, + 45, + 44, + 45, + 44, + 44, + 44, + 44, + 44 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 20, + 94, + 169, + 246, + 308, + 370, + 432, + 495, + 458, + 441, + 431, + 431, + 430, + 430, + 430, + 430, + 430, + 430, + 429, + 429, + 430, + 429, + 428, + 427, + 428, + 428, + 428, + 428, + 427, + 428, + 428, + 437, + 449, + 453, + 453, + 455, + 455, + 501, + 536, + 546, + 640, + 689, + 728, + 776, + 743, + 711, + 738, + 645, + 652, + 637, + 626, + 628, + 599, + 609, + 593, + 553, + 545, + 501, + 481, + 449, + 430, + 425, + 423, + 416 + ], + "p50Micros": 432, + "p95Micros": 728, + "minMicros": 20, + "maxMicros": 776, + "wallMicros": 30564 + }, + "sqlMicros": [ + 57, + 57, + 59, + 47, + 49, + 47, + 48, + 47, + 47, + 47, + 48, + 47, + 47, + 48, + 47, + 47, + 47, + 47, + 47, + 48, + 47, + 47, + 47, + 47, + 47, + 47, + 47, + 45, + 48, + 47, + 58, + 59, + 52, + 48, + 47, + 47, + 90, + 86, + 61, + 140, + 66, + 74, + 93, + 57, + 57, + 88, + 61, + 84, + 57, + 62, + 58, + 52, + 64, + 50, + 48, + 50, + 48, + 45, + 45, + 45, + 45, + 46, + 45, + 46 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 9 + ], + "sqlMicros": [ + 59 + ], + "ormDecodeMicros": [] + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 9 + ], + "sqlMicros": [ + 62 + ], + "ormDecodeMicros": [ + 7 + ] + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1365686852136, + "endMicros": 1365686852718, + "retainedRootRows": 100, + "rssBefore": 325025792, + "rssAfter": 325664768, + "processLifetimeMaxRss": 531660800 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14579792, + "heapCapacity": 22396928, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503428", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 109, + "bytesCurrent": 3504 + }, + { + "name": "_Map", + "instancesCurrent": 14, + "bytesCurrent": 896 + }, + { + "name": "_List", + "instancesCurrent": 40145, + "bytesCurrent": 3100672 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1130, + "bytesCurrent": 36160 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14600560, + "heapCapacity": 22396928, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503563", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 209, + "bytesCurrent": 6704 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40252, + "bytesCurrent": 3108208 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1232, + "bytesCurrent": 39424 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 340, + "counts": { + "_List": 31, + "_Record": 303, + "_GrowableList": 6 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 300 + }, + { + "function": "_allocateData", + "samples": 21 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_instanceOf", + "samples": 6 + }, + { + "function": "_request", + "samples": 3 + }, + { + "function": "_worker", + "samples": 1 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1365687020967, + "endMicros": 1365687021895, + "retainedRootRows": 100, + "rssBefore": 335495168, + "rssAfter": 336412672, + "processLifetimeMaxRss": 531660800 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14587024, + "heapCapacity": 22396928, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503594", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 109, + "bytesCurrent": 3504 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40148, + "bytesCurrent": 3101232 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1128, + "bytesCurrent": 36096 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14600688, + "heapCapacity": 22396928, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503735", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 209, + "bytesCurrent": 6704 + }, + { + "name": "_Map", + "instancesCurrent": 10, + "bytesCurrent": 640 + }, + { + "name": "_List", + "instancesCurrent": 40252, + "bytesCurrent": 3108208 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1232, + "bytesCurrent": 39424 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 1215, + "counts": { + "_List": 471, + "_SelectionPlan": 3, + "_GrowableList": 414, + "_Map": 18, + "_Record": 309 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 600 + }, + { + "function": "_allocateData", + "samples": 435 + }, + { + "function": "quote", + "samples": 36 + }, + { + "function": "_write", + "samples": 27 + }, + { + "function": "_List", + "samples": 17 + }, + { + "function": "writeSql", + "samples": 15 + }, + { + "function": "_instanceOf", + "samples": 12 + }, + { + "function": "Map._fromLiteral", + "samples": 12 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_addPart", + "samples": 12 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_plan", + "samples": 6 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "three_posts", + "latency": { + "raw": { + "samplesMicros": [ + 938, + 751, + 742, + 822, + 753, + 740, + 762, + 755, + 742, + 774, + 785, + 773, + 741, + 748, + 728, + 783, + 747, + 734, + 744, + 724, + 729, + 820, + 764, + 743, + 734, + 817, + 726, + 730, + 725, + 721, + 723, + 730, + 721, + 784, + 732, + 719, + 726, + 865, + 727, + 811, + 741, + 724, + 751, + 735, + 720, + 732, + 777, + 746, + 763, + 735, + 729, + 733, + 725, + 726, + 729, + 727, + 731, + 738, + 729, + 764, + 749, + 734, + 725, + 737, + 728, + 756, + 735, + 731, + 746, + 731, + 730, + 732, + 725, + 735, + 726, + 803, + 723, + 729, + 728, + 727, + 726, + 720, + 724, + 720, + 771, + 728, + 723, + 733, + 756, + 741, + 740, + 728, + 738, + 818, + 727, + 727, + 733, + 732, + 728, + 817, + 721, + 724, + 722, + 721, + 717, + 735, + 720, + 714, + 736, + 718, + 715, + 801, + 816, + 768, + 729, + 732, + 721, + 720, + 720, + 736, + 778, + 733, + 725, + 729, + 730, + 751, + 727, + 730, + 734, + 733, + 719, + 713, + 717, + 725, + 716, + 728, + 719, + 788, + 728, + 721, + 717, + 745, + 812, + 735, + 755, + 719, + 709, + 714, + 716, + 719, + 719, + 711, + 716, + 712, + 723, + 752, + 715, + 766, + 743, + 723, + 713, + 709, + 713, + 720, + 717, + 803, + 717, + 710, + 715, + 719, + 709, + 715, + 722, + 712, + 738, + 721, + 719, + 720, + 721, + 720, + 720, + 719, + 719, + 780, + 725, + 723, + 717, + 716, + 734, + 726, + 730, + 731, + 719, + 720, + 728, + 730, + 719, + 729, + 720, + 722 + ], + "p50Micros": 728, + "p95Micros": 803, + "minMicros": 709, + "maxMicros": 938, + "wallMicros": 147559, + "operationsPerSecond": 1355.3900473708823 + }, + "orm": { + "samplesMicros": [ + 957, + 919, + 869, + 862, + 879, + 887, + 862, + 891, + 1031, + 931, + 915, + 889, + 923, + 909, + 852, + 856, + 896, + 879, + 859, + 866, + 825, + 827, + 852, + 841, + 839, + 909, + 894, + 845, + 831, + 815, + 893, + 824, + 822, + 859, + 837, + 819, + 869, + 908, + 815, + 814, + 841, + 833, + 818, + 900, + 824, + 822, + 844, + 826, + 984, + 861, + 826, + 819, + 847, + 823, + 830, + 842, + 865, + 965, + 837, + 867, + 837, + 808, + 815, + 818, + 837, + 837, + 895, + 824, + 824, + 810, + 846, + 823, + 817, + 821, + 817, + 813, + 822, + 808, + 808, + 810, + 804, + 812, + 805, + 808, + 906, + 822, + 819, + 814, + 814, + 826, + 807, + 814, + 826, + 804, + 849, + 835, + 807, + 804, + 801, + 896, + 813, + 798, + 862, + 815, + 807, + 799, + 798, + 797, + 801, + 802, + 821, + 993, + 1079, + 1087, + 818, + 803, + 814, + 799, + 800, + 795, + 835, + 820, + 823, + 803, + 802, + 873, + 811, + 806, + 845, + 892, + 803, + 796, + 797, + 796, + 798, + 796, + 790, + 833, + 856, + 792, + 806, + 813, + 819, + 810, + 811, + 803, + 803, + 860, + 798, + 788, + 787, + 791, + 793, + 791, + 788, + 792, + 862, + 842, + 814, + 819, + 789, + 794, + 792, + 798, + 785, + 795, + 795, + 788, + 782, + 784, + 790, + 792, + 791, + 783, + 885, + 817, + 799, + 798, + 796, + 791, + 798, + 796, + 798, + 795, + 800, + 827, + 795, + 815, + 799, + 799, + 796, + 800, + 878, + 801, + 791, + 792, + 799, + 798, + 794, + 800 + ], + "p50Micros": 817, + "p95Micros": 915, + "minMicros": 782, + "maxMicros": 1087, + "wallMicros": 166380, + "operationsPerSecond": 1202.0675561966582 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 758, + 1481, + 2212, + 2936, + 3780, + 4510, + 5250, + 6008, + 5984, + 5975, + 5987, + 5987, + 5856, + 5847, + 5831, + 5787, + 5810, + 5817, + 5787, + 5774, + 5780, + 5768, + 5796, + 5808, + 5775, + 5767, + 5774, + 5943, + 5950, + 5980, + 5951, + 5957, + 5974, + 5973, + 5961, + 5800, + 5793, + 5765, + 5760, + 5746, + 5730, + 5739, + 5749, + 5740, + 5729, + 5736, + 5750, + 5747, + 5745, + 5830, + 5829, + 5827, + 5833, + 5832, + 5851, + 5880, + 5896, + 5803, + 5801, + 5815, + 5815, + 5811, + 5785, + 5771, + 5755, + 5763, + 5759, + 5787, + 5807, + 5814, + 5819, + 5868, + 5881, + 5876, + 5886, + 5856, + 5864, + 5891, + 5914, + 5871, + 5893, + 5915, + 5932, + 5984, + 5982, + 5985, + 5968, + 5974, + 5963, + 5956, + 5973, + 5942, + 5932, + 5922, + 5923, + 5918, + 5914, + 5976, + 5952, + 5954, + 5961, + 5954, + 5950, + 5957, + 5961, + 5901, + 5913, + 5903, + 5907, + 5914, + 5932, + 5916, + 5927, + 5930, + 5916, + 5931, + 5931, + 5933, + 5920, + 5929, + 5922, + 5925, + 5935, + 5926, + 5933, + 5929, + 5960, + 5971, + 5981, + 5990, + 5992, + 6011, + 6012, + 6025, + 5996, + 6002, + 6020, + 6022, + 6022, + 6026, + 6023, + 6025, + 6029, + 6023, + 6004, + 6005, + 6260, + 6233, + 6197, + 6173, + 6142, + 6103, + 6083, + 6048, + 5757, + 5736, + 5735, + 5717, + 5711, + 5713, + 5698, + 5691, + 5696, + 5705, + 5712, + 5713, + 5726, + 5730, + 5733, + 5740, + 5736, + 5726, + 5729, + 5733, + 5721, + 5715, + 5716, + 5709, + 5715, + 5714, + 5715, + 5719, + 5728, + 5740, + 5756, + 5771, + 5764, + 5770, + 5765, + 5762, + 5755, + 5738, + 5713, + 5718, + 5737, + 5734, + 5729, + 5735, + 5727, + 5731 + ], + "p50Micros": 5847, + "p95Micros": 6025, + "minMicros": 758, + "maxMicros": 6260, + "wallMicros": 146643, + "operationsPerSecond": 1363.856440471076 + }, + "orm": { + "samplesMicros": [ + 905, + 1739, + 2583, + 3444, + 4348, + 5145, + 5937, + 6731, + 6629, + 6573, + 6519, + 6446, + 6347, + 6340, + 6332, + 6358, + 6353, + 6369, + 6386, + 6387, + 6369, + 6370, + 6429, + 6436, + 6487, + 6701, + 6746, + 6791, + 6813, + 6826, + 6789, + 6766, + 6732, + 6529, + 6478, + 6450, + 6446, + 6499, + 6497, + 6489, + 6487, + 6471, + 6465, + 6454, + 6452, + 6389, + 6387, + 6391, + 6383, + 6390, + 6394, + 6396, + 6391, + 6398, + 6400, + 6399, + 6399, + 6403, + 6404, + 6443, + 6472, + 6477, + 6469, + 6483, + 6508, + 6500, + 6501, + 6461, + 6453, + 6470, + 6492, + 6488, + 6490, + 6534, + 6555, + 6583, + 6582, + 6607, + 6586, + 6573, + 6689, + 6747, + 6773, + 6939, + 6920, + 6861, + 6860, + 6855, + 6706, + 6595, + 6545, + 6346, + 6332, + 6333, + 6329, + 6329, + 6341, + 6341, + 6334, + 6340, + 6345, + 6346, + 6354, + 6349, + 6352, + 6355, + 6359, + 6373, + 6379, + 6391, + 6387, + 6415, + 6404, + 6405, + 6400, + 6382, + 6375, + 6373, + 6375, + 6350, + 6357, + 6359, + 6357, + 6366, + 6367, + 6361, + 6360, + 6359, + 6355, + 6356, + 6378, + 6376, + 6385, + 6388, + 6392, + 6395, + 6441, + 6484, + 6508, + 6531, + 6533, + 6700, + 6720, + 6749, + 6715, + 6671, + 6633, + 6605, + 6590, + 6410, + 6385, + 6347, + 6338, + 6337, + 6328, + 6340, + 6339, + 6340, + 6328, + 6367, + 6383, + 6382, + 6384, + 6368, + 6373, + 6378, + 6409, + 6377, + 6356, + 6351, + 6350, + 6348, + 6350, + 6348, + 6324, + 6326, + 6336, + 6337, + 6345, + 6352, + 6354, + 6374, + 6370, + 6368, + 6358, + 6367, + 6368, + 6391, + 6386, + 6373, + 6376, + 6373, + 6385, + 6383, + 6381, + 6378, + 6380, + 6381, + 6376, + 6468 + ], + "p50Micros": 6386, + "p95Micros": 6766, + "minMicros": 905, + "maxMicros": 6939, + "wallMicros": 161576, + "operationsPerSecond": 1237.807595187404 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 11, + 780, + 1532, + 2265, + 2999, + 3736, + 4472, + 5213, + 5170, + 5157, + 5157, + 5158, + 5200, + 5257, + 5286, + 5309, + 5340, + 5377, + 5405, + 5398, + 5375, + 5640, + 5815, + 5778, + 5722, + 5667, + 5618, + 5567, + 5095, + 5029, + 5013, + 5014, + 5028, + 5016, + 5012, + 5024, + 5022, + 5024, + 5024, + 5021, + 5026, + 5079, + 5123, + 5127, + 5132, + 5131, + 5135, + 5130, + 5083, + 5026, + 5016, + 5014, + 5010, + 5004, + 5006, + 5012, + 5017, + 5028, + 5032, + 5037, + 5033, + 5032, + 5029, + 5034 + ], + "p50Micros": 5033, + "p95Micros": 5667, + "minMicros": 11, + "maxMicros": 5815, + "wallMicros": 311022 + }, + "sqlMicros": [ + 51, + 644, + 47, + 642, + 44, + 634, + 45, + 635, + 45, + 639, + 44, + 638, + 45, + 641, + 44, + 641, + 44, + 640, + 44, + 635, + 44, + 637, + 42, + 674, + 58, + 674, + 41, + 666, + 41, + 661, + 42, + 665, + 41, + 668, + 41, + 663, + 41, + 671, + 40, + 663, + 51, + 917, + 82, + 660, + 50, + 649, + 48, + 635, + 47, + 635, + 47, + 648, + 47, + 639, + 41, + 645, + 46, + 645, + 47, + 637, + 46, + 639, + 47, + 648, + 47, + 636, + 47, + 636, + 47, + 651, + 47, + 641, + 46, + 639, + 46, + 640, + 48, + 642, + 50, + 638, + 48, + 682, + 54, + 686, + 48, + 644, + 48, + 643, + 47, + 638, + 50, + 644, + 48, + 639, + 47, + 643, + 47, + 636, + 47, + 636, + 46, + 642, + 47, + 636, + 47, + 638, + 47, + 640, + 47, + 648, + 47, + 643, + 47, + 647, + 47, + 646, + 46, + 639, + 47, + 636, + 47, + 638, + 47, + 646, + 47, + 649, + 48, + 643 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 8, + 826, + 1623, + 2414, + 3208, + 4001, + 4793, + 5589, + 5547, + 5549, + 5561, + 5561, + 5566, + 5570, + 5562, + 5570, + 5561, + 5551, + 5550, + 5544, + 5666, + 5725, + 5724, + 5725, + 5722, + 5714, + 5736, + 5603, + 5544, + 5543, + 5541, + 5539, + 5543, + 5530, + 5527, + 5530, + 5528, + 5524, + 5522, + 5525, + 5516, + 5513, + 5517, + 5512, + 5517, + 5516, + 5521, + 5513, + 5512, + 5511, + 5519, + 5521, + 5527, + 5513, + 5519, + 5522, + 5521, + 5519, + 5519, + 5531, + 5545, + 5546, + 5552, + 5556 + ], + "p50Micros": 5530, + "p95Micros": 5724, + "minMicros": 8, + "maxMicros": 5736, + "wallMicros": 333723 + }, + "sqlMicros": [ + 47, + 645, + 49, + 641, + 47, + 640, + 48, + 641, + 48, + 641, + 47, + 641, + 47, + 648, + 46, + 641, + 47, + 648, + 47, + 653, + 47, + 647, + 48, + 648, + 51, + 643, + 47, + 639, + 47, + 649, + 46, + 641, + 47, + 643, + 48, + 643, + 47, + 639, + 48, + 652, + 65, + 657, + 49, + 643, + 46, + 640, + 46, + 641, + 46, + 641, + 53, + 664, + 46, + 643, + 46, + 639, + 46, + 647, + 45, + 644, + 45, + 640, + 45, + 644, + 45, + 653, + 46, + 640, + 49, + 638, + 45, + 645, + 46, + 639, + 46, + 637, + 46, + 648, + 46, + 646, + 45, + 638, + 46, + 644, + 45, + 637, + 45, + 645, + 45, + 638, + 45, + 653, + 45, + 639, + 45, + 638, + 50, + 640, + 46, + 648, + 47, + 645, + 47, + 641, + 46, + 639, + 46, + 643, + 46, + 640, + 45, + 641, + 47, + 647, + 45, + 647, + 45, + 652, + 45, + 647, + 45, + 646, + 45, + 647, + 45, + 646, + 45, + 640 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43, ?44, ?45, ?46, ?47, ?48, ?49, ?50, ?51, ?52, ?53, ?54, ?55, ?56, ?57, ?58, ?59, ?60, ?61, ?62, ?63, ?64, ?65, ?66, ?67, ?68, ?69, ?70, ?71, ?72, ?73, ?74, ?75, ?76, ?77, ?78, ?79, ?80, ?81, ?82, ?83, ?84, ?85, ?86, ?87, ?88, ?89, ?90, ?91, ?92, ?93, ?94, ?95, ?96, ?97, ?98, ?99, ?100))) AS \"orm_partition\" WHERE \"c3\" > ?101 AND \"c3\" <= ?102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 6 + ], + "sqlMicros": [ + 52, + 655 + ], + "ormDecodeMicros": [] + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41, ?42, ?43, ?44, ?45, ?46, ?47, ?48, ?49, ?50, ?51, ?52, ?53, ?54, ?55, ?56, ?57, ?58, ?59, ?60, ?61, ?62, ?63, ?64, ?65, ?66, ?67, ?68, ?69, ?70, ?71, ?72, ?73, ?74, ?75, ?76, ?77, ?78, ?79, ?80, ?81, ?82, ?83, ?84, ?85, ?86, ?87, ?88, ?89, ?90, ?91, ?92, ?93, ?94, ?95, ?96, ?97, ?98, ?99, ?100))) AS \"orm_partition\" WHERE \"c3\" > ?101 AND \"c3\" <= ?102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 6 + ], + "sqlMicros": [ + 61, + 667 + ], + "ormDecodeMicros": [ + 45, + 6 + ] + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1365687244495, + "endMicros": 1365687248315, + "retainedRootRows": 100, + "rssBefore": 344752128, + "rssAfter": 347602944, + "processLifetimeMaxRss": 531660800 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14817008, + "heapCapacity": 27115520, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503817", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 408, + "bytesCurrent": 14672 + }, + { + "name": "_Map", + "instancesCurrent": 13, + "bytesCurrent": 832 + }, + { + "name": "_List", + "instancesCurrent": 40049, + "bytesCurrent": 3118368 + }, + { + "name": "_GrowableList", + "instancesCurrent": 926, + "bytesCurrent": 29632 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14941824, + "heapCapacity": 19775488, + "externalUsage": 80 + }, + "gcTimestamp": "1789454503972", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 808, + "bytesCurrent": 29072 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40348, + "bytesCurrent": 3137440 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1228, + "bytesCurrent": 39296 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 2505, + "counts": { + "_List": 681, + "_Record": 1506, + "_GrowableList": 315, + "_Map": 3 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 1200 + }, + { + "function": "_allocateData", + "samples": 642 + }, + { + "function": "_List.of", + "samples": 303 + }, + { + "function": "current", + "samples": 300 + }, + { + "function": "_init", + "samples": 20 + }, + { + "function": "_sendInternal", + "samples": 18 + }, + { + "function": "_instanceOf", + "samples": 6 + }, + { + "function": "_request", + "samples": 6 + }, + { + "function": "_sliceInternal", + "samples": 3 + }, + { + "function": "_slice", + "samples": 3 + }, + { + "function": "Map._fromLiteral", + "samples": 3 + }, + { + "function": "_worker", + "samples": 1 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1365687428350, + "endMicros": 1365687433726, + "retainedRootRows": 100, + "rssBefore": 356466688, + "rssAfter": 362594304, + "processLifetimeMaxRss": 531660800 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14816320, + "heapCapacity": 19775488, + "externalUsage": 80 + }, + "gcTimestamp": "1789454504001", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 408, + "bytesCurrent": 14672 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40044, + "bytesCurrent": 3117792 + }, + { + "name": "_GrowableList", + "instancesCurrent": 924, + "bytesCurrent": 29568 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14943264, + "heapCapacity": 21872640, + "externalUsage": 80 + }, + "gcTimestamp": "1789454504213", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 808, + "bytesCurrent": 29072 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40349, + "bytesCurrent": 3137472 + }, + { + "name": "_GrowableList", + "instancesCurrent": 1228, + "bytesCurrent": 39296 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 9843, + "counts": { + "_List": 4782, + "_SelectionPlan": 12, + "_GrowableList": 3462, + "_Map": 60, + "_Record": 1527 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 3321 + }, + { + "function": "", + "samples": 3300 + }, + { + "function": "parameter", + "samples": 636 + }, + { + "function": "_GrowableList._ofGrowableList", + "samples": 600 + }, + { + "function": "_List", + "samples": 346 + }, + { + "function": "_List.of", + "samples": 309 + }, + { + "function": "load", + "samples": 303 + }, + { + "function": "add", + "samples": 300 + }, + { + "function": "quote", + "samples": 180 + }, + { + "function": "_write", + "samples": 135 + }, + { + "function": "_addPart", + "samples": 96 + }, + { + "function": "writeSql", + "samples": 81 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "aggregate", + "latency": { + "raw": { + "samplesMicros": [ + 35, + 36, + 39, + 36, + 35, + 34, + 38, + 35, + 39, + 35, + 30, + 36, + 42, + 45, + 43, + 35, + 37, + 37, + 38, + 35, + 38, + 35, + 39, + 35, + 43, + 35, + 37, + 38, + 37, + 35, + 37, + 34, + 37, + 35, + 38, + 36, + 37, + 35, + 37, + 35, + 42, + 35, + 41, + 35, + 37, + 35, + 38, + 35, + 37, + 35, + 37, + 35, + 38, + 35, + 38, + 35, + 38, + 39, + 37, + 35, + 40, + 37, + 38, + 38, + 38, + 36, + 36, + 37, + 39, + 37, + 36, + 36, + 37, + 36, + 41, + 36, + 39, + 35, + 36, + 40, + 39, + 36, + 38, + 35, + 36, + 36, + 38, + 36, + 37, + 35, + 38, + 35, + 40, + 36, + 37, + 36, + 37, + 38, + 38, + 36, + 35, + 36, + 40, + 36, + 38, + 36, + 39, + 36, + 44, + 36, + 38, + 36, + 35, + 36, + 43, + 39, + 38, + 35, + 37, + 38, + 39, + 38, + 44, + 36, + 36, + 36, + 39, + 36, + 37, + 36, + 35, + 38, + 35, + 35, + 37, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 37, + 39, + 33, + 37, + 35, + 35, + 37, + 35, + 36, + 36, + 38, + 62, + 34, + 37, + 37, + 34, + 32, + 35, + 32, + 36, + 36, + 33, + 32, + 35, + 37, + 35, + 36, + 35, + 36, + 35, + 34, + 31, + 36, + 39, + 36, + 35, + 36, + 35, + 33, + 36, + 34, + 37, + 28, + 36, + 36, + 35, + 36, + 35, + 36, + 35, + 36, + 38, + 29, + 57, + 47, + 32, + 34, + 34 + ], + "p50Micros": 36, + "p95Micros": 42, + "minMicros": 28, + "maxMicros": 62, + "wallMicros": 7348, + "operationsPerSecond": 27218.290691344584 + }, + "orm": { + "samplesMicros": [ + 40, + 41, + 42, + 40, + 44, + 40, + 40, + 40, + 41, + 39, + 40, + 40, + 52, + 47, + 49, + 41, + 40, + 65, + 43, + 40, + 43, + 40, + 41, + 39, + 42, + 39, + 41, + 39, + 40, + 40, + 41, + 39, + 40, + 39, + 40, + 39, + 40, + 40, + 41, + 39, + 41, + 39, + 40, + 39, + 40, + 39, + 40, + 39, + 40, + 39, + 40, + 40, + 40, + 39, + 41, + 40, + 40, + 39, + 40, + 39, + 40, + 40, + 41, + 41, + 47, + 39, + 42, + 42, + 44, + 41, + 40, + 41, + 42, + 40, + 41, + 41, + 42, + 41, + 41, + 40, + 41, + 41, + 41, + 41, + 42, + 40, + 41, + 41, + 42, + 42, + 43, + 41, + 42, + 41, + 42, + 41, + 47, + 40, + 41, + 41, + 41, + 43, + 44, + 41, + 41, + 41, + 42, + 41, + 43, + 40, + 41, + 41, + 41, + 40, + 42, + 41, + 41, + 43, + 43, + 42, + 40, + 41, + 41, + 41, + 42, + 40, + 41, + 44, + 40, + 41, + 41, + 39, + 41, + 39, + 101, + 57, + 39, + 39, + 39, + 38, + 39, + 38, + 39, + 38, + 47, + 40, + 39, + 38, + 39, + 38, + 50, + 43, + 49, + 46, + 49, + 39, + 39, + 38, + 38, + 39, + 39, + 38, + 39, + 38, + 38, + 38, + 38, + 37, + 38, + 37, + 38, + 38, + 46, + 44, + 39, + 38, + 39, + 38, + 39, + 37, + 39, + 38, + 46, + 52, + 38, + 37, + 39, + 37, + 38, + 38, + 38, + 38, + 39, + 47, + 49, + 46, + 63, + 52, + 50, + 46 + ], + "p50Micros": 40, + "p95Micros": 49, + "minMicros": 37, + "maxMicros": 101, + "wallMicros": 8304, + "operationsPerSecond": 24084.778420038536 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 53, + 95, + 134, + 173, + 242, + 283, + 319, + 356, + 354, + 342, + 336, + 333, + 300, + 297, + 293, + 290, + 274, + 270, + 272, + 267, + 272, + 272, + 287, + 284, + 284, + 291, + 301, + 303, + 297, + 297, + 285, + 292, + 296, + 295, + 289, + 290, + 298, + 298, + 298, + 301, + 298, + 294, + 307, + 306, + 296, + 298, + 298, + 296, + 300, + 300, + 284, + 287, + 288, + 285, + 285, + 284, + 280, + 283, + 286, + 285, + 286, + 287, + 287, + 286, + 290, + 289, + 289, + 289, + 288, + 288, + 288, + 289, + 289, + 283, + 280, + 280, + 280, + 280, + 280, + 279, + 279, + 285, + 289, + 285, + 286, + 286, + 285, + 288, + 288, + 289, + 292, + 308, + 305, + 307, + 307, + 305, + 305, + 307, + 303, + 297, + 296, + 293, + 294, + 294, + 294, + 292, + 292, + 285, + 289, + 290, + 291, + 288, + 288, + 288, + 288, + 289, + 291, + 289, + 288, + 306, + 308, + 307, + 307, + 306, + 304, + 304, + 322, + 313, + 332, + 358, + 356, + 352, + 350, + 353, + 336, + 330, + 312, + 286, + 291, + 296, + 299, + 296, + 296, + 296, + 318, + 363, + 412, + 430, + 467, + 497, + 549, + 556, + 570, + 539, + 513, + 499, + 506, + 503, + 455, + 465, + 439, + 458, + 456, + 472, + 425, + 394, + 389, + 374, + 361, + 327, + 287, + 282, + 284, + 289, + 289, + 287, + 286, + 287, + 289, + 292, + 292, + 292, + 292, + 294, + 295, + 295, + 291, + 288, + 289, + 286, + 283, + 272, + 271, + 267, + 268, + 268, + 267, + 270, + 274, + 275 + ], + "p50Micros": 292, + "p95Micros": 467, + "minMicros": 53, + "maxMicros": 570, + "wallMicros": 7923, + "operationsPerSecond": 25242.963523917708 + }, + "orm": { + "samplesMicros": [ + 67, + 106, + 143, + 184, + 222, + 259, + 296, + 333, + 311, + 307, + 305, + 315, + 328, + 336, + 342, + 351, + 360, + 367, + 376, + 366, + 358, + 352, + 346, + 337, + 327, + 320, + 312, + 307, + 307, + 306, + 306, + 306, + 306, + 310, + 318, + 325, + 328, + 336, + 344, + 351, + 358, + 394, + 391, + 388, + 380, + 372, + 364, + 356, + 348, + 308, + 303, + 299, + 300, + 300, + 300, + 300, + 300, + 300, + 302, + 303, + 302, + 302, + 302, + 302, + 302, + 303, + 299, + 299, + 299, + 300, + 299, + 300, + 300, + 299, + 299, + 299, + 298, + 298, + 298, + 297, + 297, + 297, + 297, + 298, + 298, + 299, + 300, + 303, + 303, + 304, + 305, + 305, + 305, + 304, + 303, + 300, + 299, + 300, + 299, + 298, + 297, + 298, + 297, + 297, + 297, + 297, + 297, + 298, + 298, + 298, + 298, + 299, + 299, + 299, + 299, + 299, + 300, + 311, + 340, + 351, + 355, + 360, + 361, + 361, + 362, + 356, + 338, + 328, + 326, + 322, + 327, + 327, + 327, + 321, + 311, + 310, + 309, + 308, + 302, + 302, + 302, + 301, + 300, + 300, + 301, + 300, + 300, + 300, + 299, + 300, + 300, + 300, + 300, + 304, + 312, + 319, + 327, + 335, + 342, + 349, + 357, + 359, + 360, + 360, + 359, + 359, + 354, + 355, + 354, + 354, + 354, + 361, + 363, + 428, + 433, + 430, + 424, + 419, + 414, + 405, + 404, + 362, + 358, + 357, + 359, + 357, + 354, + 348, + 345, + 318, + 319, + 318, + 315, + 315, + 315, + 314, + 309, + 304, + 299, + 287 + ], + "p50Micros": 307, + "p95Micros": 388, + "minMicros": 67, + "maxMicros": 433, + "wallMicros": 8142, + "operationsPerSecond": 24563.989191844754 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 10, + 52, + 100, + 137, + 174, + 210, + 246, + 282, + 264, + 251, + 252, + 248, + 251, + 263, + 265, + 267, + 267, + 269, + 273, + 274, + 263, + 263, + 262, + 262, + 266, + 266, + 261, + 258, + 257, + 256, + 255, + 250, + 249, + 249, + 250, + 250, + 250, + 250, + 249, + 249, + 249, + 249, + 249, + 249, + 249, + 249, + 249, + 248, + 245, + 245, + 246, + 246, + 246, + 246, + 246, + 249, + 249, + 249, + 248, + 249, + 249, + 248, + 248, + 247 + ], + "p50Micros": 249, + "p95Micros": 269, + "minMicros": 10, + "maxMicros": 282, + "wallMicros": 15417 + }, + "sqlMicros": [ + 41, + 46, + 35, + 36, + 34, + 35, + 34, + 34, + 35, + 34, + 31, + 38, + 46, + 36, + 36, + 35, + 36, + 35, + 39, + 37, + 36, + 35, + 35, + 40, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 32, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 7, + 52, + 95, + 133, + 179, + 223, + 269, + 312, + 295, + 294, + 293, + 284, + 277, + 270, + 265, + 261, + 257, + 259, + 259, + 260, + 260, + 260, + 260, + 260, + 257, + 257, + 256, + 256, + 255, + 256, + 256, + 255, + 256, + 256, + 256, + 256, + 255, + 255, + 255, + 255, + 256, + 256, + 256, + 256, + 256, + 256, + 255, + 255, + 255, + 255, + 255, + 255, + 256, + 255, + 255, + 255, + 255, + 255, + 254, + 254, + 254, + 257, + 256, + 256 + ], + "p50Micros": 256, + "p95Micros": 293, + "minMicros": 7, + "maxMicros": 312, + "wallMicros": 15804 + }, + "sqlMicros": [ + 35, + 33, + 30, + 37, + 36, + 36, + 34, + 33, + 33, + 30, + 29, + 29, + 29, + 29, + 29, + 30, + 32, + 29, + 30, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 29, + 29, + 29, + 29, + 29, + 29, + 28, + 29, + 29, + 32, + 28, + 28, + 28 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 5 + ], + "sqlMicros": [ + 50 + ], + "ormDecodeMicros": [] + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= ?1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 4 + ], + "sqlMicros": [ + 45 + ], + "ormDecodeMicros": [ + 1 + ] + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1365687676371, + "endMicros": 1365687676713, + "retainedRootRows": 10, + "rssBefore": 373866496, + "rssAfter": 373964800, + "processLifetimeMaxRss": 531660800 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14725072, + "heapCapacity": 22921216, + "externalUsage": 80 + }, + "gcTimestamp": "1789454504249", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 18, + "bytesCurrent": 592 + }, + { + "name": "_Map", + "instancesCurrent": 13, + "bytesCurrent": 832 + }, + { + "name": "_List", + "instancesCurrent": 40059, + "bytesCurrent": 3118288 + }, + { + "name": "_GrowableList", + "instancesCurrent": 926, + "bytesCurrent": 29632 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14725488, + "heapCapacity": 22921216, + "externalUsage": 80 + }, + "gcTimestamp": "1789454504384", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 28, + "bytesCurrent": 912 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40067, + "bytesCurrent": 3118544 + }, + { + "name": "_GrowableList", + "instancesCurrent": 938, + "bytesCurrent": 30016 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 62, + "counts": { + "_List": 23, + "_Record": 33, + "_GrowableList": 6 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "", + "samples": 30 + }, + { + "function": "_allocateData", + "samples": 12 + }, + { + "function": "_sendInternal", + "samples": 9 + }, + { + "function": "_instanceOf", + "samples": 6 + }, + { + "function": "_request", + "samples": 3 + }, + { + "function": "_init", + "samples": 1 + }, + { + "function": "_worker", + "samples": 1 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1365687841538, + "endMicros": 1365687842091, + "retainedRootRows": 10, + "rssBefore": 382042112, + "rssAfter": 382271488, + "processLifetimeMaxRss": 531660800 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14723760, + "heapCapacity": 22921216, + "externalUsage": 80 + }, + "gcTimestamp": "1789454504414", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 18, + "bytesCurrent": 592 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40053, + "bytesCurrent": 3117680 + }, + { + "name": "_GrowableList", + "instancesCurrent": 924, + "bytesCurrent": 29568 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 14725648, + "heapCapacity": 22921216, + "externalUsage": 80 + }, + "gcTimestamp": "1789454504553", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 28, + "bytesCurrent": 912 + }, + { + "name": "_Map", + "instancesCurrent": 9, + "bytesCurrent": 576 + }, + { + "name": "_List", + "instancesCurrent": 40067, + "bytesCurrent": 3118544 + }, + { + "name": "_GrowableList", + "instancesCurrent": 938, + "bytesCurrent": 30016 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 434, + "counts": { + "_List": 218, + "_SelectionPlan": 3, + "_GrowableList": 153, + "_Map": 18, + "_Record": 42 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 159 + }, + { + "function": "", + "samples": 60 + }, + { + "function": "quote", + "samples": 42 + }, + { + "function": "_write", + "samples": 30 + }, + { + "function": "_addPart", + "samples": 24 + }, + { + "function": "writeSql", + "samples": 21 + }, + { + "function": "_instanceOf", + "samples": 12 + }, + { + "function": "Map._fromLiteral", + "samples": 12 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_List", + "samples": 12 + }, + { + "function": "_init", + "samples": 10 + }, + { + "function": "_sendInternal", + "samples": 9 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + } + ] + }, + { + "scenario": "postgres_loopback", + "environment": { + "backend": "postgres_loopback", + "databaseVersion": "18.4 (Homebrew)", + "pid": 94381 + }, + "oneWayDelayMs": 0, + "echoRoundTripMicros": { + "samplesMicros": [ + 181, + 197, + 177, + 200, + 183, + 184, + 413, + 208, + 188, + 188 + ], + "p50Micros": 188, + "p95Micros": 413, + "minMicros": 177, + "maxMicros": 413, + "wallMicros": 2119 + }, + "selectOneMicros": { + "samplesMicros": [ + 1014, + 804, + 784, + 785, + 783, + 761, + 764, + 791, + 827, + 755 + ], + "p50Micros": 784, + "p95Micros": 1014, + "minMicros": 755, + "maxMicros": 1014, + "wallMicros": 8068, + "operationsPerSecond": 1239.4645513138323 + }, + "cases": [ + { + "case": "full_rows", + "latency": { + "raw": { + "samplesMicros": [ + 1057, + 2142, + 1044, + 1108, + 1062, + 1227, + 1088, + 1035, + 1034, + 1136, + 1024, + 1016, + 995, + 1065, + 995, + 989, + 988, + 971, + 977, + 971, + 983, + 1006, + 1087, + 1149, + 1027, + 977, + 998, + 1068, + 999, + 1009, + 1013, + 963, + 1017, + 965, + 968, + 956, + 943, + 958, + 1015, + 953, + 943, + 1195, + 988, + 931, + 919, + 949, + 952, + 963, + 913, + 934, + 974, + 922, + 1005, + 953, + 940, + 1013, + 973, + 899, + 931, + 1000, + 941, + 985, + 942, + 966, + 979, + 943, + 921, + 926, + 955, + 949, + 983, + 944, + 958, + 1029, + 958, + 960, + 934, + 1008, + 915, + 970, + 913, + 943, + 985, + 964, + 935, + 945, + 905, + 932, + 1012, + 945, + 937, + 976, + 947, + 915, + 942, + 954, + 929, + 881, + 930, + 955, + 907, + 969, + 1024, + 926, + 926, + 972, + 966, + 899, + 888, + 942, + 899, + 900, + 848, + 938, + 1011, + 957, + 917, + 900, + 895, + 914, + 939, + 898, + 875, + 915, + 842, + 866, + 848, + 857, + 851, + 868, + 845, + 852, + 858, + 852, + 835, + 823, + 885, + 855, + 896, + 848, + 848, + 968, + 909, + 868, + 869, + 825, + 854, + 870, + 841, + 855, + 872, + 819, + 867, + 902, + 920, + 902, + 966, + 874, + 894, + 955, + 846, + 878, + 878, + 882, + 902, + 906, + 845, + 894, + 892, + 881, + 866, + 889, + 882, + 896, + 927, + 866, + 903, + 946, + 900, + 873, + 885, + 864, + 875, + 892, + 844, + 924, + 872, + 917, + 975, + 898, + 878, + 874, + 920, + 873, + 859, + 937, + 881, + 893, + 892, + 896 + ], + "p50Micros": 931, + "p95Micros": 1062, + "minMicros": 819, + "maxMicros": 2142, + "wallMicros": 188471, + "operationsPerSecond": 1061.171214669631 + }, + "orm": { + "samplesMicros": [ + 1165, + 1206, + 1178, + 1099, + 1119, + 1234, + 1077, + 1187, + 1066, + 1093, + 1126, + 1046, + 1056, + 1092, + 1048, + 1022, + 1103, + 1017, + 1058, + 1035, + 1093, + 1050, + 1114, + 1055, + 1057, + 1048, + 1031, + 1358, + 1014, + 1026, + 1095, + 1133, + 1051, + 1011, + 1066, + 1044, + 1022, + 1039, + 1029, + 1040, + 1016, + 1036, + 1212, + 1017, + 1070, + 1089, + 1009, + 1052, + 1089, + 1000, + 1002, + 1006, + 1000, + 1009, + 1003, + 1011, + 1005, + 996, + 1002, + 1004, + 983, + 1023, + 1011, + 1105, + 1016, + 1016, + 1084, + 994, + 994, + 984, + 1048, + 1012, + 1001, + 971, + 1060, + 992, + 1022, + 1009, + 994, + 997, + 991, + 1095, + 1078, + 995, + 1084, + 994, + 990, + 1030, + 1018, + 1028, + 997, + 1018, + 987, + 1012, + 998, + 1079, + 967, + 957, + 1163, + 1074, + 934, + 979, + 983, + 1056, + 944, + 931, + 933, + 956, + 953, + 963, + 951, + 964, + 976, + 1005, + 966, + 941, + 999, + 966, + 975, + 957, + 956, + 944, + 895, + 921, + 929, + 952, + 882, + 960, + 972, + 926, + 908, + 1137, + 895, + 900, + 953, + 898, + 904, + 884, + 885, + 931, + 896, + 947, + 906, + 968, + 894, + 940, + 892, + 891, + 875, + 947, + 938, + 918, + 992, + 939, + 950, + 966, + 946, + 926, + 936, + 941, + 930, + 909, + 936, + 996, + 973, + 957, + 1058, + 935, + 931, + 933, + 998, + 939, + 936, + 941, + 976, + 931, + 933, + 916, + 986, + 943, + 943, + 1044, + 940, + 955, + 1007, + 1016, + 937, + 949, + 1046, + 926, + 915, + 937, + 961, + 987, + 950, + 935, + 946, + 908, + 949, + 982 + ], + "p50Micros": 994, + "p95Micros": 1126, + "minMicros": 875, + "maxMicros": 1358, + "wallMicros": 199764, + "operationsPerSecond": 1001.1813940449731 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 4199, + 5857, + 5926, + 5973, + 6011, + 7878, + 7921, + 7963, + 3843, + 4005, + 4104, + 4205, + 4086, + 3901, + 3919, + 3928, + 3934, + 3798, + 3795, + 3805, + 3812, + 3761, + 3891, + 3771, + 3863, + 3830, + 3829, + 3827, + 3824, + 3753, + 3754, + 3757, + 3754, + 3732, + 3747, + 3747, + 3744, + 4013, + 4026, + 4044, + 4052, + 3955, + 3951, + 3954, + 3961, + 3877, + 3866, + 3848, + 3843, + 3797, + 3785, + 3785, + 3784, + 3737, + 3743, + 3740, + 3736, + 3807, + 3820, + 3823, + 3823, + 3892, + 3882, + 3890, + 3902, + 4000, + 4001, + 4002, + 4010, + 3864, + 3865, + 3847, + 3982, + 3856, + 3854, + 3855, + 3842, + 3901, + 3903, + 3783, + 3909, + 3878, + 3886, + 3894, + 3905, + 3854, + 3857, + 3856, + 3859, + 3797, + 3788, + 3725, + 3822, + 3988, + 3992, + 3993, + 3991, + 3928, + 3936, + 3934, + 3933, + 3888, + 3885, + 3884, + 3880, + 3873, + 3867, + 3869, + 3871, + 3633, + 3682, + 3709, + 3738, + 3458, + 3501, + 3505, + 3514, + 3471, + 3462, + 3442, + 3438, + 3558, + 3512, + 3506, + 3494, + 3519, + 3473, + 3462, + 3444, + 3620, + 3619, + 3620, + 3621, + 3789, + 3796, + 3801, + 3798, + 4037, + 4044, + 4043, + 4043, + 4002, + 4006, + 4061, + 4073, + 4002, + 3997, + 4003, + 4003, + 3995, + 3995, + 3933, + 3914, + 3699, + 3689, + 3685, + 3694, + 3783, + 3783, + 3796, + 3803, + 3829, + 3839, + 3837, + 3826, + 3735, + 3730, + 3726, + 3719, + 3773, + 3773, + 3772, + 3769, + 3680, + 3678, + 3671, + 3674, + 3744, + 3750, + 3717, + 3801, + 4029, + 4054, + 4064, + 4073, + 4170, + 4306, + 4248, + 4260, + 4285, + 4278, + 4274, + 4274, + 4058, + 4186, + 4099, + 4085, + 3441, + 3394, + 3377 + ], + "p50Micros": 3854, + "p95Micros": 4274, + "minMicros": 3377, + "maxMicros": 7963, + "wallMicros": 99938, + "operationsPerSecond": 2001.2407692769518 + }, + "orm": { + "samplesMicros": [ + 2083, + 2173, + 2270, + 2356, + 4486, + 4635, + 4722, + 4811, + 4496, + 4493, + 4469, + 4464, + 4177, + 4138, + 4122, + 4111, + 4003, + 4232, + 4151, + 4160, + 4118, + 4111, + 4127, + 4136, + 4581, + 4572, + 4566, + 4572, + 4521, + 4437, + 4429, + 4711, + 4800, + 4892, + 4901, + 4896, + 5070, + 4969, + 5136, + 5040, + 4527, + 4321, + 4306, + 4602, + 4447, + 4453, + 4462, + 4449, + 4275, + 4271, + 4262, + 4251, + 4193, + 4185, + 4191, + 4211, + 4193, + 4201, + 4288, + 4312, + 4426, + 4422, + 4410, + 4397, + 4426, + 4424, + 4339, + 4304, + 3944, + 3942, + 3948, + 3938, + 4193, + 4183, + 4183, + 4191, + 4197, + 4191, + 4174, + 4186, + 4026, + 3958, + 4133, + 4056, + 4122, + 4300, + 4133, + 4299, + 4387, + 4638, + 4543, + 4577, + 4731, + 4740, + 4745, + 4732, + 4298, + 4251, + 4229, + 4201, + 5550, + 5570, + 5588, + 5611, + 6897, + 6917, + 6923, + 6929, + 5375, + 5358, + 5364, + 5349, + 4092, + 4278, + 4192, + 4571, + 4103, + 4302, + 4193, + 4184, + 4062, + 4060, + 4046, + 4037, + 3972, + 3981, + 4008, + 4036, + 3970, + 3982, + 3990, + 3999, + 4197, + 4179, + 4135, + 4105, + 4207, + 4203, + 4198, + 4189, + 4141, + 4142, + 4141, + 4141, + 3964, + 3963, + 3992, + 4015, + 4019, + 4027, + 4042, + 4056, + 4164, + 4204, + 4184, + 4166, + 4122, + 4159, + 4224, + 4243, + 4338, + 4283, + 4190, + 4343, + 4047, + 4276, + 4055, + 4025, + 3923, + 3940, + 3943, + 3946, + 4122, + 4127, + 4137, + 4141, + 4039, + 4040, + 4097, + 4125, + 4135, + 4146, + 4152, + 4146, + 4091, + 4088, + 4043, + 4016, + 3965, + 3940, + 3931, + 3930, + 4040, + 4042, + 4145, + 4156, + 4104, + 4052, + 4033, + 4002 + ], + "p50Micros": 4186, + "p95Micros": 5358, + "minMicros": 2083, + "maxMicros": 6929, + "wallMicros": 108814, + "operationsPerSecond": 1837.9987869208007 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 78, + 107, + 109, + 111, + 2081, + 2154, + 2210, + 2267, + 1981, + 1988, + 1971, + 1972, + 2412, + 2400, + 2389, + 2394, + 2519, + 2512, + 2504, + 2505, + 1893, + 1895, + 1899, + 1895, + 2998, + 2993, + 2995, + 3015, + 2055, + 2045, + 2040, + 2042, + 3381, + 3372, + 3383, + 3390, + 2044, + 2078, + 2075, + 2082, + 2328, + 2296, + 2285, + 2269, + 1915, + 1879, + 1901, + 1913, + 1936, + 1920, + 1888, + 1867, + 1795, + 1789, + 1791, + 1791, + 1757, + 1759, + 1762, + 1778, + 2410, + 2406, + 2421, + 2419 + ], + "p50Micros": 2045, + "p95Micros": 3372, + "minMicros": 78, + "maxMicros": 3390, + "wallMicros": 134509 + }, + "sqlMicros": [ + 1968, + 2022, + 2090, + 2145, + 1919, + 1921, + 1927, + 1926, + 2356, + 2347, + 2348, + 2347, + 2403, + 2412, + 2587, + 2460, + 1838, + 1851, + 1860, + 1853, + 2939, + 2957, + 2905, + 3017, + 1985, + 2002, + 2002, + 1989, + 3316, + 3331, + 3335, + 3340, + 1896, + 2090, + 2031, + 2039, + 2244, + 2242, + 2239, + 2226, + 1856, + 1832, + 1849, + 1864, + 1886, + 1882, + 1849, + 1824, + 1751, + 1758, + 1761, + 1758, + 1701, + 1673, + 1680, + 1827, + 2366, + 2375, + 2375, + 2381, + 1797, + 1888, + 1746, + 1832 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 77, + 573, + 602, + 621, + 3152, + 3233, + 3293, + 3362, + 2555, + 2553, + 2580, + 2584, + 3600, + 4170, + 4336, + 4374, + 2746, + 2198, + 2041, + 2007, + 1740, + 1700, + 1693, + 1741, + 1875, + 1863, + 1832, + 1778, + 1964, + 1971, + 2023, + 2052, + 2169, + 2167, + 2135, + 2122, + 2051, + 2055, + 2040, + 2046, + 1966, + 1939, + 1942, + 1932, + 1993, + 1991, + 1999, + 1995, + 1839, + 1843, + 1886, + 1924, + 2085, + 2072, + 2069, + 1993, + 1850, + 1844, + 1844, + 1904, + 2024, + 2006, + 2001, + 1947 + ], + "p50Micros": 1999, + "p95Micros": 3600, + "minMicros": 77, + "maxMicros": 4374, + "wallMicros": 136562 + }, + "sqlMicros": [ + 2560, + 2628, + 2668, + 2723, + 2459, + 2493, + 2518, + 2523, + 3503, + 4086, + 4261, + 4301, + 2620, + 2116, + 1962, + 1931, + 1651, + 1624, + 1627, + 1669, + 1797, + 1795, + 1772, + 1708, + 1891, + 1910, + 1921, + 1978, + 2091, + 2093, + 2050, + 2041, + 1961, + 1988, + 1979, + 1977, + 1866, + 1875, + 1884, + 1846, + 1906, + 1930, + 1941, + 1935, + 1757, + 1773, + 1774, + 1858, + 1922, + 1875, + 2165, + 1924, + 1760, + 1784, + 1784, + 1836, + 1945, + 1955, + 1930, + 1881, + 2037, + 2007, + 1972, + 1937 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 27 + ], + "sqlMicros": [ + 962 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 237, + "received": 27784 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 25 + ], + "sqlMicros": [ + 902 + ], + "ormDecodeMicros": [ + 13 + ], + "postgresProtocolBytes": { + "sent": 237, + "received": 27784 + } + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1365689545938, + "endMicros": 1365689551123, + "retainedRootRows": 100, + "rssBefore": 308805632, + "rssAfter": 310575104, + "processLifetimeMaxRss": 523517952 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16890032, + "heapCapacity": 35717120, + "externalUsage": 272 + }, + "gcTimestamp": "1789454506116", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 106, + "bytesCurrent": 4992 + }, + { + "name": "_Map", + "instancesCurrent": 25, + "bytesCurrent": 1600 + }, + { + "name": "_List", + "instancesCurrent": 46760, + "bytesCurrent": 3590960 + }, + { + "name": "_GrowableList", + "instancesCurrent": 922, + "bytesCurrent": 29504 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16946688, + "heapCapacity": 22396928, + "externalUsage": 272 + }, + "gcTimestamp": "1789454506313", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 206, + "bytesCurrent": 9792 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46784, + "bytesCurrent": 3594384 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 3698, + "counts": { + "_List": 2393, + "_GrowableList": 993, + "_Record": 312 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 1983 + }, + { + "function": "", + "samples": 603 + }, + { + "function": "wait", + "samples": 303 + }, + { + "function": "DataRowMessage.parse", + "samples": 300 + }, + { + "function": "handleMessage", + "samples": 300 + }, + { + "function": "_List", + "samples": 114 + }, + { + "function": "current", + "samples": 48 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 23 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_worker", + "samples": 3 + }, + { + "function": "_prepare", + "samples": 3 + }, + { + "function": "bindParameters", + "samples": 3 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1365689769922, + "endMicros": 1365689773875, + "retainedRootRows": 100, + "rssBefore": 329187328, + "rssAfter": 330825728, + "processLifetimeMaxRss": 523517952 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16900640, + "heapCapacity": 22396928, + "externalUsage": 272 + }, + "gcTimestamp": "1789454506343", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 106, + "bytesCurrent": 4992 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46783, + "bytesCurrent": 3593344 + }, + { + "name": "_GrowableList", + "instancesCurrent": 920, + "bytesCurrent": 29440 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16953136, + "heapCapacity": 26591232, + "externalUsage": 272 + }, + "gcTimestamp": "1789454506547", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 206, + "bytesCurrent": 9792 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46790, + "bytesCurrent": 3595360 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 4937, + "counts": { + "_List": 3161, + "_GrowableList": 1437, + "_SelectionPlan": 3, + "_Map": 18, + "_Record": 318 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 2748 + }, + { + "function": "", + "samples": 903 + }, + { + "function": "wait", + "samples": 303 + }, + { + "function": "DataRowMessage.parse", + "samples": 300 + }, + { + "function": "handleMessage", + "samples": 300 + }, + { + "function": "_List", + "samples": 136 + }, + { + "function": "current", + "samples": 48 + }, + { + "function": "quote", + "samples": 48 + }, + { + "function": "_GrowableList._withData", + "samples": 33 + }, + { + "function": "_write", + "samples": 33 + }, + { + "function": "writeSql", + "samples": 21 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 21 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "two_columns", + "latency": { + "raw": { + "samplesMicros": [ + 792, + 1600, + 797, + 780, + 809, + 901, + 813, + 823, + 851, + 793, + 761, + 792, + 837, + 811, + 769, + 788, + 788, + 808, + 758, + 748, + 730, + 717, + 729, + 779, + 805, + 801, + 793, + 824, + 771, + 804, + 765, + 809, + 778, + 808, + 760, + 821, + 760, + 855, + 788, + 807, + 791, + 758, + 781, + 559, + 537, + 555, + 553, + 524, + 591, + 534, + 532, + 550, + 539, + 535, + 524, + 541, + 524, + 539, + 533, + 547, + 535, + 549, + 560, + 598, + 553, + 522, + 529, + 549, + 535, + 535, + 528, + 532, + 536, + 530, + 549, + 544, + 520, + 533, + 512, + 587, + 543, + 524, + 534, + 529, + 569, + 578, + 536, + 532, + 540, + 528, + 519, + 519, + 527, + 545, + 662, + 547, + 512, + 530, + 512, + 508, + 515, + 531, + 515, + 560, + 518, + 576, + 509, + 517, + 487, + 552, + 485, + 513, + 503, + 532, + 495, + 509, + 504, + 497, + 488, + 491, + 545, + 521, + 493, + 502, + 493, + 508, + 488, + 484, + 504, + 496, + 548, + 507, + 493, + 489, + 479, + 533, + 489, + 501, + 492, + 491, + 499, + 489, + 479, + 490, + 514, + 545, + 502, + 496, + 493, + 498, + 487, + 486, + 484, + 502, + 476, + 541, + 570, + 513, + 522, + 524, + 527, + 534, + 528, + 534, + 523, + 541, + 544, + 491, + 489, + 499, + 493, + 555, + 496, + 503, + 492, + 535, + 496, + 523, + 557, + 487, + 488, + 537, + 498, + 503, + 501, + 504, + 519, + 503, + 480, + 488, + 499, + 498, + 517, + 487, + 496, + 493, + 489, + 494, + 462, + 495 + ], + "p50Micros": 530, + "p95Micros": 809, + "minMicros": 462, + "maxMicros": 1600, + "wallMicros": 116461, + "operationsPerSecond": 1717.3130919363564 + }, + "orm": { + "samplesMicros": [ + 857, + 830, + 852, + 822, + 852, + 2221, + 858, + 879, + 844, + 838, + 809, + 822, + 834, + 826, + 843, + 835, + 809, + 885, + 899, + 849, + 751, + 741, + 902, + 836, + 825, + 813, + 809, + 830, + 807, + 772, + 815, + 813, + 867, + 820, + 807, + 821, + 823, + 807, + 793, + 823, + 798, + 810, + 739, + 660, + 625, + 570, + 562, + 575, + 575, + 583, + 567, + 557, + 554, + 608, + 554, + 568, + 551, + 595, + 602, + 578, + 561, + 567, + 562, + 559, + 551, + 570, + 555, + 554, + 585, + 547, + 557, + 564, + 541, + 566, + 542, + 551, + 588, + 560, + 570, + 643, + 559, + 620, + 570, + 566, + 573, + 572, + 552, + 558, + 575, + 648, + 564, + 562, + 584, + 561, + 579, + 563, + 541, + 568, + 545, + 622, + 547, + 545, + 526, + 598, + 582, + 551, + 529, + 535, + 574, + 564, + 519, + 530, + 520, + 537, + 596, + 549, + 701, + 533, + 515, + 507, + 521, + 523, + 514, + 556, + 519, + 554, + 519, + 517, + 522, + 520, + 523, + 529, + 525, + 530, + 511, + 509, + 530, + 524, + 532, + 539, + 551, + 524, + 552, + 537, + 534, + 533, + 537, + 513, + 532, + 502, + 568, + 531, + 516, + 530, + 516, + 534, + 550, + 543, + 516, + 551, + 542, + 611, + 553, + 551, + 541, + 558, + 552, + 513, + 516, + 528, + 507, + 514, + 503, + 524, + 514, + 512, + 561, + 578, + 546, + 533, + 505, + 518, + 520, + 508, + 524, + 510, + 528, + 552, + 516, + 512, + 525, + 520, + 524, + 536, + 540, + 523, + 516, + 583, + 516, + 523 + ], + "p50Micros": 555, + "p95Micros": 849, + "minMicros": 502, + "maxMicros": 2221, + "wallMicros": 123046, + "operationsPerSecond": 1625.4083838564438 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 1122, + 1154, + 1179, + 1203, + 2487, + 2635, + 2692, + 2747, + 2765, + 2735, + 2841, + 2853, + 2217, + 2861, + 2850, + 2839, + 2125, + 2707, + 2705, + 2701, + 2387, + 2402, + 2448, + 2501, + 2576, + 3093, + 3097, + 3232, + 2979, + 3221, + 3065, + 3221, + 2694, + 2827, + 2793, + 2740, + 2627, + 2784, + 2786, + 2802, + 2838, + 2426, + 2796, + 2840, + 2964, + 2461, + 2971, + 3018, + 3029, + 3101, + 2877, + 3008, + 2961, + 2721, + 2774, + 2736, + 2966, + 2166, + 2576, + 2579, + 2585, + 2464, + 2365, + 2466, + 2416, + 2424, + 2400, + 2400, + 2360, + 2365, + 2362, + 2358, + 2357, + 2362, + 2346, + 2409, + 2443, + 2400, + 2410, + 2416, + 2418, + 2489, + 2436, + 2373, + 2333, + 2322, + 2314, + 2322, + 2564, + 2325, + 2555, + 2589, + 2895, + 2664, + 3172, + 3206, + 2969, + 2817, + 2990, + 2957, + 2652, + 2541, + 2309, + 2502, + 2352, + 2479, + 3116, + 3115, + 3076, + 3154, + 3163, + 3122, + 3200, + 3164, + 2698, + 2734, + 2707, + 2782, + 2837, + 3023, + 2975, + 3057, + 2713, + 2854, + 2764, + 2770, + 2587, + 2571, + 2570, + 2565, + 2649, + 2650, + 2648, + 2645, + 2564, + 2573, + 2572, + 2570, + 2547, + 2536, + 2534, + 2536, + 2439, + 2450, + 2453, + 2467, + 2390, + 2350, + 2351, + 2472, + 2563, + 2630, + 2588, + 2688, + 2700, + 2810, + 2764, + 2766, + 2886, + 2707, + 2808, + 2894, + 2606, + 2502, + 2623, + 2615, + 2477, + 2428, + 2520, + 2473, + 2528, + 2622, + 2579, + 2586, + 2665, + 2694, + 2682, + 2796, + 2963, + 3063, + 3015, + 3010, + 3012, + 2981, + 2867, + 2976, + 2659, + 2661, + 2622, + 2696, + 2567, + 2672, + 2806, + 2739, + 2688, + 2879, + 2830, + 2895, + 2220, + 2381 + ], + "p50Micros": 2652, + "p95Micros": 3116, + "minMicros": 1122, + "maxMicros": 3232, + "wallMicros": 67302, + "operationsPerSecond": 2971.67989064218 + }, + "orm": { + "samplesMicros": [ + 1302, + 1356, + 1436, + 1525, + 2667, + 2730, + 2796, + 2872, + 2887, + 2885, + 2764, + 2900, + 2772, + 2775, + 2766, + 2751, + 2733, + 2731, + 2686, + 2789, + 2715, + 2871, + 2796, + 2796, + 2598, + 2538, + 2663, + 2611, + 2677, + 2663, + 2654, + 2646, + 2677, + 2677, + 2614, + 2730, + 2576, + 2582, + 2593, + 2607, + 2792, + 2745, + 3000, + 2909, + 2897, + 2896, + 2820, + 2944, + 2757, + 2771, + 2721, + 2823, + 2733, + 2735, + 2747, + 2743, + 2873, + 2875, + 2878, + 2867, + 2801, + 2732, + 2847, + 2777, + 2388, + 2567, + 2705, + 2621, + 2789, + 2750, + 2863, + 2818, + 2852, + 2862, + 2812, + 2933, + 2813, + 2801, + 2787, + 2778, + 2653, + 2678, + 2960, + 2948, + 2837, + 3030, + 3024, + 2973, + 2955, + 3224, + 3083, + 3229, + 2956, + 3162, + 3108, + 3131, + 3213, + 3015, + 2990, + 3203, + 2957, + 2914, + 2895, + 2892, + 2661, + 2673, + 2675, + 2677, + 2793, + 2702, + 2825, + 2750, + 2730, + 2733, + 2676, + 2781, + 2735, + 2874, + 2764, + 2864, + 2753, + 3042, + 2998, + 3016, + 2954, + 3022, + 3027, + 3031, + 3231, + 3102, + 3009, + 3140, + 2934, + 2856, + 2878, + 2878, + 2710, + 2705, + 2702, + 2690, + 2709, + 2723, + 2696, + 2701, + 2668, + 2668, + 2657, + 2663, + 2777, + 2899, + 2868, + 2990, + 2974, + 3045, + 3012, + 3129, + 3015, + 2740, + 2867, + 3002, + 2932, + 2858, + 2777, + 2895, + 2799, + 3002, + 2883, + 3047, + 3095, + 3325, + 3213, + 3372, + 3103, + 3046, + 3058, + 3234, + 2824, + 2802, + 2788, + 2791, + 2619, + 2752, + 2882, + 2730, + 2586, + 2576, + 2573, + 2565, + 2771, + 2708, + 2819, + 2775, + 2837, + 2775, + 2773, + 2915, + 2791, + 2874, + 2781, + 2747 + ], + "p50Micros": 2796, + "p95Micros": 3140, + "minMicros": 1302, + "maxMicros": 3372, + "wallMicros": 70992, + "operationsPerSecond": 2817.2188415596124 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 50, + 53, + 54, + 55, + 1200, + 1250, + 1291, + 1325, + 1229, + 1216, + 1214, + 1213, + 1296, + 1289, + 1289, + 1292, + 1330, + 1352, + 1371, + 1379, + 1400, + 1395, + 1350, + 1339, + 1444, + 1468, + 1495, + 1519, + 1448, + 1418, + 1387, + 1367, + 1392, + 1367, + 1365, + 1368, + 1179, + 1179, + 1176, + 1180, + 1482, + 1523, + 1533, + 1538, + 1463, + 1400, + 1399, + 1403, + 1420, + 1408, + 1429, + 1443, + 1468, + 1439, + 1415, + 1400, + 1404, + 1383, + 1376, + 1371, + 1175, + 1167, + 1163, + 1162 + ], + "p50Micros": 1368, + "p95Micros": 1519, + "minMicros": 50, + "maxMicros": 1538, + "wallMicros": 81348 + }, + "sqlMicros": [ + 1138, + 1189, + 1226, + 1262, + 1181, + 1185, + 1180, + 1180, + 1253, + 1259, + 1221, + 1297, + 1246, + 1257, + 1417, + 1342, + 1359, + 1337, + 1318, + 1307, + 1386, + 1352, + 1486, + 1506, + 1390, + 1386, + 1359, + 1334, + 1352, + 1337, + 1303, + 1372, + 1150, + 1150, + 1114, + 1184, + 1404, + 1464, + 1543, + 1504, + 1353, + 1325, + 1413, + 1370, + 1344, + 1324, + 1340, + 1486, + 1410, + 1344, + 1328, + 1487, + 1308, + 1312, + 1437, + 1340, + 1145, + 1145, + 1142, + 1136, + 1235, + 1222, + 1209, + 1195 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 55, + 74, + 82, + 89, + 1161, + 1227, + 1283, + 1337, + 1542, + 1608, + 1689, + 1750, + 1629, + 1531, + 1528, + 1419, + 1430, + 1434, + 1427, + 1412, + 1457, + 1431, + 1416, + 1413, + 1337, + 1333, + 1334, + 1346, + 1419, + 1404, + 1409, + 1417, + 1543, + 1570, + 1603, + 1644, + 1441, + 1416, + 1379, + 1339, + 1452, + 1445, + 1453, + 1460, + 1470, + 1439, + 1426, + 1415, + 1349, + 1340, + 1347, + 1353, + 1177, + 1197, + 1194, + 1200, + 1149, + 1147, + 1143, + 1148, + 1310, + 1295, + 1293, + 1291 + ], + "p50Micros": 1409, + "p95Micros": 1629, + "minMicros": 55, + "maxMicros": 1750, + "wallMicros": 83851 + }, + "sqlMicros": [ + 1077, + 1137, + 1189, + 1235, + 1406, + 1442, + 1624, + 1743, + 1471, + 1469, + 1284, + 1448, + 1352, + 1370, + 1303, + 1420, + 1378, + 1376, + 1312, + 1419, + 1267, + 1289, + 1290, + 1300, + 1355, + 1365, + 1365, + 1357, + 1455, + 1507, + 1524, + 1555, + 1362, + 1374, + 1333, + 1294, + 1292, + 1459, + 1357, + 1467, + 1379, + 1388, + 1324, + 1427, + 1275, + 1200, + 1307, + 1404, + 1110, + 1088, + 1207, + 1153, + 1081, + 1093, + 1100, + 1101, + 1253, + 1249, + 1253, + 1242, + 1332, + 1190, + 1271, + 1351 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 24 + ], + "sqlMicros": [ + 563 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 212, + "received": 4693 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 23 + ], + "sqlMicros": [ + 554 + ], + "ormDecodeMicros": [ + 9 + ], + "postgresProtocolBytes": { + "sent": 212, + "received": 4693 + } + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1365690039901, + "endMicros": 1365690042708, + "retainedRootRows": 100, + "rssBefore": 342867968, + "rssAfter": 343752704, + "processLifetimeMaxRss": 523517952 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16901888, + "heapCapacity": 29212672, + "externalUsage": 272 + }, + "gcTimestamp": "1789454506612", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 106, + "bytesCurrent": 3392 + }, + { + "name": "_Map", + "instancesCurrent": 25, + "bytesCurrent": 1600 + }, + { + "name": "_List", + "instancesCurrent": 46833, + "bytesCurrent": 3599920 + }, + { + "name": "_GrowableList", + "instancesCurrent": 922, + "bytesCurrent": 29504 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16909648, + "heapCapacity": 22921216, + "externalUsage": 272 + }, + "gcTimestamp": "1789454506796", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 206, + "bytesCurrent": 6592 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46828, + "bytesCurrent": 3600352 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 2665, + "counts": { + "_List": 1372, + "_GrowableList": 987, + "_Record": 306 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 1062 + }, + { + "function": "", + "samples": 603 + }, + { + "function": "wait", + "samples": 303 + }, + { + "function": "DataRowMessage.parse", + "samples": 300 + }, + { + "function": "handleMessage", + "samples": 300 + }, + { + "function": "current", + "samples": 42 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 18 + }, + { + "function": "_List", + "samples": 12 + }, + { + "function": "_GrowableList._withData", + "samples": 12 + }, + { + "function": "_List.filled", + "samples": 3 + }, + { + "function": "_prepare", + "samples": 3 + }, + { + "function": "bindParameters", + "samples": 3 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1365690252488, + "endMicros": 1365690255608, + "retainedRootRows": 100, + "rssBefore": 355418112, + "rssAfter": 356597760, + "processLifetimeMaxRss": 523517952 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16900576, + "heapCapacity": 22921216, + "externalUsage": 272 + }, + "gcTimestamp": "1789454506825", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 106, + "bytesCurrent": 3392 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46827, + "bytesCurrent": 3599312 + }, + { + "name": "_GrowableList", + "instancesCurrent": 920, + "bytesCurrent": 29440 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 16909648, + "heapCapacity": 25542656, + "externalUsage": 272 + }, + "gcTimestamp": "1789454507016", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 206, + "bytesCurrent": 6592 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46828, + "bytesCurrent": 3600352 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 3548, + "counts": { + "_List": 1808, + "_GrowableList": 1407, + "_SelectionPlan": 3, + "_Map": 18, + "_Record": 312 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 1491 + }, + { + "function": "", + "samples": 903 + }, + { + "function": "wait", + "samples": 303 + }, + { + "function": "DataRowMessage.parse", + "samples": 300 + }, + { + "function": "handleMessage", + "samples": 300 + }, + { + "function": "current", + "samples": 42 + }, + { + "function": "quote", + "samples": 36 + }, + { + "function": "_List", + "samples": 34 + }, + { + "function": "_GrowableList._withData", + "samples": 33 + }, + { + "function": "_write", + "samples": 27 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 18 + }, + { + "function": "writeSql", + "samples": 15 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "three_posts", + "latency": { + "raw": { + "samplesMicros": [ + 1830, + 1802, + 1865, + 1783, + 1825, + 1744, + 1760, + 1806, + 1757, + 1819, + 1756, + 1813, + 1747, + 1813, + 1749, + 1792, + 1806, + 1764, + 1917, + 1732, + 1778, + 1753, + 1893, + 1691, + 1737, + 1712, + 1726, + 1771, + 1690, + 1745, + 1686, + 1772, + 1686, + 1824, + 1701, + 1716, + 1744, + 1782, + 2198, + 1741, + 1751, + 1694, + 1757, + 1759, + 1732, + 1751, + 1685, + 1762, + 1693, + 1789, + 1721, + 1745, + 1696, + 1667, + 1745, + 1694, + 1744, + 1679, + 1716, + 1675, + 1736, + 1724, + 1772, + 1719, + 1698, + 1783, + 1687, + 1771, + 1725, + 1738, + 1647, + 1675, + 1780, + 1723, + 1740, + 1689, + 1743, + 1710, + 1771, + 1695, + 1694, + 1764, + 1716, + 1744, + 1656, + 1784, + 1698, + 1730, + 1701, + 1710, + 1749, + 1704, + 1769, + 1715, + 1740, + 1709, + 1748, + 1737, + 1680, + 1739, + 1674, + 1762, + 1663, + 1759, + 1671, + 1766, + 1840, + 1686, + 1729, + 1707, + 1732, + 1703, + 1729, + 1662, + 1767, + 1681, + 1709, + 1708, + 1689, + 1870, + 2311, + 2337, + 2219, + 2313, + 2293, + 2222, + 2203, + 2047, + 1916, + 1675, + 1745, + 2226, + 2251, + 2192, + 2191, + 2255, + 2202, + 2268, + 2187, + 2237, + 2215, + 2179, + 2181, + 2216, + 2226, + 2203, + 2252, + 2123, + 2231, + 2218, + 2241, + 2152, + 2188, + 2226, + 2188, + 2240, + 2207, + 2514, + 2247, + 2228, + 2219, + 2218, + 2295, + 2156, + 2242, + 2165, + 2286, + 2245, + 2273, + 2213, + 2192, + 2190, + 2226, + 2236, + 2232, + 2233, + 2141, + 2097, + 2040, + 2055, + 2117, + 2076, + 2089, + 2154, + 2156, + 2049, + 2121, + 2224, + 2212, + 2276, + 2187, + 2287, + 2156, + 2238, + 2148, + 2290, + 2201, + 2224, + 2165, + 2236 + ], + "p50Micros": 1778, + "p95Micros": 2273, + "minMicros": 1647, + "maxMicros": 2514, + "wallMicros": 384526, + "operationsPerSecond": 520.1208760916037 + }, + "orm": { + "samplesMicros": [ + 2137, + 2003, + 1906, + 2032, + 1933, + 2066, + 1965, + 1961, + 2183, + 1960, + 2025, + 1895, + 2016, + 1973, + 1943, + 1908, + 1855, + 1976, + 1961, + 1988, + 1884, + 1989, + 1975, + 1903, + 1884, + 1921, + 1974, + 1855, + 1888, + 1845, + 1902, + 1822, + 2058, + 1856, + 1869, + 1970, + 1831, + 1926, + 1923, + 1921, + 1858, + 1851, + 1862, + 1968, + 2290, + 1865, + 1876, + 1815, + 2050, + 1824, + 1903, + 1809, + 1822, + 1850, + 1868, + 1951, + 1844, + 1919, + 1861, + 1971, + 1893, + 1889, + 1822, + 1822, + 1854, + 1826, + 1921, + 1760, + 1903, + 1789, + 1865, + 1789, + 1829, + 1865, + 1809, + 1869, + 1836, + 1895, + 1824, + 1870, + 1903, + 1853, + 1854, + 1770, + 1900, + 1802, + 1885, + 1841, + 1901, + 1943, + 1782, + 1861, + 1848, + 1871, + 1825, + 1850, + 1822, + 1877, + 1838, + 1791, + 1857, + 1772, + 1861, + 1797, + 1879, + 1777, + 1809, + 1877, + 1794, + 1895, + 1785, + 1867, + 1806, + 1845, + 1769, + 1839, + 1837, + 1793, + 1852, + 1857, + 2461, + 2344, + 2363, + 2329, + 2295, + 2346, + 2242, + 2275, + 1820, + 1869, + 2193, + 2361, + 2343, + 2377, + 2254, + 2291, + 2348, + 2431, + 2351, + 2328, + 2298, + 2217, + 2309, + 2343, + 2302, + 2333, + 2319, + 2347, + 2333, + 2326, + 2280, + 2331, + 2319, + 2262, + 2358, + 2300, + 2384, + 2329, + 2429, + 2308, + 2237, + 2383, + 2302, + 2401, + 2338, + 2351, + 2503, + 2418, + 2327, + 2401, + 2355, + 2287, + 2336, + 2290, + 2439, + 2246, + 2187, + 2097, + 2126, + 2168, + 2204, + 2247, + 2213, + 2208, + 2221, + 2313, + 2342, + 2288, + 2371, + 2309, + 2378, + 2297, + 2387, + 2273, + 2370, + 2328, + 2347, + 2341, + 2337, + 2378 + ], + "p50Micros": 1960, + "p95Micros": 2383, + "minMicros": 1760, + "maxMicros": 2503, + "wallMicros": 410846, + "operationsPerSecond": 486.8004069651402 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 4589, + 4635, + 4697, + 4746, + 9633, + 9704, + 9759, + 9825, + 9825, + 9836, + 9845, + 9863, + 10778, + 10838, + 10783, + 10918, + 12298, + 12319, + 12606, + 12610, + 11167, + 11130, + 11132, + 11138, + 10496, + 10479, + 10468, + 10458, + 10484, + 10480, + 10485, + 10492, + 9752, + 9751, + 9750, + 9747, + 9782, + 9780, + 9770, + 9761, + 9705, + 9708, + 9699, + 9694, + 9548, + 9548, + 9547, + 9546, + 9371, + 9388, + 9399, + 9405, + 9701, + 9704, + 9703, + 9707, + 9918, + 9917, + 9924, + 9928, + 9907, + 9915, + 9918, + 9903, + 9704, + 9881, + 9788, + 9791, + 9884, + 9876, + 9872, + 9878, + 9979, + 9980, + 9977, + 9978, + 9736, + 9722, + 9720, + 9721, + 9378, + 9560, + 9484, + 9491, + 9594, + 9596, + 9594, + 9592, + 9714, + 9724, + 9720, + 9715, + 9462, + 9751, + 9759, + 9757, + 9924, + 9902, + 9904, + 9902, + 10013, + 9745, + 9744, + 9759, + 9636, + 9637, + 9629, + 9628, + 9527, + 9526, + 9531, + 9532, + 9669, + 9824, + 9730, + 9733, + 9827, + 9831, + 9839, + 9829, + 10454, + 10400, + 10393, + 10593, + 10335, + 10331, + 10314, + 10308, + 9543, + 9694, + 9610, + 9608, + 9592, + 9610, + 9619, + 9624, + 9483, + 9486, + 9422, + 9554, + 9639, + 9624, + 9618, + 9618, + 9376, + 9580, + 9521, + 9513, + 9504, + 9503, + 9500, + 9506, + 9222, + 9184, + 9182, + 9187, + 9219, + 9385, + 9300, + 9300, + 9627, + 9794, + 9709, + 9711, + 9720, + 9724, + 9731, + 9731, + 9765, + 9925, + 9836, + 9835, + 9943, + 9937, + 9941, + 9935, + 9991, + 10006, + 10016, + 10030, + 9949, + 9955, + 9937, + 9930, + 9655, + 9647, + 9643, + 9645, + 9514, + 9502, + 9498, + 9497, + 9845, + 10012, + 9925, + 9905, + 9821, + 9822, + 9826, + 9820 + ], + "p50Micros": 9745, + "p95Micros": 10783, + "minMicros": 4589, + "maxMicros": 12610, + "wallMicros": 246626, + "operationsPerSecond": 810.9445070673813 + }, + "orm": { + "samplesMicros": [ + 4978, + 5089, + 5193, + 5303, + 9955, + 10086, + 10195, + 10299, + 10440, + 10708, + 10593, + 10600, + 10622, + 10622, + 10632, + 10629, + 10385, + 10376, + 10383, + 10396, + 10356, + 10351, + 10340, + 10345, + 10074, + 10332, + 10179, + 10158, + 10244, + 10261, + 10261, + 10271, + 10488, + 10502, + 10501, + 10501, + 10784, + 10779, + 10670, + 10901, + 13410, + 13419, + 13436, + 13458, + 13417, + 13412, + 13400, + 13401, + 10761, + 10737, + 10731, + 10711, + 10548, + 10542, + 10537, + 10539, + 10479, + 10489, + 10486, + 10485, + 11042, + 11122, + 11487, + 11266, + 11600, + 11882, + 11658, + 11885, + 11177, + 10887, + 11272, + 11266, + 11167, + 11172, + 11052, + 11305, + 11143, + 11146, + 11144, + 11149, + 11159, + 11009, + 11003, + 11403, + 10986, + 11279, + 11124, + 11118, + 11021, + 11016, + 11024, + 11009, + 10649, + 11248, + 11286, + 11313, + 10922, + 10938, + 10925, + 10919, + 10785, + 10754, + 11270, + 10848, + 11063, + 11087, + 10994, + 11239, + 10848, + 11139, + 10866, + 11105, + 10745, + 10718, + 10572, + 10822, + 10717, + 11007, + 10869, + 10870, + 10952, + 10687, + 11044, + 11037, + 10688, + 10996, + 10744, + 11047, + 11126, + 11224, + 11263, + 11290, + 11811, + 12167, + 12006, + 11945, + 12220, + 11996, + 12261, + 12096, + 11374, + 11719, + 11384, + 11641, + 10972, + 10951, + 10808, + 11039, + 10580, + 10576, + 10566, + 10543, + 10443, + 10684, + 10556, + 10565, + 10401, + 10680, + 10535, + 10530, + 10363, + 10598, + 10488, + 10473, + 10429, + 10430, + 10431, + 10437, + 10292, + 10528, + 10407, + 10419, + 10411, + 10422, + 10417, + 10411, + 10324, + 10579, + 10478, + 10470, + 10455, + 10726, + 10485, + 10696, + 10306, + 11201, + 11127, + 11545, + 13496, + 13782, + 13643, + 13651, + 12649, + 13841, + 12783, + 12575, + 10330, + 10600, + 10427, + 10419 + ], + "p50Micros": 10779, + "p95Micros": 13410, + "minMicros": 4978, + "maxMicros": 13841, + "wallMicros": 275871, + "operationsPerSecond": 724.9765288848773 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 67, + 67, + 67, + 68, + 5095, + 5189, + 5275, + 5355, + 5047, + 5028, + 5014, + 5014, + 4919, + 4925, + 4936, + 4966, + 5238, + 5242, + 5249, + 5241, + 5403, + 5373, + 5369, + 5372, + 4761, + 4709, + 4680, + 4652, + 4692, + 4706, + 4709, + 4715, + 4791, + 4761, + 4742, + 4738, + 4575, + 4592, + 4619, + 4641, + 4709, + 4686, + 4675, + 4667, + 4935, + 4947, + 4949, + 4949, + 4770, + 4761, + 4767, + 4780, + 4801, + 4775, + 4774, + 4769, + 4621, + 4626, + 4624, + 4626, + 4846, + 4820, + 4829, + 4874 + ], + "p50Micros": 4774, + "p95Micros": 5369, + "minMicros": 67, + "maxMicros": 5403, + "wallMicros": 293182 + }, + "sqlMicros": [ + 1358, + 1410, + 1459, + 1498, + 3557, + 3627, + 3636, + 3759, + 1672, + 1620, + 1556, + 1499, + 3260, + 3332, + 3386, + 3432, + 1477, + 1435, + 1387, + 1335, + 3332, + 3405, + 3466, + 3535, + 1576, + 1518, + 1356, + 1492, + 3486, + 3635, + 3769, + 3759, + 1602, + 1545, + 1481, + 1415, + 3660, + 3742, + 3799, + 3861, + 1390, + 1343, + 1540, + 1292, + 3204, + 3239, + 3269, + 3302, + 1422, + 1397, + 1362, + 1327, + 3182, + 3250, + 3292, + 3329, + 1373, + 1344, + 1304, + 1269, + 3311, + 3417, + 3399, + 3417, + 1300, + 1286, + 1264, + 1233, + 3184, + 3242, + 3292, + 3344, + 1451, + 1402, + 1352, + 1301, + 3147, + 3253, + 3268, + 3308, + 1618, + 1598, + 1560, + 1519, + 3189, + 3320, + 3329, + 3367, + 1419, + 1374, + 1338, + 1302, + 3262, + 3324, + 3370, + 3416, + 1507, + 1464, + 1420, + 1375, + 3213, + 3260, + 3301, + 3340, + 1369, + 1334, + 1292, + 1250, + 3170, + 3232, + 3278, + 3318, + 1481, + 1442, + 1402, + 1360, + 3270, + 3373, + 3371, + 3452, + 1455, + 1437, + 1389, + 1316, + 3183, + 3282, + 3285, + 3310 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 54, + 74, + 80, + 85, + 4992, + 5115, + 5215, + 5319, + 5051, + 5055, + 5062, + 5059, + 5215, + 5189, + 5176, + 5187, + 5143, + 5148, + 5158, + 5156, + 5412, + 5369, + 5352, + 5352, + 5030, + 5040, + 5054, + 5060, + 5239, + 5217, + 5205, + 5209, + 5009, + 5040, + 5058, + 5069, + 5362, + 5331, + 5314, + 5294, + 5072, + 5069, + 5092, + 5109, + 5242, + 5226, + 5216, + 5310, + 5578, + 5654, + 5738, + 5729, + 5433, + 5352, + 5273, + 5212, + 5612, + 5644, + 5674, + 5708, + 5546, + 5509, + 5482, + 5457 + ], + "p50Micros": 5212, + "p95Micros": 5674, + "minMicros": 54, + "maxMicros": 5738, + "wallMicros": 316486 + }, + "sqlMicros": [ + 1270, + 1373, + 1417, + 1467, + 3488, + 3555, + 3608, + 3655, + 1541, + 1507, + 1469, + 1429, + 3245, + 3464, + 3452, + 3486, + 1552, + 1506, + 1467, + 1436, + 3473, + 3548, + 3575, + 3609, + 1522, + 1502, + 1461, + 1423, + 3334, + 3546, + 3551, + 3589, + 1711, + 1664, + 1621, + 1585, + 3519, + 3572, + 3600, + 3627, + 1511, + 1490, + 1455, + 1413, + 3270, + 3471, + 3460, + 3505, + 1461, + 1653, + 1495, + 1447, + 3502, + 3546, + 3580, + 3628, + 1493, + 1481, + 1451, + 1412, + 3261, + 3480, + 3458, + 3492, + 1603, + 1577, + 1528, + 1467, + 3551, + 3625, + 3662, + 3694, + 1499, + 1473, + 1447, + 1423, + 3362, + 3451, + 3490, + 3541, + 1576, + 1566, + 1529, + 1491, + 3372, + 3585, + 3531, + 3675, + 1582, + 1786, + 1522, + 1486, + 3627, + 3639, + 3965, + 4094, + 1875, + 1768, + 1638, + 1505, + 3292, + 3500, + 3490, + 3548, + 1705, + 1668, + 1607, + 1543, + 3677, + 3796, + 3895, + 3985, + 1737, + 1667, + 1583, + 1486, + 3605, + 3689, + 3750, + 3811, + 1658, + 1687, + 1636, + 1578, + 3271, + 3237, + 3266, + 3284 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100))) AS \"orm_partition\" WHERE \"c3\" > $101 AND \"c3\" <= $102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 38 + ], + "sqlMicros": [ + 755, + 1434 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 2427, + "received": 56982 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100))) AS \"orm_partition\" WHERE \"c3\" > $101 AND \"c3\" <= $102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 23 + ], + "sqlMicros": [ + 844, + 1458 + ], + "ormDecodeMicros": [ + 72, + 10 + ], + "postgresProtocolBytes": { + "sent": 2427, + "received": 56982 + } + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1365690579407, + "endMicros": 1365690587019, + "retainedRootRows": 100, + "rssBefore": 368705536, + "rssAfter": 372719616, + "processLifetimeMaxRss": 523517952 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17183264, + "heapCapacity": 27115520, + "externalUsage": 272 + }, + "gcTimestamp": "1789454507153", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 406, + "bytesCurrent": 14592 + }, + { + "name": "_Map", + "instancesCurrent": 25, + "bytesCurrent": 1600 + }, + { + "name": "_List", + "instancesCurrent": 46988, + "bytesCurrent": 3633760 + }, + { + "name": "_GrowableList", + "instancesCurrent": 922, + "bytesCurrent": 29504 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17284688, + "heapCapacity": 28164096, + "externalUsage": 272 + }, + "gcTimestamp": "1789454507421", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 806, + "bytesCurrent": 28992 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46990, + "bytesCurrent": 3635088 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 11753, + "counts": { + "_List": 6158, + "_GrowableList": 4077, + "_Record": 1515, + "_Map": 3 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 4614 + }, + { + "function": "", + "samples": 2406 + }, + { + "function": "wait", + "samples": 1206 + }, + { + "function": "DataRowMessage.parse", + "samples": 1200 + }, + { + "function": "handleMessage", + "samples": 1200 + }, + { + "function": "current", + "samples": 378 + }, + { + "function": "bindParameters", + "samples": 309 + }, + { + "function": "_List._ofGrowableList", + "samples": 303 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 43 + }, + { + "function": "_GrowableList._withData", + "samples": 21 + }, + { + "function": "_List", + "samples": 18 + }, + { + "function": "_init", + "samples": 18 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1365690877764, + "endMicros": 1365690888024, + "retainedRootRows": 100, + "rssBefore": 387825664, + "rssAfter": 394510336, + "processLifetimeMaxRss": 523517952 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17187616, + "heapCapacity": 28164096, + "externalUsage": 272 + }, + "gcTimestamp": "1789454507453", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 406, + "bytesCurrent": 14592 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46989, + "bytesCurrent": 3634048 + }, + { + "name": "_GrowableList", + "instancesCurrent": 920, + "bytesCurrent": 29440 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17288192, + "heapCapacity": 23969792, + "externalUsage": 272 + }, + "gcTimestamp": "1789454507781", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 806, + "bytesCurrent": 28992 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 46995, + "bytesCurrent": 3635456 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 19172, + "counts": { + "_List": 10282, + "_GrowableList": 7282, + "_SelectionPlan": 12, + "_Map": 60, + "_Record": 1536 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 7391 + }, + { + "function": "", + "samples": 4506 + }, + { + "function": "wait", + "samples": 1206 + }, + { + "function": "DataRowMessage.parse", + "samples": 1200 + }, + { + "function": "handleMessage", + "samples": 1200 + }, + { + "function": "_GrowableList._ofEfficientLengthIterable", + "samples": 636 + }, + { + "function": "parameter", + "samples": 636 + }, + { + "function": "_List", + "samples": 366 + }, + { + "function": "_List._ofGrowableList", + "samples": 309 + }, + { + "function": "bindParameters", + "samples": 309 + }, + { + "function": "load", + "samples": 303 + }, + { + "function": "[]", + "samples": 300 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + }, + { + "case": "aggregate", + "latency": { + "raw": { + "samplesMicros": [ + 588, + 603, + 626, + 517, + 400, + 386, + 388, + 382, + 368, + 376, + 372, + 377, + 369, + 359, + 372, + 364, + 379, + 362, + 408, + 369, + 357, + 360, + 354, + 352, + 349, + 347, + 360, + 353, + 347, + 349, + 368, + 367, + 357, + 336, + 346, + 346, + 351, + 365, + 344, + 331, + 355, + 328, + 347, + 346, + 364, + 335, + 348, + 355, + 358, + 347, + 352, + 343, + 351, + 330, + 367, + 352, + 345, + 359, + 348, + 345, + 355, + 361, + 355, + 345, + 354, + 353, + 361, + 366, + 350, + 361, + 369, + 361, + 371, + 339, + 349, + 353, + 363, + 355, + 353, + 367, + 358, + 351, + 354, + 397, + 369, + 363, + 364, + 348, + 364, + 364, + 362, + 363, + 364, + 364, + 358, + 359, + 382, + 362, + 363, + 350, + 348, + 356, + 355, + 345, + 354, + 357, + 354, + 360, + 362, + 348, + 347, + 342, + 355, + 368, + 361, + 349, + 378, + 391, + 364, + 373, + 369, + 362, + 353, + 362, + 356, + 385, + 453, + 385, + 359, + 336, + 352, + 324, + 342, + 336, + 333, + 325, + 329, + 341, + 333, + 365, + 381, + 337, + 346, + 355, + 340, + 326, + 332, + 330, + 333, + 330, + 337, + 336, + 329, + 320, + 358, + 321, + 324, + 333, + 328, + 324, + 332, + 323, + 318, + 335, + 330, + 336, + 331, + 318, + 328, + 329, + 321, + 328, + 332, + 325, + 328, + 331, + 334, + 324, + 340, + 321, + 346, + 358, + 369, + 333, + 347, + 355, + 328, + 340, + 352, + 333, + 338, + 340, + 340, + 327, + 326, + 331, + 326, + 345, + 345, + 331 + ], + "p50Micros": 352, + "p95Micros": 386, + "minMicros": 318, + "maxMicros": 626, + "wallMicros": 71140, + "operationsPerSecond": 2811.35788585887 + }, + "orm": { + "samplesMicros": [ + 655, + 650, + 646, + 635, + 419, + 398, + 380, + 369, + 379, + 402, + 392, + 398, + 393, + 456, + 406, + 388, + 377, + 380, + 377, + 385, + 374, + 370, + 350, + 368, + 376, + 385, + 368, + 388, + 367, + 358, + 367, + 378, + 362, + 367, + 370, + 379, + 359, + 370, + 359, + 380, + 370, + 379, + 382, + 377, + 362, + 386, + 367, + 377, + 378, + 377, + 359, + 363, + 371, + 366, + 361, + 363, + 358, + 379, + 365, + 371, + 362, + 357, + 360, + 371, + 363, + 378, + 361, + 375, + 354, + 371, + 353, + 393, + 350, + 378, + 366, + 388, + 366, + 380, + 360, + 358, + 404, + 396, + 413, + 377, + 361, + 380, + 378, + 384, + 366, + 371, + 373, + 354, + 365, + 388, + 373, + 374, + 400, + 380, + 374, + 369, + 368, + 380, + 360, + 374, + 366, + 375, + 353, + 365, + 371, + 375, + 360, + 372, + 361, + 385, + 369, + 384, + 379, + 377, + 367, + 376, + 364, + 373, + 360, + 373, + 371, + 374, + 436, + 409, + 370, + 361, + 426, + 356, + 356, + 361, + 348, + 347, + 344, + 352, + 335, + 340, + 357, + 353, + 351, + 355, + 351, + 352, + 339, + 344, + 338, + 338, + 336, + 370, + 351, + 354, + 341, + 345, + 349, + 352, + 338, + 340, + 329, + 350, + 342, + 354, + 338, + 363, + 343, + 347, + 352, + 335, + 333, + 335, + 344, + 361, + 343, + 360, + 334, + 351, + 344, + 344, + 344, + 353, + 375, + 350, + 351, + 340, + 357, + 350, + 347, + 357, + 356, + 346, + 346, + 346, + 348, + 347, + 344, + 356, + 358, + 350 + ], + "p50Micros": 365, + "p95Micros": 406, + "minMicros": 329, + "maxMicros": 655, + "wallMicros": 74265, + "operationsPerSecond": 2693.0586413519154 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 588, + 702, + 736, + 754, + 1177, + 1305, + 1338, + 1368, + 1270, + 1272, + 1276, + 1276, + 1267, + 1283, + 1308, + 1310, + 1277, + 1219, + 1278, + 1343, + 1150, + 1331, + 1159, + 1196, + 1141, + 1119, + 1060, + 1005, + 1167, + 1167, + 1089, + 1193, + 1221, + 1216, + 1218, + 1209, + 1133, + 1223, + 1228, + 1231, + 1128, + 1194, + 1189, + 1193, + 1100, + 1060, + 1055, + 1046, + 1081, + 1142, + 1146, + 1142, + 1170, + 1352, + 1319, + 1369, + 1308, + 1389, + 1457, + 1429, + 1360, + 1336, + 1344, + 1344, + 1151, + 1153, + 1159, + 1151, + 998, + 960, + 964, + 968, + 982, + 911, + 910, + 920, + 1007, + 1071, + 1083, + 1100, + 1183, + 1192, + 1190, + 1187, + 1110, + 1070, + 1049, + 1063, + 989, + 1090, + 1103, + 1108, + 1118, + 1139, + 1141, + 1130, + 1111, + 1026, + 1013, + 1066, + 983, + 1126, + 1189, + 1175, + 1161, + 1320, + 1313, + 1254, + 1227, + 1227, + 1177, + 1177, + 1058, + 1099, + 1188, + 1188, + 1125, + 1138, + 1250, + 1297, + 1287, + 1172, + 1125, + 1210, + 1245, + 1152, + 1136, + 1120, + 1172, + 1210, + 1252, + 1239, + 1229, + 1310, + 1199, + 1167, + 1158, + 1217, + 1232, + 1153, + 1127, + 1120, + 1343, + 1254, + 1366, + 1160, + 1284, + 1292, + 1296, + 1122, + 1097, + 1125, + 1120, + 1034, + 1069, + 1039, + 1094, + 1046, + 1205, + 1183, + 1192, + 1007, + 1185, + 1295, + 1113, + 1324, + 1117, + 1213, + 1288, + 1295, + 1085, + 1059, + 1138, + 1229, + 1109, + 1207, + 1143, + 1250, + 1215, + 1238, + 1212, + 1132, + 1239, + 1086, + 1185, + 1079, + 1102, + 1064, + 1199, + 1215, + 1106, + 1310, + 1376, + 1366, + 1241, + 1418, + 1396, + 1400, + 1176, + 1144 + ], + "p50Micros": 1175, + "p95Micros": 1366, + "minMicros": 588, + "maxMicros": 1457, + "wallMicros": 29813, + "operationsPerSecond": 6708.482876597457 + }, + "orm": { + "samplesMicros": [ + 600, + 662, + 737, + 795, + 1107, + 1197, + 1240, + 1323, + 1058, + 1041, + 1167, + 1083, + 1134, + 1165, + 1250, + 1220, + 1327, + 1252, + 1362, + 1383, + 1440, + 1391, + 1301, + 1336, + 1411, + 1254, + 1455, + 1265, + 1409, + 1404, + 1408, + 1365, + 1499, + 1442, + 1517, + 1480, + 1456, + 1442, + 1430, + 1416, + 1336, + 1332, + 1334, + 1334, + 1264, + 1278, + 1284, + 1299, + 1341, + 1376, + 1396, + 1393, + 1478, + 1459, + 1445, + 1431, + 1449, + 1422, + 1400, + 1406, + 1414, + 1413, + 1415, + 1415, + 1397, + 1404, + 1410, + 1407, + 1453, + 1451, + 1421, + 1488, + 1460, + 1436, + 1431, + 1425, + 1368, + 1374, + 1336, + 1405, + 1321, + 1331, + 1327, + 1330, + 1345, + 1348, + 1318, + 1382, + 1431, + 1434, + 1433, + 1431, + 1343, + 1325, + 1413, + 1381, + 1279, + 1378, + 1309, + 1373, + 1403, + 1370, + 1360, + 1359, + 1315, + 1307, + 1350, + 1367, + 1298, + 1267, + 1385, + 1386, + 1309, + 1396, + 1391, + 1390, + 1291, + 1251, + 1252, + 1302, + 1197, + 1201, + 1108, + 1102, + 1183, + 1214, + 1204, + 1158, + 1144, + 1142, + 1101, + 1290, + 1185, + 1212, + 1370, + 1264, + 1351, + 1402, + 1416, + 1308, + 1240, + 1232, + 1252, + 1240, + 1036, + 1175, + 1077, + 1073, + 1047, + 1137, + 1113, + 1133, + 1190, + 1195, + 1334, + 1336, + 1473, + 1389, + 1474, + 1466, + 1348, + 1473, + 1394, + 1565, + 1465, + 1464, + 1376, + 1378, + 1530, + 1504, + 1387, + 1217, + 1444, + 1499, + 1507, + 1499, + 1452, + 1475, + 1444, + 1526, + 1250, + 1184, + 1268, + 1318, + 1131, + 1348, + 1381, + 1560, + 1443, + 1540, + 1558, + 1604, + 1536, + 1475, + 1455, + 1357, + 1279, + 1282, + 1255, + 1095 + ], + "p50Micros": 1359, + "p95Micros": 1504, + "minMicros": 600, + "maxMicros": 1604, + "wallMicros": 33581, + "operationsPerSecond": 5955.748786516185 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 50, + 45, + 45, + 49, + 535, + 586, + 609, + 632, + 496, + 552, + 550, + 551, + 608, + 623, + 634, + 633, + 610, + 592, + 576, + 585, + 621, + 577, + 603, + 598, + 550, + 513, + 526, + 616, + 581, + 579, + 537, + 596, + 547, + 716, + 762, + 628, + 683, + 765, + 704, + 692, + 706, + 662, + 668, + 673, + 509, + 513, + 566, + 562, + 561, + 512, + 559, + 569, + 600, + 695, + 592, + 704, + 717, + 703, + 713, + 581, + 538, + 545, + 648, + 643 + ], + "p50Micros": 592, + "p95Micros": 716, + "minMicros": 45, + "maxMicros": 765, + "wallMicros": 36694 + }, + "sqlMicros": [ + 483, + 546, + 559, + 593, + 471, + 531, + 532, + 532, + 582, + 601, + 588, + 641, + 580, + 569, + 559, + 563, + 591, + 553, + 585, + 579, + 522, + 492, + 505, + 597, + 552, + 561, + 519, + 572, + 517, + 675, + 737, + 599, + 653, + 611, + 681, + 779, + 680, + 634, + 647, + 648, + 487, + 486, + 546, + 540, + 539, + 486, + 526, + 548, + 566, + 675, + 556, + 676, + 684, + 681, + 678, + 559, + 516, + 529, + 598, + 657, + 653, + 546, + 546, + 543 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 52, + 58, + 63, + 69, + 641, + 686, + 723, + 756, + 744, + 736, + 731, + 729, + 757, + 779, + 793, + 803, + 765, + 737, + 724, + 715, + 723, + 738, + 728, + 730, + 749, + 750, + 761, + 775, + 813, + 779, + 773, + 776, + 759, + 749, + 747, + 738, + 726, + 740, + 743, + 752, + 753, + 727, + 714, + 721, + 726, + 722, + 724, + 721, + 701, + 693, + 695, + 697, + 702, + 700, + 696, + 699, + 770, + 764, + 776, + 775, + 758, + 753, + 747, + 737 + ], + "p50Micros": 737, + "p95Micros": 779, + "minMicros": 52, + "maxMicros": 813, + "wallMicros": 44581 + }, + "sqlMicros": [ + 581, + 621, + 653, + 683, + 703, + 665, + 741, + 695, + 705, + 743, + 729, + 803, + 658, + 764, + 652, + 733, + 642, + 752, + 705, + 701, + 675, + 719, + 702, + 774, + 740, + 744, + 743, + 738, + 711, + 719, + 716, + 711, + 675, + 708, + 716, + 724, + 702, + 695, + 688, + 686, + 683, + 659, + 732, + 686, + 656, + 665, + 666, + 670, + 660, + 673, + 667, + 669, + 725, + 704, + 779, + 737, + 718, + 730, + 723, + 704, + 662, + 656, + 645, + 640 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 18 + ], + "sqlMicros": [ + 403 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 244, + "received": 413 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 20 + ], + "sqlMicros": [ + 363 + ], + "ormDecodeMicros": [ + 5 + ], + "postgresProtocolBytes": { + "sent": 244, + "received": 413 + } + } + }, + "memory": { + "raw": { + "batch": { + "runs": 3, + "startMicros": 1365691261250, + "endMicros": 1365691262869, + "retainedRootRows": 10, + "rssBefore": 360300544, + "rssAfter": 360480768, + "processLifetimeMaxRss": 523517952 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17100464, + "heapCapacity": 21872640, + "externalUsage": 272 + }, + "gcTimestamp": "1789454507835", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 16, + "bytesCurrent": 512 + }, + { + "name": "_Map", + "instancesCurrent": 25, + "bytesCurrent": 1600 + }, + { + "name": "_List", + "instancesCurrent": 47007, + "bytesCurrent": 3634736 + }, + { + "name": "_GrowableList", + "instancesCurrent": 922, + "bytesCurrent": 29504 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17099648, + "heapCapacity": 22396928, + "externalUsage": 272 + }, + "gcTimestamp": "1789454508016", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 26, + "bytesCurrent": 832 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47002, + "bytesCurrent": 3634272 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 487, + "counts": { + "_List": 274, + "_GrowableList": 177, + "_Record": 36 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 234 + }, + { + "function": "", + "samples": 63 + }, + { + "function": "current", + "samples": 42 + }, + { + "function": "wait", + "samples": 33 + }, + { + "function": "DataRowMessage.parse", + "samples": 30 + }, + { + "function": "handleMessage", + "samples": 30 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 18 + }, + { + "function": "_List", + "samples": 9 + }, + { + "function": "_GrowableList._withData", + "samples": 9 + }, + { + "function": "_GrowableList._ofEfficientLengthIterable", + "samples": 6 + }, + { + "function": "_List.filled", + "samples": 3 + }, + { + "function": "_prepare", + "samples": 3 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + }, + "orm": { + "batch": { + "runs": 3, + "startMicros": 1365691475427, + "endMicros": 1365691477652, + "retainedRootRows": 10, + "rssBefore": 368984064, + "rssAfter": 369278976, + "processLifetimeMaxRss": 523517952 + }, + "heapBefore": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17099152, + "heapCapacity": 22396928, + "externalUsage": 272 + }, + "gcTimestamp": "1789454508047", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 16, + "bytesCurrent": 512 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47001, + "bytesCurrent": 3634128 + }, + { + "name": "_GrowableList", + "instancesCurrent": 920, + "bytesCurrent": 29440 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "heapWithLastResult": { + "memoryUsage": { + "type": "MemoryUsage", + "heapUsage": 17110736, + "heapCapacity": 23445504, + "externalUsage": 272 + }, + "gcTimestamp": "1789454508225", + "classes": [ + { + "name": "_Record", + "instancesCurrent": 26, + "bytesCurrent": 832 + }, + { + "name": "_Map", + "instancesCurrent": 21, + "bytesCurrent": 1344 + }, + { + "name": "_List", + "instancesCurrent": 47005, + "bytesCurrent": 3636080 + }, + { + "name": "_GrowableList", + "instancesCurrent": 921, + "bytesCurrent": 29472 + }, + { + "name": "_SelectionPlan", + "instancesCurrent": 0, + "bytesCurrent": 0 + } + ] + }, + "allocationTraces": { + "tracedClasses": [ + "_Record", + "_Map", + "_List", + "_GrowableList", + "_SelectionPlan", + "_Combined" + ], + "sampleCount": 869, + "counts": { + "_List": 467, + "_GrowableList": 336, + "_SelectionPlan": 3, + "_Map": 18, + "_Record": 45 + }, + "truncatedStacks": 0, + "topAllocationFrames": [ + { + "function": "_allocateData", + "samples": 402 + }, + { + "function": "", + "samples": 93 + }, + { + "function": "current", + "samples": 42 + }, + { + "function": "quote", + "samples": 42 + }, + { + "function": "_GrowableList._withData", + "samples": 33 + }, + { + "function": "wait", + "samples": 33 + }, + { + "function": "_write", + "samples": 30 + }, + { + "function": "DataRowMessage.parse", + "samples": 30 + }, + { + "function": "handleMessage", + "samples": 30 + }, + { + "function": "_List", + "samples": 22 + }, + { + "function": "writeSql", + "samples": 21 + }, + { + "function": "[Native] /Users/seven/workspace/flutter/bin/cache/dart-sdk/bin/dartvm+0x2db208", + "samples": 18 + } + ], + "scope": "Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled." + } + } + } + } + ] + }, + { + "scenario": "postgres_delay_20ms", + "environment": { + "backend": "postgres_delay_20ms", + "databaseVersion": "18.4 (Homebrew)", + "pid": 94406 + }, + "oneWayDelayMs": 10, + "echoRoundTripMicros": { + "samplesMicros": [ + 26410, + 26461, + 25124, + 26778, + 25893, + 25760, + 25517, + 27314, + 27261, + 27420 + ], + "p50Micros": 26410, + "p95Micros": 27420, + "minMicros": 25124, + "maxMicros": 27420, + "wallMicros": 263938 + }, + "selectOneMicros": { + "samplesMicros": [ + 83154, + 82678, + 83276, + 81636, + 83551, + 82064, + 82557, + 82920, + 83182, + 80891 + ], + "p50Micros": 82678, + "p95Micros": 83551, + "minMicros": 80891, + "maxMicros": 83551, + "wallMicros": 825909, + "operationsPerSecond": 12.10787144830726 + }, + "cases": [ + { + "case": "full_rows", + "latency": { + "raw": { + "samplesMicros": [ + 83478, + 89359, + 88236, + 79029, + 83721, + 84105, + 84017, + 83472, + 82549, + 80607, + 79357, + 83298, + 80922, + 81369, + 85859, + 82201, + 86662, + 82706, + 85967, + 82724, + 84199, + 80070, + 80482, + 80974, + 81828, + 83020, + 82663, + 83353, + 82179, + 81666, + 82794, + 82077, + 84465, + 80310, + 80177, + 85562, + 85259, + 86908, + 82573, + 81066, + 81602, + 82970, + 83741, + 81330, + 85823, + 80699, + 80751, + 75864, + 78233, + 85627, + 83480, + 81705, + 83932, + 82931, + 81710, + 85656, + 81956, + 77207, + 78604, + 85666, + 82696, + 80567, + 83908, + 81793, + 84705, + 82263, + 82998, + 84736, + 80907, + 80163, + 77495, + 78452, + 78989, + 83645, + 81980, + 81417, + 85714, + 85110, + 84349, + 77773, + 85298, + 82617, + 80157, + 80939, + 81229, + 84324, + 85490, + 85085, + 82639, + 78802, + 81545, + 85597, + 84444, + 81963, + 79957, + 82914, + 84539, + 82206, + 84594, + 85889, + 83871, + 84781, + 84637, + 83730, + 83590, + 82767, + 79885, + 80145, + 80187, + 77302, + 80452, + 84818, + 79435, + 84755, + 83453, + 84735, + 83205, + 83543, + 80307, + 80132, + 80682, + 80910, + 80880, + 82820, + 85602, + 80306, + 82824, + 84849, + 85215, + 83243, + 80786, + 81007, + 81757, + 83239, + 83435, + 83768, + 83936, + 84768, + 82789, + 81038, + 81271, + 78454, + 80443, + 83249, + 83827, + 83428, + 83983, + 86236, + 83622, + 86031, + 80682, + 82347, + 84159, + 87045, + 81451, + 81393, + 82521, + 80864, + 84892, + 83681, + 85153, + 83349, + 81411, + 81439, + 82154, + 82338, + 81452, + 77913, + 83632, + 83622, + 82711, + 82748, + 81608, + 85910, + 81496, + 81431, + 82102, + 82869, + 80463, + 80134, + 80824, + 85812, + 83434, + 83784, + 83406, + 81415, + 81055, + 85269, + 82662, + 79933, + 83929, + 80925, + 79439, + 81565, + 82241, + 82454, + 83230, + 84352, + 82817, + 84314 + ], + "p50Micros": 82706, + "p95Micros": 85859, + "minMicros": 75864, + "maxMicros": 89359, + "wallMicros": 16516596, + "operationsPerSecond": 12.10903263602258 + }, + "orm": { + "samplesMicros": [ + 84576, + 84433, + 83967, + 82103, + 84096, + 84057, + 84498, + 84053, + 82988, + 84243, + 82899, + 84989, + 82611, + 81589, + 84790, + 87101, + 83075, + 83277, + 80635, + 84102, + 85706, + 82619, + 82422, + 81602, + 84760, + 85109, + 80589, + 84842, + 83358, + 81870, + 84287, + 82706, + 84942, + 83045, + 80784, + 85816, + 81394, + 85397, + 81480, + 84477, + 80834, + 81111, + 80852, + 83879, + 83493, + 83822, + 81511, + 83069, + 77830, + 86033, + 83912, + 86621, + 86311, + 85925, + 81302, + 85923, + 82009, + 84664, + 81421, + 82205, + 81736, + 80925, + 82415, + 87447, + 84209, + 81312, + 86752, + 81093, + 86588, + 83547, + 79458, + 79136, + 78187, + 82862, + 83714, + 85007, + 86301, + 83831, + 84143, + 82570, + 84382, + 85775, + 80459, + 82246, + 85117, + 85382, + 86316, + 82807, + 76114, + 78375, + 81422, + 81537, + 88650, + 87034, + 80176, + 81995, + 83045, + 85628, + 80700, + 86003, + 84631, + 84217, + 84246, + 81924, + 82605, + 84555, + 80428, + 81267, + 80810, + 78809, + 82642, + 84710, + 83397, + 84815, + 84683, + 84617, + 84298, + 81106, + 79936, + 81451, + 85204, + 84813, + 80248, + 83367, + 82557, + 83570, + 82910, + 83591, + 85181, + 85675, + 80092, + 79800, + 84951, + 84265, + 86332, + 84047, + 79678, + 85413, + 82928, + 80700, + 82343, + 80453, + 80556, + 81612, + 76902, + 85246, + 84403, + 85792, + 86555, + 83632, + 82142, + 86162, + 86055, + 81167, + 80170, + 80737, + 84043, + 85270, + 80638, + 82959, + 83094, + 84090, + 85445, + 83526, + 84552, + 80705, + 80228, + 79169, + 84940, + 84425, + 84659, + 84313, + 85581, + 87311, + 85210, + 84203, + 83825, + 84398, + 80673, + 80049, + 83998, + 80658, + 82246, + 84010, + 81656, + 81238, + 80267, + 80727, + 82540, + 84696, + 80022, + 79996, + 83672, + 85509, + 84816, + 83153, + 83648, + 85373, + 80638, + 84948 + ], + "p50Micros": 83526, + "p95Micros": 86316, + "minMicros": 76114, + "maxMicros": 88650, + "wallMicros": 16629923, + "operationsPerSecond": 12.026513893058915 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 80190, + 95701, + 95980, + 96228, + 163952, + 177218, + 177560, + 177929, + 167475, + 164370, + 164942, + 165558, + 164722, + 165260, + 166979, + 167422, + 161596, + 161870, + 161674, + 161480, + 160359, + 157984, + 159050, + 157328, + 159529, + 159456, + 159285, + 159454, + 158864, + 158794, + 158972, + 159156, + 158423, + 158567, + 159012, + 159316, + 161234, + 162379, + 162983, + 163185, + 161674, + 161956, + 162707, + 163559, + 160779, + 159628, + 159754, + 160820, + 161242, + 159957, + 159983, + 161044, + 164014, + 163439, + 163335, + 163284, + 164302, + 162974, + 165354, + 164926, + 159784, + 164363, + 165592, + 165681, + 156976, + 170308, + 168601, + 169242, + 157688, + 172298, + 171764, + 172646, + 176593, + 176859, + 177191, + 178541, + 181150, + 177542, + 178941, + 178823, + 169243, + 173587, + 172915, + 173629, + 171675, + 171483, + 171543, + 172725, + 176250, + 174061, + 174916, + 174543, + 177513, + 173482, + 174695, + 174659, + 176237, + 177853, + 177764, + 178427, + 175418, + 177145, + 178096, + 177677, + 178519, + 175249, + 176437, + 175796, + 177662, + 175283, + 176619, + 176494, + 170797, + 170476, + 169717, + 170143, + 166112, + 164520, + 164974, + 165470, + 163225, + 163045, + 163102, + 163136, + 163627, + 166204, + 166666, + 167231, + 168963, + 171728, + 172298, + 172438, + 179517, + 176900, + 176777, + 178482, + 183231, + 180003, + 179733, + 180542, + 178343, + 177697, + 179117, + 179030, + 178505, + 176397, + 177445, + 178306, + 177516, + 179679, + 180442, + 181203, + 176221, + 180176, + 180205, + 181645, + 179752, + 180504, + 181699, + 181372, + 184265, + 182376, + 183728, + 183710, + 182336, + 179487, + 179352, + 179171, + 175795, + 177038, + 177072, + 178445, + 172350, + 170286, + 169824, + 170659, + 172238, + 172044, + 172138, + 172234, + 178588, + 176198, + 177599, + 177494, + 178098, + 173664, + 174833, + 176015, + 171173, + 173844, + 173479, + 174128, + 173525, + 176422, + 176255, + 175860, + 178504, + 182656, + 182554, + 183546 + ], + "p50Micros": 172350, + "p95Micros": 181645, + "minMicros": 80190, + "maxMicros": 184265, + "wallMicros": 4297032, + "operationsPerSecond": 46.543753921311264 + }, + "orm": { + "samplesMicros": [ + 88989, + 90011, + 90350, + 90933, + 172286, + 172806, + 172941, + 173418, + 164165, + 164808, + 165749, + 163880, + 163365, + 163943, + 163653, + 163720, + 169470, + 170222, + 170837, + 171365, + 172628, + 173210, + 174506, + 174330, + 171671, + 174042, + 173528, + 172495, + 178098, + 177402, + 177534, + 179343, + 178916, + 178321, + 179206, + 180161, + 173447, + 174769, + 174265, + 176922, + 172450, + 172631, + 175156, + 174848, + 176119, + 176189, + 176184, + 175914, + 177128, + 176752, + 176637, + 178155, + 172606, + 173727, + 175933, + 175600, + 174816, + 175044, + 175529, + 177890, + 176126, + 177356, + 178786, + 180075, + 177863, + 178010, + 177001, + 178012, + 175455, + 176745, + 178645, + 178278, + 177298, + 178944, + 177781, + 178610, + 179806, + 180115, + 180292, + 182518, + 174480, + 176020, + 175946, + 177771, + 172704, + 174810, + 173354, + 174866, + 172336, + 172887, + 174761, + 173646, + 175845, + 174644, + 175236, + 175926, + 172686, + 172021, + 173948, + 171831, + 162803, + 163774, + 163525, + 163426, + 163753, + 163907, + 164343, + 164181, + 167760, + 168771, + 170053, + 170231, + 168525, + 168841, + 169236, + 170005, + 170914, + 172565, + 174235, + 172018, + 180298, + 179335, + 180853, + 180694, + 180690, + 180795, + 180671, + 181600, + 177078, + 178730, + 180002, + 179246, + 173140, + 173007, + 177355, + 175689, + 175933, + 175620, + 175717, + 177353, + 182118, + 185595, + 186944, + 183517, + 181444, + 183036, + 182281, + 182238, + 172925, + 174143, + 176156, + 175931, + 177391, + 176880, + 179423, + 178345, + 175671, + 176119, + 178725, + 177155, + 173137, + 174543, + 173258, + 174029, + 176716, + 178673, + 179891, + 177768, + 173866, + 174279, + 174175, + 175110, + 173333, + 174176, + 174343, + 175506, + 179757, + 178711, + 178756, + 180827, + 172607, + 173809, + 175549, + 175347, + 171679, + 172753, + 174181, + 173643, + 169342, + 168481, + 170339, + 169182, + 161536, + 161396, + 162061, + 161734, + 161051, + 160918, + 160831, + 160769 + ], + "p50Micros": 174810, + "p95Micros": 180853, + "minMicros": 88989, + "maxMicros": 186944, + "wallMicros": 4354001, + "operationsPerSecond": 45.93476207286126 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 96, + 108, + 111, + 114, + 80763, + 80870, + 80957, + 81044, + 85287, + 85699, + 87315, + 87681, + 90961, + 90802, + 90387, + 89993, + 89601, + 89432, + 88768, + 88766, + 85536, + 85493, + 85495, + 85546, + 87810, + 87735, + 87699, + 87686, + 88487, + 88625, + 88822, + 89201, + 88677, + 88450, + 88297, + 88084, + 90929, + 90933, + 90935, + 90946, + 87989, + 87886, + 87844, + 87989, + 88955, + 88872, + 88873, + 88728, + 86496, + 86799, + 87040, + 87183, + 88626, + 88206, + 88095, + 87988, + 83892, + 83910, + 83858, + 83889, + 93138, + 93001, + 92876, + 92771 + ], + "p50Micros": 87989, + "p95Micros": 92771, + "minMicros": 96, + "maxMicros": 93138, + "wallMicros": 5269045 + }, + "sqlMicros": [ + 80595, + 80755, + 80809, + 80910, + 84855, + 85351, + 86890, + 87494, + 88169, + 88310, + 91501, + 92433, + 87383, + 89701, + 88534, + 89907, + 84783, + 84881, + 84899, + 86360, + 86682, + 87927, + 87872, + 87425, + 87343, + 87562, + 89184, + 89393, + 88304, + 87624, + 87272, + 88873, + 89819, + 91073, + 91045, + 90683, + 86847, + 88086, + 87956, + 87651, + 87449, + 88370, + 89403, + 89323, + 85684, + 86231, + 87439, + 86937, + 86523, + 87435, + 89096, + 88765, + 83123, + 83211, + 84421, + 83604, + 92817, + 92880, + 92714, + 92595, + 83811, + 84550, + 84461, + 85305 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 458, + 1222, + 1383, + 1471, + 91027, + 91654, + 92136, + 92603, + 87689, + 87522, + 87546, + 87487, + 87786, + 87832, + 87809, + 87795, + 92580, + 92143, + 91749, + 91450, + 81477, + 81273, + 81261, + 81202, + 81522, + 81476, + 81459, + 81451, + 82462, + 82508, + 82589, + 82829, + 85078, + 85927, + 86608, + 86889, + 89054, + 88748, + 88224, + 88113, + 87155, + 87091, + 87115, + 87418, + 88755, + 88561, + 88294, + 87825, + 87614, + 87714, + 88463, + 88659, + 87820, + 87635, + 86991, + 86877, + 87080, + 87098, + 87086, + 87342, + 89719, + 89546, + 89353, + 88997 + ], + "p50Micros": 87522, + "p95Micros": 92136, + "minMicros": 458, + "maxMicros": 92603, + "wallMicros": 5231700 + }, + "sqlMicros": [ + 89327, + 90420, + 90475, + 91199, + 87072, + 86642, + 87650, + 87179, + 85753, + 86905, + 88471, + 88457, + 92059, + 91003, + 91563, + 92148, + 80925, + 81040, + 81440, + 81096, + 81332, + 81184, + 81380, + 81587, + 82139, + 82505, + 82486, + 82699, + 84511, + 85089, + 86452, + 86562, + 86358, + 87988, + 89491, + 87701, + 85331, + 85608, + 87837, + 88136, + 86165, + 88824, + 88025, + 88924, + 86951, + 86969, + 87227, + 89043, + 86599, + 87875, + 86598, + 86558, + 85957, + 87211, + 86747, + 86995, + 88676, + 89749, + 89056, + 88713, + 85960, + 86401, + 86941, + 86056 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 82 + ], + "sqlMicros": [ + 80561 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 237, + "received": 27784 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\", \"t0\".\"nickname\", \"t0\".\"score\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 25399 + ], + "outputUtf8JsonBytes": 25399, + "acquireMicros": [ + 235 + ], + "sqlMicros": [ + 83606 + ], + "ormDecodeMicros": [ + 113 + ], + "postgresProtocolBytes": { + "sent": 237, + "received": 27784 + } + } + } + }, + { + "case": "two_columns", + "latency": { + "raw": { + "samplesMicros": [ + 83680, + 82091, + 83139, + 82812, + 82802, + 83459, + 83974, + 83152, + 82774, + 79143, + 80479, + 81205, + 80276, + 84078, + 80953, + 82778, + 78832, + 80930, + 80796, + 77911, + 82889, + 79923, + 79576, + 80030, + 82023, + 82652, + 82863, + 78834, + 83614, + 82750, + 81988, + 81994, + 79775, + 76545, + 79607, + 82874, + 83875, + 83743, + 80801, + 85143, + 85326, + 81637, + 82737, + 81440, + 81226, + 82245, + 79797, + 83137, + 82245, + 80255, + 80208, + 82213, + 82383, + 81083, + 82118, + 81412, + 83717, + 84079, + 80693, + 79698, + 80804, + 79024, + 80870, + 80179, + 85908, + 82256, + 83393, + 80752, + 85194, + 83282, + 79266, + 81263, + 80332, + 79847, + 79988, + 83298, + 81379, + 82914, + 84733, + 82604, + 80408, + 82353, + 84633, + 80678, + 82831, + 78788, + 80639, + 84701, + 82767, + 81308, + 82344, + 83874, + 82139, + 80570, + 93926, + 78012, + 83076, + 81878, + 82041, + 81372, + 83508, + 83765, + 84143, + 82384, + 83991, + 83762, + 81926, + 80214, + 82083, + 81767, + 81913, + 81720, + 78569, + 84523, + 83432, + 81618, + 79660, + 84451, + 85670, + 78572, + 80754, + 81605, + 81850, + 82631, + 81974, + 84876, + 82483, + 84801, + 82439, + 84937, + 82100, + 79563, + 80557, + 77806, + 82985, + 84444, + 81992, + 82660, + 83648, + 82889, + 81200, + 79475, + 80222, + 80291, + 79668, + 83199, + 79802, + 82705, + 79952, + 83240, + 83440, + 84556, + 80330, + 81037, + 82767, + 79374, + 79005, + 83114, + 83152, + 84400, + 83640, + 84064, + 82684, + 79904, + 82862, + 79741, + 83007, + 81884, + 79780, + 82777, + 84639, + 84419, + 85461, + 83635, + 80352, + 81865, + 81880, + 78648, + 79335, + 80135, + 78399, + 85168, + 83327, + 80093, + 84398, + 83610, + 83351, + 81275, + 83585, + 85267, + 84607, + 80984, + 80641, + 82333, + 80808, + 79103, + 82474, + 80874, + 79879, + 81658 + ], + "p50Micros": 82083, + "p95Micros": 84876, + "minMicros": 76545, + "maxMicros": 93926, + "wallMicros": 16398176, + "operationsPerSecond": 12.19647843760184 + }, + "orm": { + "samplesMicros": [ + 80691, + 80753, + 81817, + 83879, + 80846, + 84842, + 77862, + 80834, + 83604, + 78983, + 81979, + 75936, + 80691, + 80156, + 79858, + 81816, + 79452, + 79695, + 82084, + 84543, + 83350, + 79530, + 80773, + 80307, + 82976, + 83074, + 83549, + 83782, + 79900, + 85599, + 85272, + 82741, + 81941, + 83847, + 79014, + 81700, + 79807, + 81061, + 82471, + 84921, + 81467, + 85104, + 82766, + 83431, + 79479, + 85229, + 80073, + 81652, + 77818, + 81075, + 81560, + 81063, + 83589, + 83501, + 83094, + 82115, + 77749, + 83007, + 79621, + 80582, + 80926, + 84158, + 83010, + 83800, + 83257, + 83500, + 82159, + 82857, + 83260, + 79827, + 79673, + 80291, + 80703, + 81928, + 81406, + 83646, + 80922, + 85142, + 81435, + 82237, + 83851, + 81422, + 79133, + 78778, + 82454, + 82946, + 80680, + 81032, + 84108, + 84524, + 81770, + 82396, + 89701, + 82561, + 80756, + 79673, + 78870, + 83810, + 83855, + 82161, + 85592, + 83397, + 82446, + 82409, + 82965, + 80683, + 80686, + 79676, + 81630, + 80495, + 83128, + 80972, + 84368, + 82873, + 80475, + 78973, + 81436, + 82641, + 77996, + 79921, + 82449, + 81604, + 84097, + 82440, + 82228, + 84669, + 81086, + 81505, + 79421, + 78096, + 85210, + 80804, + 80779, + 81674, + 82313, + 83750, + 78405, + 81496, + 82575, + 80564, + 85737, + 81616, + 84027, + 81095, + 81484, + 80233, + 83181, + 80928, + 81124, + 82946, + 79687, + 81447, + 82046, + 84224, + 81205, + 89256, + 79742, + 79475, + 80709, + 80774, + 81109, + 85005, + 82149, + 84606, + 83433, + 83726, + 82781, + 83673, + 79932, + 80984, + 84228, + 83923, + 82253, + 84854, + 83751, + 79773, + 83134, + 81499, + 83630, + 84234, + 78809, + 80250, + 80878, + 79209, + 84226, + 81685, + 83917, + 80976, + 85875, + 80829, + 80840, + 76810, + 79393, + 80794, + 80997, + 81412, + 79574, + 81372, + 85024, + 80027 + ], + "p50Micros": 81652, + "p95Micros": 85104, + "minMicros": 75936, + "maxMicros": 89701, + "wallMicros": 16378966, + "operationsPerSecond": 12.210783025009027 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 82815, + 83069, + 83263, + 83493, + 164131, + 164490, + 164720, + 165091, + 168270, + 169214, + 169149, + 169034, + 171360, + 172210, + 172132, + 172907, + 168992, + 168559, + 168435, + 169118, + 169922, + 170134, + 170764, + 171298, + 171064, + 172141, + 172331, + 177410, + 176261, + 176717, + 176730, + 176965, + 171992, + 172478, + 173325, + 167621, + 161585, + 160044, + 160584, + 160922, + 160444, + 160657, + 160938, + 160865, + 164331, + 164632, + 164853, + 165154, + 168532, + 168816, + 168966, + 169274, + 170915, + 171483, + 172441, + 172307, + 172759, + 173803, + 173741, + 173751, + 171220, + 172169, + 171494, + 172282, + 169387, + 169213, + 170371, + 171034, + 167908, + 168486, + 168321, + 169424, + 168750, + 169557, + 168896, + 169465, + 171299, + 172051, + 172754, + 172079, + 171226, + 172148, + 171748, + 171786, + 170182, + 169504, + 170086, + 170750, + 167810, + 168559, + 168321, + 169221, + 169532, + 169586, + 169636, + 169641, + 169893, + 170153, + 170584, + 170681, + 173464, + 174318, + 173972, + 175387, + 177073, + 177142, + 176858, + 177433, + 170886, + 171574, + 171213, + 172582, + 165329, + 166882, + 166683, + 166619, + 168864, + 168412, + 169210, + 169236, + 170737, + 170763, + 171565, + 172187, + 164998, + 164625, + 165725, + 165096, + 160571, + 160411, + 160900, + 161232, + 159256, + 159154, + 159016, + 159162, + 160204, + 160266, + 160473, + 160389, + 167418, + 167691, + 168111, + 168319, + 167945, + 168467, + 168517, + 168846, + 168802, + 168469, + 168507, + 169353, + 173139, + 172800, + 173400, + 173125, + 172932, + 172675, + 173296, + 173237, + 168616, + 168501, + 168344, + 168183, + 167272, + 167972, + 168705, + 167608, + 169275, + 169258, + 169404, + 169937, + 168842, + 169660, + 169073, + 169549, + 170749, + 170472, + 171063, + 170960, + 171674, + 172340, + 173064, + 172378, + 171863, + 171809, + 172851, + 173516, + 172669, + 173425, + 172743, + 173283, + 175905, + 175959, + 176921, + 176165, + 171362, + 172089, + 172603, + 172158 + ], + "p50Micros": 169586, + "p95Micros": 176165, + "minMicros": 82815, + "maxMicros": 177433, + "wallMicros": 4237262, + "operationsPerSecond": 47.2002911313957 + }, + "orm": { + "samplesMicros": [ + 88276, + 88807, + 89256, + 90009, + 177404, + 177863, + 178436, + 178909, + 175116, + 175345, + 175375, + 175222, + 170247, + 172250, + 171541, + 172293, + 169369, + 171145, + 169892, + 171124, + 166396, + 167435, + 166978, + 166376, + 163252, + 163904, + 163040, + 162754, + 162421, + 162387, + 162430, + 162459, + 163157, + 163774, + 164089, + 164547, + 165505, + 166289, + 166916, + 167733, + 167656, + 167499, + 166976, + 167586, + 169863, + 172019, + 170748, + 172656, + 169977, + 170890, + 170742, + 172270, + 170398, + 171628, + 170093, + 171070, + 172915, + 173965, + 174810, + 176085, + 169502, + 170659, + 171681, + 170650, + 170947, + 171097, + 172642, + 171214, + 175617, + 174776, + 175948, + 175878, + 172965, + 174593, + 173600, + 174780, + 171314, + 171518, + 172678, + 173751, + 169968, + 169408, + 169307, + 170325, + 168214, + 169275, + 168912, + 170701, + 169144, + 169991, + 169883, + 171232, + 170514, + 169972, + 169826, + 170965, + 169348, + 170762, + 171789, + 171188, + 173361, + 174759, + 174065, + 175032, + 173578, + 175294, + 174288, + 175519, + 167553, + 168606, + 170012, + 169838, + 173630, + 172473, + 173471, + 173202, + 170399, + 171621, + 171042, + 170308, + 162421, + 161931, + 162338, + 162257, + 162632, + 162921, + 163113, + 163412, + 167155, + 167849, + 168278, + 168701, + 173321, + 173592, + 174171, + 174488, + 172348, + 173558, + 174673, + 176006, + 170113, + 171569, + 171415, + 172700, + 166528, + 168991, + 168401, + 169373, + 165695, + 167046, + 167066, + 166907, + 169132, + 170719, + 171835, + 170846, + 169927, + 171370, + 170706, + 170688, + 168437, + 168042, + 168921, + 168491, + 169099, + 170072, + 171313, + 170100, + 171713, + 173711, + 173118, + 173926, + 175740, + 175290, + 176169, + 175730, + 172044, + 173027, + 173843, + 174828, + 171877, + 173921, + 173827, + 173804, + 174415, + 175382, + 176844, + 176616, + 168587, + 169682, + 170894, + 170098, + 171082, + 170590, + 170605, + 171785, + 176398, + 175473, + 174892, + 176390 + ], + "p50Micros": 170762, + "p95Micros": 176006, + "minMicros": 88276, + "maxMicros": 178909, + "wallMicros": 4271162, + "operationsPerSecond": 46.82566477225635 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 291, + 278, + 283, + 291, + 83682, + 83990, + 84277, + 84701, + 87845, + 87811, + 87772, + 87736, + 82214, + 81913, + 81692, + 81468, + 80457, + 80422, + 80416, + 80417, + 81281, + 81286, + 81296, + 81318, + 84327, + 84590, + 84775, + 84973, + 83449, + 83377, + 83388, + 83595, + 87126, + 87006, + 87044, + 87130, + 84708, + 84642, + 84700, + 84562, + 84470, + 84460, + 84418, + 84425, + 86981, + 86975, + 87068, + 87151, + 82201, + 81874, + 81538, + 81274, + 80417, + 80775, + 81060, + 81330, + 87373, + 87136, + 87046, + 87018, + 86627, + 86538, + 86479, + 86479 + ], + "p50Micros": 84327, + "p95Micros": 87736, + "minMicros": 278, + "maxMicros": 87845, + "wallMicros": 5053642 + }, + "sqlMicros": [ + 83254, + 83602, + 83994, + 84231, + 87527, + 86871, + 87875, + 87851, + 81458, + 82132, + 81875, + 81368, + 80377, + 80340, + 80435, + 80376, + 81094, + 81298, + 81304, + 81269, + 84087, + 84394, + 84735, + 84839, + 83043, + 83157, + 83219, + 83246, + 85685, + 87156, + 86795, + 87507, + 84419, + 84480, + 84027, + 84710, + 83504, + 84254, + 83942, + 85139, + 86056, + 86120, + 87520, + 87462, + 82044, + 81392, + 81850, + 81173, + 79914, + 80437, + 80951, + 81378, + 85823, + 86577, + 87689, + 87472, + 85750, + 85791, + 86612, + 87183, + 82486, + 82944, + 82582, + 82473 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 371, + 597, + 679, + 743, + 84057, + 84528, + 84930, + 85319, + 84919, + 84935, + 84958, + 85185, + 86076, + 85887, + 85835, + 85754, + 86395, + 86470, + 86428, + 86399, + 87291, + 87514, + 87655, + 88112, + 88040, + 87706, + 87563, + 87426, + 89057, + 89055, + 89068, + 88962, + 87974, + 87918, + 87802, + 87799, + 82046, + 82083, + 82140, + 82199, + 86501, + 86107, + 85824, + 85559, + 81805, + 81691, + 81612, + 81566, + 80528, + 80475, + 80490, + 80484, + 82122, + 82126, + 82133, + 82161, + 86570, + 87207, + 88329, + 88901, + 85365, + 85080, + 84918, + 83871 + ], + "p50Micros": 85365, + "p95Micros": 88962, + "minMicros": 371, + "maxMicros": 89068, + "wallMicros": 5119300 + }, + "sqlMicros": [ + 82997, + 83808, + 84111, + 84748, + 83931, + 84999, + 84242, + 85287, + 84656, + 85572, + 84852, + 86976, + 85924, + 86166, + 85644, + 86540, + 86247, + 87539, + 87311, + 87607, + 86770, + 85964, + 88480, + 87602, + 88152, + 89165, + 88766, + 88566, + 86228, + 87202, + 87960, + 88788, + 80780, + 82163, + 81498, + 82606, + 86054, + 85867, + 85218, + 85758, + 81606, + 81612, + 81541, + 81498, + 80380, + 80263, + 80500, + 80511, + 81997, + 82045, + 82062, + 82092, + 85969, + 86729, + 87374, + 88971, + 82743, + 85338, + 85072, + 83561, + 86068, + 86629, + 87323, + 86289 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 119 + ], + "sqlMicros": [ + 83481 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 212, + "received": 4693 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 100 + ], + "driverRowsUtf8JsonBytes": [ + 3393 + ], + "outputUtf8JsonBytes": 3393, + "acquireMicros": [ + 323 + ], + "sqlMicros": [ + 82312 + ], + "ormDecodeMicros": [ + 70 + ], + "postgresProtocolBytes": { + "sent": 212, + "received": 4693 + } + } + } + }, + { + "case": "three_posts", + "latency": { + "raw": { + "samplesMicros": [ + 169502, + 164980, + 161815, + 170277, + 166749, + 162675, + 171241, + 161994, + 162520, + 169251, + 172015, + 170438, + 169014, + 163334, + 163181, + 165430, + 164552, + 167452, + 165628, + 160747, + 162382, + 164361, + 167971, + 171751, + 170940, + 158390, + 162605, + 167328, + 160894, + 168829, + 164410, + 162689, + 160585, + 170015, + 169984, + 166256, + 169542, + 159688, + 163294, + 168422, + 165637, + 166953, + 165869, + 161217, + 160834, + 165252, + 169260, + 166918, + 172188, + 160916, + 161453, + 165565, + 169793, + 168941, + 165119, + 161375, + 163782, + 167072, + 164626, + 167251, + 171817, + 158704, + 163786, + 168744, + 168617, + 170543, + 168572, + 160529, + 165102, + 168089, + 164581, + 166563, + 169012, + 161352, + 164804, + 169634, + 161958, + 170811, + 166502, + 159315, + 162727, + 166993, + 167008, + 163974, + 164403, + 158897, + 161976, + 167042, + 162427, + 165098, + 163777, + 160961, + 161493, + 170886, + 166127, + 172565, + 166926, + 160699, + 166244, + 166401, + 164443, + 166362, + 167109, + 160224, + 161080, + 166460, + 164748, + 165833, + 167502, + 159010, + 166117, + 172216, + 171039, + 169273, + 165972, + 161299, + 165743, + 165652, + 168236, + 169565, + 166759, + 158630, + 168570, + 167543, + 168145, + 167886, + 170039, + 161350, + 164923, + 169822, + 163703, + 165049, + 165853, + 160208, + 166774, + 165902, + 167143, + 168927, + 164995, + 160960, + 166141, + 167765, + 169235, + 166159, + 165220, + 159978, + 168812, + 167435, + 168757, + 166134, + 166178, + 160446, + 166600, + 170372, + 166951, + 171896, + 166784, + 161147, + 167852, + 164871, + 164514, + 193041, + 184837, + 160101, + 166498, + 169804, + 165587, + 166388, + 168458, + 160611, + 169432, + 168766, + 171019, + 168175, + 183621, + 160965, + 161256, + 162489, + 162921, + 165931, + 166605, + 161458, + 167530, + 168521, + 169086, + 168048, + 169116, + 158475, + 168564, + 170407, + 174996, + 175560, + 164515, + 161357, + 164037, + 170859, + 168293, + 164047, + 168099, + 162012 + ], + "p50Micros": 166178, + "p95Micros": 171817, + "minMicros": 158390, + "maxMicros": 193041, + "wallMicros": 33234607, + "operationsPerSecond": 6.017823529551591 + }, + "orm": { + "samplesMicros": [ + 168859, + 172392, + 170984, + 167469, + 169633, + 169574, + 163388, + 170080, + 169642, + 169367, + 169460, + 173804, + 170698, + 171526, + 168983, + 163641, + 171453, + 167499, + 162818, + 171769, + 163397, + 166496, + 168793, + 168850, + 170911, + 168972, + 167354, + 166659, + 168371, + 171643, + 172014, + 175136, + 173051, + 166069, + 170223, + 164904, + 166967, + 169964, + 171182, + 163577, + 166177, + 171643, + 168265, + 174213, + 165794, + 159742, + 163465, + 164451, + 169332, + 164351, + 171567, + 166915, + 172536, + 171027, + 169852, + 171591, + 166509, + 165469, + 167577, + 174138, + 168168, + 168506, + 168732, + 167775, + 169383, + 170808, + 167854, + 165500, + 168460, + 164438, + 169735, + 171624, + 169501, + 164846, + 166433, + 169203, + 171375, + 172209, + 170410, + 163799, + 169878, + 170376, + 169666, + 165990, + 164471, + 172351, + 168170, + 164146, + 170755, + 166973, + 165128, + 162801, + 173419, + 169971, + 166357, + 166329, + 172932, + 167003, + 168691, + 168292, + 161640, + 163786, + 169020, + 167582, + 165915, + 168123, + 167299, + 169098, + 172906, + 167543, + 171865, + 167146, + 170314, + 171933, + 170722, + 170429, + 170424, + 169880, + 163413, + 169328, + 168816, + 163609, + 175647, + 187248, + 168159, + 165188, + 169486, + 165345, + 167700, + 171643, + 168622, + 167870, + 168273, + 160138, + 174182, + 170110, + 165982, + 171162, + 172007, + 161528, + 170549, + 162702, + 163681, + 164794, + 173045, + 162760, + 162014, + 166238, + 168294, + 166211, + 163250, + 166560, + 165138, + 168974, + 165265, + 168613, + 172111, + 163504, + 165880, + 167936, + 171511, + 173783, + 165423, + 164825, + 170391, + 168577, + 162722, + 171045, + 167328, + 161039, + 166321, + 165504, + 170738, + 169259, + 164849, + 157344, + 164443, + 162474, + 167248, + 165480, + 162678, + 161635, + 166149, + 164738, + 166584, + 168079, + 172238, + 162436, + 169530, + 167862, + 169012, + 171519, + 169824, + 159702, + 166327, + 172104, + 166748, + 164930, + 171771, + 161377 + ], + "p50Micros": 168265, + "p95Micros": 173045, + "minMicros": 157344, + "maxMicros": 187248, + "wallMicros": 33598791, + "operationsPerSecond": 5.952595139509633 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 176769, + 176972, + 177083, + 177241, + 355520, + 355710, + 355921, + 356147, + 359468, + 360118, + 360784, + 360711, + 363254, + 363861, + 364352, + 364053, + 358701, + 357717, + 358476, + 359445, + 355483, + 356890, + 356851, + 356886, + 366394, + 367074, + 367994, + 367758, + 361033, + 361008, + 362008, + 363557, + 353363, + 354387, + 354510, + 354743, + 348043, + 347583, + 349092, + 347120, + 331735, + 331025, + 330738, + 331558, + 340569, + 341553, + 341789, + 342233, + 355794, + 356556, + 356651, + 357141, + 356650, + 356693, + 358516, + 357045, + 358871, + 358786, + 358920, + 360084, + 360191, + 361662, + 361659, + 363251, + 355970, + 355911, + 355979, + 355983, + 354139, + 356209, + 356155, + 356263, + 347898, + 347497, + 347055, + 346737, + 330375, + 331622, + 331127, + 330243, + 335373, + 335642, + 336317, + 336103, + 333900, + 334149, + 334360, + 334615, + 330830, + 331925, + 332921, + 332440, + 343957, + 344434, + 344406, + 344928, + 355624, + 356854, + 356239, + 358592, + 348983, + 348816, + 348246, + 349119, + 333079, + 331710, + 332855, + 332512, + 336244, + 337254, + 337052, + 338231, + 348084, + 348461, + 348826, + 349810, + 353408, + 352709, + 352542, + 354079, + 355733, + 354709, + 355447, + 356336, + 358671, + 358758, + 358832, + 358915, + 355061, + 356690, + 356726, + 356934, + 351757, + 351811, + 353875, + 353652, + 349485, + 349157, + 348864, + 350556, + 340130, + 341675, + 341800, + 343359, + 347927, + 348804, + 349494, + 349675, + 360665, + 362682, + 362551, + 361971, + 350838, + 352864, + 352740, + 352964, + 349932, + 351148, + 350571, + 352899, + 358457, + 359602, + 359179, + 361420, + 358205, + 360007, + 358761, + 360217, + 357098, + 356577, + 358762, + 358004, + 354696, + 357033, + 356561, + 357468, + 341945, + 343627, + 342694, + 343302, + 327449, + 328638, + 329643, + 328397, + 339163, + 339836, + 339889, + 339976, + 351430, + 352003, + 352779, + 353306, + 351710, + 351784, + 352444, + 352589, + 354325, + 355971, + 355766, + 355392 + ], + "p50Micros": 353306, + "p95Micros": 362682, + "minMicros": 176769, + "maxMicros": 367994, + "wallMicros": 8769924, + "operationsPerSecond": 22.80521473162139 + }, + "orm": { + "samplesMicros": [ + 184513, + 185387, + 186333, + 186942, + 362417, + 363332, + 364999, + 365266, + 356960, + 356322, + 357832, + 357161, + 357644, + 355703, + 356700, + 359003, + 358124, + 358178, + 360667, + 362429, + 351307, + 350875, + 352394, + 353484, + 335627, + 337773, + 337144, + 335725, + 339210, + 339879, + 340357, + 341677, + 352103, + 353099, + 354311, + 354896, + 357140, + 358966, + 358277, + 358174, + 358533, + 360042, + 359285, + 358992, + 360937, + 362317, + 363788, + 365158, + 365445, + 367836, + 366846, + 367810, + 360439, + 360093, + 360481, + 362287, + 360810, + 362265, + 363217, + 363024, + 355454, + 354649, + 353349, + 354265, + 337898, + 339302, + 338963, + 340469, + 336412, + 336839, + 337220, + 337642, + 340588, + 341918, + 341832, + 342843, + 354345, + 354927, + 356237, + 356455, + 357305, + 358985, + 357543, + 358718, + 352232, + 351558, + 352799, + 355059, + 357900, + 356712, + 358072, + 359133, + 363011, + 365130, + 364121, + 365945, + 363094, + 365926, + 364967, + 366621, + 363990, + 365941, + 364397, + 365604, + 358066, + 357279, + 358773, + 360597, + 352039, + 350601, + 350337, + 352055, + 338636, + 339236, + 340749, + 340135, + 335789, + 337048, + 338146, + 337575, + 354948, + 355870, + 356201, + 357132, + 365564, + 365465, + 365514, + 365561, + 360429, + 361710, + 362765, + 363940, + 362418, + 362669, + 365326, + 365057, + 359453, + 361470, + 360415, + 362158, + 358745, + 361477, + 361239, + 361303, + 365599, + 365169, + 366896, + 364987, + 363625, + 362674, + 363991, + 365218, + 361487, + 361909, + 362953, + 362848, + 341256, + 342763, + 343952, + 342321, + 329448, + 329074, + 330049, + 329532, + 345679, + 346460, + 347693, + 348166, + 353003, + 352449, + 354763, + 354082, + 349852, + 348385, + 349722, + 351055, + 351764, + 351404, + 351359, + 352872, + 355624, + 354270, + 355494, + 356686, + 356779, + 356342, + 358467, + 357857, + 357763, + 357174, + 357343, + 359213, + 362400, + 363634, + 362758, + 363683, + 363586, + 365667, + 364067, + 366160 + ], + "p50Micros": 357305, + "p95Micros": 365604, + "minMicros": 184513, + "maxMicros": 367836, + "wallMicros": 8886237, + "operationsPerSecond": 22.506714596966074 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 248, + 293, + 298, + 304, + 162523, + 162652, + 162772, + 162889, + 163200, + 163245, + 163305, + 163349, + 177366, + 177640, + 177883, + 178386, + 173936, + 173913, + 173930, + 173859, + 175324, + 175330, + 175376, + 175771, + 182884, + 182659, + 182471, + 182275, + 181333, + 181448, + 181638, + 181688, + 180320, + 180045, + 179808, + 179781, + 177176, + 177140, + 177073, + 176912, + 178723, + 178697, + 178612, + 178642, + 176407, + 176525, + 176729, + 176915, + 177069, + 176786, + 176580, + 176355, + 163604, + 163434, + 163317, + 163181, + 169536, + 170809, + 171248, + 171657, + 176478, + 175444, + 175245, + 175408 + ], + "p50Micros": 176355, + "p95Micros": 182275, + "minMicros": 248, + "maxMicros": 182884, + "wallMicros": 10465844 + }, + "sqlMicros": [ + 81439, + 81503, + 81595, + 81701, + 80425, + 80698, + 80993, + 80807, + 79787, + 79987, + 80163, + 79808, + 82792, + 83090, + 83205, + 83522, + 84080, + 84279, + 84328, + 84368, + 91643, + 92903, + 93718, + 93618, + 81095, + 81710, + 80796, + 80227, + 91563, + 92115, + 92872, + 93172, + 84156, + 84106, + 85231, + 85962, + 88861, + 90007, + 89977, + 90844, + 85945, + 85573, + 84357, + 85383, + 96006, + 96521, + 97077, + 97692, + 88400, + 88272, + 88065, + 89406, + 91974, + 92317, + 92502, + 93231, + 84782, + 84584, + 85763, + 86469, + 93852, + 94046, + 94704, + 94509, + 86229, + 85418, + 86349, + 86184, + 90313, + 90452, + 91171, + 90876, + 86345, + 86156, + 87175, + 86777, + 91707, + 91439, + 92049, + 92039, + 83062, + 84100, + 83777, + 84138, + 91621, + 92509, + 92741, + 92842, + 85056, + 86189, + 84995, + 86006, + 90200, + 90819, + 90875, + 91442, + 80169, + 80000, + 80680, + 80472, + 82698, + 82888, + 83067, + 83108, + 81318, + 81619, + 81378, + 81589, + 87336, + 88446, + 89428, + 89935, + 81906, + 80186, + 79996, + 81226, + 94100, + 93953, + 94765, + 94545, + 85248, + 86052, + 87307, + 87044, + 88681, + 89816, + 89976, + 89952 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 246, + 359, + 388, + 413, + 178874, + 179622, + 180287, + 180820, + 183570, + 183475, + 183456, + 183554, + 181230, + 180966, + 180889, + 180877, + 184512, + 184653, + 184740, + 184747, + 181906, + 181688, + 181559, + 181904, + 183268, + 183530, + 183643, + 183406, + 180191, + 181227, + 181471, + 181301, + 167623, + 165044, + 164235, + 163859, + 170035, + 170146, + 170242, + 170389, + 181608, + 182020, + 182351, + 182627, + 180000, + 179735, + 179685, + 179584, + 179450, + 179463, + 179470, + 179666, + 180771, + 180623, + 180503, + 180360, + 179748, + 179836, + 179977, + 180089, + 178552, + 178777, + 179559, + 179518 + ], + "p50Micros": 180287, + "p95Micros": 184512, + "minMicros": 246, + "maxMicros": 184747, + "wallMicros": 10768317 + }, + "sqlMicros": [ + 84451, + 85123, + 85719, + 86081, + 92041, + 93847, + 93068, + 94658, + 85966, + 85210, + 85175, + 86810, + 94993, + 97253, + 96911, + 98004, + 87823, + 87739, + 86900, + 87920, + 92394, + 91724, + 93005, + 93205, + 86732, + 86912, + 86186, + 87178, + 95577, + 97393, + 96687, + 98215, + 84203, + 85476, + 87294, + 87017, + 94168, + 94421, + 94505, + 96377, + 85375, + 85408, + 84906, + 87341, + 95205, + 97007, + 97372, + 97268, + 85302, + 83720, + 85507, + 85249, + 93087, + 94428, + 95559, + 96917, + 82726, + 81964, + 84507, + 81635, + 81494, + 81953, + 81994, + 82052, + 80991, + 81335, + 81153, + 81142, + 88295, + 88468, + 89061, + 89074, + 85186, + 85499, + 86760, + 86691, + 94271, + 94731, + 94952, + 96972, + 84863, + 83986, + 85043, + 86060, + 92288, + 93951, + 94986, + 94449, + 87071, + 87127, + 87017, + 87006, + 89915, + 91983, + 91726, + 92661, + 86123, + 85583, + 85404, + 87060, + 92720, + 92853, + 94272, + 95207, + 85269, + 85373, + 85347, + 85291, + 92849, + 93234, + 93458, + 95313, + 83965, + 83312, + 83163, + 84739, + 92978, + 94299, + 95173, + 95316, + 85307, + 88702, + 88267, + 87298, + 96847, + 96058, + 96936, + 97957 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100))) AS \"orm_partition\" WHERE \"c3\" > $101 AND \"c3\" <= $102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 171 + ], + "sqlMicros": [ + 79809, + 85569 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 2427, + "received": 56982 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"id\", \"t0\".\"email\" FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) ORDER BY \"t0\".\"id\" ASC", + "parameters": [ + 10001 + ] + }, + { + "sql": "SELECT \"c0\", \"c1\", \"c2\" FROM (SELECT \"t0\".\"id\" AS \"c0\", \"t0\".\"title\" AS \"c1\", \"t0\".\"author_id\" AS \"c2\", ROW_NUMBER() OVER (PARTITION BY \"t0\".\"author_id\" ORDER BY \"t0\".\"id\" DESC) AS \"c3\" FROM \"posts\" AS \"t0\" WHERE (\"t0\".\"author_id\" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100))) AS \"orm_partition\" WHERE \"c3\" > $101 AND \"c3\" <= $102 ORDER BY \"c3\"", + "parameters": [ + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10036, + 10037, + 10038, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054, + 10055, + 10056, + 10057, + 10058, + 10059, + 10060, + 10061, + 10062, + 10063, + 10064, + 10065, + 10066, + 10067, + 10068, + 10069, + 10070, + 10071, + 10072, + 10073, + 10074, + 10075, + 10076, + 10077, + 10078, + 10079, + 10080, + 10081, + 10082, + 10083, + 10084, + 10085, + 10086, + 10087, + 10088, + 10089, + 10090, + 10091, + 10092, + 10093, + 10094, + 10095, + 10096, + 10097, + 10098, + 10099, + 10100, + 0, + 3 + ] + } + ], + "returnedRows": [ + 100, + 300 + ], + "driverRowsUtf8JsonBytes": [ + 3393, + 46141 + ], + "outputUtf8JsonBytes": 47933, + "acquireMicros": [ + 125 + ], + "sqlMicros": [ + 82743, + 85532 + ], + "ormDecodeMicros": [ + 407, + 59 + ], + "postgresProtocolBytes": { + "sent": 2427, + "received": 56982 + } + } + } + }, + { + "case": "aggregate", + "latency": { + "raw": { + "samplesMicros": [ + 81405, + 80486, + 82300, + 84842, + 78838, + 79375, + 79587, + 80040, + 81425, + 80180, + 79256, + 80756, + 83484, + 78335, + 81086, + 81057, + 74458, + 79624, + 79522, + 81191, + 81027, + 80982, + 82206, + 81306, + 80417, + 84001, + 82161, + 81228, + 79542, + 81504, + 84324, + 80323, + 79364, + 82385, + 82283, + 79971, + 82294, + 78138, + 82722, + 82119, + 81585, + 82335, + 81866, + 74981, + 82063, + 80026, + 79091, + 77007, + 76345, + 78031, + 79739, + 79307, + 81388, + 83074, + 81031, + 77776, + 80820, + 83383, + 77610, + 81415, + 76841, + 81690, + 80228, + 82579, + 81139, + 80420, + 78770, + 79780, + 78979, + 79675, + 82236, + 79895, + 80598, + 79276, + 80084, + 80907, + 81539, + 78855, + 83278, + 80034, + 77108, + 78744, + 74124, + 80340, + 79384, + 75161, + 80535, + 83798, + 77533, + 81072, + 82037, + 79226, + 80273, + 81159, + 83332, + 83785, + 82556, + 81105, + 79095, + 80249, + 82433, + 82361, + 81687, + 80593, + 82474, + 79911, + 80577, + 78009, + 80108, + 78051, + 80069, + 83987, + 82396, + 80230, + 83565, + 83463, + 80938, + 76126, + 78911, + 78084, + 78028, + 82251, + 81352, + 80920, + 81803, + 80312, + 82740, + 80921, + 80737, + 79584, + 79454, + 82684, + 81895, + 77650, + 80469, + 80976, + 78808, + 80490, + 81934, + 82529, + 80480, + 81211, + 78487, + 81979, + 81571, + 78739, + 78763, + 77751, + 80412, + 82698, + 82351, + 79303, + 80660, + 79080, + 81126, + 81518, + 80102, + 79282, + 77649, + 82758, + 77956, + 79060, + 81463, + 82672, + 82425, + 81027, + 83863, + 79750, + 78522, + 82952, + 80436, + 77009, + 81364, + 80571, + 80565, + 78739, + 84700, + 81798, + 81766, + 79354, + 80174, + 83319, + 83118, + 82304, + 80749, + 83235, + 76573, + 80512, + 81088, + 82133, + 81036, + 79420, + 79286, + 81790, + 83423, + 78867, + 81023, + 81556, + 82286, + 77243 + ], + "p50Micros": 80660, + "p95Micros": 83463, + "minMicros": 74124, + "maxMicros": 84842, + "wallMicros": 16112393, + "operationsPerSecond": 12.412805472160466 + }, + "orm": { + "samplesMicros": [ + 82883, + 80719, + 83098, + 80563, + 79622, + 80228, + 80058, + 80297, + 81076, + 78524, + 79542, + 79474, + 82006, + 79831, + 80692, + 81989, + 83080, + 84587, + 79565, + 80744, + 83936, + 80961, + 80854, + 83443, + 81718, + 80580, + 81015, + 80149, + 79719, + 83925, + 80535, + 78314, + 81064, + 78567, + 82298, + 79853, + 81365, + 82563, + 79874, + 78609, + 80982, + 82533, + 79363, + 79550, + 80474, + 80909, + 81772, + 78263, + 76298, + 80656, + 78265, + 82724, + 80543, + 81709, + 78475, + 79583, + 80477, + 80163, + 80265, + 81293, + 78767, + 76727, + 78627, + 77691, + 83951, + 82624, + 82879, + 80557, + 81118, + 83310, + 83369, + 84166, + 80725, + 80569, + 82677, + 80244, + 77893, + 79666, + 78673, + 83167, + 77509, + 80252, + 78571, + 80804, + 76428, + 80764, + 79000, + 83474, + 83571, + 77576, + 81413, + 80816, + 79412, + 80021, + 78407, + 80210, + 81883, + 74816, + 78943, + 77909, + 80685, + 78717, + 78222, + 81732, + 80066, + 80072, + 78794, + 78493, + 80356, + 85585, + 81675, + 81454, + 82028, + 83022, + 82510, + 82616, + 74213, + 80059, + 81762, + 80423, + 76862, + 81689, + 78522, + 81560, + 78549, + 81486, + 80765, + 80884, + 82272, + 81650, + 78856, + 81184, + 82808, + 82272, + 82878, + 80682, + 80136, + 82051, + 84049, + 80351, + 79333, + 83546, + 79825, + 81046, + 81924, + 80152, + 82073, + 81154, + 82547, + 76608, + 81189, + 79535, + 82491, + 83228, + 79428, + 79990, + 80396, + 79131, + 79403, + 82757, + 79502, + 77854, + 83355, + 79021, + 82220, + 74802, + 76673, + 77098, + 81748, + 82435, + 79571, + 82193, + 84814, + 79132, + 78753, + 80556, + 81360, + 83744, + 77412, + 80347, + 79624, + 80432, + 81402, + 80994, + 83647, + 77345, + 81862, + 79894, + 83488, + 80761, + 82299, + 82440, + 80269, + 74424, + 80414, + 78019, + 79318, + 83713, + 83942, + 78582 + ], + "p50Micros": 80563, + "p95Micros": 83713, + "minMicros": 74213, + "maxMicros": 85585, + "wallMicros": 16114601, + "operationsPerSecond": 12.411104686985423 + } + }, + "concurrent": { + "raw": { + "samplesMicros": [ + 81336, + 81510, + 81648, + 81764, + 164178, + 164363, + 164554, + 164726, + 166252, + 166793, + 166549, + 166546, + 166870, + 167701, + 167653, + 168037, + 166555, + 166992, + 166917, + 167634, + 165483, + 165401, + 166236, + 165580, + 165550, + 165583, + 165605, + 166031, + 164865, + 165237, + 164776, + 164626, + 159957, + 160410, + 159762, + 160756, + 158920, + 159063, + 158987, + 158993, + 162559, + 162972, + 163156, + 163484, + 164748, + 165083, + 165426, + 165707, + 165062, + 165561, + 165285, + 166301, + 167569, + 168226, + 167860, + 169076, + 168292, + 169247, + 169039, + 168994, + 163977, + 164684, + 164660, + 165349, + 164759, + 164838, + 165973, + 165056, + 163062, + 163659, + 163183, + 163553, + 156955, + 157083, + 157614, + 158087, + 160425, + 161498, + 161494, + 161511, + 164768, + 164836, + 165461, + 165032, + 163806, + 163707, + 163613, + 163578, + 165056, + 164971, + 165346, + 165943, + 168241, + 168341, + 168912, + 169415, + 164918, + 165068, + 165925, + 165853, + 163582, + 164121, + 164776, + 164673, + 166081, + 166136, + 166829, + 167331, + 167568, + 168079, + 168620, + 168331, + 177870, + 177944, + 177724, + 178129, + 183504, + 184008, + 184266, + 184628, + 174971, + 175251, + 175338, + 175331, + 164612, + 164734, + 164677, + 164908, + 161791, + 161564, + 162511, + 161420, + 160620, + 160522, + 160645, + 160644, + 160618, + 160987, + 161372, + 161484, + 163605, + 164030, + 164132, + 164354, + 164854, + 165354, + 165950, + 165299, + 158836, + 159777, + 159698, + 159669, + 156853, + 157782, + 157708, + 157685, + 164074, + 163934, + 163951, + 164542, + 163528, + 163500, + 163489, + 163503, + 161472, + 162041, + 161665, + 161582, + 163157, + 163197, + 163208, + 163184, + 164557, + 165185, + 164881, + 165333, + 166143, + 165934, + 166330, + 166139, + 162983, + 163067, + 163081, + 163916, + 164918, + 164521, + 165074, + 165068, + 166964, + 166748, + 166713, + 167274, + 166052, + 166150, + 166749, + 166286, + 165580, + 165451, + 165364, + 165322 + ], + "p50Micros": 164854, + "p95Micros": 175251, + "minMicros": 81336, + "maxMicros": 184628, + "wallMicros": 4128903, + "operationsPerSecond": 48.43901636827022 + }, + "orm": { + "samplesMicros": [ + 80509, + 81092, + 81259, + 81532, + 161271, + 161746, + 161968, + 162249, + 164696, + 164408, + 164959, + 164657, + 165869, + 167069, + 166361, + 167152, + 167327, + 167331, + 167198, + 167684, + 167307, + 167052, + 166838, + 166680, + 162858, + 162598, + 163452, + 162445, + 157078, + 157254, + 157517, + 157201, + 155887, + 156113, + 156031, + 156047, + 159597, + 160199, + 160872, + 161026, + 164160, + 164582, + 164999, + 165410, + 167748, + 168503, + 169951, + 169489, + 166948, + 167410, + 168293, + 169049, + 163667, + 165069, + 164896, + 164852, + 165832, + 166764, + 166712, + 168206, + 167187, + 168135, + 168139, + 169223, + 163766, + 163524, + 164222, + 165068, + 166861, + 166871, + 166496, + 167124, + 167935, + 168667, + 169432, + 168748, + 167355, + 166869, + 167733, + 167709, + 164143, + 165143, + 165879, + 165375, + 164787, + 164897, + 165720, + 166424, + 167811, + 167621, + 168173, + 167866, + 161857, + 162504, + 163389, + 163258, + 165252, + 164936, + 165534, + 165271, + 168420, + 169246, + 168779, + 168766, + 165746, + 166925, + 166745, + 166477, + 166010, + 166156, + 166184, + 167328, + 164984, + 165793, + 165471, + 164968, + 161710, + 161570, + 161377, + 162395, + 160515, + 160776, + 160612, + 160585, + 160162, + 160382, + 160624, + 160552, + 159422, + 159597, + 159777, + 159595, + 160744, + 160786, + 160830, + 160874, + 161955, + 162760, + 163192, + 163303, + 163215, + 163867, + 163886, + 164043, + 162553, + 163278, + 163864, + 164829, + 163613, + 163792, + 163809, + 165053, + 166044, + 166762, + 167515, + 166799, + 167048, + 167678, + 168649, + 168420, + 166941, + 166645, + 167267, + 167204, + 167153, + 167417, + 168171, + 168824, + 169083, + 168744, + 170313, + 169832, + 166370, + 167666, + 168444, + 169429, + 167948, + 169361, + 168789, + 169278, + 165145, + 167244, + 165558, + 166976, + 162231, + 162934, + 162402, + 162754, + 163183, + 163997, + 165358, + 165001, + 162665, + 162348, + 162745, + 163172, + 160633, + 160374, + 159818, + 160162 + ], + "p50Micros": 165069, + "p95Micros": 169083, + "minMicros": 80509, + "maxMicros": 170313, + "wallMicros": 4118426, + "operationsPerSecond": 48.562241982738065 + } + }, + "observedContention": { + "raw": { + "acquisition": { + "samplesMicros": [ + 185, + 159, + 162, + 165, + 83692, + 83934, + 84121, + 84327, + 85482, + 85398, + 85371, + 85394, + 81080, + 81051, + 81058, + 81051, + 81294, + 81226, + 81379, + 81441, + 82357, + 82117, + 81825, + 81646, + 80383, + 80363, + 80350, + 80339, + 79159, + 79124, + 79143, + 79145, + 81646, + 81850, + 81953, + 82068, + 84063, + 83941, + 84215, + 84480, + 79930, + 79872, + 79623, + 79422, + 81716, + 81693, + 81671, + 81697, + 82753, + 82680, + 82653, + 82657, + 82770, + 82757, + 82791, + 82844, + 79985, + 79891, + 79872, + 79877, + 85071, + 84945, + 84877, + 84822 + ], + "p50Micros": 81693, + "p95Micros": 85371, + "minMicros": 159, + "maxMicros": 85482, + "wallMicros": 4925006 + }, + "sqlMicros": [ + 83402, + 83720, + 83950, + 84182, + 84822, + 84885, + 85475, + 85934, + 80236, + 81165, + 80970, + 81327, + 80624, + 81124, + 81679, + 81301, + 81954, + 81716, + 81513, + 82378, + 80293, + 80305, + 80277, + 80407, + 78999, + 79098, + 79212, + 79118, + 81359, + 81808, + 81853, + 82033, + 83345, + 83514, + 84225, + 84901, + 79718, + 79745, + 79502, + 79266, + 80901, + 81574, + 81582, + 82178, + 82507, + 82590, + 82375, + 82739, + 82149, + 82861, + 82521, + 83070, + 79062, + 79588, + 79976, + 80445, + 84693, + 84693, + 84605, + 85344, + 81868, + 81872, + 81840, + 82408 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + }, + "orm": { + "acquisition": { + "samplesMicros": [ + 318, + 485, + 540, + 582, + 83044, + 83434, + 83711, + 83966, + 82546, + 82683, + 82685, + 82710, + 82463, + 82211, + 82145, + 82140, + 84731, + 84724, + 84728, + 84776, + 82455, + 82324, + 82323, + 82339, + 83106, + 83043, + 83011, + 83037, + 85731, + 85714, + 85717, + 85724, + 83740, + 83650, + 83902, + 83958, + 82429, + 82318, + 82066, + 82070, + 84988, + 84983, + 85244, + 85303, + 85020, + 84869, + 84580, + 84446, + 82651, + 82711, + 82746, + 82840, + 80868, + 80560, + 80337, + 80138, + 80718, + 80651, + 80645, + 80619, + 79945, + 79909, + 79911, + 79913 + ], + "p50Micros": 82685, + "p95Micros": 85714, + "minMicros": 318, + "maxMicros": 85731, + "wallMicros": 4977874 + }, + "sqlMicros": [ + 82322, + 82994, + 83072, + 83461, + 81513, + 81929, + 83157, + 83069, + 81556, + 82546, + 81997, + 81962, + 84336, + 84544, + 84590, + 84579, + 81171, + 82145, + 82770, + 82431, + 82164, + 83179, + 83139, + 82859, + 84517, + 85303, + 86116, + 86059, + 83275, + 82965, + 83982, + 84027, + 80934, + 82143, + 81900, + 83011, + 83797, + 84810, + 85624, + 85398, + 83473, + 84713, + 85263, + 84581, + 82083, + 82345, + 82430, + 83346, + 80264, + 80192, + 79968, + 80896, + 80492, + 80699, + 80612, + 80584, + 79861, + 79877, + 79841, + 79925, + 79740, + 79832, + 79756, + 79747 + ], + "scope": "Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time." + } + }, + "probes": { + "raw": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 281 + ], + "sqlMicros": [ + 81133 + ], + "ormDecodeMicros": [], + "postgresProtocolBytes": { + "sent": 244, + "received": 413 + } + }, + "orm": { + "sql": [ + { + "sql": "SELECT \"t0\".\"score\", COUNT(\"t0\".\"id\") FROM \"users\" AS \"t0\" WHERE (\"t0\".\"id\" >= $1) GROUP BY \"t0\".\"score\" ORDER BY \"t0\".\"score\" ASC", + "parameters": [ + 10001 + ] + } + ], + "returnedRows": [ + 10 + ], + "driverRowsUtf8JsonBytes": [ + 71 + ], + "outputUtf8JsonBytes": 71, + "acquireMicros": [ + 172 + ], + "sqlMicros": [ + 77320 + ], + "ormDecodeMicros": [ + 16 + ], + "postgresProtocolBytes": { + "sent": 244, + "received": 413 + } + } + } + } + ] + } + ] +} diff --git a/research/dart_migration_forms.md b/research/dart_migration_forms.md new file mode 100644 index 00000000..4e59dc77 --- /dev/null +++ b/research/dart_migration_forms.md @@ -0,0 +1,161 @@ +# 全 Dart schema 与迁移形态研究 + +研究日期:2026-09-15。基于 `next` 的 `b694ba9`;本轮只增加研究文档与隔离原型,没有更改 ORM 公开 API、生成器或 CLI。 + +实现跟进:全 Dart 工作流已实现,并修正为一套历史固定一个数据库。实际命令与文件组织见 +[迁移文档](../docs/migrations.md)。实现选择把当前物理结构单独输出为 +`schema.snapshot.dart`,使迁移工具不必加载业务模型或 Flutter 依赖;下文保留原始研究记录。 + +## 结论 + +可以让 ORM 的 schema、生成客户端、历史结构、迁移、驱动配置和迁移入口全部以 Dart 文件交付,不要求应用维护 JSON 文件。推荐默认形态是 **Dart 声明 + 自动生成的固定迁移值 + 独立历史描述 + 静态导入的运行入口**。 + +需要改变的是工具的输入输出与装载方式,不需要因为扩展名改变而重写事务、数据库驱动或恢复机制。当前运行器已经接受 `List`,JSON 主要是生成器和 CLI 的文件协议。 + +这里的“全 Dart”指 ORM 的声明和版本化工件。SQL 可以写在 Dart 多行字符串中;数据库仍有迁移记录和 checkpoint;内部校验和编码仍可使用 JSON。它不要求改造 Dart 自身的 pubspec/build 配置格式,也不要求为消除内部 `jsonEncode` 再造编码协议。 + +## 三种不同的产品形态 + +| 形态 | 开发者体验 | 需要承担的代价 | 判断 | +| --- | --- | --- | --- | +| `up(db)` / `down(db)` 执行回调 | 普通 Dart,可调用函数、循环、await | 任意代码无法在不执行它的情况下得到完整计划;闭包与外部依赖无法用函数对象可靠校验;恢复语义需要另外定义 | 保留为未来明确的高级能力,不作为默认 | +| `Migration.steps(...)` 声明操作 | 补全、参数检查、注释、SQL 多行字符串;可由 diff 自动生成 | 需要稳定的操作模型与历史结构;SQL 本身不因此获得 Dart 字段类型检查 | 推荐默认,最接近现有执行器 | +| 每次运行 `Migration.diff(old, current)` | 表面最少文件 | 缺少固定迁移历史;新版 diff 算法或当前模型会改变旧计划,数据变换也不能由结构推断 | diff 只在创建迁移时运行 | + +“构建器回调”可以作为第二种的语法糖:它只构造步骤,没有数据库会话;冻结后的步骤才进入运行器。它与执行 `await db.execute(...)` 的回调是不同协议。不要依靠试跑用户回调来发现 SQL,也不应为这层糖扩张出通用 Dart 解释器。 + +## 默认用户过程 + +1. 修改 `schema.dart`。 +2. 生成当前客户端;创建迁移时比较上一份历史快照与当前结构。 +3. 工具输出新的 Dart 迁移文件,开发者审阅、补充 SQL 或恢复步骤。 +4. 检查迁移链和旧版本升级,再把文件提交到版本库。 +5. 服务端独立运行迁移入口;Flutter 在使用业务数据库前调用相同迁移列表。 + +普通新增列仍应自动生成。全 Dart 不代表让用户重新手写每条 CREATE/ALTER,也不要求用户重复维护 Record 和一份列 DSL。 + +建议首先验证这个文件布局: + +```text +lib/data/ + schema.dart + schema.orm.dart + migrations/ + migrations.g.dart + m0001_initial.dart + m0002_nicknames.dart +tool/ + migrate.dart +``` + +- `schema.dart` 是当前业务声明;`schema.orm.dart` 是可重新生成的当前客户端和结构描述。 +- 每个 `mNNNN_*.dart` 初次由工具生成,保存固定步骤和当时的历史结构,审阅后成为版本化源码,不随当前 schema 重新生成。 +- 默认把历史描述放在同一迁移文件的私有区域,避免每次迁移固定新增两三个辅助文件。需要历史字段补全的复杂回填再评估是否值得单独生成辅助库。 +- `migrations.g.dart` 只负责静态 import 和有序注册。清空构建缓存后,可从已提交的迁移文件恢复;它不存放唯一一份历史事实。 +- `tool/migrate.dart` 负责选择驱动、读取配置和连接资源。应用持有的迁移定义不捕获密码或数据库实例。 + +除 Dart 模型、迁移代码和必要的项目配置之外,不增加需要人手同时维护的事实来源。历史结构包含重复数据是正常的;它代表另一个时间点。 + +## 可实现的迁移文件 + +下列使用当前 `Migration.steps` API;校验和与 `_after` 的内容为展示省略,完整文件见 `example/migrations/`: + +```dart +final migration = Migration.steps( + '0002_nicknames', + [ExecuteSql('ALTER TABLE "people" ADD COLUMN "nickname" TEXT')], + dialect: .sqlite, + previous: '创建迁移时固定的前驱校验和', + snapshot: _after, +); + +// 工具生成的历史结构;不导入当前 schema.orm.dart。 +final _after = SchemaSnapshot([ + TableSchema( + 'people', + columns: [ + Column('id', Codecs.integer), + Column('name', Codecs.text), + Column('nickname', Codecs.text.nullable(), nullable: true), + ], + primaryKey: ['id'], + ), +]); +``` + +这些 `Column` 提供构造参数的 Dart 类型检查;字符串列名、SQL 表达式及迁移阶段是否已存在该列仍需计划校验和真实数据库验证。不能把 `.dart` 扩展名等同于完整迁移类型安全。 + +`Migration.create/diff` 可在生成阶段调用,但保存的普通 DDL 应固定为步骤;应用启动不根据目标快照重新推导旧 SQL。重建、回填等结构化操作仍依赖运行器,所以还必须明确操作协议的版本和升级兼容测试。纯 JSON 也有同样的运行器依赖。 + +## 历史类型和高级回填 + +若用户要在迁移中写类型化字段选择,需要的是 **该迁移版本的字段描述**,不能借用最新生成客户端。比如 v1 有 `name`,v2 新增 `nickname`,v3 删除 `name`,v1 → v2 的代码必须继续存在且可编译。 + +Drift 的官方文档明确展示了这个问题,并提供每一步对应版本的生成 schema;这是有用的验证对象,不是要求复制其文件组织。[历史 schema 与逐步迁移](https://drift.simonbinder.eu/migrations/step_by_step/) + +可以进一步研究 `before.people.name`、`after.people.nickname` 这样的历史符号,以及 `copy` / `rename` / `backfill` 的类型化操作。但是,仅有 before/after 两个类型不能证明中间执行阶段:新增列必须先执行,随后才能写该列。需要步骤作用域或运行器检查;不建议为此先引入大量状态泛型。 + +普通批量 SQL 更新优先保留现在的结构化 `Backfill`:不可变主键游标、批次数据与 checkpoint 同事务、完成条件单独检查。任意 Dart 数据变换则要额外定义: + +- 读取和写入哪些历史字段、codec 如何冻结; +- 批次边界、恢复位置,以及提交未知时如何确认; +- 是否允许调用当前业务函数、网络服务和有副作用的代码; +- 修改回调依赖后如何识别历史变化。 + +最后一点没有“对函数调用 toString 后哈希”这样的可靠通用解法。若接受任意回调,只能进一步约束依赖、记录源码/构建指纹并要求明确版本与恢复规则,不能沿用“纯计划已完整校验”的承诺。 + +## CLI 和 AOT 的实际形态 + +全 Dart 文件需要一个编译入口。推荐先提供项目自己的普通入口,静态 import 迁移列表,再调用迁移运行器。`runMigrationCli` 已实现,入口形态为: + +```dart +import '../lib/data/migrations/migrations.g.dart'; + +Future main(List args) => runMigrationCli( + args, + history: migrationHistory, + directory: 'lib/data/migrations', + connect: ({required readOnly}) => postgres(PostgresOptions(url: configuredUrl)), +); +``` + +命令是 `dart run tool/migrate.dart plan|apply|check`。先检查静态计划,再根据命令决定是否连接;迁移定义本身不取得数据库会话。生成当前 schema 继续走独立的生成命令,避免“生成器入口必须先导入还没生成的文件”的循环。 + +开发环境可以编译临时入口自动 import 文件,但首版没有必要额外引入进程调度/序列化桥接。生产可编译这个项目入口;Flutter 直接静态导入列表。Dart 官方支持编译独立 executable;语言 import 本身使用声明中的 URI。[dart compile](https://dart.dev/tools/dart-compile)、[Libraries & imports](https://dart.dev/language/libraries) + +因此不承诺一个已编译的通用 ORM 程序能够运行时扫描任意新 `.dart` 文件并执行其中的迁移。静态生成注册表是装载方式,不需要反射、宏或运行时源码解释器。 + +## 校验与兼容 + +继续对固定步骤、历史结构、前驱和格式版本做内容校验,不对 Dart 格式化和注释本身做数据库历史校验。普通注释变动不应要求重放迁移;SQL 字符串的变动仍应保守地视为内容变化。 + +前驱校验和应是创建迁移时保存的字面量。若写成 `previous: old.checksum`,修改老文件会同时改变新文件的链引用,削弱离线识别历史变化的能力。应用数据库里的已应用记录仍是独立校验来源,但不应只靠它发现问题。 + +需要进一步设计冻结记录或发布指纹,识别尚未执行到当前数据库的历史文件变动,以及结构化执行器语义升级。仅校验当前对象不能证明任意 Dart 声明无环境依赖;工具可以检查生成文件的受限声明形式,但那不是 Dart 自带保证。 + +使用 `const` 有利于纯数据和不可变值,官方也明确了常量构造约束;当前 `Migration` / `SchemaSnapshot` 不是 const,不能直接在示例里加关键字冒充已支持。默认先复用不可变对象,不为追求所有值 const 重写一套类型。[Dart 常量构造](https://dart.dev/language/constructors#constant-constructors) + +## 实证与下一步范围 + +早期原型在 `37aec68` 验证了 Dart 3.13.3、SQLite/PostgreSQL 下 JIT 与独立 AOT 每后端 8 项检查。其双库计划形态已淘汰,原型源码由正式的 `test/migration_source_test.dart`、`test/migration_target_test.dart` 和恢复测试替代;旧运行结果不作为当前实现的验收。 + +## 数据库边界修正 + +ORM 支持多个数据库,与应用需要多套迁移是两件不同的事。应用选择数据库之后,每个注册表从空历史开始固定 `migrationDialect`。迁移文件保存同一 `dialect` 和一组平铺的步骤;初始化时配置一次,正常创建及执行不再询问或遍历其他数据库。 + +| 边界 | 行为 | +| --- | --- | +| 生成 | 当前声明与最后历史快照都按已选数据库解析,再比较并冻结 SQL。只要求该库的转换表达式。 | +| 指纹 | 包含数据库身份、实际步骤、选定表达式的历史 schema、前驱。另一库配置变化不能改变当前指纹。 | +| 注册 | 只接受同一库的有序历史。重建注册表保留已选数据库,不刷新文件指纹。 | +| 连接 | CLI 和执行器在迁移 SQL 前拒绝错库;CLI 的连接工厂已执行,可能已打开 SQLite 文件,不能声称零文件系统副作用。 | +| 数据库版本 | 当前迁移执行器支持 PostgreSQL 18+、SQLite 3.35+。执行前检查实际版本;不推测手写 SQL 对所有版本的兼容性。 | +| 特有能力 | SQLite 重建/外键检查与 PostgreSQL 约束解析/并发索引各走本库执行语义,不为另一库生成占位步骤或采用功能交集。 | +| 事务 | 普通迁移批次原子提交。显式 Backfill/CheckedSql 分段记账;失败及重试遵循已保存检查点,不把整个迁移描述为可回滚。 | +| 改连接地址 | 同一数据库类型可以切环境、凭据、服务器,仍核对目的库的已应用历史及实际结构。 | +| 真正换库 | 单独的数据搬迁及新库 baseline。原库历史不改写、不自动翻译或重放。 | +| 同时使用多个库 | 分别维护连接、目录、注册表与发布动作;没有跨库原子迁移承诺。 | + +SQLite 可在虚拟计算列上建索引,PostgreSQL 18 不支持这种索引;这个限制不能阻止 SQLite schema。PostgreSQL 对计算列模式转换的限制也不能阻止可通过复制重建完成的 SQLite 变更。[PostgreSQL generated columns](https://www.postgresql.org/docs/18/ddl-generated-columns.html)、[SQLite generated columns](https://www.sqlite.org/gencol.html) + +迁移验收须使用实际部署的引擎;PostgreSQL 应用的 SQLite 测试不能替代 PostgreSQL 迁移验收。重命名、丢弃数据、重计算及长时间锁定仍需明确审阅,选择单库不会消除这些边界。 diff --git a/research/flutter-web-integration.md b/research/flutter-web-integration.md new file mode 100644 index 00000000..f1269deb --- /dev/null +++ b/research/flutter-web-integration.md @@ -0,0 +1,164 @@ +# Flutter Web integration research + +The recommendation below has since been implemented; see [current setup](../docs/sqlite-web.md) +and [refactor validation](validation/sqlite-refactor.md). The measurements in this +research note describe the earlier probe. + +Date: 2026-09-16. Recommendation: retain `sqlite3` as the engine, share its +`CommonDatabase` interfaces, and make browser resource packaging an ORM product +responsibility. Flutter applications should not author a worker or assemble +SQLite WASM URLs for the normal setup. This is a researched direction with a +working packaging probe, not a released initialization API. + +## What sqlite3 already provides + +`sqlite3` 3.6.0 supports native and browser execution, including Flutter Web. +Native code imports `sqlite3.dart`; browser code imports `wasm.dart` and loads a +compatible SQLite WASM module. Both implement the database and statement +interfaces in `common.dart`. The ORM should reuse those interfaces wherever +execution logic is identical. The package also supplies the memory, IndexedDB and +OPFS file systems; there is no reason to reimplement those in the ORM. +[Source](https://pub.dev/packages/sqlite3/versions/3.6.0). + +The package exposes synchronous database operations. An async initialization +method does not move subsequent SQL off the UI thread. Its `VfsWorker` provides +file access to a separate database worker; it is not an async SQL client with ORM +transactions, cursor ownership and query notifications. Native isolate messaging +and browser worker messaging therefore remain small internal responsibilities. +[VfsWorker](https://pub.dev/documentation/sqlite3/latest/wasm/VfsWorker-class.html). + +Flutter's `compute()` executes on the main thread on Web. It cannot replace this +database worker. SQL execution in a worker also does not eliminate main-thread +row decoding and widget work, so bounded result sets and streaming still matter. +[Flutter isolates](https://docs.flutter.dev/perf/isolates). + +## Packaging options + +| Option | Application work and cost | Decision | +| --- | --- | --- | +| Package-owned, precompiled JS worker and SQLite WASM | Flutter bundles declared assets; no application worker compilation | Preferred normal Flutter path | +| Flutter asset transformer | Automatic compilation during run/build; adds compiler invocation and dependency tracking | Optional source-build/custom-worker path; not needed for the stock worker | +| ORM setup command copies matched artifacts to `web/` | One explicit setup step and stale-file checks | Useful for plain Dart deployments or explicit web-root layouts | +| Add `sqlite_async` | Reuses an asynchronous SQLite layer; still needs worker/WASM resources | Does not itself solve this packaging problem; do not add it solely for Flutter Web | + +Flutter automatically bundles assets declared by dependencies. Since Flutter +3.41, entries can specify `platforms: [web]`. A probe confirmed that the ORM +package can retain its pure Dart dependencies while adding this metadata: + +```yaml +flutter: + assets: + - path: assets/web/ + platforms: [web] +``` + +The Flutter consumer needs no matching asset declaration. Standalone `dart pub +get --offline` also succeeds for that modified package without a Flutter SDK +dependency. An Android-only sentinel is excluded from the probe's Web output; +native application packaging was not rebuilt in this research. +[Package assets and platform filters](https://docs.flutter.dev/ui/assets/assets-and-images). + +The release process would compile the ORM's worker once and package it with the +tested SQLite module. Application schema and migration definitions stay in the +application's Dart compilation; they are not inputs to the stock worker build. +Worker and engine assets currently total 924,659 bytes, or 414,884 bytes when +individually gzip-compressed. These are artifact sizes, not measured network +transfer or application startup costs. Package downloads would still include +these files even when a consumer does not build for Web. + +Flutter's asset transformer protocol is a Dart executable accepting input/output +paths, and current tooling can track an emitted dependency file. This is viable +when a source-built worker is actually needed. Current Dart hook documentation +supports native `CodeAsset` output, not a general Web resource bundler; introducing +a hook alone would not provide the proposed integration. +[Asset transformers](https://docs.flutter.dev/ui/assets/asset-transformation), +[Dart hooks](https://dart.dev/tools/hooks). + +`sqlite_async` already solves asynchronous database execution, but its documented +Web setup still supplies worker and WASM URIs. Adopting it would additionally need +a separate review of its transaction, cursor and custom-function contracts against +the ORM's requirements. This research makes no comparative performance claim. +[sqlite_async Web setup](https://pub.dev/packages/sqlite_async#web). + +## Runtime boundaries to keep explicit internally + +1. **A single SQLite product entry.** Internally select native opening/transport + or Web opening/transport. Keep SQLite and PostgreSQL configuration separate. + Storage policy remains meaningful: a browser database name is not an OS file + path. Do not guess a native application directory inside the pure Dart core. +2. **Resolve assets at the application base.** The probe loads package resources + under `/nested/assets/packages/orm/assets/web/`, starting directly at + `/nested/detail/42`. `document.baseURI` resolves to `/nested/`, whereas + `Uri.base` includes the current route. Current `SqliteWebDriver.open` resolves + relative WASM paths with `Uri.base`, so the future default resolver must supply + correct absolute URLs. The probe does that without changing the driver. +3. **Separate worker and engine URLs for advanced deployment.** Flutter's + `assetBase` can move assets to a CDN, while a direct Worker URL must satisfy the + document's same-origin constraint. Default to same-origin resources; allow + explicit URL overrides. A CDN/blob bootstrap path needs its own CORS/CSP tests + and must not silently inject a service worker. +4. **Publish matched artifacts.** Add protocol/build identity to the startup + handshake and use versioned/content-addressed filenames. Fail clearly when + app, worker or SQLite module versions disagree. Deployment must retain assets + used by still-open older application versions. This is not implemented yet. +5. **Storage capability is independent of Flutter rendering.** Current + `SimpleOpfsFileSystem` works in a dedicated worker, owns two persistent files, + and holds exclusive access. The probe verifies it without cross-origin + isolation headers. Do not force COOP/COEP just to use this storage path, and do + not silently fall back from persistent storage to memory. +6. **Multiple tabs need a separate design.** The present second-owner rejection + remains real. Replacing the worker with `SharedWorker` does not suffice because + this VFS requires a dedicated worker. Connection sharing, change notifications, + upgrades and worker lifetime would have to be handled together. + +References: [Flutter bootstrap and assetBase](https://docs.flutter.dev/platform-integration/web/initialization), +[Worker URL restrictions](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker), +[SimpleOpfsFileSystem](https://pub.dev/documentation/sqlite3/latest/wasm/SimpleOpfsFileSystem-class.html). + +Flutter's JS/WASM application compilation is independent of SQLite's WASM engine. +Both application modes can use the same JS database worker. `flutter build web +--wasm` also emits a JS fallback; a successful build alone does not prove the page +actually used WASM. The probe records `dart.tool.dart2wasm` at runtime and tests +with and without the isolation headers used for multithreaded Flutter rendering. +[Flutter WASM](https://docs.flutter.dev/platform-integration/web/wasm). + +## Actual Flutter probe + +The original probe, now evolved into the [acceptance runner](../tool/test_flutter_web.dart), copies the current ORM +library into a disposable package, adds platform-filtered assets, compiles the +existing worker and creates a real Flutter widget application. It reuses the 20 +browser acceptance scenarios, changing only resource resolution, the reload URL, +and reporting. The long SQL scenario additionally asserts that Flutter animation +ticks advance while SQL executes. It starts after the first Flutter frame and +keeps the widget tree mounted throughout the checks. + +```sh +dart run tool/build_sqlite_web.dart --check +dart run tool/test_flutter_web.dart /absolute/path/to/flutter +``` + +On this host, commands use `DEVELOPER_DIR=/Library/Developer/CommandLineTools`. +The current runner rebuilds its own `.dart_tool/flutter_web_test/` directory, needs cached +Flutter/pub dependencies for offline resolution, serves local renderer assets, +and uses disposable Chrome profiles. It does not modify the shipping pubspec. + +| Flutter release build | Actual Dart WASM | crossOriginIsolated | Result | +| --- | --- | --- | --- | +| JS | false | false | 20 checks passed | +| WASM | true | false | 20 checks passed | +| WASM | true | true | 20 checks passed | + +Environment: Flutter 3.47.4, Dart 3.13.3, Chrome 153, sqlite3 3.6.0. +[Captured evidence](validation/flutter-web-probe.json) includes SDK revisions, +engine/worker hashes, resource requests, execution-mode flags and checks. +The checks include typed relationships/projections, transactions/savepoints, +subscriptions, streaming, precision, OPFS close/reopen, interrupted-transaction +recovery across page reload, and migration upgrades. + +This establishes release-build feasibility on Chromium with same-origin assets, +including nested deployment. It does not establish Flutter hot-restart lifecycle, +Safari/Firefox operation, JS fallback selection from a WASM build, CDN/CSP support, +iframe storage, multi-tab coordination, offline cache updates, sustained frame +performance, or mobile/desktop packaging. The shipping Flutter example remains +native-only; production initialization and automatic asset delivery remain work +to implement after this research. diff --git a/research/mysql-precision-boundaries.md b/research/mysql-precision-boundaries.md new file mode 100644 index 00000000..d02a47d9 --- /dev/null +++ b/research/mysql-precision-boundaries.md @@ -0,0 +1,213 @@ +# MySQL and MariaDB decimal precision boundaries + +Date: 2026-09-19. Tested against the local MySQL 8.4 and MariaDB 11.8 +integration containers. This report records actual SQL results, not a claim +about every release in either engine family. The probes used the database CLI, +independently of the Dart driver and SQL compiler. + +## Decision + +The MySQL and MariaDB `exactDecimal` capability means exact decimal value +transport and typed comparisons within supported storage limits. It does not +promise arbitrary-precision SQL arithmetic. The current typed SQL implementation +rejects decimal addition, subtraction, multiplication, `SUM`, explicit expression +`constrained`, and decimal `UNION` / `UNION ALL` with +`CAPABILITY.DECIMAL_PRECISION`. Explicit rounding, division, and average also +remain unsupported. The Dart `Decimal` value object's local arithmetic is +unaffected by these SQL restrictions. + +Decimal columns, exact parameters, comparisons, and `MIN` / `MAX` remain +available. The shared MySQL/MariaDB storage contract is precision 1–65 and scale +0–min(precision, 30); big integers use `DECIMAL(65,0)`. Assignments let the declared +column apply its engine's storage coercion and strict-mode checks. They do not +first cast the value into a narrower expression, which could saturate before +the column sees it. Storage coercion is not a promise of arbitrary precision or +of identical rounding across engines. + +Explicit `sql(...)` expressions remain the escape hatch when an application +accepts the selected engine's bounded arithmetic. Future typed support must +propagate concrete precision and scale through expressions and query boundaries, +and prove that the complete expression remains exact. We are not adding a SQL +big-number implementation or assuming that checking warnings makes these +operations safe. + +## Conditions and notation + +The probe sessions included `STRICT_ALL_TABLES` and `NO_BACKSLASH_ESCAPES`. +Each result-producing statement was immediately followed by `SHOW WARNINGS`. +The probes below only read values; they require no application tables. + +For readable result summaries: + +- `I = 10^65 - 1`, the maximum positive `DECIMAL(65,0)` value. +- `A = 10^35 - 10^-30`, the maximum positive `DECIMAL(65,30)` value. +- `E = 10^-30`. +- `F = 0.123456789012345678901234567891`. + +These symbols describe exact mathematical values; the SQL below uses explicit +literals and casts, not floating-point exponent notation. + +| Probe | MySQL 8.4 | MariaDB 11.8 | Warnings | +| --- | --- | --- | --- | +| `A + 1` | Exact, including the 36th integer digit | Same | None | +| `A + E` | Exact carry to `10^35` | Same | None | +| `I + E` | `I.000000000`; loses `E` | Same | None | +| `I - E` | `I.000000000`; loses the fractional subtraction and borrow | Same | None | +| `A - 1`, `A - E` | Exact | Same | None | +| `F + 1.2`, `F - 1.2` | Exact | Same | None | +| Direct `SUM` of two `A` values | Exact `2A` | Same | None | +| Direct `SUM` of two `I` values | Exact `2I` (66 integer digits) | Same | None | +| `SUM` of two `E` values | Exact `2E` | Same | None | +| The `2A` sum selected from a derived table or CTE | Saturates to `A` | Same | None | +| The `2I` sum selected from a derived table or CTE | Saturates to `I` | Same | None | +| Outer `SUM` over either saturated inner sum | Sees the already saturated value | Same | None | +| `F * F` | Result has 30 fractional digits; exact result needs 60 | Result has 38 fractional digits; exact result needs 60 | None | +| `CAST(999 AS DECIMAL(2,0))` | Returns `99` | Same | Warning 1264 | +| `UNION ALL` of `I` and `E` | Large row saturates to `A`; small row remains `E` | Same | None | + +Successful small examples do not establish a safe general operator. In +particular, a direct aggregate can appear exact while an ordinary surrounding +query changes the result. Only two input rows are needed to reproduce this. + +## Reproduction SQL + +Run the statements in a session with the modes above. `SHOW WARNINGS` must stay +immediately after its corresponding `SELECT`; later statements replace the +diagnostics. + +### Mixed-scale addition and subtraction + +Both operands fit the documented storage contract, but the result silently +discards the nonzero fractional term. + +```sql +SELECT + CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) + + CAST(0.000000000000000000000000000001 AS DECIMAL(65,30)) AS result; +SHOW WARNINGS; + +SELECT + CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) + - CAST(0.000000000000000000000000000001 AS DECIMAL(65,30)) AS result; +SHOW WARNINGS; +``` + +Both statements return the 65-digit integer followed by `.000000000`. Neither +engine reports a warning. The exact addition should preserve the final +fractional `1`; the exact subtraction should decrement the integer part and +produce 30 fractional nines. + +### Aggregate precision changes at a query boundary + +The first statement returns the exact 66-digit sum. The next statements return +only `I`, silently saturating the same sum. + +```sql +SELECT SUM(v) AS result +FROM ( + SELECT CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) AS v + UNION ALL + SELECT CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) +) AS inputs; +SHOW WARNINGS; + +SELECT s +FROM ( + SELECT SUM(v) AS s + FROM ( + SELECT CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) AS v + UNION ALL + SELECT CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) + ) AS inputs +) AS sums; +SHOW WARNINGS; + +WITH sums AS ( + SELECT SUM(v) AS s + FROM ( + SELECT CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) AS v + UNION ALL + SELECT CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) + ) AS inputs +) +SELECT s FROM sums; +SHOW WARNINGS; +``` + +The fractional case was also tested: replace each value with +`99999999999999999999999999999999999.999999999999999999999999999999` +and each cast with `DECIMAL(65,30)`. The direct result is +`199999999999999999999999999999999999.999999999999999999999999999998`. +The derived-table and CTE results instead equal the original single input `A`. +Changing the outer `SELECT s` to `SELECT SUM(s)` also observes the saturated +value. All these statements produce no warnings on either tested engine. + +### Multiplication + +```sql +SELECT + CAST(0.123456789012345678901234567891 AS DECIMAL(65,30)) + * CAST(0.123456789012345678901234567891 AS DECIMAL(65,30)) AS result; +SHOW WARNINGS; +``` + +The exact product requires 60 fractional digits. MySQL returns 30 and MariaDB +returns 38, with no warning. The different result scales also rule out treating +native multiplication as a single cross-engine exact contract. + +### Explicit narrowing cast + +```sql +SELECT CAST(CAST(999 AS DECIMAL(65,0)) AS DECIMAL(2,0)) AS result; +SHOW WARNINGS; +``` + +Both engines return `99` and warning 1264 rather than raising a statement error. +A preceding cast therefore cannot enforce an overflow-error contract on behalf +of a later column assignment. + +### Set-operation precision unification + +```sql +SELECT v +FROM ( + SELECT CAST(99999999999999999999999999999999999999999999999999999999999999999 + AS DECIMAL(65,0)) AS v + UNION ALL + SELECT CAST(0.000000000000000000000000000001 AS DECIMAL(65,30)) +) AS combined; +SHOW WARNINGS; +``` + +Both engines return `A` for the large row and `E` for the small row, without +warnings. The large input loses 30 integer digits merely by sharing a result +column with another valid decimal. The current typed guard covers both `UNION` +and `UNION ALL`; this concrete probe used `UNION ALL`. + +## Evidence and scope + +The SQL results above are the deciding evidence. They show that strict mode and +an empty warnings list are insufficient to establish exact expression results. +They do not imply that every native decimal operation is inaccurate, or that +all expressions with known small bounds must remain unsupported forever. + +MySQL's [decimal characteristics documentation](https://dev.mysql.com/doc/refman/8.4/en/precision-math-decimal-characteristics.html) +describes its bounded decimal representation. Its +[8.4 aggregate implementation](https://github.com/mysql/mysql-server/blob/8.4/sql/item_sum.cc) +contains separate aggregate result-type and temporary-field paths. Those are +useful implementation references; this report does not infer identical internals +for MariaDB or rely on an unverified explanation of the optimizer's decisions. + +The present policy deliberately rejects an operation whose complete bounds are +unknown instead of guessing from a value's Dart type. Restoring an operator +requires precision/scale propagation through parameters, columns, aggregates, +casts, subqueries, CTEs, and set operations, with regression coverage against +both real servers. diff --git a/research/new-dart-orm-design.md b/research/new-dart-orm-design.md new file mode 100644 index 00000000..d15e78fc --- /dev/null +++ b/research/new-dart-orm-design.md @@ -0,0 +1,660 @@ +# 全新 Dart ORM 设计研究 + +这份设计以低代码、类型安全、易用和功能完整为目标,从数据声明到数据库运行建立一套完整契约。研究范围包括关系型数据库的建模、查询、写入、关系加载、事务、迁移、驱动配置、生成工具和性能验证;PostgreSQL 与 SQLite 是首批验证对象,服务端、Flutter 原生端和 Web 分别讨论。事实核查截至 2026 年 9 月 14 日。 + +文中的 API 是设计提案,不是已发布或已实现的 ORM。标记为“实证”的内容通过独立 Dart 原型验证;数据库行为由对应官方文档支持;其余明确属于建议或待验证设计。现有项目实现不参与本设计的推导。 + +## 1. 推荐方向 + +建议采用 **Dart 数据类型声明字段、少量声明补充数据库语义、可组合的选择器决定结果形状、显式数据库会话执行操作** 的方案。 + +低代码入口优先验证带字段注解的 Record typedef。普通 class 可以作为需要名义类型、构造逻辑和实体方法时的另一种输入形式。两者应进入同一个生成流程与执行实现;首个可用版本先完成一种入口,避免同步维护四种建模语言。 + +推荐建立以下约定: + +| 事项 | 推荐方案 | 主要代价或限制 | +| --- | --- | --- | +| 字段声明 | Dart 类型声明一次;数据库规则通过注解或相邻约束声明补充 | 需要分析与代码生成 | +| 表身份 | 每张表拥有独立生成描述符;记录值不承担表身份 | Record 别名本身不是名义类型 | +| 关系声明 | 基于键的显式关系;反向关系复用同一条边 | 自关联、多外键必须有清晰名称 | +| 字段选择 | `Selection` 同时持有字段计划和解码方式 | 任意命名结果需要一次显式映射 | +| 关系结果 | `Selection`、`Selection>` 与普通字段组合 | 批量加载和数据库内聚合需要真实执行器验证 | +| 简单写入 | 生成直接命名参数的快捷方法 | 有默认值与显式 null 的字段需要区别表达 | +| 复杂写入 | 可组合表达式、返回字段选择、显式事务 | 不把任意对象图自动变成写入命令 | +| 事务 | `transaction((tx) async { ... })` 绑定连接 | Dart 无法静态禁止 tx 逃逸,需运行时生命周期检查 | +| 迁移 | 可审阅的版本化迁移;schema 是目标,数据库探测是实况 | 重命名和数据回填不能完全自动推断 | +| 驱动配置 | 每类驱动有自己的强类型配置;应用数据库入口统一 | 相同 SQL 方言不保证相同事务或流式能力 | + +这套方案的新价值在于把简短声明、准确结果类型和可解释执行成本连起来。Record、注解、选择器和关系描述本身都有既有技术基础;不宣称这些单项概念是首次出现。 + +## 2. 最新 Dart 能力与实际边界 + +本机直接调用独立 SDK 得到 `Dart SDK version: 3.13.3 (stable)`。官方 Dart 3.13 于 2026 年 8 月发布,主构造函数已稳定。[^1] + +| 能力 | 对 ORM 的实际作用 | 不能据此承诺的事情 | +| --- | --- | --- | +| 主构造函数,3.13 | 缩短普通模型类的字段和构造器声明 | 自动生成数据库映射、值相等或 copyWith | +| Dot shorthand,3.10 | 上下文明确时使用 `.new()`、`.set()`、`.desc` | 任意位置省略类型名,或增加新 DSL 语法 | +| Records,3.0 | 异构结果、命名投影、结构相等 | 通用 mapped types、任意字段枚举、把 Expr 字段自动拆成值 | +| Patterns、sealed classes | 解构结果、穷尽处理状态和错误 | 在编译期证明 SQL 业务约束 | +| Extension types | 低额外包装成本的 UserId、编码值等接口 | 运行时身份隔离、动态反射识别领域类型 | +| 泛型扩展方法 | 对有限 arity 的异构字段组合做类型推断 | 对任意 Record 形状逐字段递归变换类型 | +| build_runner/watch | 常规生成、增量构建、生态集成 | 无生成步骤或生成成本为零 | + +Dot shorthand 与 extension types 的能力依据官方语言说明。[^2][^3][^4] Macros 的原有工作已停止,当前 build_runner 文档仍将生成作为编译器不支持 macros 时的可用路径。Flutter 不提供 mirrors 反射系统。原型在稳定 SDK 上也未通过 `augment class`;本设计不依赖未稳定的增强功能。[^5][^6][^7] + +**实证:Record 字段允许元数据。** 下列声明可以通过 Dart 3.13.3 分析: + +```dart +typedef User = ({ + @Id.generated() int id, + @Unique() String email, + String? nickname, +}); +``` + +`Id` 与 `Unique` 是提案中的普通 const 注解。元数据需要生成器读取源码;运行时 Record 对象不会携带可供 ORM 枚举的字段注解。 + +**实证:Record 别名是结构类型。** 若 User 与另一种别名拥有完全相同的字段形状,两者可以相互赋值。`User(id: ...)` 也不是 Record 的构造方式。因此默认写入 API 应围绕生成的 `db.users.create(...)`,表身份使用独立描述符;不能使用 `Map` 区分同形的 Record 实体。[^3] + +**语言边界:不能用普通布尔闭包冒充 SQL 表达式树。** `&&`、`||`、`!` 不可重载,`==` 返回 bool。因此运行时查询条件使用 `.eq()`、`.and()`、`.or()` 等表达式操作。若未来提供 `(u) => u.age > 18 && u.active`,它必须是一种独立、受限、明确生成的查询声明语法。[^8] + +## 3. 用完整使用过程衡量设计 + +低代码衡量“修改一个字段需要改几处”“写一段真实业务需要多少概念”,不能只比较模型行数。类型安全需要同时覆盖输入、表达式、查询结果、空值、关系形状和解码边界。功能完整则需要覆盖数据从创建到长期升级的整个过程。 + +| 验收场景 | 应得到的体验 | +| --- | --- | +| 新增一个有默认值的字段 | 改声明、生成、审阅迁移;已有创建调用仍可省略该字段 | +| 查询用户的邮箱列表 | 实际 SQL 只选邮箱;返回 `List` | +| 用户+每人最近三篇文章标题 | 根分页正确,子限制按每个父记录计算,无逐行查询 | +| 创建用户及文章 | 可在同一事务取得生成 ID,再创建子记录 | +| 清空昵称、增加浏览量 | 显式 null 与未修改区分;递增在数据库完成 | +| 变更字段名称 | 代码重命名与数据库列重命名分别处理 | +| 切换 SQLite 文件/内存/Web | 主要改变连接配置,无法支持的能力明确报错 | +| 替换 PostgreSQL 驱动 | 保留模型和查询 API;重新验证事务、取消、编码能力 | +| 处理大结果集 | 分页或真实流式读取,释放会话可验证 | +| 部署新版本 | 应用所需 schema 版本、迁移状态和实际数据库差异可检查 | + +## 4. 声明模式:超过“类还是 DSL”的比较 + +| 声明方式 | 优点 | 长期代价 | 建议角色 | +| --- | --- | --- | --- | +| 普通 class+注解 | Dart 熟悉、名义类型、可写方法和构造逻辑 | ORM 要约束可映射构造方式;实体对象容易混入加载状态 | 需要领域对象时使用 | +| Record typedef+注解 | 最少数据样板;字段类型直接是 Dart 类型;自然解构和值相等 | 无实体名义身份;没有普通类构造器与实例方法 | 优先验证的低代码入口 | +| typed Dart schema DSL | 数据库类型、约束表达集中,列对象能直接参与关系配置 | 需要学习字段构造方法;普通行类型仍需生成 | 数据库优先项目的备选,不与默认入口同步建设 | +| class 型表声明 | 每个列成员有符号身份,复合约束和跨表引用方便 | getter/column 类型样板较多,常需另外生成行类 | 对照方案,不因既有生态成熟就默认采用 | +| SQL 文件/数据库优先 | SQL 能力完整;已有数据库接入自然 | Dart 类型要生成;跨数据库声明需要分别维护 | 已有数据库导入、命名复杂查询与迁移的正式路径 | + +推荐的第三种组织方式是:**数据类型声明基础字段,相邻的强类型声明描述键、关系和索引。** 这样既不要求把所有数据库配置塞进构造参数,也不要求重新用列 DSL 复写每个字段的 Dart 类型。 + +### 4.1 候选声明 + +```dart +typedef User = ({ + @Id.generated() int id, + @Unique() String email, + String? nickname, +}); + +typedef Post = ({ + @Id.generated() int id, + int authorId, + String title, + DateTime createdAt, +}); + +final users = entity(); +final posts = entity(); + +final author = posts + .key((p) => p.authorId) + .references( + users.key((u) => u.id), + inverse: 'posts', + onDelete: .restrict, + ); + +final authorTimeline = posts.index( + (p) => (p.authorId, p.createdAt, p.id), +); +``` + +上例是**生成器声明 API 候选**,并非已验证的整套生成实现。`entity()` 绑定声明中的 User 与 users 表身份;默认数据库名称从声明推导,支持显式 table/column 映射。`author` 声明提供 Post 到 User 的关系名称,`inverse` 一次性命名反向关系。这个字符串是新 API 名称的声明,不是查询时使用的字符串字段引用。 + +`.key((p) => p.authorId)` 的闭包只承担源码声明。生成器只接受字段访问和字段 Record 组合,拒绝任意业务计算;不能在运行时执行一个虚构 Post 来发现列。字段拼写和 Dart 类型可以由 analyzer 检查,目标是否为唯一键、列的数据库类型是否匹配、路径是否属于受限语法,由生成器检查。此处需要一个小型明确的语法读取器,不能扩张成通用 Dart 解释器。 + +这一方案无需先生成 `UserFields` 再让 schema 引用它,可以避免“声明依赖尚未生成代码”的启动循环。是否足够简短、编辑器重命名体验是否可靠,仍需做真实 analyzer/generator 原型。 + +### 4.2 复杂关系和约束 + +一对一需要外键加唯一约束;仅命名成 `one` 不足以保证唯一。多对多使用显式中间表,可包含加入时间、角色等业务字段。复合主键和外键以同序字段 Record 配对声明,不强制虚构单列 id。自关联和同表间多个外键各有独立关系名。 + +```dart +final membershipKey = memberships.primaryKey( + (m) => (m.teamId, m.userId), +); + +final accountOwner = accounts + .key((a) => (a.tenantId, a.ownerId)) + .references(users.key((u) => (u.tenantId, u.id))); +``` + +这是扩展示意,不与前面的单列 User schema 同时使用。复合键要求列数、次序、底层存储类型及目标唯一性一致。索引、唯一约束、外键各有不同目的,不能自动给所有外键额外叠加重复索引。 + +关系导航和数据库约束关联但不等价。生成器默认让正式外键生成导航边;也允许显式声明无数据库外键的只读关系,但必须标记其较弱的完整性保证。Drizzle 的关系文档展示了关系与外键分离的价值;当前所查 `defineRelations` 页面使用 RC 安装示例,只作为设计参考。[^9] + +### 4.3 默认值与名称 + +Dart 构造默认值、客户端生成值、数据库 DEFAULT、数据库 generated column 是四件不同的事。元数据必须分别表达;不能把 Dart 的 `DateTime.now()` 偷换成数据库时间,也不能仅根据一个普通类的默认参数自动修改数据库 DEFAULT。 + +字段重命名优先保留明确列映射。需要真的重命名数据库列时,写入迁移操作。生成代码、schema 目标定义、历史迁移之间可以有派生信息重复;禁止的是多份需要人手同时维护的事实。 + +## 5. 类型契约 + +建议至少区分行值、创建输入、更新指令、SQL 表达式和查询结果。它们由同一声明派生,不需要开发者手写五套实体类。 + +| 类型或规则 | 编译阶段能保证什么 | 仍需其他阶段保证什么 | +| --- | --- | --- | +| `Expr` 与字段操作 | 参数基础类型,支持的操作类别 | 数值范围、排序规则、SQL 三值逻辑细节 | +| `Selection` | 返回 Record/DTO 的具体字段类型 | 数据库实际返回值与 codec 合法性 | +| 生成创建参数 | 必填字段,禁止写计算列 | 唯一冲突、外键存在性、CHECK | +| `Change` | 非空字段不能 `.set(null)` | DEFAULT 是否存在、目标列可否更新 | +| 生成表身份 | 查询与写入绑定哪张表 | alias 是否属于当前 SQL 作用域 | +| 后端泛型 | PostgreSQL 专用 API 不出现在 SQLite 实例上 | 实际服务器版本及连接驱动能力 | +| 显式 tx 对象 | 调用代码可以传递事务会话 | 事务结束后误用、跨连接或未等待操作 | + +NULL 使用 `.isNull()`、`.isNotNull()` 明确表达;非空字段 `.eq()` 只接受非空 T。空 `IN` 集合应有确定的 false 语义。存在 null 的集合不能依赖 Dart 集合语义猜测 SQL 的 `IN`/`NOT IN` 结果。可空聚合、外连接、过滤后的关系也必须反映在输出类型中。 + +数据库类型映射需要独立元数据与 codec: + +| 领域 | 推荐处理 | +| --- | --- | +| 整数 | 指定数据库位宽;跨 JS 编译边界的完整 64 位值使用验证过的 BigInt/Int64 codec | +| 精确十进制 | 使用 Decimal codec 或可验证的精确表示,不能默认转 double | +| 时间点 | 明确 UTC instant 存储;区分无时区 timestamp、date、time 和时区名称 | +| JSON | JSON 值与任意 Dart 对象分开;显式 codec 负责验证 | +| 枚举 | 默认稳定文本值;原生数据库枚举是后端能力和迁移选择 | +| 二进制 | 统一 Uint8List 等明确边界,避免每层复制 | +| 自定义 ID | 可用 extension type;编解码器必须注册对应存储表示 | + +Dart JS 数值范围与原生整数范围不同;PostgreSQL numeric 是精确数值类型;SQLite 的类型亲和性与 STRICT 表规则也不同。这些差异不能用一个 `String/int/double` 映射表完全掩盖。[^10][^11][^12][^13] + +## 6. 字段选择与复杂查询 + +### 6.1 一套可实现的选择协议 + +```dart +final emails = await db.users + .select((u) => u.email) + .get(); // List + +final cards = await db.users + .select((u) => (u.id, u.email).map( + (id, email) => (id: id, email: email), + )) + .get(); // List<({int id, String email})> + +final objects = await db.users + .select((u) => (u.id, u.email).map(UserCard.new)) + .get(); // List +``` + +**实证:** 字段组合到命名 Record、DTO 构造器、单字段、可空整个对象及异构嵌套列表的泛型推断已通过 Dart 3.13.3。规划阶段不调用映射函数;只有行值到达时才解码并调用函数。 + +最小内部关系是 `Expr implements Selection`,组合后的选择器仍是 `Selection`。它需要知道列依赖及如何重建 R。示例原型使用字符串标签和合成行数据;真实实现应使用列引用、作用域和位置解码,不应照搬原型中的字符串 Map。 + +对 2—6 项提供少量通用扩展可降低使用成本。更大结果通过嵌套组合或显式命名投影完成,不预生成所有字段组合。具体快捷 arity 数量是工具体积与使用频率的测量结果,不是功能上限。 + +### 6.2 明确放弃的伪简化 + +```dart +// 不作为普通运行时库的承诺: +select((u) => (id: u.id, email: u.email)); + +// 只能安全解释为解码回调,不能独自确定 SELECT 列: +select((u, read) => (id: read(u.id), email: read(u.email))); +``` + +第一种回调实际返回 Expr 的 Record,Dart 不会通用地把每个字段解包成值。第二种写法能推断值类型,但尝试以假数据执行来发现列,会被条件分支、自定义 codec 和副作用破坏。原型中 `read(id) > 0 ? read(email) : 'hidden'` 就证明了假数据发现的列可能不完整。 + +命名的生成式 query/view 可以未来支持更简短声明:构建时读取固定查询,生成参数和结果类型。它应是显式能力,不应扫描并转换应用里的所有任意闭包。Drift 的 SQL 自定义查询展示了生成类型化查询的实际路径。[^14] + +### 6.3 动态查询与 SQL 上限 + +`where/orderBy/limit` 返回可复用的查询值,条件可以通过普通 Dart if 组合;只有终结操作执行。`.get()` 返回列表,`.first()` 返回可空单条,`.single()` 校验恰好一条,`.count()` 和 `.exists()` 使用专门 SQL。筛选常量全部绑定参数,标识符来自 schema 或显式允许的动态字段集合。 + +join、group by、having、聚合、子查询、CTE、窗口函数、union 应使用同一表达式与选择协议。聚合合法性、表别名归属等无法经济地完全塞进 Dart 类型参数的问题,在 SQL 发出前验证。原生 SQL 是正式出口:参数绑定与显式 decoder 是基础保证;只有经过解析或数据库检查的命名 SQL 才能声称查询结构也经过静态验证。 + +CTE 必须保留 SQL 列身份:`.map()` 构造出的 Record/DTO 属性不是 SQL 列,不能自动提供 `cte.someProperty`。候选使用 `cte.ref(selectedExpression)` 得到准确的 `Expr`,是否确实导出了该表达式由规划阶段验证。需要静态命名属性的 CTE/view 则通过明确的命名查询生成类型。mapper 中任意 Dart 计算不能反向成为数据库表达式。 + +动态勾选任意字段的后台导出无法同时得到每种运行时组合的静态 Record 类型。提供显式动态结果 `Map`,或一组预定义投影;不要把强制转换包装成类型安全。 + +分页同时提供 offset 与复合 keyset。keyset 必须明确稳定排序、唯一 tie-breaker、null 顺序和游标版本;不得对未定义顺序的结果宣传稳定翻页。 + +## 7. 关系数据作为可组合查询结果 + +关系描述存储连接规则;关系查询存储筛选、排序、投影和加载策略;行值只存已查询的数据。这样一个 `User` 不必永久携带 `List?` 或隐式懒加载代理。 + +```dart +final rows = await db.users + .orderBy((u) => [u.id.asc()]) + .take(20) + .select((u) => ( + u.id, + u.email, + u.posts + .orderBy((p) => [p.createdAt.desc(), p.id.desc()]) + .take(3) + .select((p) => p.title) + .many(), + ).map((id, email, titles) => ( + id: id, + email: email, + titles: titles, + ))) + .get(); +``` + +候选结果是 `List<({int id, String email, List titles})>`。没有选择的字段在类型中不存在;已经加载但没有子行的列表是空列表。可空单关联用 `T?`;外连接整行可空依赖明确的存在性标记,不能把每个字段变 nullable 再假装一定有对象。 + +上例的泛型组合已经通过合成数据原型;关系 SQL 编译、分组装配、分页正确性尚未实现。一个选择器可以表达多个 SQL 步骤,不要求所有 ORM 调用强制单 SQL。 + +### 7.1 加载策略与成本 + +| 策略 | 推荐用途 | 必须处理的代价 | +| --- | --- | --- | +| 同语句 join | 单关联、显式扁平 join | 多个一对多会放大行数;根 limit 不能作用到展开后的行 | +| 批量 select-in | 默认的一对多/多对多加载 | 多次往返、参数数量限制、跨语句一致性 | +| 数据库聚合 | 支持目标上的 JSON/array 嵌套结果 | 数据库 CPU、序列化、null/时间/精确数值 codec | +| 显式懒查询 | 单个对象详情或刻意延后加载 | 循环里调用容易形成 N+1,必须能看见执行次数 | + +默认策略应确定且可检查:简单行与一对一/多对一尽量一条语句;一对多/多对多列表使用批量加载;需要特殊策略时允许在关系边上指定。不要加入未经测量的自适应“智能优化器”。SQLAlchemy、Drizzle 和 Kysely 的相关能力是评估对照,不是默认必须照搬的规则。[^9][^15][^16] + +查询数按关系层级与分块增长,不按每个父对象增长。例如没有子关系且无分块时,一组根行加一条列表关系通常是两次查询;实际次数还取决于复合键宽度、参数上限和嵌套层数。根结果为空时不查询子关系。 + +“每人三篇文章”必须使用分区窗口、相关子查询或等价的按父级限制方案。不能对 `WHERE author_id IN (...)` 直接加全局 `LIMIT 3`,也不能拉取所有文章后在 Dart 丢弃绝大部分数据。 + +多语句加载需要明确一致性:一般读取可以接受各语句各自快照;需要统一视图时选择明确的快照事务。在 PostgreSQL 下,仅套一个默认 Read Committed 事务不等于所有语句使用同一快照。[^17] + +### 7.2 关系筛选与边界 + +提供 `any`、`none`、`every`、`count` 与按关系字段排序。通常降低成 EXISTS、NOT EXISTS 或聚合子查询。`every` 对空集合为真必须文档化,必要时与 `any` 联合使用。建议把谓词 UNKNOWN 当作未满足;编译为不存在“谓词不为 TRUE”的子行,并测试可空字段,不能简单把 `NOT predicate` 等同于这条规则。不为了关系过滤自动读取整个关联对象。 + +一条非空外键并不保证经过权限过滤、软删除或自定义过滤后仍能看见目标。可见性受筛选影响的关系需要可空结果,或由显式的 required 查询在缺失时报告错误。多态关系、无约束关系和跨数据库关系不享有普通外键的完整性保证。 + +## 8. 写入与默认值 + +日常创建用生成的命名参数快捷入口: + +```dart +final user = await db.users.create(email: 'a@example.com'); + +await db.users.byId(user.id).update( + nickname: .set(null), +); +``` + +需要选择返回列、批量写入或表达式时使用相同执行实现的 builder: + +```dart +final id = await db.users + .insert(.new(email: 'a@example.com')) + .returning((u) => u.id) + .single(); + +await db.posts.byId(postId).updateWith((p) => [ + p.views.increment(1), + p.title.set('New title'), +]); +``` + +`views` 是后续扩展字段示意。这里 `increment` 返回写入指令,与查询表达式的加法区别明确。简单方法只是预设操作,不应拥有另一套 SQL 引擎。 + +更新内部使用三态或操作集合:省略、设值(可能为 null)、数据库 DEFAULT。非空字段不得接受 `.set(null)`。`.defaultValue()` 除要求该列有 DEFAULT,还要验证后端支持;SQLite 没有 PostgreSQL 式的 `UPDATE SET column = DEFAULT`,不能直接生成这条语句。若提供默认表达式展开,只支持已经定义且可保持语义的情况,否则明确拒绝。计算列和只读列不进入普通写入参数。空更新应在执行前拒绝,返回明确错误。[^48] + +创建时,非空且无默认值的字段必填;数据库生成字段可省略;可空字段如果还具有数据库默认值,则必须能区分“使用默认值”和“显式 null”。快捷创建方法不应靠 `Object?` 加运行时强转把所有输入类型放宽。 + +批量插入按参数限制分块,默认返回影响行数,只有显式 returning 才读取结果。一次批量调用默认让所有分块位于同一事务,已有 tx 时复用其范围;超大导入逐块提交必须显式选择,并返回可恢复的进度和部分失败结果。数据库未规定的返回顺序不能被视为输入顺序;依赖输入对应关系的场景使用稳定键或显式关联标识。SQLite returning 还不保证包含随后 AFTER trigger 的修改,不得声称返回的是所有触发器完成后的最终行。[^49] + +upsert 基于唯一约束执行,不能用先查再插入模拟原子性;冲突目标、更新表达式、返回结果均需保留数据库差异。原子递增与乐观并发使用 SQL 表达式和版本条件实现,更新零行与唯一冲突是不同结果。 + +关联写入首先用显式事务表达 create/connect/disconnect/delete 的顺序,之后再增加具有清晰语义的嵌套语法糖。不对传入的任意 User/Post 对象图做脏检查和自动级联保存。这样可以避免隐藏查询、意外删除与复杂的对象身份管理。 + +## 9. 事务、并发与生命周期 + +```dart +final user = await db.transaction((tx) async { + final user = await tx.users.create(email: 'a@example.com'); + await tx.posts.create( + authorId: user.id, + title: 'Hello', + createdAt: DateTime.now().toUtc(), + ); + return user; +}); +``` + +tx 暴露同样的表 API,并绑定一条明确的连接。callback 正常完成后提交,提交成功才完成外部 Future;异常时回滚。tx 在作用域结束后失效。复用业务方法可以传入执行会话,而不是依赖全局单例或 Zone 自动把任意 db 调用转成事务调用。 + +所有异步查询必须 await。Dart 类型系统无法证明没有未等待 Future,也无法禁止 tx 被保存到外部对象;运行时必须拒绝失效会话,并检测 callback 结束时仍在进行的工作。不能自动提交一个仍有未完成语句的事务。Drift 的事务说明也明确要求等待事务内的异步操作。[^18] + +### 9.1 嵌套与隔离 + +```dart +await db.transaction((tx) async { + await tx.users.create(email: 'a@example.com'); + try { + await tx.savepoint((sp) async { + await sp.invites.create(code: inviteCode); + }); + } on UniqueViolation { + // 仅撤销内层,外层继续。 + } +}); +``` + +内层成功只是释放 savepoint,最终持久化仍依赖外层提交。内层失败后,只有确认外层事务仍活跃且 rollback-to/release 成功,才能允许外层继续;SQLite 的部分严重错误可能已经整体回滚,连接故障也不能靠 savepoint 恢复。无法恢复时使外层失效并传播错误;异常未被捕获时外层也结束。同连接 savepoint 必须保持栈顺序,不允许并发异步块交错操作,也不为每条 SQL 自动增加 savepoint 往返。[^19][^20][^22] + +| 设置 | PostgreSQL | SQLite | +| --- | --- | --- | +| 默认事务 | Read Committed | Deferred | +| 稳定读快照 | Repeatable Read 等明确语义 | WAL 模式下的读事务快照 | +| 写并发 | MVCC 与锁协调多个写事务 | 同一数据库同一时间只有一个写事务 | +| 提前开始写事务 | 数据库锁与事务选项 | Immediate | +| 后端专有设置 | isolation、readOnly、deferrable | deferred、immediate、exclusive | + +不把 PostgreSQL 隔离级别名称机械映射到 SQLite。对于动态选择的驱动,不支持的设置在执行前报错;绝不静默降级。SQLite COMMIT 遇到 BUSY 时,事务可能仍活跃;需要区分重试提交和重跑整个 callback,不能一律回到事务开头。[^17][^21][^22] + +### 9.2 重试与提交未知 + +自动重试默认关闭。显式启用时,只对适配器已正确分类的可重试错误重跑整个事务 callback,并限制次数与总时间;PostgreSQL 序列化失败要求连同决定 SQL 的应用逻辑一起重试,不能只重发最后一条 SQL。[^23] + +commit 时连接断开,可能无法确定数据库究竟提交还是回滚。此时返回 `CommitOutcomeUnknown` 一类明确结果,不无条件重跑。外部邮件、支付或事件等副作用不能因 callback 重试而被假定只执行一次。afterCommit 只表示当前进程观察到提交后执行;可靠事件投递可使用同事务 outbox 配方,ORM 不必因此内建消息系统。 + +### 9.3 超时与取消 + +需要分别建模等待连接、连接建立、语句运行、等待数据库锁、事务整体期限。`Future.timeout` 不会取消原始 Future,因此不足以证明 SQL 已停止。SQLite busy timeout 只处理锁等待,也不等价于语句运行上限。[^24][^25] + +取消流程应停止接收新操作、发送真实取消或触发数据库期限、处理游标和协议剩余响应、完成回滚,再确认连接可复用。无法确认状态就销毁该连接,不能直接归还连接池。SQLite 的真实中断依赖驱动提供相应能力。[^26] + +## 10. 迁移是产品主能力 + +当前 schema 表达目标;版本化迁移表达如何到达目标;数据库 introspection 表达实际状态。这三者各有职责,不能用启动时自动 diff 代替部署历史,也不能只检查一个版本号就声称数据库没有漂移。 + +### 10.1 工作流 + +候选命令保持生成、审阅、执行清晰: + +```text +dart run orm generate +dart run orm migration create add_nickname +dart run orm migration check +dart run orm migrate --plan +dart run orm migrate +dart run orm db inspect +dart run orm db verify +``` + +这些是提议的命令名。默认先用线性历史,迁移文件包含顺序 ID、目标数据库、SQL、校验和及事务方式。迁移记录保存执行状态和失败阶段。schema snapshot 由工具维护,用于差异生成和旧版本测试。已部署迁移不允许随意改写;只提交当前 schema 不足以保留手写数据变换的历史。[^27] + +一套应用迁移历史固定一个数据库引擎,包括空注册表。单个迁移只有该引擎的一组步骤、转换 SQL 和历史物理 schema;ORM 的多库编译能力不进入应用的迁移文件。共享声明的另一引擎配置不影响当前历史的 diff 或指纹。多库应用分别维护目录、注册表和连接;换库属于数据搬迁及新库 baseline,不能重放或改写原库历史。生产运行器只执行已审阅迁移。Flutter 本地数据库可以在业务开始前应用打包的迁移,服务端则建议作为独立发布步骤。 + +### 10.2 改名、回填和兼容升级 + +diff 无法确定“删除 name、新增 displayName”到底是重命名还是两个独立动作。生成器可以提出候选,但需要明确 rename 记录;CI 遇到歧义不得自行选择删除数据。保留数据库列名的 Dart 重命名不应该产生数据库 DDL。 + +大数据变换按 expand → backfill → contract 进行:先增加兼容结构,再发布兼容代码,分块回填并校验,切换使用,最后确认旧进程和作业退出后删除旧结构。长回填有进度、checkpoint、重复运行条件和完成证明。回填逻辑使用该迁移的历史 schema,不导入永远变化的最新应用模型。Drift 的版本化迁移与测试工具提供了可参考的实际做法。[^28][^29] + +没有通用的自动无损回滚。类型转换、拆列和删除字段可能不可逆;默认前向修复,显式 down 或恢复备份由具体迁移定义语义。应用降级遇到更高 schema 版本时应报不兼容,不自动删表重建。 + +### 10.3 数据库差异与失败恢复 + +PostgreSQL `CREATE INDEX CONCURRENTLY` 不能运行在事务块内;失败还可能留下 INVALID 索引。迁移必须能表达非事务步骤,并通过前置/后置检查处理“SQL 成功但进程未记账就退出”的情况。`IF NOT EXISTS` 不能证明同名对象定义正确。[^30] + +事务性迁移的 DDL 与成功记录应在同一事务提交。包含非事务步骤的迁移使用专用连接与合适的运行器锁,失败后先检查实况,再决定恢复。PostgreSQL 的会话级与事务级 advisory lock 生命周期不同,使用方式要匹配迁移范围;锁只协调遵循该协议的运行器,不能阻止手动 ALTER。[^31] + +SQLite 的部分结构变更需要新建表、显式列映射复制、替换表、重建受影响索引/触发器/视图。必须按正确顺序处理外键设置并运行 foreign_key_check;不能把所有 ALTER 简化成一个改字符串操作。未知依赖对象必须保留或明确交给手写迁移。[^32][^33] + +### 10.4 已有数据库、baseline 与 drift + +introspection 覆盖受管表、列、约束、索引及可理解的附属对象,输出支持范围和未建模对象。已有数据库可导入声明初稿并验证 baseline;登记 baseline 不应重新执行建表或搬动已有数据。[^34] + +完整 drift 检查比较实际结构与迁移预期。默认函数、表达式索引、自动生成名称等由数据库专有规范化处理。无法理解的 trigger、view、RLS policy、extension 不等于应删除的对象。 + +成本上,应用常规启动做轻量版本兼容检查;明确的 verify/CI/迁移流程做适当范围的真实结构检查。每次打开数据库都全量扫描 catalog 并无必要。迁移预览应展示重建、扫描、锁与非事务步骤的已知影响,不宣传未经验证的“零停机”。 + +## 11. 不同数据库与驱动配置 + +数据库方言、传输驱动、部署环境是不同因素。PostgreSQL 的 TCP 驱动与 HTTP 驱动可能支持相似 SQL,却拥有不同的连接、交互事务、取消和流式协议。SQLite 原生 FFI 与浏览器 WASM 也不能使用完全相同的文件和线程配置。 + +**实证:** `AppDatabase` 可以从 `Driver` 推断后端类型;每种驱动接收不同的配置类型。原型正确拒绝把 SqliteOptions 传给 PgDriver,也拒绝在 `AppDatabase` 上调用 PostgreSQL 专用扩展。 + +### 11.1 日常配置入口 + +以下是产品 API 候选;底层参数名称由适配器映射,并非对应驱动现成类的逐字用法。 + +```dart +final db = await AppDatabase.open( + PostgresDriver(.new( + url: config.databaseUrl, + tls: .verifyFull, + pool: .new( + maxConnections: 8, + acquireTimeout: Duration(seconds: 5), + ), + )), +); +``` + +```dart +final db = await AppDatabase.open( + SqliteDriver.file( + 'app.sqlite', + execution: .background, + journal: .wal, + busyTimeout: Duration(seconds: 5), + ), +); + +final testDb = await AppDatabase.open(SqliteDriver.memory()); +``` + +```dart +final db = await AppDatabase.open( + SqliteWebDriver( + name: 'app', + wasm: Uri.parse('/sqlite3.wasm'), + worker: Uri.parse('/database_worker.js'), + storage: .opfs, + ), +); +``` + +示例中的连接数与超时是配置演示,不是通用性能默认值。简单用户选一个驱动并打开生成的数据库入口即可。默认行为应保守、确定、有文档;对性能和部署敏感的选项允许精确调整。 + +### 11.2 配置职责 + +| 类别 | 典型配置 | 应归属的位置 | +| --- | --- | --- | +| 端点和认证 | URL、socket、数据库、用户名、证书 | 对应网络驱动 | +| 连接池 | 最大连接、借用等待、寿命、回收 | 池提供者;不再在外面套第二个池 | +| 会话 | 时区、search_path、编码、初始化 | 驱动/后端,明确何时设置与清理 | +| SQLite 文件 | 路径、只读、journal、busy timeout | SQLite native 驱动 | +| SQLite 执行线程 | 本 isolate、后台 isolate | native 执行配置 | +| Web 存储 | WASM、worker、OPFS、IndexedDB | Web 驱动 | +| 查询期限 | acquire/connect/statement/transaction | 能准确实现该期限的一层 | +| 类型映射 | 原生数据库类型、应用 codec | ORM schema 与驱动 codec 桥接 | +| 迁移 | 发布时运行/打开前升级/仅验证 | 应用初始化与迁移工具 | +| 日志 | SQL 模板、参数脱敏、耗时、trace | ORM 执行观察接口 | + +配置由调用者显式传入。环境变量、文件、配置服务可以在应用层解析成这些类型;ORM 不依赖某个特定配置框架。连接字符串和显式参数冲突必须拒绝或有唯一明确的优先规则;不得静默忽略未识别选项。日志不输出连接密码、token 或默认展开所有绑定值。 + +### 11.3 现有驱动与适配范围 + +| 后端与运行方式 | 当前依据 | 推荐策略 | +| --- | --- | --- | +| PostgreSQL native | `postgres` 3.5.12 提供二进制协议、连接池、事务会话和 statement 能力 | 优先适配,复用其连接池与协议实现 | +| SQLite native | `sqlite3` 3.6.0 提供底层连接、prepare、执行、更新通知等 | 复用引擎绑定;Flutter 默认验证后台 isolate 路径 | +| SQLite Web | sqlite3 WASM+worker+浏览器文件系统 | 独立平台配置,查询和选择协议共用 | +| 其他 PostgreSQL 驱动 | 如 `pg` 有不同实现与发布者基准 | 作为可替换适配器,先用同一工作负载验证 | +| MySQL/MariaDB | Dart 存在带 TLS、池与事务的驱动候选 | 下一批适配;先核实认证、类型、DDL 与返回值契约 | +| HTTP/serverless SQL | Turso、Neon 展示 HTTP/WebSocket 的不同连接方式 | 按传输与会话能力建模,不冒充 native 长连接 | + +版本号来自研究时的包页;未进行驱动性能排名。`postgres` 的连接设置和池设置已提供大量本来就属于驱动的参数,没必要全部重新发明。[^35][^36][^37][^38] `pg` 发布者的性能数据没有在本研究复现,因此不据此更换首选驱动。[^39] + +PostgreSQL TLS 不能只用 `ssl: true` 表示安全级别:`postgres` 的 require 与 verifyFull 行为不同,verifyFull 才验证证书。推荐网络部署默认采用可验证的 TLS 配置,兼容本地 socket 或明确指定的开发配置。[^40] + +SQLite FFI 执行是同步的,主 isolate 大查询可能影响 UI;后台 isolate 应长期持有连接,不为每个查询重复启动。内存库的生命周期通常随连接,不能创建多个私有 `:memory:` 连接后假设它们共享数据。WAL 也不会把 SQLite 变成多写者数据库。[^41][^42][^43] + +SQLite 正常会话应在事务外开启并验证每条连接的 `PRAGMA foreign_keys = ON`,覆盖 native、Web 和借入连接。若借入的事务连接尚未启用外键,不能在事务中假装设置成功;应拒绝提供相应完整性保证或要求调用方先正确初始化。迁移临时调整设置后也必须恢复和复验。[^33] + +Web 配置必须验证 WASM/worker 版本兼容、持久化方式、跨标签页协调和必要的浏览器能力。若所选持久化不可用,应返回明确结果或采用调用者允许的 fallback,不能悄悄退化成会丢数据的内存库。[^44] + +MySQL/MariaDB 当前只确认存在候选 Dart 驱动,并未完成兼容认证。SQL Server 等也不因有通用 Driver 接口就自动获得支持。Turso 与 Neon 的官方示例验证了传输形态差异,但其中的 JS SDK 不能直接当 Dart 依赖;对应 Dart 适配器仍需实现和测试。[^45][^46][^47] + +### 11.4 驱动替换与所有权 + +支持两条清楚的路径:ORM 创建并拥有驱动资源,或调用者提供现有连接池/会话。后者需要明确 `borrow` 语义,`db.close()` 不擅自关闭外部仍在共享的连接池;也可以显式移交所有权。 + +驱动接口保持直接:获取会话、执行参数化语句、启动和结束事务、释放资源。prepared statements、取消、游标、批量、更新通知作为具体能力扩展,避免要求每个驱动实现几十个无意义空方法。 + +能力来自方言、实际服务器版本、编译选项、驱动和传输的交集。一个布尔 `supportsSqlite` 或 URL 前缀不足以决定 returning、交互事务、参数上限、流式或取消。已知后端能力可以静态暴露;连接时才能确认的能力在规划前验证。 + +多个数据库通过多个明确的实例使用。跨数据库 join、跨数据库事务、读写副本路由都不是通用 Driver 自然附送的能力;需要专门契约。副本读取必须明确延迟与读己之写要求,不能在调用者未知的情况下把事务读取发送给副本。 + +## 12. 运行、生成与功能完整性 + +内部只需要围绕实际职责组织:schema 分析与生成、类型化表达式与选择、SQL 编译和关系装配、会话执行、迁移。先作为一个产品保持一致入口;物理包拆分由可选驱动依赖和平台隔离的实际需要决定,不先设计大量对外插件协议。 + +生成成本主要取决于模型字段数、关系数和生成 API 规模。每个表生成必要字段、解码器、创建参数和关系句柄;不生成全部 select/include 组合。schema 根文件显式指定,只追踪相关输入。先测量标准 build_runner 的 watch 路径,再决定是否需要自有 CLI;不能宣称自研天然更快。[^6] + +编译 SQL 与解码对象可以缓存,但必须有容量边界并考虑 schema/driver 类型变化。查询默认参数化,prepared statement 缓存按连接归属。不要默认全局结果缓存、隐式 identity map、自动 flush 或任意实体脏检查;这些会增加一致性与分配成本。 + +Flutter `watch()` 建议定位为已知写入后的查询失效和重取。相同事务中的变化提交后合并通知,rollback 不产生提交事件。SQLite 独立外部连接的变更、PostgreSQL 其他进程写入,并不会自动变成完整的跨进程响应式数据;需要显式通知、轮询或 CDC 方案。基础 ORM 不承担离线同步和冲突解决系统。 + +大结果的 `Stream` 必须绑定真实读取进度和会话释放,不把已经加载到内存的 List 包装成 Stream 后声称流式。HTTP 驱动是否缓冲整包、SQLite 是否逐步读取,以及消费者取消后的资源清理,分别测量。 + +建议提供 `compile()`/`explain()` 和执行观察:SQL 模板、选列、内部关联键、策略、语句数量、参数分块、池等待、数据库耗时、解码耗时及结果行数。数据库 EXPLAIN ANALYZE 可能执行语句,必须与只展示计划区别;日志观察默认不额外发数据库查询。 + +| 能力域 | 完整设计所需内容 | 首批落地顺序 | +| --- | --- | --- | +| 声明 | 默认值、复合键、unique/check、索引、列映射、codec | 首批 | +| 查询 | 条件、排序、分页、标量/Record/DTO 选择 | 首批 | +| 关系 | 正反向、自关联、多对多、按父分页、any/count | 首批核心,复杂策略随后 | +| 写入 | 创建、更新、删除、原子表达式、批量、upsert、returning | 首批 | +| 事务 | 显式会话、rollback、savepoint、生命周期 | 首批 | +| 迁移 | 线性历史、SQL、snapshot、检查和升级测试 | 首批 | +| 驱动 | PostgreSQL、SQLite native;明确配置与关闭 | 首批 | +| 高级 SQL | 聚合、having、join、子查询、CTE、窗口、union | 完整版逐步补齐,保留 raw SQL | +| 运行可靠性 | 真实取消、未知提交、重试策略、连接池耗尽 | 在生产承诺前完成 | +| 高级迁移 | baseline/drift、复杂重建、可恢复回填、非事务恢复 | 接管生产数据前完成 | +| Flutter/Web | isolate、worker、持久化、旧版本升级、watch | 对应平台发布前完成 | +| 特有能力 | PG JSON/array/COPY/锁、SQLite FTS 等 | 按真实需求扩展 | +| 非首版目标 | 分布式事务、同步引擎、任意对象图跟踪、通用查询优化器 | 单独评估 | + +“功能健全”是有明确能力边界和完整数据生命周期,不意味着第一天实现每种数据库的每个扩展。高级 SQL 出口应从开始就存在,但不能永久用它替代已经承诺的常见类型化功能。 + +## 13. 证据、成本与后续验证 + +### 13.1 已完成的语言原型 + +可复查的合并原型为 [type_feasibility.dart](/Users/seven/workspace/dart-orm/research/type_feasibility.dart)。它显式声明语言版本 3.13,不改变项目的 SDK 配置;需使用 Dart 3.13 或更高 SDK 运行: + +```text +dart analyze research/type_feasibility.dart +dart --enable-asserts research/type_feasibility.dart +``` + +该合并文件已通过 analyzer、JIT 断言运行、AOT 编译及断言执行、JS 编译。另用临时负例验证了六类错误调用:结果字段类型颠倒、直接返回 Expr Record、跨驱动配置、错误后端专用 API、非空字段 set(null)、引用不同类型的键;analyzer 全部拒绝,共产生七条预期诊断。 + +| 原型 | 结果 | 证明范围 | +| --- | --- | --- | +| 主构造+字段 metadata+dot shorthand | analyzer/运行/AOT 编译通过 | 稳定 Dart 语法可用 | +| Record 字段 metadata | analyzer 通过 | 可作为生成器输入节点 | +| 同形 Record alias | 可互相赋值;别名构造调用被拒绝 | 必须独立保存表身份 | +| sealed Change 与 `.set(null)` | 分析和运行通过 | 未提供/显式 null 可区分 | +| 复合关系键 selector | 正例通过;错误类型目标被拒绝 | 部分键形状错误可静态拦截 | +| 任意命名 Record/DTO/scalar 选择 | 分析、JIT、AOT 断言运行通过;JS 编译通过 | 选择与解码的类型组合成立 | +| 整体 optional、异构关联列表 | 合成数据断言通过 | 嵌套结果类型和解码组合成立 | +| 假数据 read 发现列 | 反例证明会漏选 | 不能用于运行时列规划 | +| typed 驱动配置与后端扩展 | 正例分析运行通过;两个负例被拒绝 | 可以静态阻止跨驱动配置和特有 API 误用 | + +以上没有连接真实数据库,没有实现 SQL 编译器、schema generator、迁移器或完整驱动。JS 编译不等于浏览器运行,AOT 程序也不等于 Flutter 应用验证。 + +### 13.2 尚需最先攻克的四个实验 + +1. **声明生成实测。** 同一组 User/Post/Profile/Follow,分别使用 Record、主构造 class、类式 table。生成完全相同的 API,测字段修改、重命名、错误位置和复杂约束。验证受限 selector AST,而不只验证签名。 +2. **真实投影和关系执行。** 同一 Selection 在 PostgreSQL 和 SQLite 读取两个列、可空单关联、每父三条子记录。比较批量与单语句结果,覆盖复合键和参数分块。 +3. **事务失败实测。** 外层回滚、savepoint、并发冲突、取消、连接中断、提交未知。证明数据库状态和连接复用,不只看 Future 是否报错。 +4. **迁移升级实测。** 空库重放、每个支持旧版本升级、rename 保数据、SQLite 关联对象重建、PG 非事务索引中断恢复、baseline 与 drift。 + +### 13.3 性能评价方法 + +用同一驱动、同一 SQL、相同结果形状与相同数据比较:裸驱动、完整行 ORM、字段投影 ORM、批量关系、数据库聚合。固定本地与远程 RTT 场景;记录 query count、字节数、p50/p95、吞吐、内存、解码分配和池等待。生成基准覆盖 10/100/1000 模型的冷构建、改单字段增量构建、analyze 与 IDE 补全。 + +100 个用户、每人 10 篇文章和 5 个角色,平铺双列表 join 在这些基数假设下约产生 5000 行;按关系分开约为 100+1000+500 行。这只是行数算例,不是速度结论。少往返和少重复数据之间的选择必须结合 RTT、行宽、索引与数据库 CPU。 + +当前不声称 Record 比 class 更快、不声称比 Drift 更快,也不为生成器或 ORM 设置未经测量的性能百分比。最终默认策略由上述真实实验决定。 + +## 14. 最终取舍与讨论顺序 + +最值得优先验证的是 **Record 数据声明+独立表身份+强类型关系边+可组合 Selection**。它比只在 class 上堆注解更贴近“数据定义一次、操作形状按需产生”的目标,同时能利用 Dart 已稳定的语言能力。 + +这仍是有依据的候选选择:如果真实编辑器重命名、元数据提取和大型 schema 体验不足,主构造 class 或类式表声明可能胜出。切换声明前端不应迫使重写关系、查询、驱动和迁移的语义。 + +建议先通过四个实验锁定最难的语言与执行边界,再定最终方法名称与文件组织。需要继续讨论的重点依次是:Record 能否成为默认声明、关系命名和约束是否足够简短、字段组合映射的书写成本、驱动默认行为与平台优先级。可以保留未来选择,但不同时开工多个平行产品。 + +## 来源 + +以下均为官方语言、数据库或项目维护者资料。未标注发布日期的持续更新文档按本报告研究日期读取;发布者基准与 RC/Preview 功能不作为本 ORM 已验证性能或稳定性的证据。 + +[^1]: Dart team. [Announcing Dart 3.13](https://dart.dev/blog/announcing-dart-3-13), 2026-08-12;[Primary constructors](https://dart.dev/language/primary-constructors). +[^2]: Dart team. [Dot shorthands](https://dart.dev/language/dot-shorthands). +[^3]: Dart team. [Records](https://dart.dev/language/records). +[^4]: Dart team. [Extension types](https://dart.dev/language/extension-types). +[^5]: Dart team. [An update on Dart macros & data serialization](https://dart.dev/blog/an-update-on-dart-macros-data-serialization), 2025-01-29. +[^6]: Dart team. [build_runner](https://dart.dev/tools/build_runner). +[^7]: Flutter team. [Does Flutter come with a reflection / mirrors system?](https://docs.flutter.dev/resources/faq#does-flutter-come-with-a-reflection--mirrors-system). +[^8]: Dart team. [Methods: Operators](https://dart.dev/language/methods#operators);[Non-bool operands](https://dart.dev/tools/diagnostics/non_bool_operand). +[^9]: Drizzle team. [Drizzle relations](https://orm.drizzle.team/docs/relations), 所查页面使用 RC 安装示例。 +[^10]: Dart team. [Built-in types: Numbers](https://dart.dev/language/built-in-types#numbers). +[^11]: PostgreSQL Global Development Group. [Numeric Types](https://www.postgresql.org/docs/18/datatype-numeric.html), PostgreSQL 18. +[^12]: SQLite project. [Datatypes In SQLite](https://www.sqlite.org/datatype3.html). +[^13]: SQLite project. [STRICT Tables](https://www.sqlite.org/stricttables.html). +[^14]: Drift project. [Custom queries](https://drift.simonbinder.eu/sql_api/custom_queries/). +[^15]: SQLAlchemy authors. [Relationship Loading Techniques](https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html), SQLAlchemy 2.0. +[^16]: Kysely project. [Nested array](https://kysely.dev/docs/examples/select/nested-array). +[^17]: PostgreSQL Global Development Group. [Transaction Isolation](https://www.postgresql.org/docs/18/transaction-iso.html). +[^18]: Drift project. [Transactions](https://drift.simonbinder.eu/dart_api/transactions/). +[^19]: PostgreSQL Global Development Group. [SAVEPOINT](https://www.postgresql.org/docs/18/sql-savepoint.html). +[^20]: SQLite project. [Savepoints](https://www.sqlite.org/lang_savepoint.html). +[^21]: SQLite project. [Isolation In SQLite](https://www.sqlite.org/isolation.html). +[^22]: SQLite project. [Transaction](https://www.sqlite.org/lang_transaction.html). +[^23]: PostgreSQL Global Development Group. [Serialization Failure Handling](https://www.postgresql.org/docs/18/mvcc-serialization-failure-handling.html). +[^24]: Dart team. [Future.timeout](https://api.dart.dev/dart-async/Future/timeout.html). +[^25]: SQLite project. [Set A Busy Timeout](https://www.sqlite.org/c3ref/busy_timeout.html). +[^26]: SQLite project. [Interrupt A Long-Running Query](https://www.sqlite.org/c3ref/interrupt.html). +[^27]: Prisma. [Migration histories](https://docs.prisma.io/docs/orm/v7/prisma-migrate/understanding-prisma-migrate/migration-histories). +[^28]: Drift project. [Migrations](https://drift.simonbinder.eu/migrations/). +[^29]: Drift project. [Migration tests](https://drift.simonbinder.eu/migrations/tests/). +[^30]: PostgreSQL Global Development Group. [CREATE INDEX](https://www.postgresql.org/docs/18/sql-createindex.html). +[^31]: PostgreSQL Global Development Group. [Explicit Locking](https://www.postgresql.org/docs/18/explicit-locking.html). +[^32]: SQLite project. [ALTER TABLE](https://www.sqlite.org/lang_altertable.html). +[^33]: SQLite project. [Foreign Key Support](https://www.sqlite.org/foreignkeys.html). +[^34]: Prisma. [Baselining a database](https://www.prisma.io/docs/orm/prisma-migrate/workflows/baselining). +[^35]: PostgreSQL Dart maintainers. [postgres package](https://pub.dev/packages/postgres), 3.5.12 at retrieval. +[^36]: PostgreSQL Dart maintainers. [ConnectionSettings](https://pub.dev/documentation/postgres/latest/postgres/ConnectionSettings-class.html). +[^37]: PostgreSQL Dart maintainers. [PoolSettings](https://pub.dev/documentation/postgres/latest/postgres/PoolSettings-class.html). +[^38]: sqlite3 Dart maintainers. [sqlite3 package](https://pub.dev/packages/sqlite3), 3.6.0 at retrieval;[CommonDatabase](https://pub.dev/documentation/sqlite3/latest/common/CommonDatabase-class.html). +[^39]: pg Dart maintainers. [pg package](https://pub.dev/packages/pg), 发布者性能材料未独立复现。 +[^40]: PostgreSQL Dart maintainers. [SslMode](https://pub.dev/documentation/postgres/latest/postgres/SslMode.html). +[^41]: Drift project. [Isolates](https://drift.simonbinder.eu/isolates/). +[^42]: SQLite project. [In-Memory Databases](https://www.sqlite.org/inmemorydb.html). +[^43]: SQLite project. [Write-Ahead Logging](https://www.sqlite.org/wal.html). +[^44]: Drift project. [Web](https://drift.simonbinder.eu/platforms/web/). +[^45]: mysql_client_plus maintainers. [mysql_client_plus package](https://pub.dev/packages/mysql_client_plus), 驱动候选,未进行独立兼容验证。 +[^46]: Turso. [SQL over HTTP quickstart](https://docs.turso.tech/sdk/http/quickstart);[TypeScript SDK reference](https://docs.turso.tech/sdk/ts/reference). +[^47]: Neon. [Serverless driver 1.0.0](https://neon.com/blog/serverless-driver-ga), JavaScript/TypeScript 驱动资料,仅用于传输设计参考。 +[^48]: SQLite project. [UPDATE](https://www.sqlite.org/lang_update.html). +[^49]: SQLite project. [RETURNING](https://www.sqlite.org/lang_returning.html). diff --git a/research/prisma-v8-reassessment.md b/research/prisma-v8-reassessment.md new file mode 100644 index 00000000..3601c788 --- /dev/null +++ b/research/prisma-v8-reassessment.md @@ -0,0 +1,233 @@ +# Prisma 8 reassessment + +Research date: 2026-09-19. This report separates verified upstream behavior +from decisions for this Dart package. It supplements the original design; +it does not adopt Prisma's API or artifact format as this package's contract. + +## Verified release state + +Prisma 8 is a **release candidate**, with general availability expected in +October 2026. Live npm registry reads on the research date returned: + +| Package | `latest` | +| --- | --- | +| `prisma` CLI | `8.0.0-rc.15` | +| `@prisma/orm-postgres` | `8.0.0-rc.11` | + +The CLI and database libraries are versioned separately. A headline saying +“Prisma 8 is here” does not mean all version 7 features have shipped in 8. + +Sources: [release status](https://www.prisma.io/docs/orm/release-status), +[CLI registry tags](https://registry.npmjs.org/-/package/prisma/dist-tags), +[PostgreSQL registry tags](https://registry.npmjs.org/-/package/@prisma%2form-postgres/dist-tags). + +| Database | Prisma 8 status on this date | +| --- | --- | +| PostgreSQL | Release candidate | +| MongoDB | Early access; ORM transactions are not available | +| SQLite | Experimental package | +| MySQL | Planned next; no Prisma 8 library yet | +| MariaDB, SQL Server, CockroachDB | Planned; no Prisma 8 library yet | + +This is the version 8 matrix, not the older version 7 compatibility matrix. +Hosted PostgreSQL services use the PostgreSQL connection path; hosting does +not establish support for every extension or PostgreSQL-compatible engine. +Source: [supported databases](https://www.prisma.io/docs/orm/supported-databases). + +The old `prisma-next` repository is archived. Current source resolves to +[`prisma/orm`](https://github.com/prisma/orm), including requests through the +`prisma/prisma` redirect. Source inspection below is pinned to commit +`ad23f6f47f7964a12f07899c39d2449f1d5b99ea` (2026-09-18), which is a main-branch +snapshot rather than proof that every source feature is in the published RC. + +## What the architecture actually provides + +Prisma separates a model/storage contract, query construction, runtime +execution, target-specific SQL behavior, driver I/O, and migration tooling. +ORM queries, SQL-builder queries, and raw SQL converge on an inspectable +plan. The runtime executes that plan; a driver manages database access. +Authoring and migration code are kept out of runtime dependencies. The +repository checks import boundaries rather than relying on folder names. + +The application-facing PostgreSQL package has public subpaths for +`contract`, `contract-builder`, `builder`, `orm-client`, `family-runtime`, +`adapter`, `driver`, `migration`, and `config`. Its own description presents +it as the one package a PostgreSQL application installs. Internal packages +and public subpaths are different concepts: independent use does not +require making application developers assemble dozens of packages. + +Sources: [architecture](https://github.com/prisma/orm/blob/ad23f6f47f7964a12f07899c39d2449f1d5b99ea/ARCHITECTURE.md), +[dependency boundaries](https://github.com/prisma/orm/blob/ad23f6f47f7964a12f07899c39d2449f1d5b99ea/docs/architecture%20docs/Package-Layering.md), +[public package exports](https://github.com/prisma/orm/blob/ad23f6f47f7964a12f07899c39d2449f1d5b99ea/packages/9-public/%40prisma/orm-postgres/package.json). + +Two details qualify the older announcement's claims: + +- The current PostgreSQL runtime reference says `for await` still loads all + rows first. An async iterable alone is not evidence of bounded memory. +- The default `verifyMarker: 'onFirstUse'` reports a contract mismatch as a + warning and continues. A marker check is also not a live catalog audit. + +Source: [transactions and runtime reference](https://www.prisma.io/docs/orm/reference/transactions-and-runtime). +Our existing explicit sessions, backpressure, cancellation, and capability +checks should retain their measured guarantees through this refactor. + +## Model declaration and typed use + +Prisma accepts either its schema language or a TypeScript builder. Both +produce a deterministic contract JSON file plus TypeScript declarations. +The TypeScript builder supports composition and data-driven declarations; +relations are added through model handles. The documentation still +recommends the schema language for its brevity when composition is not +needed. This is not evidence that the TypeScript object syntax transfers +unchanged to Dart. + +Source: [TypeScript contract authoring](https://www.prisma.io/docs/orm/contract-authoring/typescript-schema-builder). + +The query surfaces deliberately separate model names (`db.orm.public.User`) +from table names (`db.sql.public.user`). Selections determine result types. +Raw queries supply column codecs or an explicit result shape. This gives +applications an escape route for SQL that the ORM cannot express while +preserving value parameterization and decoding. + +Sources: [ORM client](https://www.prisma.io/docs/orm/reference/orm-client), +[SQL query builder](https://www.prisma.io/docs/orm/reference/sql-query-builder), +[raw queries](https://www.prisma.io/docs/orm/reference/raw-queries). + +### Dart decision + +Support an ordinary immutable **Dart class** as a first-class model source. +Fields keep their Dart types and annotations; a validated constructor gives +the generator a direct way to reconstruct the class. Generate typed column +handles, creates, patches, and relationships from that source. Preserve +record declarations as a compact alternative and records as projection +results. Both declaration forms must produce the same runtime semantics. + +Important boundaries: + +- A model class describes values. It does not own a connection, issue SQL + from getters, or hide lazy relation loading. +- Table identity remains explicit and independent of a class or record + type. Two tables may store the same Dart shape. +- Constructor mapping is checked during generation. Unsupported constructor + shapes must produce a source-located error, not a runtime cast failure. +- Typed selectors use generated Dart members. Do not translate Prisma's + string-key projections into stringly typed Dart maps or claim that Dart + can infer new record members from arbitrary runtime data. +- Low-level query construction accepts typed table/column declarations + without requiring a model class or code generation. +- All authoring forms converge on one storage description. Data-driven + configuration is not a second competing schema or a parallel execution + implementation. + +These are design choices for Dart and this repository, not Prisma features. +Static generation is useful here; eliminating all generated Dart would work +against directly typed members and predictable AOT/Flutter behavior. + +## Six useful layers, one product package + +Use independently importable public libraries with tested import boundaries. +The following are responsibilities, not a requirement to create six Pub +packages or a plugin registration framework. + +| Layer | Responsibility | Must work independently | +| --- | --- | --- | +| Values and contract | SQL value codecs, errors, engine capabilities, storage description | Use values and describe storage without a driver or ORM | +| SQL construction | Typed columns, expressions, selections, compilation | Build and inspect parameterized SQL without a connection or generated model | +| Driver and runtime | Connections, leases, cursors, transactions, cancellation | Execute parameterized SQL without ORM/model imports | +| ORM | Typed model queries, relation loading, creates and patches | Add model convenience over the same SQL/runtime behavior | +| Schema and migrations | Authoring, frozen snapshots, catalog checks, planning and history execution | Use migration tools with driver/runtime without generated application models | +| CLI | Project discovery, arguments, display, exit status | Call public tooling APIs; contain no second migration implementation | + +Dependencies point toward shared value/contract types. SQL construction and +driver I/O are peers consuming the same small statement/result contracts; +neither should depend on ORM. ORM composes them. Migrations consume storage +and execution interfaces, not the application's live model objects. CLI +consumes migration/generation services. Keep analysis and filesystem imports +out of runtime libraries. + +Before calling this modularity complete, prove concrete independent usage: +compile SQL without opening a database; execute driver SQL without importing +`orm.dart`; run migrations without generated entities; and import the +portable runtime on Web without pulling native or CLI libraries. A facade +that merely hides names while all files remain in one Dart `part` library +does not establish independent implementation boundaries. + +## Migrations: borrow checks, keep Dart artifacts + +Prisma migrations have start/end contract hashes and form a graph. In the +current release candidate, branch reconciliation needs explicit migrations +from each branch state to the merged state; squash and split are not yet +implemented. The live database marker records a state, while the ledger +records executed migrations. Reading the marker does not inspect the actual +tables. + +Source: [migration graph and its current limits](https://www.prisma.io/docs/orm/migrations/the-migration-graph). + +Prisma authors `migration.ts` but executes compiled operations from +`ops.json`. Historical snapshots type its data transformations. Prechecks +and postchecks support verification and recovery, but recompilation is +required after a source edit. PostgreSQL currently executes a migration run +inside one transaction, so this route cannot execute `CREATE INDEX +CONCURRENTLY`. + +Source: [editing a migration](https://www.prisma.io/docs/orm/migrations/editing-a-migration). + +For this package, retain **Dart source snapshots and migration definitions**, +static registration, frozen historical types, reviewed fingerprints, and +one engine per history. Borrow inspectable operations, explicit preconditions, +catalog verification, and precise recovery states. Do not restore the +rejected JSON workflow or add a migration graph without a demonstrated need. +Schema rollback also cannot promise recovery of deleted application data. + +Database support changes migration execution semantics. SQLite rebuilds, +PostgreSQL transactional DDL, and MySQL/MariaDB implicit DDL commits need +their own checks and recovery behavior. A shared verb must not advertise +identical atomicity across those engines. + +## CLI: intent and predictable output + +Prisma separates offline contract emission and migration planning from +database initialization, verification, and migration execution. `migration +plan` reports an unknown origin instead of silently generating full creation +steps when existing history makes that assumption unsafe. Global flags +support explicit output formats, config paths, noninteractive operation, +help, and structured diagnostics. + +Sources: [migration plan](https://www.prisma.io/docs/cli/migration-plan), +[global flags](https://www.prisma.io/docs/cli/global-flags). + +For Dart, expose a small, discoverable command tree: project setup, model +generation, schema inspection, migration plan/check/status/apply. Keep +preview and execution distinct, retain source paths in errors, never print +credentials, reject unknown options, and return stable nonzero exit codes +for misuse or failed checks. Help should contain the next useful command. +Structured CLI output is optional presentation; it does not make JSON a +schema or migration storage format. + +## Database delivery decision + +The user has requested real SQLite, PostgreSQL, MySQL, and MariaDB support +and acceptance. Implement and validate each; do not inherit Prisma 8's +current database limits or claim compatibility based only on a shared wire +protocol. + +MySQL and MariaDB can share a transport implementation where justified, +while identifying the actual server and retaining distinct capability and +migration decisions. Acceptance must cover real values, transactions, +rollback, generated keys, nulls, large integers/decimals, temporal values, +errors, and schema evolution. SQLite Web remains a separately verified +platform within the SQLite implementation. SQL Server and document stores +remain outside the accepted implementation scope until explicitly added. + +## Adoption checklist + +Adopt: one package with usable lower layers; model/storage identity +separation; class and record authoring through one generator; common SQL +execution contracts; visible query plans; engine-specific capabilities; +small CLI workflows with inspectable results. + +Do not copy: the JSON artifact requirement, TypeScript type-level syntax, +the full multidimensional package/SPI topology, eager claims of streaming, +warning-only verification, or roadmap features presented as delivered +database support. Preserve existing correctness guarantees while reducing +the amount of ORM machinery a low-level caller must import or understand. diff --git a/research/standalone-orm-redesign.md b/research/standalone-orm-redesign.md new file mode 100644 index 00000000..88b35e43 --- /dev/null +++ b/research/standalone-orm-redesign.md @@ -0,0 +1,67 @@ +# Standalone Dart ORM redesign + +Decision date: 2026-09-19. This is a development redesign, not a compatibility +release. The user's accepted database scope is SQLite (including Flutter/Web), +PostgreSQL, MySQL and MariaDB. The implementation remains one product package. + +## Product contract + +- Declare an immutable Dart model once; generated reads return that nominal type. + Records remain useful for projections and explicit structural data shapes. +- Keep model rows, insert inputs, patches and relation projections distinct. + Generated IDs are required in a persisted row but omitted from create inputs; + updates distinguish keeping a value from writing SQL NULL. +- Every layer can be used without the layer above it. This is an import-graph + requirement, not merely a filtered export list. +- Preserve bound parameters, identifier quoting, alias scope checks, explicit + transactions and observable relationship query counts. +- A database engine is selected explicitly. Capabilities and physical schema + behavior reflect that engine, including differences between MySQL and MariaDB. +- Current schema, configuration and immutable migration history are Dart source. + No compatibility reader for retired artifact formats is introduced. + +## Library boundaries + +| Library | Responsibility | Must not depend on | +| --- | --- | --- | +| `values.dart` | Codecs, exact numbers, calendar values and errors | Drivers, queries, models, tooling | +| `driver.dart` | Commands, rows, cursors, connection leases and capabilities | ORM, schema generation, migrations | +| `schema_model.dart` | Physical columns, keys, indexes and constraints | Connections, ORM, analyzer | +| `schema.dart` | Typed application declarations | ORM execution and generation | +| `runtime.dart` | Raw execution, sessions, transactions, cancellation and observations | Query builder, generated models | +| SQL construction | Typed expressions, query descriptions, compilation and decoding plans | Concrete drivers and connected databases | +| `orm.dart` | Bound typed queries, relationship loading and subscriptions | Platform drivers and development tools | +| `migrate.dart` | Frozen histories, diff, catalog verification and execution | Application models and ORM queries | +| Generation/CLI | Static analysis, reproducible source output and project commands | Application runtime initialization for offline work | + +Concrete adapters consume the driver contract. Convenience entrypoints may +compose adapters with the ORM, but a separate direct driver import must retain +the lower-level dependency boundary. A single package still resolves its declared +development dependencies; independent imports do not imply separate pub downloads. + +## Implementation sequence + +1. Extract values, physical metadata, driver contracts and the existing transaction + state machine into actual libraries. Keep one transaction implementation. +2. Read primary-constructor models directly and materialize the user's class in + generated typed selections. Preserve table identity independently of row type. +3. Make SQL construction and compilation usable without opening a database or + constructing a fake driver. Preserve alias scope and explicit session ownership. +4. Implement MySQL/MariaDB adapters, SQL dialects, catalog and migration behavior. + Treat nontransactional DDL, generated IDs, returning, exact values and errors + explicitly; do not advertise SQLite/PostgreSQL semantics for these engines. +5. Provide a coherent CLI onboarding/configuration/generation/migration workflow + using typed Dart configuration and static history imports. +6. Update user-facing examples and boundary documentation. Verify isolated layer + consumers, negative type cases and actual database/platform behavior. +7. Commit reviewable changes, push, open a PR against `main`, request Codex review, + address findings, and merge only the verified reviewed head. + +## Acceptance evidence + +Required evidence includes import-graph checks, standalone layer consumers, +nominal model CRUD/relations/projections, invalid types rejected by analysis, +CLI setup through migration replay, real SQLite/PostgreSQL/MySQL/MariaDB tests, +and refreshed SQLite Web assets with Dart and Flutter JS/WASM acceptance. +Existing validation records remain historical baselines. New records identify +the tested versions and any unsupported feature or deployment target explicitly. diff --git a/research/type_feasibility.dart b/research/type_feasibility.dart new file mode 100644 index 00000000..0f90b7d1 --- /dev/null +++ b/research/type_feasibility.dart @@ -0,0 +1,317 @@ +// @dart = 3.13 +// Standalone Dart 3.13 language/type experiment for the accompanying report. +// Synthetic rows only. This is not an ORM, SQL compiler, or database test. +// Run: dart --enable-asserts research/type_feasibility.dart + +// Dart 3.13.3 proof of type inference and deterministic selection/decode separation. +// No database, SQL compiler, query planner, or ORM is implemented here. +typedef Row = Map; +typedef Read = T Function(Expr expression); + +abstract interface class Selection { + List get plan; + T decode(Row row); +} + +final class Expr implements Selection { + const Expr(this.name, this.codec); + final String name; + final T Function(Object?) codec; + @override + List get plan => [name]; + @override + T decode(Row row) => codec(row[name]); +} + +final class Mapped implements Selection { + const Mapped(this.plan, this.mapper); + @override + final List plan; + final T Function(Row) mapper; + @override + T decode(Row row) => mapper(row); +} + +extension PairProjection on (Selection, Selection) { + Selection map(R Function(A, B) mapper) => Mapped([ + ...$1.plan, + ...$2.plan, + ], (row) => mapper($1.decode(row), $2.decode(row))); +} + +extension TripleProjection + on (Selection, Selection, Selection) { + Selection map(R Function(A, B, C) mapper) => Mapped([ + ...$1.plan, + ...$2.plan, + ...$3.plan, + ], (row) => mapper($1.decode(row), $2.decode(row), $3.decode(row))); +} + +Selection optional(String presence, Selection item) => Mapped([ + presence, + ...item.plan, +], (row) => row[presence] == null ? null : item.decode(row)); + +Selection> many(String key, Selection item) => Mapped( + ['$key[${item.plan.join(',')}]'], + (row) => switch (row[key]) { + final List rows => rows.map(item.decode).toList(), + _ => throw FormatException('Expected nested rows at $key'), + }, +); + +int integer(Object? value) => switch (value) { + final int result => result, + _ => throw const FormatException('Expected integer'), +}; +String string(Object? value) => switch (value) { + final String result => result, + _ => throw const FormatException('Expected text'), +}; + +final class Users { + final id = const Expr('user.id', integer); + final email = const Expr('user.email', string); +} + +final class Posts { + final id = const Expr('post.id', integer); + final title = const Expr('post.title', string); +} + +final class Card { + const Card(this.id, this.email); + final int id; + final String email; +} + +Selection select(Selection Function(Users) build) => build(Users()); + +R rowReader(Users u, Row row, R Function(Users, Read) mapper) { + T read(Expr expression) => expression.decode(row); + return mapper(u, read); +} + +void verifySelections() { + final u = Users(); + final p = Posts(); + var mapperCalls = 0; + final named = select( + (u) => (u.id, u.email).map((id, email) { + mapperCalls++; + return (identifier: id, contact: email); + }), + ); + assert(mapperCalls == 0); // Plan collection never executes a row callback. + final Selection<({int identifier, String contact})> checkedNamed = named; + final row = {'user.id': 1, 'user.email': 'a@example.com'}; + final decoded = checkedNamed.decode(row); + assert(mapperCalls == 1 && decoded.identifier == 1); + assert(named.plan.join(',') == 'user.id,user.email'); + + final Selection scalar = select((u) => u.id); + assert(scalar.decode(row) == 1); + final Selection dto = select((u) => (u.id, u.email).map(Card.new)); + assert(dto.decode(row).email == 'a@example.com'); + + final author = optional('user.id', (u.id, u.email).map(Card.new)); + final Selection checkedAuthor = author; + assert(checkedAuthor.decode({'user.id': null, 'user.email': null}) == null); + assert(checkedAuthor.decode(row)?.id == 1); + + final titles = many('posts', p.title); + final authors = many('friends', (u.id, u.email).map(Card.new)); + final graph = ( + u.id, + titles, + authors, + ).map((id, titles, friends) => (id: id, titles: titles, friends: friends)); + final Selection<({int id, List titles, List friends})> + checkedGraph = graph; + final decodedGraph = checkedGraph.decode({ + ...row, + 'posts': [ + {'post.title': 'Hello'}, + ], + 'friends': [ + {'user.id': 2, 'user.email': 'b@example.com'}, + ], + }); + assert(decodedGraph.titles.single == 'Hello'); + assert(decodedGraph.friends.single.id == 2); + + // Reader callback independently proves named Record inference, only for decode. + final readRecord = rowReader( + u, + row, + (u, read) => (account: read(u.id), address: read(u.email)), + ); + final ({int account, String address}) checkedReadRecord = readRecord; + assert(checkedReadRecord.account == 1); + + // Counterexample: placeholder discovery misses conditionally accessed columns. + final selected = []; + T fakeRead(Expr expression) { + selected.add(expression.name); + return expression.codec(switch (expression.name) { + 'user.id' => 0, + 'user.email' => '', + _ => throw StateError('No valid dummy value'), + }); + } + + String conditional(Read read) => read(u.id) > 0 ? read(u.email) : 'hidden'; + conditional(fakeRead); + assert(!selected.contains('user.email')); + assert(rowReader(u, row, (_, read) => conditional(read)) == 'a@example.com'); + print( + 'PASS: arbitrary named record, single scalar, DTO constructor tear-off,', + ); + print('optional whole object, heterogeneous nested relation selections,'); + print('generic reader callback inference, and dummy-reader counterexample.'); + print('Record plan: ${named.plan}'); + print('Nested plan (structural labels, not SQL): ${graph.plan}'); + print('Dummy reader discovered only: $selected'); +} + +// Language feasibility only. No database driver is implemented here. +sealed class Backend {} + +final class Pg extends Backend {} + +final class Sqlite extends Backend {} + +enum TlsMode { verifyFull, disable } + +enum Journal { wal, rollback } + +final class PgOptions({ + required final Uri url, + final TlsMode tls = .verifyFull, + final int maxConnections = 8, +}); + +final class SqliteOptions.file( + final String path, { + final Journal journal = .wal, +}); + +abstract interface class Driver { + String get description; +} + +final class PgDriver(final PgOptions options) implements Driver { + @override + String get description => 'postgres'; +} + +final class SqliteDriver(final SqliteOptions options) + implements Driver { + @override + String get description => 'sqlite'; +} + +final class AppDatabase { + AppDatabase._(this.driver); + final Driver driver; + + static Future> open( + Driver driver, + ) async { + return AppDatabase._(driver); + } +} + +extension PgFeatures on AppDatabase { + String get advisoryLockFeature => 'pg_advisory_xact_lock'; +} + +Future verifyDrivers() async { + final pg = await AppDatabase.open( + PgDriver(.new(url: Uri.parse('postgresql://localhost/example'))), + ); + final sqlite = await AppDatabase.open( + SqliteDriver(.file('/tmp/example.sqlite')), + ); + AppDatabase typedPg = pg; + AppDatabase typedSqlite = sqlite; + assert(typedPg.advisoryLockFeature == 'pg_advisory_xact_lock'); + assert(typedSqlite.driver.description == 'sqlite'); + print('PASS: typed driver options and backend-specific extension'); +} + +// Schema and patch language probes. No schema extraction is implemented. +final class Id { + const Id.generated(); +} + +final class Unique { + const Unique(); +} + +typedef User = ({ + @Id.generated() int id, + @Unique() String email, + String? nickname, +}); +typedef OtherRow = ({int id, String email, String? nickname}); + +sealed class Change { + const Change(); + const factory Change.keep() = Keep; + const factory Change.set(T value) = SetValue; +} + +final class Keep extends Change { + const Keep(); +} + +final class SetValue extends Change { + const SetValue(this.value); + final T value; +} + +String describeChange(Change change) => switch (change) { + Keep() => 'keep', + SetValue(:final value) => 'set:$value', +}; + +final class Entity { + const Entity(this.name); + final String name; + EntityKey key(K Function(M) selector) => EntityKey(this, selector); +} + +final class EntityKey { + const EntityKey(this.entity, this.selector); + final Entity entity; + final K Function(M) selector; + String references(EntityKey target) => + '${entity.name}->${target.entity.name}'; +} + +void verifyDeclarations() { + final OtherRow other = (id: 1, email: 'a@example.com', nickname: null); + final User sameShape = other; // Aliases do not create entity identity. + final users = Entity('users'); + final others = Entity('others'); + assert(!identical(users, others)); + assert(sameShape.email == 'a@example.com'); + assert(describeChange(.keep()) == 'keep'); + assert(describeChange(.set(null)) == 'set:null'); + assert(describeChange(.set('seven')) == 'set:seven'); + final link = users + .key((u) => (u.id, u.email)) + .references(others.key((u) => (u.id, u.email))); + assert(link == 'users->others'); + print( + 'PASS: record metadata, structural identity, typed keys and tri-state patch', + ); +} + +Future main() async { + verifySelections(); + verifyDeclarations(); + await verifyDrivers(); +} diff --git a/research/validation/api-audit-browser-js.log b/research/validation/api-audit-browser-js.log new file mode 100644 index 00000000..c8e0f5a6 --- /dev/null +++ b/research/validation/api-audit-browser-js.log @@ -0,0 +1,90 @@ +Running build hooks...Running build hooks...{ + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 66 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 41 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 17 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 21 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 19 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 34 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 27 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 3 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 2 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 1 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 4 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 4 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 160 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 31 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 103 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 3 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 22 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "js", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" +} diff --git a/research/validation/api-audit-browser-wasm.log b/research/validation/api-audit-browser-wasm.log new file mode 100644 index 00000000..06fa1544 --- /dev/null +++ b/research/validation/api-audit-browser-wasm.log @@ -0,0 +1,90 @@ +Running build hooks...Running build hooks...{ + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 56 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 40 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 14 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 19 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 27 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 24 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 3 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 159 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 31 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 101 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 14 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "wasm", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" +} diff --git a/research/validation/api-audit-native.log b/research/validation/api-audit-native.log new file mode 100644 index 00000000..17b7c068 --- /dev/null +++ b/research/validation/api-audit-native.log @@ -0,0 +1,937 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/client_default_test.dart +00:00 +0: test/client_default_test.dart: client defaults sqlite omitted values use typed Dart factories and return stored records +00:00 +1: test/client_default_test.dart: client defaults sqlite explicit values, null and SQL DEFAULT bypass their client factory +00:00 +2: test/client_default_test.dart: client defaults sqlite prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +3: test/client_default_test.dart: client defaults sqlite batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +4: test/client_default_test.dart: client defaults sqlite updates and conflict updates do not reset omitted client values +00:00 +5: test/client_default_test.dart: client defaults sqlite factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +6: test/client_default_test.dart: client defaults sqlite snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +7: test/client_default_test.dart: client defaults sqlite explicit DEFAULT can choose database identity instead of the client value +00:00 +8: test/client_default_test.dart: client defaults postgres omitted values use typed Dart factories and return stored records +00:00 +9: test/client_default_test.dart: client defaults postgres explicit values, null and SQL DEFAULT bypass their client factory +00:00 +10: test/client_default_test.dart: client defaults postgres prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +11: test/client_default_test.dart: client defaults postgres batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +12: test/client_default_test.dart: client defaults postgres updates and conflict updates do not reset omitted client values +00:00 +13: test/client_default_test.dart: client defaults postgres factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +14: test/client_default_test.dart: client defaults postgres snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +15: test/client_default_test.dart: client defaults postgres explicit DEFAULT can choose database identity instead of the client value +00:00 +16: test/client_default_test.dart: generation reads factory references without executing them and supports moved output +00:03 +17: loading test/decimal_precision_test.dart +00:03 +17: test/decimal_precision_test.dart: decimal precision sqlite column coercion rounds signed ties and handles negative and excess scales +00:03 +18: test/decimal_precision_test.dart: decimal precision sqlite writes constrain columns while arithmetic and wider aggregates retain their result precision +00:03 +19: test/decimal_precision_test.dart: decimal precision sqlite batch inserts and upserts use column coercion and roll back as a unit +00:03 +20: test/decimal_precision_test.dart: decimal precision sqlite catalogs snapshots and imported declarations retain precision scale and defaults +00:05 +21: test/decimal_precision_test.dart: decimal precision sqlite reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:05 +22: test/decimal_precision_test.dart: decimal precision sqlite physical renames and changing constrained defaults preserve column contracts +00:05 +23: test/decimal_precision_test.dart: decimal precision sqlite maximum declared precision and scale bounds remain exact +00:05 +24: test/decimal_precision_test.dart: decimal precision sqlite historical backfills retain constrained decimal key declarations +00:05 +25: test/decimal_precision_test.dart: decimal precision postgres column coercion rounds signed ties and handles negative and excess scales +00:05 +26: test/decimal_precision_test.dart: decimal precision postgres writes constrain columns while arithmetic and wider aggregates retain their result precision +00:05 +27: test/decimal_precision_test.dart: decimal precision postgres batch inserts and upserts use column coercion and roll back as a unit +00:05 +28: test/decimal_precision_test.dart: decimal precision postgres catalogs snapshots and imported declarations retain precision scale and defaults +00:06 +29: test/decimal_precision_test.dart: decimal precision postgres reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:06 +30: test/decimal_precision_test.dart: decimal precision postgres physical renames and changing constrained defaults preserve column contracts +00:06 +31: test/decimal_precision_test.dart: decimal precision postgres maximum declared precision and scale bounds remain exact +00:06 +32: test/decimal_precision_test.dart: decimal precision postgres historical backfills retain constrained decimal key declarations +00:06 +33: test/decimal_precision_test.dart: SQLite enforces stored precision and checks default coercion and trusted-schema behavior +00:06 +34: test/decimal_precision_test.dart: generation validates DecimalDigits and retains custom decimal codec types +00:09 +35: test/decimal_precision_test.dart: metadata rejects invalid precisions scales and non-decimal columns +00:09 +36: loading test/selection_test.dart +00:09 +36: test/selection_test.dart: selection sqlite all typed arities defer mapping and preserve left-to-right evaluation +00:09 +37: test/selection_test.dart: selection sqlite first decoder failure prevents later fields and outer mapper +00:09 +38: test/selection_test.dart: selection sqlite dynamic fields snapshot keys and preserve nulls and mapping order +00:09 +39: test/selection_test.dart: selection postgres all typed arities defer mapping and preserve left-to-right evaluation +00:09 +40: test/selection_test.dart: selection postgres first decoder failure prevents later fields and outer mapper +00:09 +41: test/selection_test.dart: selection postgres dynamic fields snapshot keys and preserve nulls and mapping order +00:09 +42: loading test/build_runner_test.dart +00:09 +42: test/build_runner_test.dart: real build_runner build/watch tracks imports, recovers errors and deletes stale outputs +00:38 +43: loading test/schema_version_test.dart +00:38 +43: test/schema_version_test.dart: schema version sqlite invalid ranges fail before SQL; caller transactions stay usable +00:38 +44: test/schema_version_test.dart: schema version sqlite unversioned existing data is rejected without creating history +00:38 +45: test/schema_version_test.dart: schema version sqlite default requires latest; inclusive compatibility range is explicit +00:38 +46: test/schema_version_test.dart: schema version sqlite newer database rejects downgrade without changing data or schema +00:38 +47: test/schema_version_test.dart: schema version sqlite complete applied prefix is checked, including old checksums and missing entries +00:38 +48: test/schema_version_test.dart: schema version sqlite matching version does not claim that the catalog is free of drift +00:38 +49: test/schema_version_test.dart: schema version sqlite verified baseline is compatible without replaying creation +00:38 +50: test/schema_version_test.dart: schema version sqlite borrowed session uses a read snapshot and survives rejected versions +00:38 +51: test/schema_version_test.dart: schema version postgres invalid ranges fail before SQL; caller transactions stay usable +00:38 +52: test/schema_version_test.dart: schema version postgres unversioned existing data is rejected without creating history +00:38 +53: test/schema_version_test.dart: schema version postgres default requires latest; inclusive compatibility range is explicit +00:38 +54: test/schema_version_test.dart: schema version postgres newer database rejects downgrade without changing data or schema +00:38 +55: test/schema_version_test.dart: schema version postgres complete applied prefix is checked, including old checksums and missing entries +00:38 +56: test/schema_version_test.dart: schema version postgres matching version does not claim that the catalog is free of drift +00:38 +57: test/schema_version_test.dart: schema version postgres verified baseline is compatible without replaying creation +00:38 +58: test/schema_version_test.dart: schema version postgres borrowed session uses a read snapshot and survives rejected versions +00:38 +59: test/schema_version_test.dart: schema version postgres unfinished recoverable migration blocks startup until repaired and completed +00:38 +60: test/schema_version_test.dart: schema version postgres catalog and history share a snapshot and the supplied history is frozen +00:39 +61: test/schema_version_test.dart: SQLite version gate works on an explicitly read-only file +00:39 +62: loading test/types_test.dart +00:39 +62: test/types_test.dart: junction-table keys, enum payloads and nested results keep generated types +00:41 +63: test/types_test.dart: computed generated fields are statically readable and never writable +00:41 +64: test/types_test.dart: client-default create parameters preserve domain, nullable and timestamp types +00:42 +65: test/types_test.dart: query-only relation APIs retain result types and expose no graph writes +00:42 +66: test/types_test.dart: calendar APIs reject instants, strings and mixed temporal types +00:43 +67: test/types_test.dart: actual generated APIs reject wrong inputs, results and backend options +00:43 +68: loading test/migration_test.dart +00:43 +68: test/migration_test.dart: (setUpAll) +00:43 +68: test/migration_test.dart: migrations sqlite explicit table and column renames preserve incoming references and data +00:43 +69: test/migration_test.dart: migrations sqlite constraint changes resolve catalog names and retain rows +00:43 +70: test/migration_test.dart: migrations sqlite failed NOT NULL rebuild restores data history and foreign key settings +00:43 +71: test/migration_test.dart: migrations sqlite reviewed conversion expressions preserve values when storage types change +00:43 +72: test/migration_test.dart: migrations sqlite additive changes use ALTER without a table rebuild +00:43 +73: test/migration_test.dart: migrations sqlite reviewed table removal handles restrictive foreign keys atomically +00:43 +74: test/migration_test.dart: migrations sqlite broken migration chain is refused before DDL +00:43 +75: test/migration_test.dart: migrations sqlite catalog distinguishes nullable non-rowid primary keys +00:43 +76: test/migration_test.dart: migrations sqlite rebuild preserves unmanaged index trigger and view definitions +00:43 +77: test/migration_test.dart: migrations sqlite foreign key check rolls back changed parent keys without cascading data away +00:43 +78: test/migration_test.dart: migrations sqlite unmodeled CHECK constraints are refused without losing their definition +00:44 +79: test/migration_test.dart: migrations postgres explicit table and column renames preserve incoming references and data +00:44 +80: test/migration_test.dart: migrations postgres constraint changes resolve catalog names and retain rows +00:44 +81: test/migration_test.dart: migrations postgres failed NOT NULL rebuild restores data history and foreign key settings +00:44 +82: test/migration_test.dart: migrations postgres reviewed conversion expressions preserve values when storage types change +00:44 +83: test/migration_test.dart: migrations postgres additive changes use ALTER without a table rebuild +00:44 +84: test/migration_test.dart: migrations postgres reviewed table removal handles restrictive foreign keys atomically +00:44 +85: test/migration_test.dart: migrations postgres broken migration chain is refused before DDL +00:44 +86: test/migration_test.dart: diff requires explicit renames, casts and backfills +00:44 +87: test/migration_test.dart: (tearDownAll) +00:44 +87: loading test/many_to_many_test.dart +00:44 +87: test/many_to_many_test.dart: many-to-many sqlite two declared FKs and a composite primary key enforce association identity +00:44 +88: test/many_to_many_test.dart: many-to-many sqlite forward projection keeps payload, shared targets and per-user limits in two statements +00:44 +89: test/many_to_many_test.dart: many-to-many sqlite reverse projection applies tie-breaking and offset to each team without changing root pagination +00:44 +90: test/many_to_many_test.dart: many-to-many sqlite nested team rosters deduplicate shared team keys and batch by level +00:44 +91: test/many_to_many_test.dart: many-to-many sqlite association predicates and counts stay in one SQL statement +00:44 +92: test/many_to_many_test.dart: many-to-many sqlite limited parameter capacity chunks associations without changing per-parent limits +00:44 +93: test/many_to_many_test.dart: many-to-many sqlite transaction rollback restores payload changes and new endpoints after an invalid association +00:44 +94: test/many_to_many_test.dart: many-to-many sqlite composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:44 +95: test/many_to_many_test.dart: many-to-many sqlite watch tracks junction payload and opposite endpoint changes after commit +00:44 +96: test/many_to_many_test.dart: many-to-many postgres two declared FKs and a composite primary key enforce association identity +00:44 +97: test/many_to_many_test.dart: many-to-many postgres forward projection keeps payload, shared targets and per-user limits in two statements +00:44 +98: test/many_to_many_test.dart: many-to-many postgres reverse projection applies tie-breaking and offset to each team without changing root pagination +00:44 +99: test/many_to_many_test.dart: many-to-many postgres nested team rosters deduplicate shared team keys and batch by level +00:44 +100: test/many_to_many_test.dart: many-to-many postgres association predicates and counts stay in one SQL statement +00:44 +101: test/many_to_many_test.dart: many-to-many postgres limited parameter capacity chunks associations without changing per-parent limits +00:44 +102: test/many_to_many_test.dart: many-to-many postgres transaction rollback restores payload changes and new endpoints after an invalid association +00:44 +103: test/many_to_many_test.dart: many-to-many postgres composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:44 +104: test/many_to_many_test.dart: many-to-many postgres watch tracks junction payload and opposite endpoint changes after commit +00:45 +105: loading test/migration_target_test.dart +00:45 +105: test/migration_target_test.dart: history requires a single engine even when empty or SQL is portable +00:45 +106: test/migration_target_test.dart: unselected expressions cannot change a saved plan, hash or source +00:45 +107: test/migration_target_test.dart: SQLite virtual indexes and computed mode changes ignore PostgreSQL limits +00:45 +108: test/migration_target_test.dart: replacing stored values with computed values requires explicit review +00:45 +109: test/migration_target_test.dart: older PostgreSQL is rejected before locks, journals or partial execution +00:45 +110: test/migration_target_test.dart: registry fixes its engine at initialization and refuses retargeting +00:45 +111: test/migration_target_test.dart: wrong engine fails before any migration SQL for runner and every connected CLI command +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +00:45 +112: test/migration_target_test.dart: PostgreSQL applies only its own generated conversion and rejects a SQLite history before SQL +00:45 +113: loading test/stream_test.dart +00:45 +113: test/stream_test.dart: sqlite cancellation between batch chunks prevents a partial commit +00:45 +114: test/stream_test.dart: sqlite cancellation interrupts an active cursor fetch +00:45 +115: test/stream_test.dart: sqlite timed-out mutations return no partial write +00:45 +116: test/stream_test.dart: sqlite database close stops a paused cursor and releases the driver +00:45 +117: test/stream_test.dart: sqlite cancellation between stream creation and listening needs no rollback +00:45 +118: test/stream_test.dart: sqlite bounded cursor fetches preserve parameters and typed rows +00:45 +119: test/stream_test.dart: sqlite pause stops fetches and early cancellation releases the lease +00:45 +120: test/stream_test.dart: sqlite await-for provides demand and take closes without exhausting +00:45 +121: test/stream_test.dart: sqlite relation projections load once per root batch +00:45 +122: test/stream_test.dart: sqlite mapper failure closes the cursor and rolls back +00:45 +123: test/stream_test.dart: sqlite early stream end inside a transaction preserves other work +00:45 +124: test/stream_test.dart: sqlite escaping paused stream cannot keep a transaction alive +00:45 +125: test/stream_test.dart: sqlite cancels executing SQL and allows the next statement +00:45 +126: test/stream_test.dart: sqlite statement timeout stops SQL and leaves a usable connection +00:45 +127: test/stream_test.dart: sqlite cancelled statement poisons a transaction even if caught +00:45 +128: test/stream_test.dart: sqlite cancelling a paused stream releases its connection before resume +00:45 +129: test/stream_test.dart: sqlite cancellation before starting performs no SQL +00:45 +130: test/stream_test.dart: postgres cancellation between batch chunks prevents a partial commit +00:45 +131: test/stream_test.dart: postgres cancellation interrupts an active cursor fetch +00:46 +132: test/stream_test.dart: postgres timed-out mutations return no partial write +00:46 +133: test/stream_test.dart: postgres database close stops a paused cursor and releases the driver +00:46 +134: test/stream_test.dart: postgres cancellation between stream creation and listening needs no rollback +00:46 +135: test/stream_test.dart: postgres bounded cursor fetches preserve parameters and typed rows +00:46 +136: test/stream_test.dart: postgres pause stops fetches and early cancellation releases the lease +00:46 +137: test/stream_test.dart: postgres await-for provides demand and take closes without exhausting +00:46 +138: test/stream_test.dart: postgres relation projections load once per root batch +00:46 +139: test/stream_test.dart: postgres mapper failure closes the cursor and rolls back +00:46 +140: test/stream_test.dart: postgres early stream end inside a transaction preserves other work +00:46 +141: test/stream_test.dart: postgres escaping paused stream cannot keep a transaction alive +00:46 +142: test/stream_test.dart: postgres cancels executing SQL and allows the next statement +00:46 +143: test/stream_test.dart: postgres statement timeout stops SQL and leaves a usable connection +00:46 +144: test/stream_test.dart: postgres cancelled statement poisons a transaction even if caught +00:46 +145: test/stream_test.dart: postgres cancelling a paused stream releases its connection before resume +00:46 +146: test/stream_test.dart: postgres cancellation before starting performs no SQL +00:46 +147: test/stream_test.dart: late PostgreSQL cancellation cannot reach the next statement +00:46 +148: test/stream_test.dart: borrowed pool requires cancellation support and retains ownership +00:46 +149: test/stream_test.dart: failed cancellation control discards the physical connection +00:46 +150: loading test/migration_recovery_test.dart +00:47 +150: test/migration_recovery_test.dart: (setUpAll) +00:47 +150: test/migration_recovery_test.dart: SQLite and transaction controls reject recoverable autocommit steps +00:47 +151: test/migration_recovery_test.dart: mixed migrations checkpoint SQL and verify concurrent indexes +00:56 +152: test/migration_recovery_test.dart: same-name wrong-definition indexes are never accepted as completion +00:57 +153: test/migration_recovery_test.dart: failed unique builds leave INVALID indexes and require explicit repair +00:57 +154: test/migration_recovery_test.dart: failed attempts freeze migration checksums and block baseline +00:57 +155: test/migration_recovery_test.dart: failed postconditions report their actual failing stage +00:57 +156: test/migration_recovery_test.dart: recovery probes must produce one boolean value +00:57 +157: test/migration_recovery_test.dart: concurrent runners serialize normal and autocommit steps under one lock +00:57 +158: test/migration_recovery_test.dart: runner lock timeout leaves history untouched and the connection reusable +00:57 +159: test/migration_recovery_test.dart: ordinary SQL inside a mixed migration rolls back only its failed step and resumes +00:57 +160: test/migration_recovery_test.dart: completion checks reject partial and NULLS NOT DISTINCT definitions +00:57 +161: test/migration_recovery_test.dart: real process exit after concurrent DDL resumes without replay +01:00 +162: test/migration_recovery_test.dart: real process exit after transaction commit resumes without replay +01:02 +163: test/migration_recovery_test.dart: (tearDownAll) +01:02 +163: loading test/acquisition_test.dart +01:02 +163: test/acquisition_test.dart: an expired lease cannot enter before a delayed Timer gets its turn +01:02 +164: test/acquisition_test.dart: acquisition sqlite expired raw writes never execute after a late lease becomes available +01:02 +165: test/acquisition_test.dart: acquisition sqlite query cancellation returns while the pool is still occupied +01:02 +166: test/acquisition_test.dart: acquisition sqlite typed mutations and batch transactions propagate acquisition limits +01:02 +167: test/acquisition_test.dart: acquisition sqlite expired transactions and sessions never enter their callbacks +01:02 +168: test/acquisition_test.dart: acquisition sqlite acquisition timeout stops on entry and statement timeout starts afterward +01:03 +169: test/acquisition_test.dart: acquisition sqlite invalid and already-cancelled acquisition options never queue work +01:03 +170: test/acquisition_test.dart: acquisition sqlite stream cancellation and timeout return without opening a cursor +01:03 +171: test/acquisition_test.dart: acquisition sqlite query watch acquisition failure can recover on a later invalidation +01:03 +172: test/acquisition_test.dart: acquisition sqlite close drains abandoned reservations without running their callbacks +01:03 +173: test/acquisition_test.dart: acquisition sqlite abandoned requests preserve pool reuse and subsequent action errors +01:03 +174: test/acquisition_test.dart: PostgreSQL native pool timeout is classified before entering the callback +01:03 +175: test/acquisition_test.dart: PostgreSQL connection establishment timeout does not cap pool waiting +01:03 +176: test/acquisition_test.dart: acquisition postgres expired raw writes never execute after a late lease becomes available +01:03 +177: test/acquisition_test.dart: acquisition postgres query cancellation returns while the pool is still occupied +01:03 +178: test/acquisition_test.dart: acquisition postgres typed mutations and batch transactions propagate acquisition limits +01:03 +179: test/acquisition_test.dart: acquisition postgres expired transactions and sessions never enter their callbacks +01:04 +180: test/acquisition_test.dart: acquisition postgres acquisition timeout stops on entry and statement timeout starts afterward +01:04 +181: test/acquisition_test.dart: acquisition postgres invalid and already-cancelled acquisition options never queue work +01:04 +182: test/acquisition_test.dart: acquisition postgres stream cancellation and timeout return without opening a cursor +01:04 +183: test/acquisition_test.dart: acquisition postgres query watch acquisition failure can recover on a later invalidation +01:04 +184: test/acquisition_test.dart: acquisition postgres close drains abandoned reservations without running their callbacks +01:04 +185: test/acquisition_test.dart: acquisition postgres abandoned requests preserve pool reuse and subsequent action errors +01:04 +186: test/acquisition_test.dart: PostgreSQL timeout during real connection initialization releases the late lease +01:04 +187: loading test/computed_test.dart +01:04 +187: test/computed_test.dart: computed sqlite native expressions preserve quoted identifiers, comments and SQL-like string values +01:04 +188: test/computed_test.dart: computed sqlite generated CRUD, projections, batch and conflict writes compute stored and virtual values +01:04 +189: test/computed_test.dart: computed sqlite catalog/import roundtrip preserves SQL and modes and detects drift +01:07 +190: test/computed_test.dart: computed sqlite ComputedStorage.stored add, expression/type change and drop preserve existing rows +01:07 +191: test/computed_test.dart: computed sqlite ComputedStorage.virtual add, expression/type change and drop preserve existing rows +01:07 +192: test/computed_test.dart: computed sqlite stored to ordinary materialization retains values and permits later writes +01:07 +193: test/computed_test.dart: computed sqlite explicit renamed expressions preserve indexes, checks, references and views +01:07 +194: test/computed_test.dart: computed sqlite invalid recomputation rolls back the table and migration history +01:07 +195: test/computed_test.dart: computed sqlite decimal computed SQL keeps precision coercion and imports exactly once +01:08 +196: test/computed_test.dart: computed postgres native expressions preserve quoted identifiers, comments and SQL-like string values +01:08 +197: test/computed_test.dart: computed postgres generated CRUD, projections, batch and conflict writes compute stored and virtual values +01:08 +198: test/computed_test.dart: computed postgres catalog/import roundtrip preserves SQL and modes and detects drift +01:08 +199: test/computed_test.dart: computed postgres ComputedStorage.stored add, expression/type change and drop preserve existing rows +01:08 +200: test/computed_test.dart: computed postgres ComputedStorage.virtual add, expression/type change and drop preserve existing rows +01:08 +201: test/computed_test.dart: computed postgres stored to ordinary materialization retains values and permits later writes +01:08 +202: test/computed_test.dart: computed postgres explicit renamed expressions preserve indexes, checks, references and views +01:09 +203: test/computed_test.dart: computed postgres invalid recomputation rolls back the table and migration history +01:09 +204: test/computed_test.dart: computed postgres decimal computed SQL keeps precision coercion and imports exactly once +01:09 +205: test/computed_test.dart: mode conversions need explicit plans and historical copy/backfill cannot write computed columns +01:09 +206: loading test/named_sql_test.dart +01:09 +206: test/named_sql_test.dart: template binds repeated names once and preserves quotes/comments +01:09 +207: test/named_sql_test.dart: templates reject ambiguous bindings and incomplete or multiple statements +01:09 +208: test/named_sql_test.dart: generated client is deterministic and checked against its Dart declaration +01:12 +209: test/named_sql_test.dart: sqlite typed parameters, projection and repeated nullable bindings compose +01:12 +210: test/named_sql_test.dart: sqlite native numeric and temporal codecs survive binding and streaming +01:12 +211: test/named_sql_test.dart: sqlite CTEs and unions retain distinct query arguments and field identity +01:12 +212: test/named_sql_test.dart: sqlite transactions see pending writes and rollback normally +01:12 +213: test/named_sql_test.dart: sqlite named sources reject table mutations and require explicit watch reads +01:12 +214: test/named_sql_test.dart: sqlite native checks accept declared shape and preserve prepared-statement state +01:12 +215: test/named_sql_test.dart: sqlite native checks reject syntax, missing tables and missing result aliases +01:12 +216: test/named_sql_test.dart: sqlite native checks do not evaluate application expressions +01:12 +217: test/named_sql_test.dart: postgres typed parameters, projection and repeated nullable bindings compose +01:12 +218: test/named_sql_test.dart: postgres native numeric and temporal codecs survive binding and streaming +01:12 +219: test/named_sql_test.dart: postgres CTEs and unions retain distinct query arguments and field identity +01:13 +220: test/named_sql_test.dart: postgres transactions see pending writes and rollback normally +01:13 +221: test/named_sql_test.dart: postgres named sources reject table mutations and require explicit watch reads +01:13 +222: test/named_sql_test.dart: postgres native checks accept declared shape and preserve prepared-statement state +01:13 +223: test/named_sql_test.dart: postgres native checks reject syntax, missing tables and missing result aliases +01:13 +224: test/named_sql_test.dart: postgres native checks do not evaluate application expressions +01:13 +225: loading test/named_sql_generation_test.dart +01:13 +225: test/named_sql_generation_test.dart: source contracts reject missing/extra parameters and row metadata +01:19 +226: test/named_sql_generation_test.dart: SQL edits and modified generated code fail freshness checks +01:21 +227: test/named_sql_generation_test.dart: asset builder and standalone generator emit identical SQL contracts +01:23 +228: test/named_sql_generation_test.dart: generated APIs reject missing/wrong parameters, result types and unsupported backends +01:23 +229: test/named_sql_generation_test.dart: CLI checks a persistent SQLite database without creating missing files +01:45 +230: loading test/decimal_average_test.dart +01:45 +230: test/decimal_average_test.dart: integer fractions round once and only bound their final result +01:46 +231: test/decimal_average_test.dart: decimal average sqlite empty null and constant inputs retain aggregate semantics +01:46 +232: test/decimal_average_test.dart: decimal average sqlite all rounding modes match exact signed sums across scales +01:46 +233: test/decimal_average_test.dart: decimal average sqlite valid means survive sums exceeding the finite numeric range +01:48 +234: test/decimal_average_test.dart: decimal average sqlite minimum fractions and outermost rounding scales stay exact +01:48 +235: test/decimal_average_test.dart: decimal average sqlite grouping HAVING ordering and CTE references preserve the mean +01:48 +236: test/decimal_average_test.dart: decimal average sqlite illegal window nesting and aggregate assignments fail before SQL +01:48 +237: test/decimal_average_test.dart: decimal average sqlite windows and rounded window means respect partitions and pagination +01:48 +238: test/decimal_average_test.dart: decimal average sqlite correlated means can update values and failures roll back transactions +01:48 +239: test/decimal_average_test.dart: decimal average sqlite batched relationship windows keep per-parent limits +01:48 +240: test/decimal_average_test.dart: decimal average sqlite sliding windows remove values, reset empty frames and reject varying policies +01:48 +241: test/decimal_average_test.dart: decimal average sqlite window means of grouped sums run after HAVING +01:48 +242: test/decimal_average_test.dart: decimal average postgres empty null and constant inputs retain aggregate semantics +01:48 +243: test/decimal_average_test.dart: decimal average postgres all rounding modes match exact signed sums across scales +01:48 +244: test/decimal_average_test.dart: decimal average postgres valid means survive sums exceeding the finite numeric range +01:48 +245: test/decimal_average_test.dart: decimal average postgres minimum fractions and outermost rounding scales stay exact +01:48 +246: test/decimal_average_test.dart: decimal average postgres grouping HAVING ordering and CTE references preserve the mean +01:48 +247: test/decimal_average_test.dart: decimal average postgres illegal window nesting and aggregate assignments fail before SQL +01:49 +248: test/decimal_average_test.dart: decimal average postgres windows and rounded window means respect partitions and pagination +01:49 +249: test/decimal_average_test.dart: decimal average postgres correlated means can update values and failures roll back transactions +01:49 +250: test/decimal_average_test.dart: decimal average postgres batched relationship windows keep per-parent limits +01:49 +251: test/decimal_average_test.dart: decimal average postgres window means of grouped sums run after HAVING +01:49 +252: test/decimal_average_test.dart: decimal average postgres volatile inputs and window ordering are evaluated once per row +01:49 +253: loading test/public_api_test.dart +01:49 +253: test/public_api_test.dart: generated clients expose row types and an immutable schema +01:49 +254: test/public_api_test.dart: entrypoint exports and runtime dependency boundaries stay explicit +01:56 +255: loading test/instant_test.dart +01:56 +255: test/instant_test.dart: strict UTC parsing handles eras, offsets, endpoints and invalid components +01:56 +256: test/instant_test.dart: UTC defaults and local DateTime inputs do not depend on the process timezone +01:57 +257: test/instant_test.dart: historical migration checksum and timestamp tag remain immutable +01:57 +258: test/instant_test.dart: UTC instant sqlite native round trips and streams preserve exact microseconds and common endpoints +01:57 +259: test/instant_test.dart: UTC instant sqlite chronological comparisons, grouping, windows, union and CTEs use instants +01:57 +260: test/instant_test.dart: UTC instant sqlite offset-equivalent keys join and batch through the same unique index +01:57 +261: test/instant_test.dart: UTC instant sqlite current snapshots, physical catalog and imported DateTime declarations agree +02:00 +262: test/instant_test.dart: UTC instant sqlite offset literals compare by value while expression casts remain visible +02:00 +263: test/instant_test.dart: UTC instant sqlite reviewed migration preserves history and repairs legacy timestamp ordering +02:00 +264: test/instant_test.dart: UTC instant sqlite legacy key collisions and invalid calendar values roll back conversion +02:00 +265: test/instant_test.dart: UTC instant sqlite instant primary-key backfills resume across sub-millisecond boundaries +02:00 +266: test/instant_test.dart: UTC instant sqlite external infinity and out-of-DateTime values fail decoding without wrapping +02:00 +267: test/instant_test.dart: UTC instant postgres native round trips and streams preserve exact microseconds and common endpoints +02:00 +268: test/instant_test.dart: UTC instant postgres chronological comparisons, grouping, windows, union and CTEs use instants +02:00 +269: test/instant_test.dart: UTC instant postgres offset-equivalent keys join and batch through the same unique index +02:00 +270: test/instant_test.dart: UTC instant postgres current snapshots, physical catalog and imported DateTime declarations agree +02:01 +271: test/instant_test.dart: UTC instant postgres offset literals compare by value while expression casts remain visible +02:01 +272: test/instant_test.dart: UTC instant postgres reviewed migration preserves history and repairs legacy timestamp ordering +02:01 +273: test/instant_test.dart: UTC instant postgres instant primary-key backfills resume across sub-millisecond boundaries +02:01 +274: test/instant_test.dart: UTC instant postgres external infinity and out-of-DateTime values fail decoding without wrapping +02:01 +275: loading test/codec_test.dart +02:01 +275: test/codec_test.dart: numeric aggregate text decodes without integer rounding or overflow +02:01 +276: test/codec_test.dart: erased codecs keep checked argument types without function getter casts +02:01 +277: test/codec_test.dart: mapped domain codecs preserve one decode and one encode boundary +02:01 +278: test/codec_test.dart: parsed JSON scalars and document presence have unambiguous decoding +02:01 +279: test/codec_test.dart: enum mappings reject duplicates and do not follow mutable caller maps +02:01 +280: loading test/sql_real_test.dart +02:01 +280: test/sql_real_test.dart: sqlite explicitly typed REAL parameters preserve floating storage +02:01 +281: test/sql_real_test.dart: postgres explicitly typed REAL parameters preserve floating storage +02:01 +282: loading test/migration_source_test.dart +02:01 +282: test/migration_source_test.dart: Dart emission preserves storage metadata and every operation without application imports +02:04 +283: test/migration_source_test.dart: project CLI creates Dart history, upgrades a database and preserves recorded fingerprints +03:29 +284: test/migration_source_test.dart: AOT migration bundle works without Dart sources or migration assets +03:37 +285: loading test/generator_test.dart +03:37 +285: test/generator_test.dart: (setUpAll) +03:37 +285: test/generator_test.dart: target capabilities are checked when choosing a migration engine +03:40 +286: test/generator_test.dart: unusable extension getters and generated symbol collisions fail at generation +03:42 +287: test/generator_test.dart: client and snapshot paths cannot overwrite the declaration +03:42 +288: test/generator_test.dart: record annotations, physical names, relations and snapshots +03:42 +289: test/generator_test.dart: output is deterministic and matches the committed generated client +03:47 +290: test/generator_test.dart: composite keys and more than six fields generate analyzable code +03:48 +291: test/generator_test.dart: computed keys are rejected instead of executing a fake row +03:48 +292: test/generator_test.dart: computed declarations reject conflicting defaults, empty SQL and invalid row identities +03:50 +293: test/generator_test.dart: client defaults reject incompatible, asynchronous, private and ambiguous factories +03:53 +294: test/generator_test.dart: CHECK declarations reject dynamic SQL, empty SQL and duplicate names +03:54 +295: test/generator_test.dart: custom types resolve defining libraries when output moves to another directory +03:55 +296: test/generator_test.dart: nested generic and record types preserve nullable aliases without running codecs +03:56 +297: test/generator_test.dart: invalid custom declaration: wrong_codec +03:56 +298: test/generator_test.dart: invalid custom declaration: nullable_codec +03:56 +299: test/generator_test.dart: invalid custom declaration: erased_extension +03:57 +300: test/generator_test.dart: invalid custom declaration: unknown_storage +03:57 +301: test/generator_test.dart: invalid custom declaration: private_codec +03:57 +302: test/generator_test.dart: invalid custom declaration: private_type +03:58 +303: test/generator_test.dart: invalid custom declaration: inline_codec +03:58 +304: test/generator_test.dart: invalid custom declaration: duplicate_codec +03:58 +305: test/generator_test.dart: invalid custom declaration: missing_codec +03:58 +306: test/generator_test.dart: invalid custom declaration: duplicate_labels +03:59 +307: test/generator_test.dart: invalid custom declaration: duplicate_enum_annotation +03:59 +308: test/generator_test.dart: foreign keys require unique targets +03:59 +309: test/generator_test.dart: query-only edges do not alter physical snapshots or require unique targets +04:00 +310: test/generator_test.dart: constrained and unconstrained edges retain separate migration meaning +04:00 +311: test/generator_test.dart: invalid query-only relation: lookup_computed +04:00 +312: test/generator_test.dart: invalid query-only relation: lookup_codec +04:00 +313: test/generator_test.dart: invalid query-only relation: lookup_inverse +04:01 +314: test/generator_test.dart: invalid query-only relation: lookup_type +04:01 +315: test/generator_test.dart: invalid query-only relation: lookup_delete +04:01 +316: test/generator_test.dart: Dart type errors stop generation +04:01 +317: test/generator_test.dart: duplicate physical names and nullable primary keys are rejected +04:02 +318: test/generator_test.dart: (tearDownAll) +04:02 +318: loading test/transaction_test.dart +04:02 +318: test/transaction_test.dart: transaction control sqlite idle callback expires, rolls back and cannot issue late SQL +04:02 +319: test/transaction_test.dart: transaction control sqlite deadline interrupts active SQL before releasing and reusing the lease +04:02 +320: test/transaction_test.dart: transaction control sqlite interrupted write rolls back and leaves the connection usable +04:02 +321: test/transaction_test.dart: transaction control sqlite explicit transaction cancellation stops an idle callback and active SQL +04:02 +322: test/transaction_test.dart: transaction control sqlite global transaction cancellation also abandons acquisition +04:02 +323: test/transaction_test.dart: transaction control sqlite savepoint inherits deadline, drains and rolls back through raw cleanup +04:03 +324: test/transaction_test.dart: transaction control sqlite deadline closes a paused transactional cursor before rollback +04:03 +325: test/transaction_test.dart: transaction control sqlite deadline interrupts an active transactional cursor fetch +04:03 +326: test/transaction_test.dart: transaction control sqlite transaction clock starts after acquisition and a borrowed session recovers +04:03 +327: test/transaction_test.dart: transaction control sqlite failed rollback is explicit and discards an uncertain connection +04:03 +328: test/transaction_test.dart: transaction control sqlite transaction controls reject adapters without actual cancellation +04:03 +329: test/transaction_test.dart: transaction control sqlite shorter statement limit retains its error and rolls back the transaction +04:03 +330: test/transaction_test.dart: transaction control sqlite monotonic deadline prevents commit after a CPU-bound callback +04:03 +331: test/transaction_test.dart: transaction control sqlite completed scopes remove their timers and cancellation listeners +04:03 +332: test/transaction_test.dart: transaction control sqlite confirmed commit wins a cancellation race; lost acknowledgement stays unknown +04:03 +333: test/transaction_test.dart: transaction control sqlite deferred constraint rejection at COMMIT is a known failure +04:03 +334: test/transaction_test.dart: transaction control sqlite invalid deadlines and already cancelled tokens never begin a transaction +04:03 +335: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=false +04:03 +336: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=true +04:03 +337: test/transaction_test.dart: transaction control postgres idle callback expires, rolls back and cannot issue late SQL +04:04 +338: test/transaction_test.dart: transaction control postgres deadline interrupts active SQL before releasing and reusing the lease +04:04 +339: test/transaction_test.dart: transaction control postgres interrupted write rolls back and leaves the connection usable +04:04 +340: test/transaction_test.dart: transaction control postgres explicit transaction cancellation stops an idle callback and active SQL +04:04 +341: test/transaction_test.dart: transaction control postgres global transaction cancellation also abandons acquisition +04:04 +342: test/transaction_test.dart: transaction control postgres savepoint inherits deadline, drains and rolls back through raw cleanup +04:04 +343: test/transaction_test.dart: transaction control postgres deadline closes a paused transactional cursor before rollback +04:04 +344: test/transaction_test.dart: transaction control postgres deadline interrupts an active transactional cursor fetch +04:04 +345: test/transaction_test.dart: transaction control postgres transaction clock starts after acquisition and a borrowed session recovers +04:05 +346: test/transaction_test.dart: transaction control postgres failed rollback is explicit and discards an uncertain connection +04:05 +347: test/transaction_test.dart: transaction control postgres transaction controls reject adapters without actual cancellation +04:05 +348: test/transaction_test.dart: transaction control postgres shorter statement limit retains its error and rolls back the transaction +04:05 +349: test/transaction_test.dart: transaction control postgres monotonic deadline prevents commit after a CPU-bound callback +04:05 +350: test/transaction_test.dart: transaction control postgres completed scopes remove their timers and cancellation listeners +04:05 +351: test/transaction_test.dart: transaction control postgres confirmed commit wins a cancellation race; lost acknowledgement stays unknown +04:05 +352: test/transaction_test.dart: transaction control postgres deferred constraint rejection at COMMIT is a known failure +04:05 +353: test/transaction_test.dart: transaction control postgres invalid deadlines and already cancelled tokens never begin a transaction +04:05 +354: test/transaction_test.dart: transaction control postgres SQLSTATE 40003 at COMMIT is never classified as a confirmed rejection +04:05 +355: test/transaction_test.dart: transaction control postgres real PostgreSQL serialization failure is classified after rollback +04:05 +356: test/transaction_test.dart: transaction control postgres real PostgreSQL deadlock rolls back exactly one transaction +04:06 +357: test/transaction_test.dart: SQLite COMMIT busy is a known rejection followed by verified rollback +04:06 +358: test/transaction_test.dart: SQLite deadline while BEGIN waits for a writer preserves its connection +04:06 +359: loading test/backfill_keys_test.dart +04:07 +359: test/backfill_keys_test.dart: backfill keys sqlite integer cursors preserve native values across resume +04:07 +360: test/backfill_keys_test.dart: backfill keys sqlite bigint cursors preserve native values across resume +04:07 +361: test/backfill_keys_test.dart: backfill keys sqlite text cursors preserve native values across resume +04:07 +362: test/backfill_keys_test.dart: backfill keys sqlite real cursors preserve native values across resume +04:07 +363: test/backfill_keys_test.dart: backfill keys sqlite boolean cursors preserve native values across resume +04:07 +364: test/backfill_keys_test.dart: backfill keys sqlite timestamp cursors preserve native values across resume +04:07 +365: test/backfill_keys_test.dart: backfill keys sqlite blob cursors preserve native values across resume +04:07 +366: test/backfill_keys_test.dart: backfill keys sqlite composite key batches account for every bound parameter +04:07 +367: test/backfill_keys_test.dart: backfill keys postgres integer cursors preserve native values across resume +04:07 +368: test/backfill_keys_test.dart: backfill keys postgres bigint cursors preserve native values across resume +04:07 +369: test/backfill_keys_test.dart: backfill keys postgres text cursors preserve native values across resume +04:07 +370: test/backfill_keys_test.dart: backfill keys postgres real cursors preserve native values across resume +04:07 +371: test/backfill_keys_test.dart: backfill keys postgres boolean cursors preserve native values across resume +04:07 +372: test/backfill_keys_test.dart: backfill keys postgres timestamp cursors preserve native values across resume +04:07 +373: test/backfill_keys_test.dart: backfill keys postgres blob cursors preserve native values across resume +04:07 +374: test/backfill_keys_test.dart: backfill keys postgres composite key batches account for every bound parameter +04:07 +375: test/backfill_keys_test.dart: invalid key, assignments and completion declarations fail before execution +04:07 +376: loading test/retry_test.dart +04:07 +376: test/retry_test.dart: retry sqlite opt-in retries roll back all writes and use new closed-over views +04:08 +377: test/retry_test.dart: retry sqlite default is one attempt; an explicit attempt limit is exact +04:08 +378: test/retry_test.dart: retry sqlite application and constraint errors and caught statement failures do not replay +04:08 +379: test/retry_test.dart: retry sqlite unknown commit and unconfirmed rollback prohibit replay +04:08 +380: test/retry_test.dart: retry sqlite retry time budget bounds active SQL and does not retry cancellation +04:08 +381: test/retry_test.dart: retry sqlite deadline and cancellation interrupt backoff without another callback +04:08 +382: test/retry_test.dart: retry sqlite time budget includes acquisition and abandons late callbacks +04:08 +383: test/retry_test.dart: retry sqlite transaction timeout is shared across attempts and sessions recover after expiry +04:08 +384: test/retry_test.dart: retry sqlite subscriptions publish only the final committed retry result +04:08 +385: test/retry_test.dart: retry sqlite invalid policies reject before SQL +04:08 +386: test/retry_test.dart: retry postgres opt-in retries roll back all writes and use new closed-over views +04:08 +387: test/retry_test.dart: retry postgres default is one attempt; an explicit attempt limit is exact +04:08 +388: test/retry_test.dart: retry postgres application and constraint errors and caught statement failures do not replay +04:08 +389: test/retry_test.dart: retry postgres unknown commit and unconfirmed rollback prohibit replay +04:08 +390: test/retry_test.dart: retry postgres retry time budget bounds active SQL and does not retry cancellation +04:08 +391: test/retry_test.dart: retry postgres deadline and cancellation interrupt backoff without another callback +04:08 +392: test/retry_test.dart: retry postgres time budget includes acquisition and abandons late callbacks +04:08 +393: test/retry_test.dart: retry postgres transaction timeout is shared across attempts and sessions recover after expiry +04:09 +394: test/retry_test.dart: retry postgres subscriptions publish only the final committed retry result +04:09 +395: test/retry_test.dart: retry postgres invalid policies reject before SQL +04:09 +396: test/retry_test.dart: retry postgres real unknown COMMIT response prohibits opted-in retry +04:09 +397: test/retry_test.dart: retry postgres real serialization conflict repeats the reads and decisions +04:09 +398: test/retry_test.dart: retry postgres real deadlock retries only the aborted callback +04:10 +399: test/retry_test.dart: SQLite WAL snapshot conflict replays reads after rollback +04:10 +400: test/retry_test.dart: SQLite real busy COMMIT: success +04:10 +401: test/retry_test.dart: SQLite real busy COMMIT: exhausted +04:10 +402: test/retry_test.dart: SQLite real busy COMMIT: deadline +04:10 +403: test/retry_test.dart: SQLite real busy COMMIT: cancel +04:10 +404: test/retry_test.dart: SQLite real busy COMMIT: shared-budget +04:10 +405: loading test/builder_test.dart +04:10 +405: test/builder_test.dart: build assets and standalone CLI produce identical client and snapshot +04:14 +406: test/builder_test.dart: multiple explicit schema roots resolve shared public types under lib +04:15 +407: test/builder_test.dart: invalid semantic_error never publishes partial outputs +04:15 +408: test/builder_test.dart: invalid enum_labels never publishes partial outputs +04:16 +409: test/builder_test.dart: invalid no_models never publishes partial outputs +04:17 +410: test/builder_test.dart: invalid part_file never publishes partial outputs +04:17 +411: test/builder_test.dart: unknown options fail instead of silently changing build behavior +04:18 +412: test/builder_test.dart: schema resolution can consume an earlier builder output +04:18 +413: loading test/temporal_precision_test.dart +04:19 +413: test/temporal_precision_test.dart: temporal precision sqlite generated writes, defaults and computed columns use database precision +04:19 +414: test/temporal_precision_test.dart: temporal precision sqlite SQL casts match all precisions on both sides of the PostgreSQL epoch +04:19 +415: test/temporal_precision_test.dart: temporal precision sqlite nulls, CTE references, grouping, streams and wrong scopes retain type semantics +04:19 +416: test/temporal_precision_test.dart: temporal precision sqlite rounded keys work in batch writes, upserts and foreign-key relation loading +04:19 +417: test/temporal_precision_test.dart: temporal precision sqlite catalog import preserves precision, defaults and computed expressions +04:21 +418: test/temporal_precision_test.dart: temporal precision sqlite narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +04:21 +419: test/temporal_precision_test.dart: temporal precision sqlite timestamp defaults and explicit six-digit catalogs retain precision +04:21 +420: test/temporal_precision_test.dart: temporal precision sqlite extended timestamps round in SQL without total-microsecond overflow +04:21 +421: test/temporal_precision_test.dart: temporal precision sqlite raw SQL respects native or managed precision constraints +04:21 +422: test/temporal_precision_test.dart: temporal precision postgres generated writes, defaults and computed columns use database precision +04:21 +423: test/temporal_precision_test.dart: temporal precision postgres SQL casts match all precisions on both sides of the PostgreSQL epoch +04:21 +424: test/temporal_precision_test.dart: temporal precision postgres nulls, CTE references, grouping, streams and wrong scopes retain type semantics +04:21 +425: test/temporal_precision_test.dart: temporal precision postgres rounded keys work in batch writes, upserts and foreign-key relation loading +04:21 +426: test/temporal_precision_test.dart: temporal precision postgres catalog import preserves precision, defaults and computed expressions +04:22 +427: test/temporal_precision_test.dart: temporal precision postgres narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +04:22 +428: test/temporal_precision_test.dart: temporal precision postgres timestamp defaults and explicit six-digit catalogs retain precision +04:22 +429: test/temporal_precision_test.dart: temporal precision postgres extended timestamps round in SQL without total-microsecond overflow +04:22 +430: test/temporal_precision_test.dart: temporal precision postgres raw SQL respects native or managed precision constraints +04:22 +431: test/temporal_precision_test.dart: value rounding keeps extended ranges and rejects overflow/invalid precision +04:22 +432: test/temporal_precision_test.dart: default and explicit six preserve historical snapshots and migration checksums +04:22 +433: test/temporal_precision_test.dart: generator rejects invalid annotations and preserves generated fixture metadata +04:25 +434: loading test/observation_test.dart +04:25 +434: test/observation_test.dart: observation sqlite actual lease queue and synchronous mapping have separate clocks +04:25 +435: test/observation_test.dart: observation sqlite expired acquisition emits one failure and no late SQL +04:25 +436: test/observation_test.dart: observation sqlite cancelled acquisition emits one failure and no late SQL +04:25 +437: test/observation_test.dart: observation sqlite sessions transactions and savepoints inherit hooks and report reused leases +04:25 +438: test/observation_test.dart: observation sqlite nested reads attribute decoding to each statement without repeating SQL +04:25 +439: test/observation_test.dart: observation sqlite SQL and mapper failures belong to their own phases +04:25 +440: test/observation_test.dart: observation sqlite cursor batches and returning writes produce decoding observations +04:25 +441: test/observation_test.dart: observation sqlite observer exceptions preserve successful commits and original mapper errors +04:25 +442: test/observation_test.dart: observation postgres actual lease queue and synchronous mapping have separate clocks +04:25 +443: test/observation_test.dart: observation postgres expired acquisition emits one failure and no late SQL +04:25 +444: test/observation_test.dart: observation postgres cancelled acquisition emits one failure and no late SQL +04:25 +445: test/observation_test.dart: observation postgres sessions transactions and savepoints inherit hooks and report reused leases +04:25 +446: test/observation_test.dart: observation postgres nested reads attribute decoding to each statement without repeating SQL +04:25 +447: test/observation_test.dart: observation postgres SQL and mapper failures belong to their own phases +04:25 +448: test/observation_test.dart: observation postgres cursor batches and returning writes produce decoding observations +04:25 +449: test/observation_test.dart: observation postgres observer exceptions preserve successful commits and original mapper errors +04:25 +450: test/observation_test.dart: synchronous driver acquisition failures are reported once +04:25 +451: loading test/integer_test.dart +04:26 +451: test/integer_test.dart: integer widths sqlite storage bounds, nulls and wider sums keep the normal integer value codec +04:26 +452: test/integer_test.dart: integer widths sqlite relations, union and cursors share int expressions across physical widths +04:26 +453: test/integer_test.dart: integer widths sqlite defaulted and quoted columns retain enforced nullable bounds +04:26 +454: test/integer_test.dart: integer widths sqlite Dart snapshots and imports retain widths without confusing value codecs +04:28 +455: test/integer_test.dart: integer widths sqlite widening and narrowing use reviewed conversions with atomic failure recovery +04:28 +456: test/integer_test.dart: integer widths sqlite column renames preserve width metadata and range constraints +04:28 +457: test/integer_test.dart: integer widths sqlite historical bounded backfills verify widths and resume normally +04:28 +458: test/integer_test.dart: integer widths sqlite changing an identity width preserves generation and rejects out-of-range explicit IDs +04:28 +459: test/integer_test.dart: integer widths sqlite quoted defaults and comments cannot masquerade as managed range checks +04:28 +460: test/integer_test.dart: integer widths sqlite extra weaker expressions remain unmanaged and cannot claim a narrower width +04:28 +461: test/integer_test.dart: integer widths postgres storage bounds, nulls and wider sums keep the normal integer value codec +04:28 +462: test/integer_test.dart: integer widths postgres relations, union and cursors share int expressions across physical widths +04:28 +463: test/integer_test.dart: integer widths postgres defaulted and quoted columns retain enforced nullable bounds +04:28 +464: test/integer_test.dart: integer widths postgres Dart snapshots and imports retain widths without confusing value codecs +04:29 +465: test/integer_test.dart: integer widths postgres widening and narrowing use reviewed conversions with atomic failure recovery +04:29 +466: test/integer_test.dart: integer widths postgres column renames preserve width metadata and range constraints +04:29 +467: test/integer_test.dart: integer widths postgres historical bounded backfills verify widths and resume normally +04:29 +468: test/integer_test.dart: integer widths postgres changing an identity width preserves generation and rejects out-of-range explicit IDs +04:29 +469: test/integer_test.dart: analyzer validates width annotations and integer-backed domain codecs +04:31 +470: test/integer_test.dart: width declarations reject wrong kinds and ranges without changing default snapshots +04:31 +471: loading test/relation_test.dart +04:31 +471: test/relation_test.dart: sqlite automatic unique joins preserve root pagination in one statement +04:31 +472: test/relation_test.dart: sqlite all-null projections still distinguish a present row from absence +04:31 +473: test/relation_test.dart: sqlite explicit batch deduplicates composite keys and skips partial nulls +04:31 +474: test/relation_test.dart: sqlite composite chunks include filter and window parameters with nested joins +04:31 +475: test/relation_test.dart: sqlite nested self joins and two edges to the same table use distinct aliases +04:31 +476: test/relation_test.dart: sqlite batch collection below a joined optional parent loads on the same plan +04:31 +477: test/relation_test.dart: sqlite join refuses unproven cardinality while automatic batch checks it +04:31 +478: test/relation_test.dart: sqlite filters and to-one pagination do not drop root rows +04:31 +479: test/relation_test.dart: sqlite joined records survive CTE projection and cursor decoding +04:31 +480: test/relation_test.dart: sqlite required tests row presence independently of nullable selected values +04:31 +481: test/relation_test.dart: sqlite relation aggregates restore the alias used by a joined selection +04:31 +482: test/relation_test.dart: sqlite reusing an identical edge deduplicates its join +04:31 +483: test/relation_test.dart: sqlite different filters need fresh relation occurrences +04:31 +484: test/relation_test.dart: sqlite large composite batches avoid expression-depth failure +04:31 +485: test/relation_test.dart: postgres automatic unique joins preserve root pagination in one statement +04:31 +486: test/relation_test.dart: postgres all-null projections still distinguish a present row from absence +04:31 +487: test/relation_test.dart: postgres explicit batch deduplicates composite keys and skips partial nulls +04:31 +488: test/relation_test.dart: postgres composite chunks include filter and window parameters with nested joins +04:31 +489: test/relation_test.dart: postgres nested self joins and two edges to the same table use distinct aliases +04:31 +490: test/relation_test.dart: postgres batch collection below a joined optional parent loads on the same plan +04:31 +491: test/relation_test.dart: postgres join refuses unproven cardinality while automatic batch checks it +04:31 +492: test/relation_test.dart: postgres filters and to-one pagination do not drop root rows +04:31 +493: test/relation_test.dart: postgres joined records survive CTE projection and cursor decoding +04:31 +494: test/relation_test.dart: postgres required tests row presence independently of nullable selected values +04:31 +495: test/relation_test.dart: postgres relation aggregates restore the alias used by a joined selection +04:31 +496: test/relation_test.dart: postgres reusing an identical edge deduplicates its join +04:32 +497: test/relation_test.dart: postgres different filters need fresh relation occurrences +04:32 +498: test/relation_test.dart: postgres large composite batches avoid expression-depth failure +04:32 +499: loading test/schema_boundary_test.dart +04:32 +499: test/schema_boundary_test.dart: schema rejects malformed names, keys and local FK definitions offline +04:32 +500: test/schema_boundary_test.dart: identifier case is resolved for the selected database +04:32 +501: test/schema_boundary_test.dart: generated sqlite schema executes its native computed-key contract +04:34 +502: test/schema_boundary_test.dart: generated postgres schema executes its native computed-key contract +04:36 +503: loading test/generated_database_test.dart +04:36 +503: test/generated_database_test.dart: (setUpAll) +04:36 +503: test/generated_database_test.dart: generated sqlite generated client, DDL, relations, dates and patches work together +04:36 +504: test/generated_database_test.dart: generated sqlite planning is read-only and repeat migration is a no-op +04:36 +505: test/generated_database_test.dart: generated sqlite compiled physical snapshot and full managed catalog comparison +04:36 +506: test/generated_database_test.dart: generated sqlite baseline preserves existing rows and verifies keys before recording history +04:36 +507: test/generated_database_test.dart: generated sqlite full verification sees defaults and additional unique constraints +04:36 +508: test/generated_database_test.dart: generated sqlite history tampering and missing applied migrations fail +04:36 +509: test/generated_database_test.dart: generated sqlite failed DDL rolls back schema and history atomically +04:36 +510: test/generated_database_test.dart: generated sqlite actual column drift is detected independently of history +04:36 +511: test/generated_database_test.dart: generated sqlite transaction control cannot be hidden behind SQL comments +04:36 +512: test/generated_database_test.dart: generated sqlite concurrent migration callers serialize against shared history +04:36 +513: test/generated_database_test.dart: generated postgres generated client, DDL, relations, dates and patches work together +04:36 +514: test/generated_database_test.dart: generated postgres planning is read-only and repeat migration is a no-op +04:36 +515: test/generated_database_test.dart: generated postgres compiled physical snapshot and full managed catalog comparison +04:36 +516: test/generated_database_test.dart: generated postgres baseline preserves existing rows and verifies keys before recording history +04:36 +517: test/generated_database_test.dart: generated postgres full verification sees defaults and additional unique constraints +04:36 +518: test/generated_database_test.dart: generated postgres history tampering and missing applied migrations fail +04:36 +519: test/generated_database_test.dart: generated postgres failed DDL rolls back schema and history atomically +04:36 +520: test/generated_database_test.dart: generated postgres actual column drift is detected independently of history +04:36 +521: test/generated_database_test.dart: generated postgres transaction control cannot be hidden behind SQL comments +04:36 +522: test/generated_database_test.dart: generated postgres concurrent migration callers serialize against shared history +04:36 +523: test/generated_database_test.dart: (tearDownAll) +04:36 +523: loading test/import_test.dart +04:36 +523: test/import_test.dart: catalog import sqlite imports records, identities, defaults and constraints then baselines unchanged rows +04:42 +524: test/import_test.dart: catalog import sqlite normalizes reserved, colliding and hostile identifiers without changing physical names +04:43 +525: test/import_test.dart: catalog import sqlite composite and self relations preserve key order and unique indexes +04:44 +526: test/import_test.dart: catalog import sqlite unsupported storage excludes its table while computed SQL imports with a review note +04:44 +527: test/import_test.dart: catalog import sqlite read-only snapshot and selected scope reject temporary shadowing and active transactions +04:44 +528: test/import_test.dart: catalog import sqlite physical storage mapping does not infer domain types from data +04:45 +529: test/import_test.dart: catalog import sqlite views triggers and specialized indexes remain in the review report +04:45 +530: test/import_test.dart: catalog import sqlite nullable primary keys, virtual tables and their storage tables need explicit handling +04:45 +531: test/import_test.dart: catalog import sqlite foreign keys with coercible but different Dart types are reported +04:45 +532: test/import_test.dart: catalog import postgres imports records, identities, defaults and constraints then baselines unchanged rows +04:47 +533: test/import_test.dart: catalog import postgres normalizes reserved, colliding and hostile identifiers without changing physical names +04:48 +534: test/import_test.dart: catalog import postgres composite and self relations preserve key order and unique indexes +04:49 +535: test/import_test.dart: catalog import postgres unsupported storage excludes its table while computed SQL imports with a review note +04:49 +536: test/import_test.dart: catalog import postgres read-only snapshot and selected scope reject temporary shadowing and active transactions +04:49 +537: test/import_test.dart: catalog import postgres physical storage mapping does not infer domain types from data +04:49 +538: test/import_test.dart: catalog import postgres views triggers and specialized indexes remain in the review report +04:50 +539: test/import_test.dart: catalog import postgres policy reports retain roles, commands, mode and row-security state +04:50 +540: test/import_test.dart: catalog import postgres always identity and inherited tables are not flattened into ordinary writable models +04:50 +541: loading test/database_test.dart +04:50 +541: test/database_test.dart: sqlite typed CRUD, exact projection and parameter binding +04:50 +542: test/database_test.dart: sqlite record and DTO mapping run only when decoding +04:50 +543: test/database_test.dart: sqlite immutable filters, empty IN, null and pagination +04:50 +544: test/database_test.dart: sqlite cross-table identity and duplicate assignments rejected before IO +04:50 +545: test/database_test.dart: sqlite commit, rollback, savepoint recovery and escaped session +04:50 +546: test/database_test.dart: sqlite caught statement failures cannot masquerade as a commit +04:50 +547: test/database_test.dart: sqlite recoverable constraint errors stay within their savepoint +04:50 +548: test/database_test.dart: sqlite unawaited work is drained and rolled back +04:50 +549: test/database_test.dart: sqlite close waits for active operations and rejects further use +04:50 +550: test/database_test.dart: sqlite leased sessions retain one connection across transactions and expire +04:50 +551: test/database_test.dart: sqlite native upsert updates atomically using existing and incoming values +04:50 +552: test/database_test.dart: sqlite bulk inserts split at parameter limits and opt into returning +04:50 +553: test/database_test.dart: sqlite a failed later bulk chunk rolls back all previous chunks +04:50 +554: test/database_test.dart: sqlite bulk optional field shapes preserve defaults and explicit null +04:50 +555: test/database_test.dart: sqlite relations CTEs preserve SQL column identity through mapped projections +04:50 +556: test/database_test.dart: sqlite relations explicit aliases support multiple joins and self joins +04:50 +557: test/database_test.dart: sqlite relations left join distinguishes a missing row from an all-null selection +04:50 +558: test/database_test.dart: sqlite relations correlated scalar, IN and EXISTS use nested SQL scopes +04:50 +559: test/database_test.dart: sqlite relations grouping is checked and window functions preserve row cardinality +04:50 +560: test/database_test.dart: sqlite relations root pagination and each parent limit use two queries +04:50 +561: test/database_test.dart: sqlite relations empty roots skip child queries; empty lists remain typed lists +04:50 +562: test/database_test.dart: sqlite relations nested relation projections and optional all-null projection +04:50 +563: test/database_test.dart: sqlite relations correlated any, none, count and every with SQL nulls +04:50 +564: test/database_test.dart: sqlite relations relation parameters bind correctly in projection and filter +04:50 +565: test/database_test.dart: sqlite keyset pagination uses a unique tie breaker in mixed directions +04:50 +566: test/database_test.dart: sqlite keyset pagination handles null order and validates versioned tokens +04:50 +567: test/database_test.dart: postgres typed CRUD, exact projection and parameter binding +04:50 +568: test/database_test.dart: postgres record and DTO mapping run only when decoding +04:50 +569: test/database_test.dart: postgres immutable filters, empty IN, null and pagination +04:50 +570: test/database_test.dart: postgres cross-table identity and duplicate assignments rejected before IO +04:50 +571: test/database_test.dart: postgres commit, rollback, savepoint recovery and escaped session +04:50 +572: test/database_test.dart: postgres caught statement failures cannot masquerade as a commit +04:50 +573: test/database_test.dart: postgres recoverable constraint errors stay within their savepoint +04:50 +574: test/database_test.dart: postgres unawaited work is drained and rolled back +04:51 +575: test/database_test.dart: postgres close waits for active operations and rejects further use +04:51 +576: test/database_test.dart: postgres leased sessions retain one connection across transactions and expire +04:51 +577: test/database_test.dart: postgres native upsert updates atomically using existing and incoming values +04:51 +578: test/database_test.dart: postgres bulk inserts split at parameter limits and opt into returning +04:51 +579: test/database_test.dart: postgres a failed later bulk chunk rolls back all previous chunks +04:51 +580: test/database_test.dart: postgres bulk optional field shapes preserve defaults and explicit null +04:51 +581: test/database_test.dart: postgres relations CTEs preserve SQL column identity through mapped projections +04:51 +582: test/database_test.dart: postgres relations explicit aliases support multiple joins and self joins +04:51 +583: test/database_test.dart: postgres relations left join distinguishes a missing row from an all-null selection +04:51 +584: test/database_test.dart: postgres relations correlated scalar, IN and EXISTS use nested SQL scopes +04:51 +585: test/database_test.dart: postgres relations grouping is checked and window functions preserve row cardinality +04:51 +586: test/database_test.dart: postgres relations root pagination and each parent limit use two queries +04:51 +587: test/database_test.dart: postgres relations empty roots skip child queries; empty lists remain typed lists +04:51 +588: test/database_test.dart: postgres relations nested relation projections and optional all-null projection +04:51 +589: test/database_test.dart: postgres relations correlated any, none, count and every with SQL nulls +04:51 +590: test/database_test.dart: postgres relations relation parameters bind correctly in projection and filter +04:51 +591: test/database_test.dart: postgres keyset pagination uses a unique tie breaker in mixed directions +04:51 +592: test/database_test.dart: postgres keyset pagination handles null order and validates versioned tokens +04:51 +593: loading test/union_test.dart +04:51 +593: test/union_test.dart: union sqlite SQL duplicate and NULL semantics use one statement +04:51 +594: test/union_test.dart: union sqlite heterogeneous records map only after SQL union and final pagination +04:51 +595: test/union_test.dart: union sqlite operand limits, order, parameters and final filter stay scoped +04:51 +596: test/union_test.dart: union sqlite repeated SQL expressions preserve positional columns +04:51 +597: test/union_test.dart: union sqlite nested sets preserve association, CTE exports and streaming +04:51 +598: test/union_test.dart: union sqlite same CTE names in distinct operands stay in separate scopes +04:51 +599: test/union_test.dart: union sqlite SQL scalar, IN subqueries, aggregate and zero-row results compose +04:51 +600: test/union_test.dart: union sqlite grouped branches and window branches remain SQL queries +04:51 +601: test/union_test.dart: union sqlite bound constant projections retain Dart types +04:51 +602: test/union_test.dart: union sqlite Dart maps and relation selections are rejected before execution +04:51 +603: test/union_test.dart: union sqlite codecs and nullability cannot silently change across operands +04:51 +604: test/union_test.dart: union sqlite shared domain codecs work, and nullable wrappers retain decoding semantics +04:51 +605: test/union_test.dart: union sqlite export references and outer join nullability are guarded +04:51 +606: test/union_test.dart: union sqlite compound mutations and mixed sessions fail before SQL +04:51 +607: test/union_test.dart: union sqlite all record arities use typed SQL decoding +04:51 +608: test/union_test.dart: union sqlite subscription observes committed changes in both operands +04:51 +609: test/union_test.dart: union postgres SQL duplicate and NULL semantics use one statement +04:51 +610: test/union_test.dart: union postgres heterogeneous records map only after SQL union and final pagination +04:51 +611: test/union_test.dart: union postgres operand limits, order, parameters and final filter stay scoped +04:51 +612: test/union_test.dart: union postgres repeated SQL expressions preserve positional columns +04:51 +613: test/union_test.dart: union postgres nested sets preserve association, CTE exports and streaming +04:51 +614: test/union_test.dart: union postgres same CTE names in distinct operands stay in separate scopes +04:51 +615: test/union_test.dart: union postgres SQL scalar, IN subqueries, aggregate and zero-row results compose +04:51 +616: test/union_test.dart: union postgres grouped branches and window branches remain SQL queries +04:51 +617: test/union_test.dart: union postgres bound constant projections retain Dart types +04:51 +618: test/union_test.dart: union postgres Dart maps and relation selections are rejected before execution +04:51 +619: test/union_test.dart: union postgres codecs and nullability cannot silently change across operands +04:51 +620: test/union_test.dart: union postgres shared domain codecs work, and nullable wrappers retain decoding semantics +04:51 +621: test/union_test.dart: union postgres export references and outer join nullability are guarded +04:51 +622: test/union_test.dart: union postgres compound mutations and mixed sessions fail before SQL +04:51 +623: test/union_test.dart: union postgres all record arities use typed SQL decoding +04:51 +624: test/union_test.dart: union postgres subscription observes committed changes in both operands +04:51 +625: loading test/import_cli_test.dart +04:52 +625: test/import_cli_test.dart: CLI sqlite imports reviews generates and baselines existing data without replacing files +05:42 +626: test/import_cli_test.dart: CLI postgres imports reviews generates and baselines existing data without replacing files +06:26 +627: loading test/decimal_division_test.dart +06:26 +627: test/decimal_division_test.dart: decimal division sqlite signed modes and scales match exact integer arithmetic +06:26 +628: test/decimal_division_test.dart: decimal division sqlite exact defaults reject lost digits and zero divisors, NULL propagates +06:26 +629: test/decimal_division_test.dart: decimal division sqlite precision exceeds native division scale without double rounding +06:26 +630: test/decimal_division_test.dart: decimal division sqlite huge remainders avoid overflowing intermediate scaled products +06:49 +631: test/decimal_division_test.dart: decimal division sqlite outermost integer scale and result overflow are explicit +06:50 +632: test/decimal_division_test.dart: decimal division sqlite aggregates windows CTEs predicates and unions compose +06:50 +633: test/decimal_division_test.dart: decimal division sqlite expression assignments and transaction rollback preserve values +06:50 +634: test/decimal_division_test.dart: decimal division sqlite window filtering ordering DISTINCT and pagination keep their SQL order +06:50 +635: test/decimal_division_test.dart: decimal division sqlite windows over grouped aggregates run after HAVING +06:50 +636: test/decimal_division_test.dart: decimal division sqlite window results compose through CTEs sets and correlated subqueries +06:50 +637: test/decimal_division_test.dart: decimal division sqlite batched relations retain per-parent window pagination +06:50 +638: test/decimal_division_test.dart: decimal division postgres signed modes and scales match exact integer arithmetic +06:50 +639: test/decimal_division_test.dart: decimal division postgres exact defaults reject lost digits and zero divisors, NULL propagates +06:50 +640: test/decimal_division_test.dart: decimal division postgres precision exceeds native division scale without double rounding +06:50 +641: test/decimal_division_test.dart: decimal division postgres huge remainders avoid overflowing intermediate scaled products +07:12 +642: test/decimal_division_test.dart: decimal division postgres outermost integer scale and result overflow are explicit +07:12 +643: test/decimal_division_test.dart: decimal division postgres aggregates windows CTEs predicates and unions compose +07:12 +644: test/decimal_division_test.dart: decimal division postgres expression assignments and transaction rollback preserve values +07:12 +645: test/decimal_division_test.dart: decimal division postgres window filtering ordering DISTINCT and pagination keep their SQL order +07:12 +646: test/decimal_division_test.dart: decimal division postgres windows over grouped aggregates run after HAVING +07:12 +647: test/decimal_division_test.dart: decimal division postgres window results compose through CTEs sets and correlated subqueries +07:12 +648: test/decimal_division_test.dart: decimal division postgres batched relations retain per-parent window pagination +07:12 +649: test/decimal_division_test.dart: decimal division postgres volatile operands are evaluated exactly once each +07:12 +650: loading test/query_boundary_test.dart +07:12 +650: test/query_boundary_test.dart: one failing cancellation listener cannot block other operations +07:12 +651: test/query_boundary_test.dart: sqlite explicit Backend construction rejects wrong transaction options before SQL +07:12 +652: test/query_boundary_test.dart: sqlite every close caller waits until the same pending lease is drained +07:12 +653: test/query_boundary_test.dart: sqlite subqueries and CTEs cannot escape their database or transaction +07:12 +654: test/query_boundary_test.dart: sqlite expired query cannot be embedded in its former parent +07:12 +655: test/query_boundary_test.dart: sqlite optional presence covers only its own left join +07:12 +656: test/query_boundary_test.dart: sqlite nested CTE decoding retains its nullable presence guards +07:12 +657: test/query_boundary_test.dart: sqlite mutation predicates and assignments reject aggregate/window SQL before execution +07:12 +658: test/query_boundary_test.dart: sqlite returning rejects empty selections and joined relationships +07:12 +659: test/query_boundary_test.dart: postgres explicit Backend construction rejects wrong transaction options before SQL +07:12 +660: test/query_boundary_test.dart: postgres every close caller waits until the same pending lease is drained +07:13 +661: test/query_boundary_test.dart: postgres subqueries and CTEs cannot escape their database or transaction +07:13 +662: test/query_boundary_test.dart: postgres expired query cannot be embedded in its former parent +07:13 +663: test/query_boundary_test.dart: postgres optional presence covers only its own left join +07:13 +664: test/query_boundary_test.dart: postgres nested CTE decoding retains its nullable presence guards +07:13 +665: test/query_boundary_test.dart: postgres mutation predicates and assignments reject aggregate/window SQL before execution +07:13 +666: test/query_boundary_test.dart: postgres returning rejects empty selections and joined relationships +07:13 +667: loading test/cli_test.dart +07:13 +667: test/cli_test.dart: Dart CLI generates, reviews, upgrades and verifies constraints without overwriting history +08:19 +668: test/cli_test.dart: Dart CLI baselines existing rows; read-only connections and invalid options reject writes +08:45 +669: test/cli_test.dart: Dart PostgreSQL configuration supports checked nontransactional recovery operations +09:31 +670: loading test/decimal_test.dart +09:31 +670: test/decimal_test.dart: exact decimal sqlite generated writes, defaults, patches and projections preserve all digits +09:31 +671: test/decimal_test.dart: exact decimal sqlite numeric ordering, predicates, IN, distinct and stable cursors +09:31 +672: test/decimal_test.dart: exact decimal sqlite sums, min/max, grouped aggregates and sliding windows stay exact +09:31 +673: test/decimal_test.dart: exact decimal sqlite computed CTEs, union results, subqueries and streaming keep decimal semantics +09:31 +674: test/decimal_test.dart: exact decimal sqlite numeric primary keys, foreign keys, joined and batched relations +09:31 +675: test/decimal_test.dart: exact decimal sqlite snapshots, catalog verification and imported declarations retain decimal storage +09:33 +676: test/decimal_test.dart: exact decimal sqlite aggregate intermediate overflow may cancel before a valid final result +09:33 +677: test/decimal_test.dart: exact decimal sqlite historical decimal keys resume backfills without lexical ordering or precision loss +09:33 +678: test/decimal_test.dart: exact decimal sqlite reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +09:33 +679: test/decimal_test.dart: exact decimal sqlite invalid external values fail decoding; later valid queries still work +09:33 +680: test/decimal_test.dart: exact decimal postgres generated writes, defaults, patches and projections preserve all digits +09:33 +681: test/decimal_test.dart: exact decimal postgres numeric ordering, predicates, IN, distinct and stable cursors +09:33 +682: test/decimal_test.dart: exact decimal postgres sums, min/max, grouped aggregates and sliding windows stay exact +09:33 +683: test/decimal_test.dart: exact decimal postgres computed CTEs, union results, subqueries and streaming keep decimal semantics +09:34 +684: test/decimal_test.dart: exact decimal postgres numeric primary keys, foreign keys, joined and batched relations +09:34 +685: test/decimal_test.dart: exact decimal postgres snapshots, catalog verification and imported declarations retain decimal storage +09:34 +686: test/decimal_test.dart: exact decimal postgres aggregate intermediate overflow may cancel before a valid final result +09:34 +687: test/decimal_test.dart: exact decimal postgres historical decimal keys resume backfills without lexical ordering or precision loss +09:34 +688: test/decimal_test.dart: exact decimal postgres reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +09:34 +689: test/decimal_test.dart: exact decimal postgres invalid external values fail decoding; later valid queries still work +09:34 +690: test/decimal_test.dart: decimal SQL rejects drivers without exact operations before execution +09:34 +691: test/decimal_test.dart: SQLite recognizes only column decimal collations, and detects drift +09:34 +692: loading test/watch_test.dart +09:35 +692: test/watch_test.dart: independent native SQLite workers require explicit notification +09:35 +693: test/watch_test.dart: watch sqlite lazy initial snapshot and relevant table invalidation +09:35 +694: test/watch_test.dart: watch sqlite transactions publish one snapshot after commit and none on rollback +09:35 +695: test/watch_test.dart: watch sqlite savepoint changes merge on release and disappear on rollback +09:35 +696: test/watch_test.dart: watch sqlite failed statements, zero-row writes and conflict no-ops do not refresh +09:35 +697: test/watch_test.dart: watch sqlite batch writes and upsert returning publish committed changes +09:35 +698: test/watch_test.dart: watch sqlite joined and nested batch relations track child writes +09:35 +699: test/watch_test.dart: watch sqlite relation predicates, SQL subqueries and CTE sources are dependencies +09:35 +700: test/watch_test.dart: watch sqlite declared cascade deletion invalidates a child-only query +09:35 +701: test/watch_test.dart: watch sqlite CASCADE tracks declared transitive deletion effects +09:35 +702: test/watch_test.dart: watch sqlite SET NULL tracks declared transitive deletion effects +09:35 +703: test/watch_test.dart: watch sqlite explicit JOINs and joined CTE definitions track both physical sources +09:35 +704: test/watch_test.dart: watch sqlite raw SQL declares changed and read tables; explicit invalidation follows transactions +09:35 +705: test/watch_test.dart: watch sqlite paused listeners coalesce writes and stop fetching until resumed +09:35 +706: test/watch_test.dart: watch sqlite invalidation during a read suppresses the stale snapshot and refetches +09:35 +707: test/watch_test.dart: watch sqlite decoding errors are reported and a later write can recover +09:35 +708: test/watch_test.dart: watch sqlite separate subscriptions and views of one driver receive changes +09:35 +709: test/watch_test.dart: watch sqlite watching leased sessions is rejected and cancelled tokens close the stream +09:35 +710: test/watch_test.dart: watch sqlite database close finishes watchers even when a listener is paused +09:35 +711: test/watch_test.dart: watch sqlite cancelling an active watched query interrupts SQL and releases its lease +09:35 +712: test/watch_test.dart: watch postgres lazy initial snapshot and relevant table invalidation +09:35 +713: test/watch_test.dart: watch postgres transactions publish one snapshot after commit and none on rollback +09:35 +714: test/watch_test.dart: watch postgres savepoint changes merge on release and disappear on rollback +09:35 +715: test/watch_test.dart: watch postgres failed statements, zero-row writes and conflict no-ops do not refresh +09:35 +716: test/watch_test.dart: watch postgres batch writes and upsert returning publish committed changes +09:35 +717: test/watch_test.dart: watch postgres joined and nested batch relations track child writes +09:35 +718: test/watch_test.dart: watch postgres relation predicates, SQL subqueries and CTE sources are dependencies +09:35 +719: test/watch_test.dart: watch postgres declared cascade deletion invalidates a child-only query +09:35 +720: test/watch_test.dart: watch postgres CASCADE tracks declared transitive deletion effects +09:35 +721: test/watch_test.dart: watch postgres SET NULL tracks declared transitive deletion effects +09:35 +722: test/watch_test.dart: watch postgres explicit JOINs and joined CTE definitions track both physical sources +09:35 +723: test/watch_test.dart: watch postgres raw SQL declares changed and read tables; explicit invalidation follows transactions +09:35 +724: test/watch_test.dart: watch postgres paused listeners coalesce writes and stop fetching until resumed +09:35 +725: test/watch_test.dart: watch postgres invalidation during a read suppresses the stale snapshot and refetches +09:35 +726: test/watch_test.dart: watch postgres decoding errors are reported and a later write can recover +09:35 +727: test/watch_test.dart: watch postgres separate subscriptions and views of one driver receive changes +09:35 +728: test/watch_test.dart: watch postgres watching leased sessions is rejected and cancelled tokens close the stream +09:35 +729: test/watch_test.dart: watch postgres database close finishes watchers even when a listener is paused +09:35 +730: test/watch_test.dart: watch postgres cancelling an active watched query interrupts SQL and releases its lease +09:35 +731: test/watch_test.dart: watch postgres lost commit acknowledgement triggers a conservative re-read +09:35 +732: test/watch_test.dart: watch postgres independent connections require an explicit external-write notification +09:36 +733: loading test/backfill_test.dart +09:36 +733: test/backfill_test.dart: backfill sqlite chunks pause and resume from a checksummed historical declaration +09:36 +734: test/backfill_test.dart: backfill sqlite failed chunk rolls back both writes and cursor, then resumes after repair +09:36 +735: test/backfill_test.dart: backfill sqlite failed completion proof preserves finished chunks without replay +09:36 +736: test/backfill_test.dart: backfill sqlite frozen upper bound and completion proof detect incompatible new writes +09:36 +737: test/backfill_test.dart: backfill sqlite competing runners share committed chunks without duplicate updates +09:36 +738: test/backfill_test.dart: backfill sqlite lost chunk commit acknowledgement preserves the committed frontier +09:36 +739: test/backfill_test.dart: backfill sqlite old checkpoint tables upgrade on apply; completed reads remain nonmutating +09:36 +740: test/backfill_test.dart: backfill sqlite completion conditions reject numeric results instead of truthiness +09:36 +741: test/backfill_test.dart: backfill sqlite empty input still requires the completion condition +09:36 +742: test/backfill_test.dart: backfill sqlite later ordinary migrations retain group atomicity after a completed backfill +09:36 +743: test/backfill_test.dart: backfill sqlite AFTER triggers cannot silently move keys past a committed cursor +09:36 +744: test/backfill_test.dart: backfill sqlite corrupt checkpoint dimensions and invalid run limits are rejected +09:36 +745: test/backfill_test.dart: backfill sqlite rebuild, chunked backfill and required-column rebuild preserve FK enforcement +09:36 +746: test/backfill_test.dart: backfill sqlite CLI previews non-atomic work and reports a bounded run as incomplete +09:58 +747: test/backfill_test.dart: backfill sqlite temporary tables cannot redirect the historical backfill target +09:58 +748: test/backfill_test.dart: backfill sqlite temporary objects cannot impersonate durable migration metadata +09:58 +749: test/backfill_test.dart: backfill sqlite committed chunks invalidate relevant typed query subscriptions +09:58 +750: test/backfill_test.dart: backfill sqlite real process exit after second chunk write resumes correctly +10:01 +751: test/backfill_test.dart: backfill sqlite real process exit after second chunk commit resumes correctly +10:04 +752: test/backfill_test.dart: backfill postgres chunks pause and resume from a checksummed historical declaration +10:04 +753: test/backfill_test.dart: backfill postgres failed chunk rolls back both writes and cursor, then resumes after repair +10:04 +754: test/backfill_test.dart: backfill postgres failed completion proof preserves finished chunks without replay +10:04 +755: test/backfill_test.dart: backfill postgres frozen upper bound and completion proof detect incompatible new writes +10:04 +756: test/backfill_test.dart: backfill postgres competing runners share committed chunks without duplicate updates +10:05 +757: test/backfill_test.dart: backfill postgres lost chunk commit acknowledgement preserves the committed frontier +10:05 +758: test/backfill_test.dart: backfill postgres old checkpoint tables upgrade on apply; completed reads remain nonmutating +10:05 +759: test/backfill_test.dart: backfill postgres completion conditions reject numeric results instead of truthiness +10:05 +760: test/backfill_test.dart: backfill postgres empty input still requires the completion condition +10:05 +761: test/backfill_test.dart: backfill postgres later ordinary migrations retain group atomicity after a completed backfill +10:05 +762: test/backfill_test.dart: backfill postgres AFTER triggers cannot silently move keys past a committed cursor +10:05 +763: test/backfill_test.dart: backfill postgres corrupt checkpoint dimensions and invalid run limits are rejected +10:05 +764: test/backfill_test.dart: backfill postgres CLI previews non-atomic work and reports a bounded run as incomplete +10:26 +765: test/backfill_test.dart: backfill postgres temporary tables cannot redirect the historical backfill target +10:26 +766: test/backfill_test.dart: backfill postgres temporary objects cannot impersonate durable migration metadata +10:26 +767: test/backfill_test.dart: backfill postgres row security cannot hide unfinished rows from the completion proof +10:26 +768: test/backfill_test.dart: backfill postgres inherited duplicate primary keys cannot advance a cursor +10:26 +769: test/backfill_test.dart: backfill postgres committed chunks invalidate relevant typed query subscriptions +10:26 +770: test/backfill_test.dart: backfill postgres real process exit after second chunk write resumes correctly +10:29 +771: test/backfill_test.dart: backfill postgres real process exit after second chunk commit resumes correctly +10:32 +772: loading test/custom_codec_test.dart +10:32 +772: test/custom_codec_test.dart: (setUpAll) +10:32 +772: test/custom_codec_test.dart: custom codecs sqlite generated custom values round trip through create, select and patch +10:32 +773: test/custom_codec_test.dart: custom codecs sqlite enum labels are stable text and unknown labels fail decoding +10:32 +774: test/custom_codec_test.dart: custom codecs sqlite domain validation and bound parameters apply to writes and predicates +10:32 +775: test/custom_codec_test.dart: custom codecs sqlite JSON documents distinguish SQL NULL, JSON null and string scalars +10:32 +776: test/custom_codec_test.dart: custom codecs sqlite nullable structured codec rejects JSON null instead of masking invalid data +10:32 +777: test/custom_codec_test.dart: custom codecs sqlite custom IDs and domain projections work across joined and batched relations +10:32 +778: test/custom_codec_test.dart: custom codecs sqlite custom values participate in atomic batch writes and native upsert +10:32 +779: test/custom_codec_test.dart: custom codecs sqlite custom IDs and enums survive cursor transport and streaming decoders +10:32 +780: test/custom_codec_test.dart: custom codecs sqlite migration snapshots retain physical storage without executing domain codecs +10:32 +781: test/custom_codec_test.dart: custom codecs postgres generated custom values round trip through create, select and patch +10:32 +782: test/custom_codec_test.dart: custom codecs postgres enum labels are stable text and unknown labels fail decoding +10:32 +783: test/custom_codec_test.dart: custom codecs postgres domain validation and bound parameters apply to writes and predicates +10:32 +784: test/custom_codec_test.dart: custom codecs postgres JSON documents distinguish SQL NULL, JSON null and string scalars +10:32 +785: test/custom_codec_test.dart: custom codecs postgres nullable structured codec rejects JSON null instead of masking invalid data +10:32 +786: test/custom_codec_test.dart: custom codecs postgres custom IDs and domain projections work across joined and batched relations +10:32 +787: test/custom_codec_test.dart: custom codecs postgres custom values participate in atomic batch writes and native upsert +10:32 +788: test/custom_codec_test.dart: custom codecs postgres custom IDs and enums survive cursor transport and streaming decoders +10:32 +789: test/custom_codec_test.dart: custom codecs postgres migration snapshots retain physical storage without executing domain codecs +10:32 +790: test/custom_codec_test.dart: (tearDownAll) +10:32 +790: loading test/plan_test.dart +10:32 +790: test/plan_test.dart: inspect sqlite reports composite key width and validates each potential batch +10:32 +791: test/plan_test.dart: inspect sqlite compiles nested plans without acquiring or decoding +10:32 +792: test/plan_test.dart: inspect sqlite distinguishes same-statement joins and skipped batches +10:32 +793: test/plan_test.dart: inspect sqlite retains CTE, UNION and raw SQL boundaries +10:32 +794: test/plan_test.dart: plan execution sqlite one-key SQL templates match executed SQL and internal slots +10:32 +795: test/plan_test.dart: inspect postgres reports composite key width and validates each potential batch +10:32 +796: test/plan_test.dart: inspect postgres compiles nested plans without acquiring or decoding +10:32 +797: test/plan_test.dart: inspect postgres distinguishes same-statement joins and skipped batches +10:32 +798: test/plan_test.dart: inspect postgres retains CTE, UNION and raw SQL boundaries +10:32 +799: test/plan_test.dart: plan execution postgres one-key SQL templates match executed SQL and internal slots +10:32 +800: loading test/check_test.dart +10:33 +800: test/check_test.dart: CHECK sqlite generated named and unnamed constraints enforce values and permit NULL +10:33 +801: test/check_test.dart: CHECK sqlite import preserves names and SQL then generates a matching snapshot +10:35 +802: test/check_test.dart: CHECK sqlite add, change and remove constraints validate old rows atomically +10:35 +803: test/check_test.dart: CHECK sqlite duplicate unnamed checks consume and drop exactly one catalog entry +10:35 +804: test/check_test.dart: CHECK sqlite explicit checked renames preserve rows, incoming references, indexes and views +10:35 +805: test/check_test.dart: CHECK sqlite verification preserves casts, precedence, literals and trailing comments +10:35 +806: test/check_test.dart: CHECK sqlite quoted Unicode identifiers retain SQLite case distinctions +10:35 +807: test/check_test.dart: CHECK sqlite undeclared CHECK is refused before a rebuild and remains enforced +10:35 +808: test/check_test.dart: CHECK postgres generated named and unnamed constraints enforce values and permit NULL +10:35 +809: test/check_test.dart: CHECK postgres import preserves names and SQL then generates a matching snapshot +10:36 +810: test/check_test.dart: CHECK postgres add, change and remove constraints validate old rows atomically +10:36 +811: test/check_test.dart: CHECK postgres duplicate unnamed checks consume and drop exactly one catalog entry +10:36 +812: test/check_test.dart: CHECK postgres explicit checked renames preserve rows, incoming references, indexes and views +10:36 +813: test/check_test.dart: CHECK postgres verification preserves casts, precedence, literals and trailing comments +10:36 +814: test/check_test.dart: CHECK postgres catalog excludes unvalidated and NO INHERIT constraints +10:36 +815: test/check_test.dart: CHECK postgres verification plans but does not execute volatile row expressions +10:36 +816: test/check_test.dart: dialect overrides only alter the affected backend and preserve old snapshot JSON +10:36 +817: loading test/temporal_test.dart +10:36 +817: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: false +10:36 +818: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: true +10:36 +819: test/temporal_test.dart: Gregorian cycles, era boundaries and full finite date range +10:36 +820: test/temporal_test.dart: local time precision, day endpoint and timestamp arithmetic +10:36 +821: test/temporal_test.dart: PostgreSQL binary registry covers boundaries without DateTime +10:36 +822: test/temporal_test.dart: local temporal sqlite generated writes, defaults, null patches and typed parameters +10:36 +823: test/temporal_test.dart: local temporal sqlite finite extremes and microseconds survive native rows and streams +10:36 +824: test/temporal_test.dart: local temporal sqlite calendar ordering, min/max, grouping and stable date cursors +10:36 +825: test/temporal_test.dart: local temporal sqlite equivalent date keys drive batched relations and unique indexes +10:36 +826: test/temporal_test.dart: local temporal sqlite time and local timestamp comparisons retain calendar semantics +10:36 +827: test/temporal_test.dart: local temporal sqlite reviewed text-to-time migration rolls back duplicate clock values +10:36 +828: test/temporal_test.dart: local temporal sqlite invalid external dates fail typed reads without damaging the connection +10:36 +829: test/temporal_test.dart: local temporal sqlite snapshots, catalog import and regenerated defaults agree +10:39 +830: test/temporal_test.dart: local temporal sqlite historical date keys resume a bounded backfill in calendar order +10:39 +831: test/temporal_test.dart: local temporal postgres catalog retains time-truncating casts in expression defaults +10:39 +832: test/temporal_test.dart: local temporal postgres generated writes, defaults, null patches and typed parameters +10:39 +833: test/temporal_test.dart: local temporal postgres finite extremes and microseconds survive native rows and streams +10:39 +834: test/temporal_test.dart: local temporal postgres calendar ordering, min/max, grouping and stable date cursors +10:39 +835: test/temporal_test.dart: local temporal postgres equivalent date keys drive batched relations and unique indexes +10:39 +836: test/temporal_test.dart: local temporal postgres time and local timestamp comparisons retain calendar semantics +10:39 +837: test/temporal_test.dart: local temporal postgres reviewed text-to-time migration rolls back duplicate clock values +10:39 +838: test/temporal_test.dart: local temporal postgres invalid external dates fail typed reads without damaging the connection +10:39 +839: test/temporal_test.dart: local temporal postgres snapshots, catalog import and regenerated defaults agree +10:40 +840: test/temporal_test.dart: local temporal postgres historical date keys resume a bounded backfill in calendar order +10:40 +841: loading test/unconstrained_relation_test.dart +10:40 +841: test/unconstrained_relation_test.dart: unconstrained sqlite catalogs contain no FK, implicit index or uniqueness promise +10:40 +842: test/unconstrained_relation_test.dart: unconstrained sqlite adding and removing a real FK remains a reviewed physical migration +10:40 +843: test/unconstrained_relation_test.dart: unconstrained sqlite unique joins preserve root rows, NULL components and missing targets +10:40 +844: test/unconstrained_relation_test.dart: unconstrained sqlite required target and filter visibility remain explicit +10:40 +845: test/unconstrained_relation_test.dart: unconstrained sqlite nonunique composite collections batch by tuple and page per parent +10:40 +846: test/unconstrained_relation_test.dart: unconstrained sqlite to-one uses cardinality checks without assuming a unique lookup +10:40 +847: test/unconstrained_relation_test.dart: unconstrained sqlite correlated filters and counts do not materialize related rows +10:40 +848: test/unconstrained_relation_test.dart: unconstrained sqlite self relations and inverse collections share the existing planner +10:40 +849: test/unconstrained_relation_test.dart: unconstrained sqlite delete has no cascade and watches track the joined target +10:40 +850: test/unconstrained_relation_test.dart: unconstrained sqlite transaction reads observe writes and outer rollback restores targets +10:40 +851: test/unconstrained_relation_test.dart: unconstrained sqlite streaming preserves optional and batched relation result shapes +10:40 +852: test/unconstrained_relation_test.dart: unconstrained sqlite floating relation keys retain storage intent and key equality +10:40 +853: test/unconstrained_relation_test.dart: unconstrained postgres catalogs contain no FK, implicit index or uniqueness promise +10:40 +854: test/unconstrained_relation_test.dart: unconstrained postgres adding and removing a real FK remains a reviewed physical migration +10:40 +855: test/unconstrained_relation_test.dart: unconstrained postgres unique joins preserve root rows, NULL components and missing targets +10:40 +856: test/unconstrained_relation_test.dart: unconstrained postgres required target and filter visibility remain explicit +10:40 +857: test/unconstrained_relation_test.dart: unconstrained postgres nonunique composite collections batch by tuple and page per parent +10:40 +858: test/unconstrained_relation_test.dart: unconstrained postgres to-one uses cardinality checks without assuming a unique lookup +10:40 +859: test/unconstrained_relation_test.dart: unconstrained postgres correlated filters and counts do not materialize related rows +10:40 +860: test/unconstrained_relation_test.dart: unconstrained postgres self relations and inverse collections share the existing planner +10:41 +861: test/unconstrained_relation_test.dart: unconstrained postgres delete has no cascade and watches track the joined target +10:41 +862: test/unconstrained_relation_test.dart: unconstrained postgres transaction reads observe writes and outer rollback restores targets +10:41 +863: test/unconstrained_relation_test.dart: unconstrained postgres streaming preserves optional and batched relation result shapes +10:41 +864: test/unconstrained_relation_test.dart: unconstrained postgres floating relation keys retain storage intent and key equality +10:41 +865: loading test/decimal_value_test.dart +10:41 +865: test/decimal_value_test.dart: normalization, scientific notation, equality and hash are exact +10:41 +866: test/decimal_value_test.dart: invalid and approximate inputs fail instead of silently rounding +10:41 +867: test/decimal_value_test.dart: range boundaries are checked without binary numeric conversions +10:41 +868: test/decimal_value_test.dart: addition, subtraction, multiplication and ordering keep every digit +10:41 +869: test/decimal_value_test.dart: division requires an explicit scale and rejects implicit rounding +10:41 +870: test/decimal_value_test.dart: rounding modes handle negative values, ties and negative scales +10:41 +871: test/decimal_value_test.dart: small fixed-scale arithmetic agrees with independent integer arithmetic +10:41 +872: All tests passed! diff --git a/research/validation/api-audit-usage.log b/research/validation/api-audit-usage.log new file mode 100644 index 00000000..4b8fb618 --- /dev/null +++ b/research/validation/api-audit-usage.log @@ -0,0 +1,27 @@ +dart run .dart_tool/audit_onboarding.dart +[seven@example.com] +Running build hooks...Running build hooks...dart run example/migrate.dart check +{ + "valid": true, + "dialect": "sqlite", + "migrations": [ + "0001_initial" + ] +} +Running build hooks...Running build hooks...dart run example/migrate.dart apply +{ + "applied": [ + "0001_initial" + ] +} +Running build hooks...Running build hooks...dart run example/migrate.dart verify +{ + "matches": true, + "differences": [], + "unmanaged": [] +} +Running build hooks...Running build hooks...dart run example/migrate.dart apply +{ + "applied": [] +} +Running build hooks...Running build hooks...README and migration lifecycle passed; final apply did not replay history. diff --git a/research/validation/api-audit.md b/research/validation/api-audit.md new file mode 100644 index 00000000..8daabed1 --- /dev/null +++ b/research/validation/api-audit.md @@ -0,0 +1,79 @@ +# API and correctness refactor validation + +Captured 2026-09-16 on macOS arm64. Dart 3.13.3 stable, sqlite3 package 3.6.0, +PostgreSQL 18.4 (Homebrew), and real headless Chrome 153. The PostgreSQL instance, +browser profiles and usage database were created for this run and cleaned up. + +Runtime source SHA-256: +`8344748e7f2dbc2e22a54f330510250c953e35ac67e7e92d238ec3dafb3d37f7`. +The digest covers lexically sorted Dart paths under `lib`/`bin` and +`pubspec.yaml`/`pubspec.lock`: UTF-8 path, NUL, file bytes, NUL. These files were +unchanged during the final native/browser/usage runs. Implementation commits are +`5b6feed` and `ef7438d`; final test harness, browser regression and documentation +changes are recorded in the subsequent local commit. + +| Check | Result | Evidence | +| --- | --- | --- | +| Complete native suite, PostgreSQL enabled | **872 passed, 10:41, exit 0** | [Full native log](api-audit-native.log) | +| Real Chrome JavaScript | **20 checks, passed, exit 0** | [JS log](api-audit-browser-js.log) | +| Real Chrome Dart WASM | **20 checks, passed, exit 0** | [WASM log](api-audit-browser-wasm.log) | +| README application example and persistent SQLite migration CLI | check/apply/verify/repeat apply passed; no replay | [Usage log](api-audit-usage.log) | +| Static analysis | No issues | `dart analyze` | +| Whitespace validation | Clean | `git diff --check` | + +The full native invocation includes existing regression suites plus 25 new test +cases: query/transaction boundaries, export/dependency contracts, schema/name +validation, both native computed-key contracts, and generation diagnostics. +It also runs real process-exit recovery, consumer source generation, build_runner +watch/recovery, historical fingerprints and an AOT migration bundle after deleting +the consumer's Dart sources and migration assets. + +Both browser runs add a new scenario for mixed transaction/root subqueries and +CTEs, independent left-join presence guards, valid nested optional decoding and +rejected writes with unchanged data. Their remaining scenarios cover the existing +generated API, codecs, relations, transactions, cursors, observation and OPFS +reload/recovery. The report independently records compilation mode and the pinned +SQLite WASM digest: +`13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4`. + +The README Dart block was executed directly, changing only its relative import +to reach the generated example client. It returned `[seven@example.com]` using +the generated `User` export. A fresh temporary file then passed the example +migration entrypoint's check/apply/verify/apply sequence; the second apply returned +an empty list. Historical definitions retained their recorded fingerprints. + +## Reproduction + +```sh +ORM_TEST_POSTGRES= dart test --reporter expanded +dart run tool/test_browser.dart +dart run tool/test_browser.dart --wasm +dart run example/migrate.dart check +ORM_SQLITE_PATH= dart run example/migrate.dart apply +ORM_SQLITE_PATH= dart run example/migrate.dart verify +ORM_SQLITE_PATH= dart run example/migrate.dart apply +dart analyze +git diff --check +``` + +Native runs use the standalone SDK and the installed CommandLineTools through +`DEVELOPER_DIR`. Run native tests and browser acceptance sequentially because +macOS native-asset startup can rewrite/code-sign a shared output. Disposable +consumer fixtures copy existing content-addressed SQLite downloads; sqlite3's +normal hook rechecks the published hash before reusing them. This avoids repeated +network downloads without replacing the native driver or AOT bundle path. + +## Limits + +This is the final clean full invocation, not a combination of earlier failing +and passing runs. An interrupted earlier attempt and the fixes it prompted are +described in [the audit](../api-correctness-audit.md). After the final native run, +only brace formatting in the test cache-copy helper and documentation/evidence +changed; no runtime, generator, schema or migration behavior changed. + +Android, Apple platforms and runtime/generation performance measurements were not +rerun. Their earlier captures stay tied to their recorded sources. Browser proof +is Chrome JS/WASM, not every browser. The native suite is SQLite and PostgreSQL +18.4, not every server version or custom build. Raw SQL, custom codecs/drivers and +unmanaged database objects retain their documented application-owned contracts. +No push or package publication was performed. diff --git a/research/validation/browser.json b/research/validation/browser.json new file mode 100644 index 00000000..befb3d23 --- /dev/null +++ b/research/validation/browser.json @@ -0,0 +1,192 @@ +{ + "capturedAt": "2026-09-15T11:25:08.771057+00:00", + "runtimeSourceCommit": "85c4e42bf82924f806d1ce79ba96f1f61af61b1f", + "scope": "Real isolated Chrome JS and Dart WASM clients after switching generated schema snapshots and migration history to Dart; correctness checks, not throughput measurements.", + "commands": [ + "dart run tool/test_browser.dart", + "dart run tool/test_browser.dart --wasm" + ], + "reports": [ + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 64 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 41 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 16 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 19 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 34 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 26 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 3 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 2 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 1 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 4 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 4 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 159 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 30 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 96 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 3 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 22 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "js", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 55 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 39 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 14 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 29 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 22 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 161 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 29 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 102 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 39 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "wasm", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + } + ], + "sources": { + "tool/test_browser.dart": "f88fd14775583a1f91e8e96f912d37df3254bfd31c71b0f4afb68929ff38acfc", + "test/support/browser/main.dart": "c94b884c5756cc873f4a028224695556aee6030e7e2b16d64b2b9b80c3a4b58e", + "test/support/browser/schema.dart": "2435a02d5d25151f7c6b71bc204359b3a1a6161a09cc717fd471770d9e07aab7", + "test/support/browser/schema.snapshot.dart": "72f9a41c4dd3884df5f26eac0da77567ea45adecb01cd91d14fc8fb353de5241", + "lib/src/migrate/snapshot.dart": "7a8d38fdd9d9078deb97fab50e851462c52cf8df606c65be387ff49e9792dfc3", + "lib/src/migrate/source.dart": "ee9637f5efa3a85a89573561027bc82930ddac14e5578742cdf1a27f609ceb7e", + "lib/src/migrate/history.dart": "3d236ce64e8428e83692d2bce5f0b35a63401cbbba305d8a397bca402eb04563" + } +} diff --git a/research/validation/dart-migrations-aot.log b/research/validation/dart-migrations-aot.log new file mode 100644 index 00000000..ecb55c0a --- /dev/null +++ b/research/validation/dart-migrations-aot.log @@ -0,0 +1,3 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/migration_source_test.dart +00:00 +0: AOT migration bundle works without Dart sources or migration assets +00:13 +1: All tests passed! diff --git a/research/validation/dart-migrations-backfill-aot.log b/research/validation/dart-migrations-backfill-aot.log new file mode 100644 index 00000000..27e42ec8 --- /dev/null +++ b/research/validation/dart-migrations-backfill-aot.log @@ -0,0 +1,4 @@ +Running build hooks...Running build hooks...Running link hooks...Running link hooks...Copying 1 build assets: +package:sqlite3/src/ffi/libsqlite3.g.dart +Generated: /Users/seven/workspace/dart-orm/.dart_tool/backfill-aot/bundle/bin/native_backfill +Native AOT: historical backfill declaration, bounded pause, concurrent resume and startup compatibility passed. diff --git a/research/validation/dart-migrations-followup.log b/research/validation/dart-migrations-followup.log new file mode 100644 index 00000000..9f9b8ab1 --- /dev/null +++ b/research/validation/dart-migrations-followup.log @@ -0,0 +1,84 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/instant_test.dart +00:00 +0: test/instant_test.dart: strict UTC parsing handles eras, offsets, endpoints and invalid components +00:00 +1: test/instant_test.dart: UTC defaults and local DateTime inputs do not depend on the process timezone +00:01 +2: test/instant_test.dart: historical migration checksum and timestamp tag remain immutable +00:01 +3: test/instant_test.dart: UTC instant sqlite native round trips and streams preserve exact microseconds and common endpoints +00:01 +4: test/instant_test.dart: UTC instant sqlite chronological comparisons, grouping, windows, union and CTEs use instants +00:01 +5: test/instant_test.dart: UTC instant sqlite offset-equivalent keys join and batch through the same unique index +00:01 +6: test/instant_test.dart: UTC instant sqlite current snapshots, physical catalog and imported DateTime declarations agree +00:03 +7: test/instant_test.dart: UTC instant sqlite offset literals compare by value while expression casts remain visible +00:03 +8: test/instant_test.dart: UTC instant sqlite reviewed migration preserves history and repairs legacy timestamp ordering +00:03 +9: test/instant_test.dart: UTC instant sqlite legacy key collisions and invalid calendar values roll back conversion +00:03 +10: test/instant_test.dart: UTC instant sqlite instant primary-key backfills resume across sub-millisecond boundaries +00:03 +11: test/instant_test.dart: UTC instant sqlite external infinity and out-of-DateTime values fail decoding without wrapping +00:03 +12: test/instant_test.dart: UTC instant postgres native round trips and streams preserve exact microseconds and common endpoints +00:04 +13: test/instant_test.dart: UTC instant postgres chronological comparisons, grouping, windows, union and CTEs use instants +00:04 +14: test/instant_test.dart: UTC instant postgres offset-equivalent keys join and batch through the same unique index +00:04 +15: test/instant_test.dart: UTC instant postgres current snapshots, physical catalog and imported DateTime declarations agree +00:04 +16: test/instant_test.dart: UTC instant postgres offset literals compare by value while expression casts remain visible +00:04 +17: test/instant_test.dart: UTC instant postgres reviewed migration preserves history and repairs legacy timestamp ordering +00:04 +18: test/instant_test.dart: UTC instant postgres instant primary-key backfills resume across sub-millisecond boundaries +00:04 +19: test/instant_test.dart: UTC instant postgres external infinity and out-of-DateTime values fail decoding without wrapping +00:04 +20: loading test/generator_test.dart +00:05 +20: test/generator_test.dart: (setUpAll) +00:05 +20: test/generator_test.dart: client and snapshot paths cannot overwrite the declaration +00:05 +21: test/generator_test.dart: record annotations, physical names, relations and snapshots +00:06 +22: test/generator_test.dart: output is deterministic and matches the committed generated client +00:12 +23: test/generator_test.dart: composite keys and more than six fields generate analyzable code +00:13 +24: test/generator_test.dart: computed keys are rejected instead of executing a fake row +00:13 +25: test/generator_test.dart: computed declarations reject conflicting defaults, empty SQL and invalid row identities +00:16 +26: test/generator_test.dart: client defaults reject incompatible, asynchronous, private and ambiguous factories +00:18 +27: test/generator_test.dart: CHECK declarations reject dynamic SQL, empty overrides and duplicate names +00:20 +28: test/generator_test.dart: custom types resolve defining libraries when output moves to another directory +00:20 +29: test/generator_test.dart: nested generic and record types preserve nullable aliases without running codecs +00:21 +30: test/generator_test.dart: invalid custom declaration: wrong_codec +00:21 +31: test/generator_test.dart: invalid custom declaration: nullable_codec +00:22 +32: test/generator_test.dart: invalid custom declaration: erased_extension +00:22 +33: test/generator_test.dart: invalid custom declaration: unknown_storage +00:22 +34: test/generator_test.dart: invalid custom declaration: private_codec +00:22 +35: test/generator_test.dart: invalid custom declaration: private_type +00:23 +36: test/generator_test.dart: invalid custom declaration: inline_codec +00:23 +37: test/generator_test.dart: invalid custom declaration: duplicate_codec +00:23 +38: test/generator_test.dart: invalid custom declaration: missing_codec +00:23 +39: test/generator_test.dart: invalid custom declaration: duplicate_labels +00:24 +40: test/generator_test.dart: invalid custom declaration: duplicate_enum_annotation +00:24 +41: test/generator_test.dart: foreign keys require unique targets +00:24 +42: test/generator_test.dart: query-only edges do not alter physical snapshots or require unique targets +00:25 +43: test/generator_test.dart: constrained and unconstrained edges retain separate migration meaning +00:25 +44: test/generator_test.dart: invalid query-only relation: lookup_computed +00:25 +45: test/generator_test.dart: invalid query-only relation: lookup_codec +00:25 +46: test/generator_test.dart: invalid query-only relation: lookup_inverse +00:26 +47: test/generator_test.dart: invalid query-only relation: lookup_type +00:26 +48: test/generator_test.dart: invalid query-only relation: lookup_delete +00:26 +49: test/generator_test.dart: Dart type errors stop generation +00:26 +50: test/generator_test.dart: duplicate physical names and nullable primary keys are rejected +00:27 +51: test/generator_test.dart: (tearDownAll) +00:27 +51: loading test/named_sql_generation_test.dart +00:27 +51: test/named_sql_generation_test.dart: source contracts reject missing/extra parameters and row metadata +00:31 +52: test/named_sql_generation_test.dart: SQL edits and modified generated code fail freshness checks +00:33 +53: test/named_sql_generation_test.dart: asset builder and standalone generator emit identical SQL contracts +00:35 +54: test/named_sql_generation_test.dart: generated APIs reject missing/wrong parameters, result types and unsupported backends +00:35 +55: test/named_sql_generation_test.dart: CLI checks a persistent SQLite database without creating missing files +00:55 +56: loading test/named_sql_test.dart +00:55 +56: test/named_sql_test.dart: template binds repeated names once and preserves quotes/comments +00:55 +57: test/named_sql_test.dart: templates reject ambiguous bindings and incomplete or multiple statements +00:55 +58: test/named_sql_test.dart: generated client is deterministic and checked against its Dart declaration +00:58 +59: test/named_sql_test.dart: sqlite typed parameters, projection and repeated nullable bindings compose +00:58 +60: test/named_sql_test.dart: sqlite native numeric and temporal codecs survive binding and streaming +00:58 +61: test/named_sql_test.dart: sqlite CTEs and unions retain distinct query arguments and field identity +00:58 +62: test/named_sql_test.dart: sqlite transactions see pending writes and rollback normally +00:58 +63: test/named_sql_test.dart: sqlite named sources reject table mutations and require explicit watch reads +00:58 +64: test/named_sql_test.dart: sqlite native checks accept declared shape and preserve prepared-statement state +00:58 +65: test/named_sql_test.dart: sqlite native checks reject syntax, missing tables and missing result aliases +00:58 +66: test/named_sql_test.dart: sqlite native checks do not evaluate application expressions +00:58 +67: test/named_sql_test.dart: postgres typed parameters, projection and repeated nullable bindings compose +00:59 +68: test/named_sql_test.dart: postgres native numeric and temporal codecs survive binding and streaming +00:59 +69: test/named_sql_test.dart: postgres CTEs and unions retain distinct query arguments and field identity +00:59 +70: test/named_sql_test.dart: postgres transactions see pending writes and rollback normally +00:59 +71: test/named_sql_test.dart: postgres named sources reject table mutations and require explicit watch reads +00:59 +72: test/named_sql_test.dart: postgres native checks accept declared shape and preserve prepared-statement state +00:59 +73: test/named_sql_test.dart: postgres native checks reject syntax, missing tables and missing result aliases +00:59 +74: test/named_sql_test.dart: postgres native checks do not evaluate application expressions +00:59 +75: loading test/build_runner_test.dart +00:59 +75: test/build_runner_test.dart: real build_runner build/watch tracks imports, recovers errors and deletes stale outputs +01:33 +76: All tests passed! diff --git a/research/validation/dart-migrations-full.log b/research/validation/dart-migrations-full.log new file mode 100644 index 00000000..40ab5390 --- /dev/null +++ b/research/validation/dart-migrations-full.log @@ -0,0 +1,903 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/client_default_test.dart +00:00 +0: test/client_default_test.dart: client defaults sqlite omitted values use typed Dart factories and return stored records +00:00 +1: test/client_default_test.dart: client defaults sqlite explicit values, null and SQL DEFAULT bypass their client factory +00:00 +2: test/client_default_test.dart: client defaults sqlite prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +3: test/client_default_test.dart: client defaults sqlite batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +4: test/client_default_test.dart: client defaults sqlite updates and conflict updates do not reset omitted client values +00:00 +5: test/client_default_test.dart: client defaults sqlite factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +6: test/client_default_test.dart: client defaults sqlite snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +7: test/client_default_test.dart: client defaults sqlite explicit DEFAULT can choose database identity instead of the client value +00:00 +8: test/client_default_test.dart: client defaults postgres omitted values use typed Dart factories and return stored records +00:00 +9: test/client_default_test.dart: client defaults postgres explicit values, null and SQL DEFAULT bypass their client factory +00:00 +10: test/client_default_test.dart: client defaults postgres prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +11: test/client_default_test.dart: client defaults postgres batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +12: test/client_default_test.dart: client defaults postgres updates and conflict updates do not reset omitted client values +00:00 +13: test/client_default_test.dart: client defaults postgres factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +14: test/client_default_test.dart: client defaults postgres snapshots, migrations, catalogs and imports never store or run a client factory +00:01 +15: test/client_default_test.dart: client defaults postgres explicit DEFAULT can choose database identity instead of the client value +00:01 +16: test/client_default_test.dart: generation reads factory references without executing them and supports moved output +00:02 +17: loading test/decimal_precision_test.dart +00:02 +17: test/decimal_precision_test.dart: decimal precision sqlite column coercion rounds signed ties and handles negative and excess scales +00:02 +18: test/decimal_precision_test.dart: decimal precision sqlite writes constrain columns while arithmetic and wider aggregates retain their result precision +00:02 +19: test/decimal_precision_test.dart: decimal precision sqlite batch inserts and upserts use column coercion and roll back as a unit +00:03 +20: test/decimal_precision_test.dart: decimal precision sqlite catalogs snapshots and imported declarations retain precision scale and defaults +00:05 +21: test/decimal_precision_test.dart: decimal precision sqlite reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:05 +22: test/decimal_precision_test.dart: decimal precision sqlite physical renames and changing constrained defaults preserve column contracts +00:05 +23: test/decimal_precision_test.dart: decimal precision sqlite maximum declared precision and scale bounds remain exact +00:05 +24: test/decimal_precision_test.dart: decimal precision sqlite historical backfills retain constrained decimal key declarations +00:05 +25: test/decimal_precision_test.dart: decimal precision postgres column coercion rounds signed ties and handles negative and excess scales +00:05 +26: test/decimal_precision_test.dart: decimal precision postgres writes constrain columns while arithmetic and wider aggregates retain their result precision +00:05 +27: test/decimal_precision_test.dart: decimal precision postgres batch inserts and upserts use column coercion and roll back as a unit +00:05 +28: test/decimal_precision_test.dart: decimal precision postgres catalogs snapshots and imported declarations retain precision scale and defaults +00:05 +29: test/decimal_precision_test.dart: decimal precision postgres reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:06 +30: test/decimal_precision_test.dart: decimal precision postgres physical renames and changing constrained defaults preserve column contracts +00:06 +31: test/decimal_precision_test.dart: decimal precision postgres maximum declared precision and scale bounds remain exact +00:06 +32: test/decimal_precision_test.dart: decimal precision postgres historical backfills retain constrained decimal key declarations +00:06 +33: test/decimal_precision_test.dart: SQLite enforces stored precision and checks default coercion and trusted-schema behavior +00:06 +34: test/decimal_precision_test.dart: generation validates DecimalDigits and retains custom decimal codec types +00:08 +35: test/decimal_precision_test.dart: metadata rejects invalid precisions scales and non-decimal columns +00:08 +36: loading test/selection_test.dart +00:08 +36: test/selection_test.dart: selection sqlite all typed arities defer mapping and preserve left-to-right evaluation +00:08 +37: test/selection_test.dart: selection sqlite first decoder failure prevents later fields and outer mapper +00:08 +38: test/selection_test.dart: selection sqlite dynamic fields snapshot keys and preserve nulls and mapping order +00:08 +39: test/selection_test.dart: selection postgres all typed arities defer mapping and preserve left-to-right evaluation +00:08 +40: test/selection_test.dart: selection postgres first decoder failure prevents later fields and outer mapper +00:08 +41: test/selection_test.dart: selection postgres dynamic fields snapshot keys and preserve nulls and mapping order +00:08 +42: loading test/build_runner_test.dart +00:08 +42: test/build_runner_test.dart: real build_runner build/watch tracks imports, recovers errors and deletes stale outputs +00:39 +43: loading test/schema_version_test.dart +00:39 +43: test/schema_version_test.dart: schema version sqlite invalid ranges fail before SQL; caller transactions stay usable +00:39 +44: test/schema_version_test.dart: schema version sqlite unversioned existing data is rejected without creating history +00:39 +45: test/schema_version_test.dart: schema version sqlite default requires latest; inclusive compatibility range is explicit +00:39 +46: test/schema_version_test.dart: schema version sqlite newer database rejects downgrade without changing data or schema +00:39 +47: test/schema_version_test.dart: schema version sqlite complete applied prefix is checked, including old checksums and missing entries +00:39 +48: test/schema_version_test.dart: schema version sqlite matching version does not claim that the catalog is free of drift +00:39 +49: test/schema_version_test.dart: schema version sqlite verified baseline is compatible without replaying creation +00:39 +50: test/schema_version_test.dart: schema version sqlite borrowed session uses a read snapshot and survives rejected versions +00:39 +51: test/schema_version_test.dart: schema version postgres invalid ranges fail before SQL; caller transactions stay usable +00:39 +52: test/schema_version_test.dart: schema version postgres unversioned existing data is rejected without creating history +00:39 +53: test/schema_version_test.dart: schema version postgres default requires latest; inclusive compatibility range is explicit +00:39 +54: test/schema_version_test.dart: schema version postgres newer database rejects downgrade without changing data or schema +00:39 +55: test/schema_version_test.dart: schema version postgres complete applied prefix is checked, including old checksums and missing entries +00:39 +56: test/schema_version_test.dart: schema version postgres matching version does not claim that the catalog is free of drift +00:39 +57: test/schema_version_test.dart: schema version postgres verified baseline is compatible without replaying creation +00:39 +58: test/schema_version_test.dart: schema version postgres borrowed session uses a read snapshot and survives rejected versions +00:39 +59: test/schema_version_test.dart: schema version postgres unfinished recoverable migration blocks startup until repaired and completed +00:39 +60: test/schema_version_test.dart: schema version postgres catalog and history share a snapshot and the supplied history is frozen +00:40 +61: test/schema_version_test.dart: SQLite version gate works on an explicitly read-only file +00:40 +62: loading test/types_test.dart +00:40 +62: test/types_test.dart: junction-table keys, enum payloads and nested results keep generated types +00:41 +63: test/types_test.dart: computed generated fields are statically readable and never writable +00:42 +64: test/types_test.dart: client-default create parameters preserve domain, nullable and timestamp types +00:43 +65: test/types_test.dart: query-only relation APIs retain result types and expose no graph writes +00:43 +66: test/types_test.dart: calendar APIs reject instants, strings and mixed temporal types +00:43 +67: test/types_test.dart: actual generated APIs reject wrong inputs, results and backend options +00:44 +68: loading test/migration_test.dart +00:44 +68: test/migration_test.dart: (setUpAll) +00:44 +68: test/migration_test.dart: migrations sqlite explicit table and column renames preserve incoming references and data +00:44 +69: test/migration_test.dart: migrations sqlite constraint changes resolve catalog names and retain rows +00:44 +70: test/migration_test.dart: migrations sqlite failed NOT NULL rebuild restores data history and foreign key settings +00:44 +71: test/migration_test.dart: migrations sqlite reviewed conversion expressions preserve values when storage types change +00:44 +72: test/migration_test.dart: migrations sqlite additive changes use ALTER without a table rebuild +00:44 +73: test/migration_test.dart: migrations sqlite reviewed table removal handles restrictive foreign keys atomically +00:44 +74: test/migration_test.dart: migrations sqlite broken migration chain is refused before DDL +00:44 +75: test/migration_test.dart: migrations sqlite catalog distinguishes nullable non-rowid primary keys +00:44 +76: test/migration_test.dart: migrations sqlite rebuild preserves unmanaged index trigger and view definitions +00:44 +77: test/migration_test.dart: migrations sqlite foreign key check rolls back changed parent keys without cascading data away +00:44 +78: test/migration_test.dart: migrations sqlite unmodeled CHECK constraints are refused without losing their definition +00:44 +79: test/migration_test.dart: migrations postgres explicit table and column renames preserve incoming references and data +00:44 +80: test/migration_test.dart: migrations postgres constraint changes resolve catalog names and retain rows +00:44 +81: test/migration_test.dart: migrations postgres failed NOT NULL rebuild restores data history and foreign key settings +00:44 +82: test/migration_test.dart: migrations postgres reviewed conversion expressions preserve values when storage types change +00:44 +83: test/migration_test.dart: migrations postgres additive changes use ALTER without a table rebuild +00:44 +84: test/migration_test.dart: migrations postgres reviewed table removal handles restrictive foreign keys atomically +00:44 +85: test/migration_test.dart: migrations postgres broken migration chain is refused before DDL +00:44 +86: test/migration_test.dart: diff requires explicit renames, casts and backfills +00:44 +87: test/migration_test.dart: (tearDownAll) +00:44 +87: loading test/many_to_many_test.dart +00:45 +87: test/many_to_many_test.dart: many-to-many sqlite two declared FKs and a composite primary key enforce association identity +00:45 +88: test/many_to_many_test.dart: many-to-many sqlite forward projection keeps payload, shared targets and per-user limits in two statements +00:45 +89: test/many_to_many_test.dart: many-to-many sqlite reverse projection applies tie-breaking and offset to each team without changing root pagination +00:45 +90: test/many_to_many_test.dart: many-to-many sqlite nested team rosters deduplicate shared team keys and batch by level +00:45 +91: test/many_to_many_test.dart: many-to-many sqlite association predicates and counts stay in one SQL statement +00:45 +92: test/many_to_many_test.dart: many-to-many sqlite limited parameter capacity chunks associations without changing per-parent limits +00:45 +93: test/many_to_many_test.dart: many-to-many sqlite transaction rollback restores payload changes and new endpoints after an invalid association +00:45 +94: test/many_to_many_test.dart: many-to-many sqlite composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:45 +95: test/many_to_many_test.dart: many-to-many sqlite watch tracks junction payload and opposite endpoint changes after commit +00:45 +96: test/many_to_many_test.dart: many-to-many postgres two declared FKs and a composite primary key enforce association identity +00:45 +97: test/many_to_many_test.dart: many-to-many postgres forward projection keeps payload, shared targets and per-user limits in two statements +00:45 +98: test/many_to_many_test.dart: many-to-many postgres reverse projection applies tie-breaking and offset to each team without changing root pagination +00:45 +99: test/many_to_many_test.dart: many-to-many postgres nested team rosters deduplicate shared team keys and batch by level +00:45 +100: test/many_to_many_test.dart: many-to-many postgres association predicates and counts stay in one SQL statement +00:45 +101: test/many_to_many_test.dart: many-to-many postgres limited parameter capacity chunks associations without changing per-parent limits +00:45 +102: test/many_to_many_test.dart: many-to-many postgres transaction rollback restores payload changes and new endpoints after an invalid association +00:45 +103: test/many_to_many_test.dart: many-to-many postgres composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:45 +104: test/many_to_many_test.dart: many-to-many postgres watch tracks junction payload and opposite endpoint changes after commit +00:45 +105: loading test/stream_test.dart +00:45 +105: test/stream_test.dart: sqlite cancellation between batch chunks prevents a partial commit +00:45 +106: test/stream_test.dart: sqlite cancellation interrupts an active cursor fetch +00:45 +107: test/stream_test.dart: sqlite timed-out mutations return no partial write +00:45 +108: test/stream_test.dart: sqlite database close stops a paused cursor and releases the driver +00:45 +109: test/stream_test.dart: sqlite cancellation between stream creation and listening needs no rollback +00:45 +110: test/stream_test.dart: sqlite bounded cursor fetches preserve parameters and typed rows +00:45 +111: test/stream_test.dart: sqlite pause stops fetches and early cancellation releases the lease +00:45 +112: test/stream_test.dart: sqlite await-for provides demand and take closes without exhausting +00:45 +113: test/stream_test.dart: sqlite relation projections load once per root batch +00:45 +114: test/stream_test.dart: sqlite mapper failure closes the cursor and rolls back +00:45 +115: test/stream_test.dart: sqlite early stream end inside a transaction preserves other work +00:45 +116: test/stream_test.dart: sqlite escaping paused stream cannot keep a transaction alive +00:45 +117: test/stream_test.dart: sqlite cancels executing SQL and allows the next statement +00:45 +118: test/stream_test.dart: sqlite statement timeout stops SQL and leaves a usable connection +00:46 +119: test/stream_test.dart: sqlite cancelled statement poisons a transaction even if caught +00:46 +120: test/stream_test.dart: sqlite cancelling a paused stream releases its connection before resume +00:46 +121: test/stream_test.dart: sqlite cancellation before starting performs no SQL +00:46 +122: test/stream_test.dart: postgres cancellation between batch chunks prevents a partial commit +00:46 +123: test/stream_test.dart: postgres cancellation interrupts an active cursor fetch +00:46 +124: test/stream_test.dart: postgres timed-out mutations return no partial write +00:46 +125: test/stream_test.dart: postgres database close stops a paused cursor and releases the driver +00:46 +126: test/stream_test.dart: postgres cancellation between stream creation and listening needs no rollback +00:46 +127: test/stream_test.dart: postgres bounded cursor fetches preserve parameters and typed rows +00:46 +128: test/stream_test.dart: postgres pause stops fetches and early cancellation releases the lease +00:46 +129: test/stream_test.dart: postgres await-for provides demand and take closes without exhausting +00:46 +130: test/stream_test.dart: postgres relation projections load once per root batch +00:46 +131: test/stream_test.dart: postgres mapper failure closes the cursor and rolls back +00:46 +132: test/stream_test.dart: postgres early stream end inside a transaction preserves other work +00:46 +133: test/stream_test.dart: postgres escaping paused stream cannot keep a transaction alive +00:46 +134: test/stream_test.dart: postgres cancels executing SQL and allows the next statement +00:46 +135: test/stream_test.dart: postgres statement timeout stops SQL and leaves a usable connection +00:46 +136: test/stream_test.dart: postgres cancelled statement poisons a transaction even if caught +00:46 +137: test/stream_test.dart: postgres cancelling a paused stream releases its connection before resume +00:46 +138: test/stream_test.dart: postgres cancellation before starting performs no SQL +00:46 +139: test/stream_test.dart: late PostgreSQL cancellation cannot reach the next statement +00:47 +140: test/stream_test.dart: borrowed pool requires cancellation support and retains ownership +00:47 +141: test/stream_test.dart: failed cancellation control discards the physical connection +00:47 +142: loading test/migration_recovery_test.dart +00:47 +142: test/migration_recovery_test.dart: (setUpAll) +00:47 +142: test/migration_recovery_test.dart: SQLite and transaction controls reject recoverable autocommit steps +00:47 +143: test/migration_recovery_test.dart: mixed migrations checkpoint SQL and verify concurrent indexes +00:57 +144: test/migration_recovery_test.dart: same-name wrong-definition indexes are never accepted as completion +00:57 +145: test/migration_recovery_test.dart: failed unique builds leave INVALID indexes and require explicit repair +00:57 +146: test/migration_recovery_test.dart: failed attempts freeze migration checksums and block baseline +00:57 +147: test/migration_recovery_test.dart: failed postconditions report their actual failing stage +00:57 +148: test/migration_recovery_test.dart: recovery probes must produce one boolean value +00:57 +149: test/migration_recovery_test.dart: concurrent runners serialize normal and autocommit steps under one lock +00:57 +150: test/migration_recovery_test.dart: runner lock timeout leaves history untouched and the connection reusable +00:57 +151: test/migration_recovery_test.dart: ordinary SQL inside a mixed migration rolls back only its failed step and resumes +00:57 +152: test/migration_recovery_test.dart: completion checks reject partial and NULLS NOT DISTINCT definitions +00:57 +153: test/migration_recovery_test.dart: real process exit after concurrent DDL resumes without replay +01:03 +154: test/migration_recovery_test.dart: real process exit after transaction commit resumes without replay +01:07 +155: test/migration_recovery_test.dart: (tearDownAll) +01:07 +155: loading test/acquisition_test.dart +01:07 +155: test/acquisition_test.dart: an expired lease cannot enter before a delayed Timer gets its turn +01:07 +156: test/acquisition_test.dart: acquisition sqlite expired raw writes never execute after a late lease becomes available +01:08 +157: test/acquisition_test.dart: acquisition sqlite query cancellation returns while the pool is still occupied +01:08 +158: test/acquisition_test.dart: acquisition sqlite typed mutations and batch transactions propagate acquisition limits +01:08 +159: test/acquisition_test.dart: acquisition sqlite expired transactions and sessions never enter their callbacks +01:08 +160: test/acquisition_test.dart: acquisition sqlite acquisition timeout stops on entry and statement timeout starts afterward +01:08 +161: test/acquisition_test.dart: acquisition sqlite invalid and already-cancelled acquisition options never queue work +01:08 +162: test/acquisition_test.dart: acquisition sqlite stream cancellation and timeout return without opening a cursor +01:08 +163: test/acquisition_test.dart: acquisition sqlite query watch acquisition failure can recover on a later invalidation +01:08 +164: test/acquisition_test.dart: acquisition sqlite close drains abandoned reservations without running their callbacks +01:08 +165: test/acquisition_test.dart: acquisition sqlite abandoned requests preserve pool reuse and subsequent action errors +01:08 +166: test/acquisition_test.dart: PostgreSQL native pool timeout is classified before entering the callback +01:08 +167: test/acquisition_test.dart: PostgreSQL connection establishment timeout does not cap pool waiting +01:09 +168: test/acquisition_test.dart: acquisition postgres expired raw writes never execute after a late lease becomes available +01:09 +169: test/acquisition_test.dart: acquisition postgres query cancellation returns while the pool is still occupied +01:09 +170: test/acquisition_test.dart: acquisition postgres typed mutations and batch transactions propagate acquisition limits +01:09 +171: test/acquisition_test.dart: acquisition postgres expired transactions and sessions never enter their callbacks +01:09 +172: test/acquisition_test.dart: acquisition postgres acquisition timeout stops on entry and statement timeout starts afterward +01:09 +173: test/acquisition_test.dart: acquisition postgres invalid and already-cancelled acquisition options never queue work +01:09 +174: test/acquisition_test.dart: acquisition postgres stream cancellation and timeout return without opening a cursor +01:09 +175: test/acquisition_test.dart: acquisition postgres query watch acquisition failure can recover on a later invalidation +01:09 +176: test/acquisition_test.dart: acquisition postgres close drains abandoned reservations without running their callbacks +01:09 +177: test/acquisition_test.dart: acquisition postgres abandoned requests preserve pool reuse and subsequent action errors +01:09 +178: test/acquisition_test.dart: PostgreSQL timeout during real connection initialization releases the late lease +01:09 +179: loading test/computed_test.dart +01:10 +179: test/computed_test.dart: computed sqlite native expressions preserve quoted identifiers, comments and SQL-like string values +01:10 +180: test/computed_test.dart: computed sqlite generated CRUD, projections, batch and conflict writes compute stored and virtual values +01:10 +181: test/computed_test.dart: computed sqlite catalog/import roundtrip preserves SQL and modes and detects drift +01:12 +182: test/computed_test.dart: computed sqlite ComputedStorage.stored add, expression/type change and drop preserve existing rows +01:12 +183: test/computed_test.dart: computed sqlite ComputedStorage.virtual add, expression/type change and drop preserve existing rows +01:12 +184: test/computed_test.dart: computed sqlite stored to ordinary materialization retains values and permits later writes +01:12 +185: test/computed_test.dart: computed sqlite explicit renamed expressions preserve indexes, checks, references and views +01:12 +186: test/computed_test.dart: computed sqlite invalid recomputation rolls back the table and migration history +01:12 +187: test/computed_test.dart: computed sqlite decimal computed SQL keeps precision coercion and imports exactly once +01:12 +188: test/computed_test.dart: computed postgres native expressions preserve quoted identifiers, comments and SQL-like string values +01:13 +189: test/computed_test.dart: computed postgres generated CRUD, projections, batch and conflict writes compute stored and virtual values +01:13 +190: test/computed_test.dart: computed postgres catalog/import roundtrip preserves SQL and modes and detects drift +01:13 +191: test/computed_test.dart: computed postgres ComputedStorage.stored add, expression/type change and drop preserve existing rows +01:13 +192: test/computed_test.dart: computed postgres ComputedStorage.virtual add, expression/type change and drop preserve existing rows +01:13 +193: test/computed_test.dart: computed postgres stored to ordinary materialization retains values and permits later writes +01:13 +194: test/computed_test.dart: computed postgres explicit renamed expressions preserve indexes, checks, references and views +01:13 +195: test/computed_test.dart: computed postgres invalid recomputation rolls back the table and migration history +01:13 +196: test/computed_test.dart: computed postgres decimal computed SQL keeps precision coercion and imports exactly once +01:14 +197: test/computed_test.dart: mode conversions need explicit plans and historical copy/backfill cannot write computed columns +01:14 +198: loading test/named_sql_test.dart +01:14 +198: test/named_sql_test.dart: template binds repeated names once and preserves quotes/comments +01:14 +199: test/named_sql_test.dart: templates reject ambiguous bindings and incomplete or multiple statements +01:14 +200: test/named_sql_test.dart: generated client and manifest are deterministic and fresh +01:16 +201: test/named_sql_test.dart: sqlite typed parameters, projection and repeated nullable bindings compose +01:16 +202: test/named_sql_test.dart: sqlite native numeric and temporal codecs survive binding and streaming +01:16 +203: test/named_sql_test.dart: sqlite CTEs and unions retain distinct query arguments and field identity +01:16 +204: test/named_sql_test.dart: sqlite transactions see pending writes and rollback normally +01:16 +205: test/named_sql_test.dart: sqlite named sources reject table mutations and require explicit watch reads +01:16 +206: test/named_sql_test.dart: sqlite native checks accept declared shape and preserve prepared-statement state +01:17 +207: test/named_sql_test.dart: sqlite native checks reject syntax, missing tables and missing result aliases +01:17 +208: test/named_sql_test.dart: sqlite native checks do not evaluate application expressions +01:17 +209: test/named_sql_test.dart: postgres typed parameters, projection and repeated nullable bindings compose +01:17 +210: test/named_sql_test.dart: postgres native numeric and temporal codecs survive binding and streaming +01:17 +211: test/named_sql_test.dart: postgres CTEs and unions retain distinct query arguments and field identity +01:17 +212: test/named_sql_test.dart: postgres transactions see pending writes and rollback normally +01:17 +213: test/named_sql_test.dart: postgres named sources reject table mutations and require explicit watch reads +01:17 +214: test/named_sql_test.dart: postgres native checks accept declared shape and preserve prepared-statement state +01:17 +215: test/named_sql_test.dart: postgres native checks reject syntax, missing tables and missing result aliases +01:17 +216: test/named_sql_test.dart: postgres native checks do not evaluate application expressions +01:17 +217: loading test/named_sql_generation_test.dart +01:18 +217: test/named_sql_generation_test.dart: source contracts reject missing/extra parameters and row metadata +01:21 +218: test/named_sql_generation_test.dart: SQL edits and modified generated code fail freshness checks +01:23 +219: test/named_sql_generation_test.dart: asset builder and standalone generator emit identical SQL contracts +01:24 +220: test/named_sql_generation_test.dart: generated APIs reject missing/wrong parameters, result types and unsupported backends +01:24 +221: test/named_sql_generation_test.dart: CLI checks a persistent SQLite database without creating missing files +01:44 +222: loading test/decimal_average_test.dart +01:44 +222: test/decimal_average_test.dart: integer fractions round once and only bound their final result +01:45 +223: test/decimal_average_test.dart: decimal average sqlite empty null and constant inputs retain aggregate semantics +01:46 +224: test/decimal_average_test.dart: decimal average sqlite all rounding modes match exact signed sums across scales +01:46 +225: test/decimal_average_test.dart: decimal average sqlite valid means survive sums exceeding the finite numeric range +01:48 +226: test/decimal_average_test.dart: decimal average sqlite minimum fractions and outermost rounding scales stay exact +01:48 +227: test/decimal_average_test.dart: decimal average sqlite grouping HAVING ordering and CTE references preserve the mean +01:48 +228: test/decimal_average_test.dart: decimal average sqlite illegal window nesting and aggregate assignments fail before SQL +01:48 +229: test/decimal_average_test.dart: decimal average sqlite windows and rounded window means respect partitions and pagination +01:48 +230: test/decimal_average_test.dart: decimal average sqlite correlated means can update values and failures roll back transactions +01:48 +231: test/decimal_average_test.dart: decimal average sqlite batched relationship windows keep per-parent limits +01:48 +232: test/decimal_average_test.dart: decimal average sqlite sliding windows remove values, reset empty frames and reject varying policies +01:48 +233: test/decimal_average_test.dart: decimal average sqlite window means of grouped sums run after HAVING +01:48 +234: test/decimal_average_test.dart: decimal average postgres empty null and constant inputs retain aggregate semantics +01:48 +235: test/decimal_average_test.dart: decimal average postgres all rounding modes match exact signed sums across scales +01:48 +236: test/decimal_average_test.dart: decimal average postgres valid means survive sums exceeding the finite numeric range +01:48 +237: test/decimal_average_test.dart: decimal average postgres minimum fractions and outermost rounding scales stay exact +01:48 +238: test/decimal_average_test.dart: decimal average postgres grouping HAVING ordering and CTE references preserve the mean +01:48 +239: test/decimal_average_test.dart: decimal average postgres illegal window nesting and aggregate assignments fail before SQL +01:48 +240: test/decimal_average_test.dart: decimal average postgres windows and rounded window means respect partitions and pagination +01:48 +241: test/decimal_average_test.dart: decimal average postgres correlated means can update values and failures roll back transactions +01:48 +242: test/decimal_average_test.dart: decimal average postgres batched relationship windows keep per-parent limits +01:48 +243: test/decimal_average_test.dart: decimal average postgres window means of grouped sums run after HAVING +01:48 +244: test/decimal_average_test.dart: decimal average postgres volatile inputs and window ordering are evaluated once per row +01:48 +245: loading test/instant_test.dart +01:48 +245: test/instant_test.dart: strict UTC parsing handles eras, offsets, endpoints and invalid components +01:48 +246: test/instant_test.dart: UTC defaults and local DateTime inputs do not depend on the process timezone +01:50 +247: test/instant_test.dart: historical migration checksum and timestamp tag remain immutable +01:50 +247 -1: test/instant_test.dart: historical migration checksum and timestamp tag remain immutable [E] + PathNotFoundException: Cannot open file, path = 'test/support/instants/0001_legacy.json' (OS Error: No such file or directory, errno = 2) + test/instant_test.dart 92:11 main. + +01:50 +247 -1: test/instant_test.dart: UTC instant sqlite native round trips and streams preserve exact microseconds and common endpoints +01:50 +248 -1: test/instant_test.dart: UTC instant sqlite chronological comparisons, grouping, windows, union and CTEs use instants +01:50 +249 -1: test/instant_test.dart: UTC instant sqlite offset-equivalent keys join and batch through the same unique index +01:50 +250 -1: test/instant_test.dart: UTC instant sqlite current snapshots, physical catalog and imported DateTime declarations agree +01:52 +251 -1: test/instant_test.dart: UTC instant sqlite offset literals compare by value while expression casts remain visible +01:52 +252 -1: test/instant_test.dart: UTC instant sqlite reviewed migration preserves history and repairs legacy timestamp ordering +01:52 +253 -1: test/instant_test.dart: UTC instant sqlite legacy key collisions and invalid calendar values roll back conversion +01:52 +254 -1: test/instant_test.dart: UTC instant sqlite instant primary-key backfills resume across sub-millisecond boundaries +01:52 +255 -1: test/instant_test.dart: UTC instant sqlite external infinity and out-of-DateTime values fail decoding without wrapping +01:52 +256 -1: test/instant_test.dart: UTC instant postgres native round trips and streams preserve exact microseconds and common endpoints +01:52 +257 -1: test/instant_test.dart: UTC instant postgres chronological comparisons, grouping, windows, union and CTEs use instants +01:52 +258 -1: test/instant_test.dart: UTC instant postgres offset-equivalent keys join and batch through the same unique index +01:52 +259 -1: test/instant_test.dart: UTC instant postgres current snapshots, physical catalog and imported DateTime declarations agree +01:53 +260 -1: test/instant_test.dart: UTC instant postgres offset literals compare by value while expression casts remain visible +01:53 +261 -1: test/instant_test.dart: UTC instant postgres reviewed migration preserves history and repairs legacy timestamp ordering +01:53 +262 -1: test/instant_test.dart: UTC instant postgres instant primary-key backfills resume across sub-millisecond boundaries +01:53 +263 -1: test/instant_test.dart: UTC instant postgres external infinity and out-of-DateTime values fail decoding without wrapping +01:53 +264 -1: loading test/codec_test.dart +01:53 +264 -1: test/codec_test.dart: numeric aggregate text decodes without integer rounding or overflow +01:53 +265 -1: test/codec_test.dart: erased codecs keep checked argument types without function getter casts +01:53 +266 -1: test/codec_test.dart: mapped domain codecs preserve one decode and one encode boundary +01:53 +267 -1: test/codec_test.dart: parsed JSON scalars and document presence have unambiguous decoding +01:53 +268 -1: test/codec_test.dart: enum mappings reject duplicates and do not follow mutable caller maps +01:53 +269 -1: loading test/sql_real_test.dart +01:53 +269 -1: test/sql_real_test.dart: sqlite explicitly typed REAL parameters preserve floating storage +01:53 +270 -1: test/sql_real_test.dart: postgres explicitly typed REAL parameters preserve floating storage +01:53 +271 -1: loading test/migration_source_test.dart +01:53 +271 -1: test/migration_source_test.dart: Dart emission preserves storage metadata and every operation without application imports +01:58 +272 -1: test/migration_source_test.dart: project CLI creates Dart history, upgrades a database and preserves recorded fingerprints +03:17 +273 -1: test/migration_source_test.dart: AOT migration bundle works without Dart sources or migration assets +03:28 +274 -1: loading test/generator_test.dart +03:28 +274 -1: test/generator_test.dart: (setUpAll) +03:28 +274 -1: test/generator_test.dart: record annotations, physical names, relations and snapshots +03:30 +275 -1: test/generator_test.dart: output is deterministic and matches the committed generated client +03:35 +276 -1: test/generator_test.dart: composite keys and more than six fields generate analyzable code +03:36 +277 -1: test/generator_test.dart: computed keys are rejected instead of executing a fake row +03:36 +278 -1: test/generator_test.dart: computed declarations reject conflicting defaults, empty SQL and invalid row identities +03:39 +279 -1: test/generator_test.dart: client defaults reject incompatible, asynchronous, private and ambiguous factories +03:41 +280 -1: test/generator_test.dart: CHECK declarations reject dynamic SQL, empty overrides and duplicate names +03:43 +281 -1: test/generator_test.dart: custom types resolve defining libraries when output moves to another directory +03:43 +282 -1: test/generator_test.dart: nested generic and record types preserve nullable aliases without running codecs +03:44 +283 -1: test/generator_test.dart: invalid custom declaration: wrong_codec +03:44 +284 -1: test/generator_test.dart: invalid custom declaration: nullable_codec +03:44 +285 -1: test/generator_test.dart: invalid custom declaration: erased_extension +03:45 +286 -1: test/generator_test.dart: invalid custom declaration: unknown_storage +03:45 +287 -1: test/generator_test.dart: invalid custom declaration: private_codec +03:45 +288 -1: test/generator_test.dart: invalid custom declaration: private_type +03:45 +289 -1: test/generator_test.dart: invalid custom declaration: inline_codec +03:46 +290 -1: test/generator_test.dart: invalid custom declaration: duplicate_codec +03:46 +291 -1: test/generator_test.dart: invalid custom declaration: missing_codec +03:46 +292 -1: test/generator_test.dart: invalid custom declaration: duplicate_labels +03:46 +293 -1: test/generator_test.dart: invalid custom declaration: duplicate_enum_annotation +03:47 +294 -1: test/generator_test.dart: foreign keys require unique targets +03:47 +295 -1: test/generator_test.dart: query-only edges do not alter physical snapshots or require unique targets +03:47 +296 -1: test/generator_test.dart: constrained and unconstrained edges retain separate migration meaning +03:48 +297 -1: test/generator_test.dart: invalid query-only relation: lookup_computed +03:48 +298 -1: test/generator_test.dart: invalid query-only relation: lookup_codec +03:48 +299 -1: test/generator_test.dart: invalid query-only relation: lookup_inverse +03:48 +300 -1: test/generator_test.dart: invalid query-only relation: lookup_type +03:49 +301 -1: test/generator_test.dart: invalid query-only relation: lookup_delete +03:49 +302 -1: test/generator_test.dart: Dart type errors stop generation +03:49 +303 -1: test/generator_test.dart: duplicate physical names and nullable primary keys are rejected +03:50 +304 -1: test/generator_test.dart: (tearDownAll) +03:50 +304 -1: loading test/transaction_test.dart +03:50 +304 -1: test/transaction_test.dart: transaction control sqlite idle callback expires, rolls back and cannot issue late SQL +03:50 +305 -1: test/transaction_test.dart: transaction control sqlite deadline interrupts active SQL before releasing and reusing the lease +03:50 +306 -1: test/transaction_test.dart: transaction control sqlite interrupted write rolls back and leaves the connection usable +03:50 +307 -1: test/transaction_test.dart: transaction control sqlite explicit transaction cancellation stops an idle callback and active SQL +03:50 +308 -1: test/transaction_test.dart: transaction control sqlite global transaction cancellation also abandons acquisition +03:50 +309 -1: test/transaction_test.dart: transaction control sqlite savepoint inherits deadline, drains and rolls back through raw cleanup +03:50 +310 -1: test/transaction_test.dart: transaction control sqlite deadline closes a paused transactional cursor before rollback +03:50 +311 -1: test/transaction_test.dart: transaction control sqlite deadline interrupts an active transactional cursor fetch +03:51 +312 -1: test/transaction_test.dart: transaction control sqlite transaction clock starts after acquisition and a borrowed session recovers +03:51 +313 -1: test/transaction_test.dart: transaction control sqlite failed rollback is explicit and discards an uncertain connection +03:51 +314 -1: test/transaction_test.dart: transaction control sqlite transaction controls reject adapters without actual cancellation +03:51 +315 -1: test/transaction_test.dart: transaction control sqlite shorter statement limit retains its error and rolls back the transaction +03:51 +316 -1: test/transaction_test.dart: transaction control sqlite monotonic deadline prevents commit after a CPU-bound callback +03:51 +317 -1: test/transaction_test.dart: transaction control sqlite completed scopes remove their timers and cancellation listeners +03:51 +318 -1: test/transaction_test.dart: transaction control sqlite confirmed commit wins a cancellation race; lost acknowledgement stays unknown +03:51 +319 -1: test/transaction_test.dart: transaction control sqlite deferred constraint rejection at COMMIT is a known failure +03:51 +320 -1: test/transaction_test.dart: transaction control sqlite invalid deadlines and already cancelled tokens never begin a transaction +03:51 +321 -1: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=false +03:51 +322 -1: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=true +03:51 +323 -1: test/transaction_test.dart: transaction control postgres idle callback expires, rolls back and cannot issue late SQL +03:51 +324 -1: test/transaction_test.dart: transaction control postgres deadline interrupts active SQL before releasing and reusing the lease +03:51 +325 -1: test/transaction_test.dart: transaction control postgres interrupted write rolls back and leaves the connection usable +03:51 +326 -1: test/transaction_test.dart: transaction control postgres explicit transaction cancellation stops an idle callback and active SQL +03:52 +327 -1: test/transaction_test.dart: transaction control postgres global transaction cancellation also abandons acquisition +03:52 +328 -1: test/transaction_test.dart: transaction control postgres savepoint inherits deadline, drains and rolls back through raw cleanup +03:52 +329 -1: test/transaction_test.dart: transaction control postgres deadline closes a paused transactional cursor before rollback +03:52 +330 -1: test/transaction_test.dart: transaction control postgres deadline interrupts an active transactional cursor fetch +03:52 +331 -1: test/transaction_test.dart: transaction control postgres transaction clock starts after acquisition and a borrowed session recovers +03:52 +332 -1: test/transaction_test.dart: transaction control postgres failed rollback is explicit and discards an uncertain connection +03:52 +333 -1: test/transaction_test.dart: transaction control postgres transaction controls reject adapters without actual cancellation +03:52 +334 -1: test/transaction_test.dart: transaction control postgres shorter statement limit retains its error and rolls back the transaction +03:52 +335 -1: test/transaction_test.dart: transaction control postgres monotonic deadline prevents commit after a CPU-bound callback +03:52 +336 -1: test/transaction_test.dart: transaction control postgres completed scopes remove their timers and cancellation listeners +03:53 +337 -1: test/transaction_test.dart: transaction control postgres confirmed commit wins a cancellation race; lost acknowledgement stays unknown +03:53 +338 -1: test/transaction_test.dart: transaction control postgres deferred constraint rejection at COMMIT is a known failure +03:53 +339 -1: test/transaction_test.dart: transaction control postgres invalid deadlines and already cancelled tokens never begin a transaction +03:53 +340 -1: test/transaction_test.dart: transaction control postgres SQLSTATE 40003 at COMMIT is never classified as a confirmed rejection +03:53 +341 -1: test/transaction_test.dart: transaction control postgres real PostgreSQL serialization failure is classified after rollback +03:53 +342 -1: test/transaction_test.dart: transaction control postgres real PostgreSQL deadlock rolls back exactly one transaction +03:54 +343 -1: test/transaction_test.dart: SQLite COMMIT busy is a known rejection followed by verified rollback +03:54 +344 -1: test/transaction_test.dart: SQLite deadline while BEGIN waits for a writer preserves its connection +03:54 +345 -1: loading test/backfill_keys_test.dart +03:54 +345 -1: test/backfill_keys_test.dart: backfill keys sqlite integer cursors preserve native values across resume +03:54 +346 -1: test/backfill_keys_test.dart: backfill keys sqlite bigint cursors preserve native values across resume +03:54 +347 -1: test/backfill_keys_test.dart: backfill keys sqlite text cursors preserve native values across resume +03:54 +348 -1: test/backfill_keys_test.dart: backfill keys sqlite real cursors preserve native values across resume +03:54 +349 -1: test/backfill_keys_test.dart: backfill keys sqlite boolean cursors preserve native values across resume +03:54 +350 -1: test/backfill_keys_test.dart: backfill keys sqlite timestamp cursors preserve native values across resume +03:54 +351 -1: test/backfill_keys_test.dart: backfill keys sqlite blob cursors preserve native values across resume +03:54 +352 -1: test/backfill_keys_test.dart: backfill keys sqlite composite key batches account for every bound parameter +03:54 +353 -1: test/backfill_keys_test.dart: backfill keys postgres integer cursors preserve native values across resume +03:54 +354 -1: test/backfill_keys_test.dart: backfill keys postgres bigint cursors preserve native values across resume +03:55 +355 -1: test/backfill_keys_test.dart: backfill keys postgres text cursors preserve native values across resume +03:55 +356 -1: test/backfill_keys_test.dart: backfill keys postgres real cursors preserve native values across resume +03:55 +357 -1: test/backfill_keys_test.dart: backfill keys postgres boolean cursors preserve native values across resume +03:55 +358 -1: test/backfill_keys_test.dart: backfill keys postgres timestamp cursors preserve native values across resume +03:55 +359 -1: test/backfill_keys_test.dart: backfill keys postgres blob cursors preserve native values across resume +03:55 +360 -1: test/backfill_keys_test.dart: backfill keys postgres composite key batches account for every bound parameter +03:55 +361 -1: test/backfill_keys_test.dart: invalid key, assignments and completion declarations fail before execution +03:55 +362 -1: loading test/retry_test.dart +03:55 +362 -1: test/retry_test.dart: retry sqlite opt-in retries roll back all writes and use new closed-over views +03:55 +363 -1: test/retry_test.dart: retry sqlite default is one attempt; an explicit attempt limit is exact +03:55 +364 -1: test/retry_test.dart: retry sqlite application and constraint errors and caught statement failures do not replay +03:55 +365 -1: test/retry_test.dart: retry sqlite unknown commit and unconfirmed rollback prohibit replay +03:55 +366 -1: test/retry_test.dart: retry sqlite retry time budget bounds active SQL and does not retry cancellation +03:55 +367 -1: test/retry_test.dart: retry sqlite deadline and cancellation interrupt backoff without another callback +03:55 +368 -1: test/retry_test.dart: retry sqlite time budget includes acquisition and abandons late callbacks +03:55 +369 -1: test/retry_test.dart: retry sqlite transaction timeout is shared across attempts and sessions recover after expiry +03:55 +370 -1: test/retry_test.dart: retry sqlite subscriptions publish only the final committed retry result +03:55 +371 -1: test/retry_test.dart: retry sqlite invalid policies reject before SQL +03:55 +372 -1: test/retry_test.dart: retry postgres opt-in retries roll back all writes and use new closed-over views +03:56 +373 -1: test/retry_test.dart: retry postgres default is one attempt; an explicit attempt limit is exact +03:56 +374 -1: test/retry_test.dart: retry postgres application and constraint errors and caught statement failures do not replay +03:56 +375 -1: test/retry_test.dart: retry postgres unknown commit and unconfirmed rollback prohibit replay +03:56 +376 -1: test/retry_test.dart: retry postgres retry time budget bounds active SQL and does not retry cancellation +03:56 +377 -1: test/retry_test.dart: retry postgres deadline and cancellation interrupt backoff without another callback +03:56 +378 -1: test/retry_test.dart: retry postgres time budget includes acquisition and abandons late callbacks +03:56 +379 -1: test/retry_test.dart: retry postgres transaction timeout is shared across attempts and sessions recover after expiry +03:56 +380 -1: test/retry_test.dart: retry postgres subscriptions publish only the final committed retry result +03:56 +381 -1: test/retry_test.dart: retry postgres invalid policies reject before SQL +03:56 +382 -1: test/retry_test.dart: retry postgres real unknown COMMIT response prohibits opted-in retry +03:56 +383 -1: test/retry_test.dart: retry postgres real serialization conflict repeats the reads and decisions +03:56 +384 -1: test/retry_test.dart: retry postgres real deadlock retries only the aborted callback +03:57 +385 -1: test/retry_test.dart: SQLite WAL snapshot conflict replays reads after rollback +03:57 +386 -1: test/retry_test.dart: SQLite real busy COMMIT: success +03:57 +387 -1: test/retry_test.dart: SQLite real busy COMMIT: exhausted +03:57 +388 -1: test/retry_test.dart: SQLite real busy COMMIT: deadline +03:57 +389 -1: test/retry_test.dart: SQLite real busy COMMIT: cancel +03:57 +390 -1: test/retry_test.dart: SQLite real busy COMMIT: shared-budget +03:58 +391 -1: loading test/builder_test.dart +03:58 +391 -1: test/builder_test.dart: build assets and standalone CLI produce identical client and snapshot +04:01 +392 -1: test/builder_test.dart: multiple explicit schema roots resolve shared public types under lib +04:01 +393 -1: test/builder_test.dart: invalid semantic_error never publishes partial outputs +04:02 +394 -1: test/builder_test.dart: invalid enum_labels never publishes partial outputs +04:03 +395 -1: test/builder_test.dart: invalid no_models never publishes partial outputs +04:04 +396 -1: test/builder_test.dart: invalid part_file never publishes partial outputs +04:04 +397 -1: test/builder_test.dart: unknown options fail instead of silently changing build behavior +04:04 +398 -1: test/builder_test.dart: schema resolution can consume an earlier builder output +04:05 +399 -1: loading test/temporal_precision_test.dart +04:05 +399 -1: test/temporal_precision_test.dart: temporal precision sqlite generated writes, defaults and computed columns use database precision +04:05 +400 -1: test/temporal_precision_test.dart: temporal precision sqlite SQL casts match all precisions on both sides of the PostgreSQL epoch +04:05 +401 -1: test/temporal_precision_test.dart: temporal precision sqlite nulls, CTE references, grouping, streams and wrong scopes retain type semantics +04:05 +402 -1: test/temporal_precision_test.dart: temporal precision sqlite rounded keys work in batch writes, upserts and foreign-key relation loading +04:05 +403 -1: test/temporal_precision_test.dart: temporal precision sqlite catalog import preserves precision, defaults and computed expressions +04:08 +404 -1: test/temporal_precision_test.dart: temporal precision sqlite narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +04:08 +405 -1: test/temporal_precision_test.dart: temporal precision sqlite timestamp defaults and explicit six-digit catalogs retain precision +04:08 +406 -1: test/temporal_precision_test.dart: temporal precision sqlite extended timestamps round in SQL without total-microsecond overflow +04:08 +407 -1: test/temporal_precision_test.dart: temporal precision sqlite raw SQL respects native or managed precision constraints +04:08 +408 -1: test/temporal_precision_test.dart: temporal precision postgres generated writes, defaults and computed columns use database precision +04:08 +409 -1: test/temporal_precision_test.dart: temporal precision postgres SQL casts match all precisions on both sides of the PostgreSQL epoch +04:08 +410 -1: test/temporal_precision_test.dart: temporal precision postgres nulls, CTE references, grouping, streams and wrong scopes retain type semantics +04:08 +411 -1: test/temporal_precision_test.dart: temporal precision postgres rounded keys work in batch writes, upserts and foreign-key relation loading +04:08 +412 -1: test/temporal_precision_test.dart: temporal precision postgres catalog import preserves precision, defaults and computed expressions +04:08 +413 -1: test/temporal_precision_test.dart: temporal precision postgres narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +04:09 +414 -1: test/temporal_precision_test.dart: temporal precision postgres timestamp defaults and explicit six-digit catalogs retain precision +04:09 +415 -1: test/temporal_precision_test.dart: temporal precision postgres extended timestamps round in SQL without total-microsecond overflow +04:09 +416 -1: test/temporal_precision_test.dart: temporal precision postgres raw SQL respects native or managed precision constraints +04:09 +417 -1: test/temporal_precision_test.dart: value rounding keeps extended ranges and rejects overflow/invalid precision +04:09 +418 -1: test/temporal_precision_test.dart: default and explicit six preserve historical snapshots and migration checksums +04:09 +419 -1: test/temporal_precision_test.dart: generator rejects invalid annotations and preserves generated fixture metadata +04:11 +420 -1: loading test/observation_test.dart +04:11 +420 -1: test/observation_test.dart: observation sqlite actual lease queue and synchronous mapping have separate clocks +04:11 +421 -1: test/observation_test.dart: observation sqlite expired acquisition emits one failure and no late SQL +04:11 +422 -1: test/observation_test.dart: observation sqlite cancelled acquisition emits one failure and no late SQL +04:11 +423 -1: test/observation_test.dart: observation sqlite sessions transactions and savepoints inherit hooks and report reused leases +04:11 +424 -1: test/observation_test.dart: observation sqlite nested reads attribute decoding to each statement without repeating SQL +04:11 +425 -1: test/observation_test.dart: observation sqlite SQL and mapper failures belong to their own phases +04:11 +426 -1: test/observation_test.dart: observation sqlite cursor batches and returning writes produce decoding observations +04:11 +427 -1: test/observation_test.dart: observation sqlite observer exceptions preserve successful commits and original mapper errors +04:11 +428 -1: test/observation_test.dart: observation postgres actual lease queue and synchronous mapping have separate clocks +04:11 +429 -1: test/observation_test.dart: observation postgres expired acquisition emits one failure and no late SQL +04:11 +430 -1: test/observation_test.dart: observation postgres cancelled acquisition emits one failure and no late SQL +04:11 +431 -1: test/observation_test.dart: observation postgres sessions transactions and savepoints inherit hooks and report reused leases +04:12 +432 -1: test/observation_test.dart: observation postgres nested reads attribute decoding to each statement without repeating SQL +04:12 +433 -1: test/observation_test.dart: observation postgres SQL and mapper failures belong to their own phases +04:12 +434 -1: test/observation_test.dart: observation postgres cursor batches and returning writes produce decoding observations +04:12 +435 -1: test/observation_test.dart: observation postgres observer exceptions preserve successful commits and original mapper errors +04:12 +436 -1: test/observation_test.dart: synchronous driver acquisition failures are reported once +04:12 +437 -1: loading test/integer_test.dart +04:12 +437 -1: test/integer_test.dart: integer widths sqlite storage bounds, nulls and wider sums keep the normal integer value codec +04:12 +438 -1: test/integer_test.dart: integer widths sqlite relations, union and cursors share int expressions across physical widths +04:12 +439 -1: test/integer_test.dart: integer widths sqlite defaulted and quoted columns retain enforced nullable bounds +04:12 +440 -1: test/integer_test.dart: integer widths sqlite Dart snapshots and imports retain widths without confusing value codecs +04:14 +441 -1: test/integer_test.dart: integer widths sqlite widening and narrowing use reviewed conversions with atomic failure recovery +04:14 +442 -1: test/integer_test.dart: integer widths sqlite column renames preserve width metadata and range constraints +04:14 +443 -1: test/integer_test.dart: integer widths sqlite historical bounded backfills verify widths and resume normally +04:14 +444 -1: test/integer_test.dart: integer widths sqlite changing an identity width preserves generation and rejects out-of-range explicit IDs +04:14 +445 -1: test/integer_test.dart: integer widths sqlite quoted defaults and comments cannot masquerade as managed range checks +04:14 +446 -1: test/integer_test.dart: integer widths sqlite extra weaker expressions remain unmanaged and cannot claim a narrower width +04:14 +447 -1: test/integer_test.dart: integer widths postgres storage bounds, nulls and wider sums keep the normal integer value codec +04:14 +448 -1: test/integer_test.dart: integer widths postgres relations, union and cursors share int expressions across physical widths +04:14 +449 -1: test/integer_test.dart: integer widths postgres defaulted and quoted columns retain enforced nullable bounds +04:14 +450 -1: test/integer_test.dart: integer widths postgres Dart snapshots and imports retain widths without confusing value codecs +04:15 +451 -1: test/integer_test.dart: integer widths postgres widening and narrowing use reviewed conversions with atomic failure recovery +04:15 +452 -1: test/integer_test.dart: integer widths postgres column renames preserve width metadata and range constraints +04:15 +453 -1: test/integer_test.dart: integer widths postgres historical bounded backfills verify widths and resume normally +04:15 +454 -1: test/integer_test.dart: integer widths postgres changing an identity width preserves generation and rejects out-of-range explicit IDs +04:15 +455 -1: test/integer_test.dart: analyzer validates width annotations and integer-backed domain codecs +04:17 +456 -1: test/integer_test.dart: width declarations reject wrong kinds and ranges without changing default snapshots +04:17 +457 -1: loading test/relation_test.dart +04:17 +457 -1: test/relation_test.dart: sqlite automatic unique joins preserve root pagination in one statement +04:17 +458 -1: test/relation_test.dart: sqlite all-null projections still distinguish a present row from absence +04:17 +459 -1: test/relation_test.dart: sqlite explicit batch deduplicates composite keys and skips partial nulls +04:17 +460 -1: test/relation_test.dart: sqlite composite chunks include filter and window parameters with nested joins +04:17 +461 -1: test/relation_test.dart: sqlite nested self joins and two edges to the same table use distinct aliases +04:17 +462 -1: test/relation_test.dart: sqlite batch collection below a joined optional parent loads on the same plan +04:17 +463 -1: test/relation_test.dart: sqlite join refuses unproven cardinality while automatic batch checks it +04:17 +464 -1: test/relation_test.dart: sqlite filters and to-one pagination do not drop root rows +04:17 +465 -1: test/relation_test.dart: sqlite joined records survive CTE projection and cursor decoding +04:17 +466 -1: test/relation_test.dart: sqlite required tests row presence independently of nullable selected values +04:17 +467 -1: test/relation_test.dart: sqlite relation aggregates restore the alias used by a joined selection +04:17 +468 -1: test/relation_test.dart: sqlite reusing an identical edge deduplicates its join +04:17 +469 -1: test/relation_test.dart: sqlite different filters need fresh relation occurrences +04:17 +470 -1: test/relation_test.dart: sqlite large composite batches avoid expression-depth failure +04:17 +471 -1: test/relation_test.dart: postgres automatic unique joins preserve root pagination in one statement +04:17 +472 -1: test/relation_test.dart: postgres all-null projections still distinguish a present row from absence +04:17 +473 -1: test/relation_test.dart: postgres explicit batch deduplicates composite keys and skips partial nulls +04:17 +474 -1: test/relation_test.dart: postgres composite chunks include filter and window parameters with nested joins +04:17 +475 -1: test/relation_test.dart: postgres nested self joins and two edges to the same table use distinct aliases +04:17 +476 -1: test/relation_test.dart: postgres batch collection below a joined optional parent loads on the same plan +04:17 +477 -1: test/relation_test.dart: postgres join refuses unproven cardinality while automatic batch checks it +04:17 +478 -1: test/relation_test.dart: postgres filters and to-one pagination do not drop root rows +04:17 +479 -1: test/relation_test.dart: postgres joined records survive CTE projection and cursor decoding +04:17 +480 -1: test/relation_test.dart: postgres required tests row presence independently of nullable selected values +04:17 +481 -1: test/relation_test.dart: postgres relation aggregates restore the alias used by a joined selection +04:17 +482 -1: test/relation_test.dart: postgres reusing an identical edge deduplicates its join +04:17 +483 -1: test/relation_test.dart: postgres different filters need fresh relation occurrences +04:17 +484 -1: test/relation_test.dart: postgres large composite batches avoid expression-depth failure +04:17 +485 -1: loading test/generated_database_test.dart +04:17 +485 -1: test/generated_database_test.dart: (setUpAll) +04:17 +485 -1: test/generated_database_test.dart: generated sqlite generated client, DDL, relations, dates and patches work together +04:17 +486 -1: test/generated_database_test.dart: generated sqlite planning is read-only and repeat migration is a no-op +04:17 +487 -1: test/generated_database_test.dart: generated sqlite compiled physical snapshot and full managed catalog comparison +04:17 +488 -1: test/generated_database_test.dart: generated sqlite baseline preserves existing rows and verifies keys before recording history +04:17 +489 -1: test/generated_database_test.dart: generated sqlite full verification sees defaults and additional unique constraints +04:17 +490 -1: test/generated_database_test.dart: generated sqlite history tampering and missing applied migrations fail +04:17 +491 -1: test/generated_database_test.dart: generated sqlite failed DDL rolls back schema and history atomically +04:17 +492 -1: test/generated_database_test.dart: generated sqlite actual column drift is detected independently of history +04:17 +493 -1: test/generated_database_test.dart: generated sqlite transaction control cannot be hidden behind SQL comments +04:18 +494 -1: test/generated_database_test.dart: generated sqlite concurrent migration callers serialize against shared history +04:18 +495 -1: test/generated_database_test.dart: generated postgres generated client, DDL, relations, dates and patches work together +04:18 +496 -1: test/generated_database_test.dart: generated postgres planning is read-only and repeat migration is a no-op +04:18 +497 -1: test/generated_database_test.dart: generated postgres compiled physical snapshot and full managed catalog comparison +04:18 +498 -1: test/generated_database_test.dart: generated postgres baseline preserves existing rows and verifies keys before recording history +04:18 +499 -1: test/generated_database_test.dart: generated postgres full verification sees defaults and additional unique constraints +04:18 +500 -1: test/generated_database_test.dart: generated postgres history tampering and missing applied migrations fail +04:18 +501 -1: test/generated_database_test.dart: generated postgres failed DDL rolls back schema and history atomically +04:18 +502 -1: test/generated_database_test.dart: generated postgres actual column drift is detected independently of history +04:18 +503 -1: test/generated_database_test.dart: generated postgres transaction control cannot be hidden behind SQL comments +04:18 +504 -1: test/generated_database_test.dart: generated postgres concurrent migration callers serialize against shared history +04:18 +505 -1: test/generated_database_test.dart: (tearDownAll) +04:18 +505 -1: loading test/import_test.dart +04:18 +505 -1: test/import_test.dart: catalog import sqlite imports records, identities, defaults and constraints then baselines unchanged rows +04:22 +506 -1: test/import_test.dart: catalog import sqlite normalizes reserved, colliding and hostile identifiers without changing physical names +04:23 +507 -1: test/import_test.dart: catalog import sqlite composite and self relations preserve key order and unique indexes +04:24 +508 -1: test/import_test.dart: catalog import sqlite unsupported storage excludes its table while computed SQL imports with a review note +04:24 +509 -1: test/import_test.dart: catalog import sqlite read-only snapshot and selected scope reject temporary shadowing and active transactions +04:24 +510 -1: test/import_test.dart: catalog import sqlite physical storage mapping does not infer domain types from data +04:25 +511 -1: test/import_test.dart: catalog import sqlite views triggers and specialized indexes remain in the review report +04:25 +512 -1: test/import_test.dart: catalog import sqlite nullable primary keys, virtual tables and their storage tables need explicit handling +04:25 +513 -1: test/import_test.dart: catalog import sqlite foreign keys with coercible but different Dart types are reported +04:26 +514 -1: test/import_test.dart: catalog import postgres imports records, identities, defaults and constraints then baselines unchanged rows +04:27 +515 -1: test/import_test.dart: catalog import postgres normalizes reserved, colliding and hostile identifiers without changing physical names +04:28 +516 -1: test/import_test.dart: catalog import postgres composite and self relations preserve key order and unique indexes +04:29 +517 -1: test/import_test.dart: catalog import postgres unsupported storage excludes its table while computed SQL imports with a review note +04:29 +518 -1: test/import_test.dart: catalog import postgres read-only snapshot and selected scope reject temporary shadowing and active transactions +04:29 +519 -1: test/import_test.dart: catalog import postgres physical storage mapping does not infer domain types from data +04:29 +520 -1: test/import_test.dart: catalog import postgres views triggers and specialized indexes remain in the review report +04:30 +521 -1: test/import_test.dart: catalog import postgres policy reports retain roles, commands, mode and row-security state +04:30 +522 -1: test/import_test.dart: catalog import postgres always identity and inherited tables are not flattened into ordinary writable models +04:30 +523 -1: loading test/database_test.dart +04:30 +523 -1: test/database_test.dart: sqlite typed CRUD, exact projection and parameter binding +04:30 +524 -1: test/database_test.dart: sqlite record and DTO mapping run only when decoding +04:30 +525 -1: test/database_test.dart: sqlite immutable filters, empty IN, null and pagination +04:30 +526 -1: test/database_test.dart: sqlite cross-table identity and duplicate assignments rejected before IO +04:30 +527 -1: test/database_test.dart: sqlite commit, rollback, savepoint recovery and escaped session +04:30 +528 -1: test/database_test.dart: sqlite caught statement failures cannot masquerade as a commit +04:30 +529 -1: test/database_test.dart: sqlite recoverable constraint errors stay within their savepoint +04:30 +530 -1: test/database_test.dart: sqlite unawaited work is drained and rolled back +04:30 +531 -1: test/database_test.dart: sqlite close waits for active operations and rejects further use +04:30 +532 -1: test/database_test.dart: sqlite leased sessions retain one connection across transactions and expire +04:30 +533 -1: test/database_test.dart: sqlite native upsert updates atomically using existing and incoming values +04:30 +534 -1: test/database_test.dart: sqlite bulk inserts split at parameter limits and opt into returning +04:30 +535 -1: test/database_test.dart: sqlite a failed later bulk chunk rolls back all previous chunks +04:30 +536 -1: test/database_test.dart: sqlite bulk optional field shapes preserve defaults and explicit null +04:30 +537 -1: test/database_test.dart: sqlite relations CTEs preserve SQL column identity through mapped projections +04:30 +538 -1: test/database_test.dart: sqlite relations explicit aliases support multiple joins and self joins +04:30 +539 -1: test/database_test.dart: sqlite relations left join distinguishes a missing row from an all-null selection +04:30 +540 -1: test/database_test.dart: sqlite relations correlated scalar, IN and EXISTS use nested SQL scopes +04:30 +541 -1: test/database_test.dart: sqlite relations grouping is checked and window functions preserve row cardinality +04:30 +542 -1: test/database_test.dart: sqlite relations root pagination and each parent limit use two queries +04:30 +543 -1: test/database_test.dart: sqlite relations empty roots skip child queries; empty lists remain typed lists +04:30 +544 -1: test/database_test.dart: sqlite relations nested relation projections and optional all-null projection +04:30 +545 -1: test/database_test.dart: sqlite relations correlated any, none, count and every with SQL nulls +04:30 +546 -1: test/database_test.dart: sqlite relations relation parameters bind correctly in projection and filter +04:30 +547 -1: test/database_test.dart: sqlite keyset pagination uses a unique tie breaker in mixed directions +04:30 +548 -1: test/database_test.dart: sqlite keyset pagination handles null order and validates versioned tokens +04:30 +549 -1: test/database_test.dart: postgres typed CRUD, exact projection and parameter binding +04:30 +550 -1: test/database_test.dart: postgres record and DTO mapping run only when decoding +04:30 +551 -1: test/database_test.dart: postgres immutable filters, empty IN, null and pagination +04:30 +552 -1: test/database_test.dart: postgres cross-table identity and duplicate assignments rejected before IO +04:30 +553 -1: test/database_test.dart: postgres commit, rollback, savepoint recovery and escaped session +04:30 +554 -1: test/database_test.dart: postgres caught statement failures cannot masquerade as a commit +04:31 +555 -1: test/database_test.dart: postgres recoverable constraint errors stay within their savepoint +04:31 +556 -1: test/database_test.dart: postgres unawaited work is drained and rolled back +04:31 +557 -1: test/database_test.dart: postgres close waits for active operations and rejects further use +04:31 +558 -1: test/database_test.dart: postgres leased sessions retain one connection across transactions and expire +04:31 +559 -1: test/database_test.dart: postgres native upsert updates atomically using existing and incoming values +04:31 +560 -1: test/database_test.dart: postgres bulk inserts split at parameter limits and opt into returning +04:31 +561 -1: test/database_test.dart: postgres a failed later bulk chunk rolls back all previous chunks +04:31 +562 -1: test/database_test.dart: postgres bulk optional field shapes preserve defaults and explicit null +04:31 +563 -1: test/database_test.dart: postgres relations CTEs preserve SQL column identity through mapped projections +04:31 +564 -1: test/database_test.dart: postgres relations explicit aliases support multiple joins and self joins +04:31 +565 -1: test/database_test.dart: postgres relations left join distinguishes a missing row from an all-null selection +04:31 +566 -1: test/database_test.dart: postgres relations correlated scalar, IN and EXISTS use nested SQL scopes +04:31 +567 -1: test/database_test.dart: postgres relations grouping is checked and window functions preserve row cardinality +04:31 +568 -1: test/database_test.dart: postgres relations root pagination and each parent limit use two queries +04:31 +569 -1: test/database_test.dart: postgres relations empty roots skip child queries; empty lists remain typed lists +04:31 +570 -1: test/database_test.dart: postgres relations nested relation projections and optional all-null projection +04:31 +571 -1: test/database_test.dart: postgres relations correlated any, none, count and every with SQL nulls +04:31 +572 -1: test/database_test.dart: postgres relations relation parameters bind correctly in projection and filter +04:31 +573 -1: test/database_test.dart: postgres keyset pagination uses a unique tie breaker in mixed directions +04:31 +574 -1: test/database_test.dart: postgres keyset pagination handles null order and validates versioned tokens +04:31 +575 -1: loading test/union_test.dart +04:31 +575 -1: test/union_test.dart: union sqlite SQL duplicate and NULL semantics use one statement +04:31 +576 -1: test/union_test.dart: union sqlite heterogeneous records map only after SQL union and final pagination +04:31 +577 -1: test/union_test.dart: union sqlite operand limits, order, parameters and final filter stay scoped +04:31 +578 -1: test/union_test.dart: union sqlite repeated SQL expressions preserve positional columns +04:31 +579 -1: test/union_test.dart: union sqlite nested sets preserve association, CTE exports and streaming +04:31 +580 -1: test/union_test.dart: union sqlite same CTE names in distinct operands stay in separate scopes +04:31 +581 -1: test/union_test.dart: union sqlite SQL scalar, IN subqueries, aggregate and zero-row results compose +04:31 +582 -1: test/union_test.dart: union sqlite grouped branches and window branches remain SQL queries +04:31 +583 -1: test/union_test.dart: union sqlite bound constant projections retain Dart types +04:31 +584 -1: test/union_test.dart: union sqlite Dart maps and relation selections are rejected before execution +04:31 +585 -1: test/union_test.dart: union sqlite codecs and nullability cannot silently change across operands +04:31 +586 -1: test/union_test.dart: union sqlite shared domain codecs work, and nullable wrappers retain decoding semantics +04:31 +587 -1: test/union_test.dart: union sqlite export references and outer join nullability are guarded +04:31 +588 -1: test/union_test.dart: union sqlite compound mutations and mixed sessions fail before SQL +04:31 +589 -1: test/union_test.dart: union sqlite all record arities use typed SQL decoding +04:31 +590 -1: test/union_test.dart: union sqlite subscription observes committed changes in both operands +04:31 +591 -1: test/union_test.dart: union postgres SQL duplicate and NULL semantics use one statement +04:31 +592 -1: test/union_test.dart: union postgres heterogeneous records map only after SQL union and final pagination +04:31 +593 -1: test/union_test.dart: union postgres operand limits, order, parameters and final filter stay scoped +04:31 +594 -1: test/union_test.dart: union postgres repeated SQL expressions preserve positional columns +04:31 +595 -1: test/union_test.dart: union postgres nested sets preserve association, CTE exports and streaming +04:31 +596 -1: test/union_test.dart: union postgres same CTE names in distinct operands stay in separate scopes +04:31 +597 -1: test/union_test.dart: union postgres SQL scalar, IN subqueries, aggregate and zero-row results compose +04:31 +598 -1: test/union_test.dart: union postgres grouped branches and window branches remain SQL queries +04:31 +599 -1: test/union_test.dart: union postgres bound constant projections retain Dart types +04:31 +600 -1: test/union_test.dart: union postgres Dart maps and relation selections are rejected before execution +04:31 +601 -1: test/union_test.dart: union postgres codecs and nullability cannot silently change across operands +04:31 +602 -1: test/union_test.dart: union postgres shared domain codecs work, and nullable wrappers retain decoding semantics +04:31 +603 -1: test/union_test.dart: union postgres export references and outer join nullability are guarded +04:31 +604 -1: test/union_test.dart: union postgres compound mutations and mixed sessions fail before SQL +04:31 +605 -1: test/union_test.dart: union postgres all record arities use typed SQL decoding +04:31 +606 -1: test/union_test.dart: union postgres subscription observes committed changes in both operands +04:31 +607 -1: loading test/import_cli_test.dart +04:32 +607 -1: test/import_cli_test.dart: CLI sqlite imports reviews generates and baselines existing data without replacing files +05:26 +608 -1: test/import_cli_test.dart: CLI postgres imports reviews generates and baselines existing data without replacing files +06:16 +609 -1: loading test/decimal_division_test.dart +06:16 +609 -1: test/decimal_division_test.dart: decimal division sqlite signed modes and scales match exact integer arithmetic +06:17 +610 -1: test/decimal_division_test.dart: decimal division sqlite exact defaults reject lost digits and zero divisors, NULL propagates +06:17 +611 -1: test/decimal_division_test.dart: decimal division sqlite precision exceeds native division scale without double rounding +06:17 +612 -1: test/decimal_division_test.dart: decimal division sqlite huge remainders avoid overflowing intermediate scaled products +06:40 +613 -1: test/decimal_division_test.dart: decimal division sqlite outermost integer scale and result overflow are explicit +06:41 +614 -1: test/decimal_division_test.dart: decimal division sqlite aggregates windows CTEs predicates and unions compose +06:41 +615 -1: test/decimal_division_test.dart: decimal division sqlite expression assignments and transaction rollback preserve values +06:41 +616 -1: test/decimal_division_test.dart: decimal division sqlite window filtering ordering DISTINCT and pagination keep their SQL order +06:41 +617 -1: test/decimal_division_test.dart: decimal division sqlite windows over grouped aggregates run after HAVING +06:41 +618 -1: test/decimal_division_test.dart: decimal division sqlite window results compose through CTEs sets and correlated subqueries +06:41 +619 -1: test/decimal_division_test.dart: decimal division sqlite batched relations retain per-parent window pagination +06:41 +620 -1: test/decimal_division_test.dart: decimal division postgres signed modes and scales match exact integer arithmetic +06:41 +621 -1: test/decimal_division_test.dart: decimal division postgres exact defaults reject lost digits and zero divisors, NULL propagates +06:41 +622 -1: test/decimal_division_test.dart: decimal division postgres precision exceeds native division scale without double rounding +06:41 +623 -1: test/decimal_division_test.dart: decimal division postgres huge remainders avoid overflowing intermediate scaled products +07:03 +624 -1: test/decimal_division_test.dart: decimal division postgres outermost integer scale and result overflow are explicit +07:03 +625 -1: test/decimal_division_test.dart: decimal division postgres aggregates windows CTEs predicates and unions compose +07:03 +626 -1: test/decimal_division_test.dart: decimal division postgres expression assignments and transaction rollback preserve values +07:03 +627 -1: test/decimal_division_test.dart: decimal division postgres window filtering ordering DISTINCT and pagination keep their SQL order +07:03 +628 -1: test/decimal_division_test.dart: decimal division postgres windows over grouped aggregates run after HAVING +07:03 +629 -1: test/decimal_division_test.dart: decimal division postgres window results compose through CTEs sets and correlated subqueries +07:03 +630 -1: test/decimal_division_test.dart: decimal division postgres batched relations retain per-parent window pagination +07:03 +631 -1: test/decimal_division_test.dart: decimal division postgres volatile operands are evaluated exactly once each +07:03 +632 -1: loading test/cli_test.dart +07:03 +632 -1: test/cli_test.dart: Dart CLI generates, reviews, upgrades and verifies constraints without overwriting history +08:16 +633 -1: test/cli_test.dart: Dart CLI baselines existing rows; read-only connections and invalid options reject writes +08:45 +634 -1: test/cli_test.dart: Dart PostgreSQL configuration supports checked nontransactional recovery operations +09:30 +635 -1: loading test/decimal_test.dart +09:30 +635 -1: test/decimal_test.dart: exact decimal sqlite generated writes, defaults, patches and projections preserve all digits +09:30 +636 -1: test/decimal_test.dart: exact decimal sqlite numeric ordering, predicates, IN, distinct and stable cursors +09:30 +637 -1: test/decimal_test.dart: exact decimal sqlite sums, min/max, grouped aggregates and sliding windows stay exact +09:30 +638 -1: test/decimal_test.dart: exact decimal sqlite computed CTEs, union results, subqueries and streaming keep decimal semantics +09:30 +639 -1: test/decimal_test.dart: exact decimal sqlite numeric primary keys, foreign keys, joined and batched relations +09:30 +640 -1: test/decimal_test.dart: exact decimal sqlite snapshots, catalog verification and imported declarations retain decimal storage +09:32 +641 -1: test/decimal_test.dart: exact decimal sqlite aggregate intermediate overflow may cancel before a valid final result +09:32 +642 -1: test/decimal_test.dart: exact decimal sqlite historical decimal keys resume backfills without lexical ordering or precision loss +09:32 +643 -1: test/decimal_test.dart: exact decimal sqlite reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +09:32 +644 -1: test/decimal_test.dart: exact decimal sqlite invalid external values fail decoding; later valid queries still work +09:32 +645 -1: test/decimal_test.dart: exact decimal postgres generated writes, defaults, patches and projections preserve all digits +09:32 +646 -1: test/decimal_test.dart: exact decimal postgres numeric ordering, predicates, IN, distinct and stable cursors +09:32 +647 -1: test/decimal_test.dart: exact decimal postgres sums, min/max, grouped aggregates and sliding windows stay exact +09:32 +648 -1: test/decimal_test.dart: exact decimal postgres computed CTEs, union results, subqueries and streaming keep decimal semantics +09:32 +649 -1: test/decimal_test.dart: exact decimal postgres numeric primary keys, foreign keys, joined and batched relations +09:32 +650 -1: test/decimal_test.dart: exact decimal postgres snapshots, catalog verification and imported declarations retain decimal storage +09:33 +651 -1: test/decimal_test.dart: exact decimal postgres aggregate intermediate overflow may cancel before a valid final result +09:33 +652 -1: test/decimal_test.dart: exact decimal postgres historical decimal keys resume backfills without lexical ordering or precision loss +09:33 +653 -1: test/decimal_test.dart: exact decimal postgres reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +09:33 +654 -1: test/decimal_test.dart: exact decimal postgres invalid external values fail decoding; later valid queries still work +09:33 +655 -1: test/decimal_test.dart: decimal SQL rejects drivers without exact operations before execution +09:33 +656 -1: test/decimal_test.dart: SQLite recognizes only column decimal collations, and detects drift +09:33 +657 -1: loading test/watch_test.dart +09:33 +657 -1: test/watch_test.dart: independent native SQLite workers require explicit notification +09:33 +658 -1: test/watch_test.dart: watch sqlite lazy initial snapshot and relevant table invalidation +09:33 +659 -1: test/watch_test.dart: watch sqlite transactions publish one snapshot after commit and none on rollback +09:33 +660 -1: test/watch_test.dart: watch sqlite savepoint changes merge on release and disappear on rollback +09:33 +661 -1: test/watch_test.dart: watch sqlite failed statements, zero-row writes and conflict no-ops do not refresh +09:33 +662 -1: test/watch_test.dart: watch sqlite batch writes and upsert returning publish committed changes +09:33 +663 -1: test/watch_test.dart: watch sqlite joined and nested batch relations track child writes +09:33 +664 -1: test/watch_test.dart: watch sqlite relation predicates, SQL subqueries and CTE sources are dependencies +09:33 +665 -1: test/watch_test.dart: watch sqlite declared cascade deletion invalidates a child-only query +09:33 +666 -1: test/watch_test.dart: watch sqlite CASCADE tracks declared transitive deletion effects +09:33 +667 -1: test/watch_test.dart: watch sqlite SET NULL tracks declared transitive deletion effects +09:33 +668 -1: test/watch_test.dart: watch sqlite explicit JOINs and joined CTE definitions track both physical sources +09:33 +669 -1: test/watch_test.dart: watch sqlite raw SQL declares changed and read tables; explicit invalidation follows transactions +09:33 +670 -1: test/watch_test.dart: watch sqlite paused listeners coalesce writes and stop fetching until resumed +09:33 +671 -1: test/watch_test.dart: watch sqlite invalidation during a read suppresses the stale snapshot and refetches +09:33 +672 -1: test/watch_test.dart: watch sqlite decoding errors are reported and a later write can recover +09:33 +673 -1: test/watch_test.dart: watch sqlite separate subscriptions and views of one driver receive changes +09:33 +674 -1: test/watch_test.dart: watch sqlite watching leased sessions is rejected and cancelled tokens close the stream +09:33 +675 -1: test/watch_test.dart: watch sqlite database close finishes watchers even when a listener is paused +09:33 +676 -1: test/watch_test.dart: watch sqlite cancelling an active watched query interrupts SQL and releases its lease +09:33 +677 -1: test/watch_test.dart: watch postgres lazy initial snapshot and relevant table invalidation +09:34 +678 -1: test/watch_test.dart: watch postgres transactions publish one snapshot after commit and none on rollback +09:34 +679 -1: test/watch_test.dart: watch postgres savepoint changes merge on release and disappear on rollback +09:34 +680 -1: test/watch_test.dart: watch postgres failed statements, zero-row writes and conflict no-ops do not refresh +09:34 +681 -1: test/watch_test.dart: watch postgres batch writes and upsert returning publish committed changes +09:34 +682 -1: test/watch_test.dart: watch postgres joined and nested batch relations track child writes +09:34 +683 -1: test/watch_test.dart: watch postgres relation predicates, SQL subqueries and CTE sources are dependencies +09:34 +684 -1: test/watch_test.dart: watch postgres declared cascade deletion invalidates a child-only query +09:34 +685 -1: test/watch_test.dart: watch postgres CASCADE tracks declared transitive deletion effects +09:34 +686 -1: test/watch_test.dart: watch postgres SET NULL tracks declared transitive deletion effects +09:34 +687 -1: test/watch_test.dart: watch postgres explicit JOINs and joined CTE definitions track both physical sources +09:34 +688 -1: test/watch_test.dart: watch postgres raw SQL declares changed and read tables; explicit invalidation follows transactions +09:34 +689 -1: test/watch_test.dart: watch postgres paused listeners coalesce writes and stop fetching until resumed +09:34 +690 -1: test/watch_test.dart: watch postgres invalidation during a read suppresses the stale snapshot and refetches +09:34 +691 -1: test/watch_test.dart: watch postgres decoding errors are reported and a later write can recover +09:34 +692 -1: test/watch_test.dart: watch postgres separate subscriptions and views of one driver receive changes +09:34 +693 -1: test/watch_test.dart: watch postgres watching leased sessions is rejected and cancelled tokens close the stream +09:34 +694 -1: test/watch_test.dart: watch postgres database close finishes watchers even when a listener is paused +09:34 +695 -1: test/watch_test.dart: watch postgres cancelling an active watched query interrupts SQL and releases its lease +09:34 +696 -1: test/watch_test.dart: watch postgres lost commit acknowledgement triggers a conservative re-read +09:34 +697 -1: test/watch_test.dart: watch postgres independent connections require an explicit external-write notification +09:34 +698 -1: loading test/backfill_test.dart +09:34 +698 -1: test/backfill_test.dart: backfill sqlite chunks pause and resume from a checksummed historical declaration +09:34 +699 -1: test/backfill_test.dart: backfill sqlite failed chunk rolls back both writes and cursor, then resumes after repair +09:34 +700 -1: test/backfill_test.dart: backfill sqlite failed completion proof preserves finished chunks without replay +09:34 +701 -1: test/backfill_test.dart: backfill sqlite frozen upper bound and completion proof detect incompatible new writes +09:34 +702 -1: test/backfill_test.dart: backfill sqlite competing runners share committed chunks without duplicate updates +09:34 +703 -1: test/backfill_test.dart: backfill sqlite lost chunk commit acknowledgement preserves the committed frontier +09:34 +704 -1: test/backfill_test.dart: backfill sqlite old checkpoint tables upgrade on apply; completed reads remain nonmutating +09:34 +705 -1: test/backfill_test.dart: backfill sqlite completion conditions reject numeric results instead of truthiness +09:34 +706 -1: test/backfill_test.dart: backfill sqlite empty input still requires the completion condition +09:34 +707 -1: test/backfill_test.dart: backfill sqlite later ordinary migrations retain group atomicity after a completed backfill +09:34 +708 -1: test/backfill_test.dart: backfill sqlite AFTER triggers cannot silently move keys past a committed cursor +09:34 +709 -1: test/backfill_test.dart: backfill sqlite corrupt checkpoint dimensions and invalid run limits are rejected +09:34 +710 -1: test/backfill_test.dart: backfill sqlite rebuild, chunked backfill and required-column rebuild preserve FK enforcement +09:34 +711 -1: test/backfill_test.dart: backfill sqlite CLI previews non-atomic work and reports a bounded run as incomplete +09:59 +712 -1: test/backfill_test.dart: backfill sqlite temporary tables cannot redirect the historical backfill target +09:59 +713 -1: test/backfill_test.dart: backfill sqlite temporary objects cannot impersonate durable migration metadata +09:59 +714 -1: test/backfill_test.dart: backfill sqlite committed chunks invalidate relevant typed query subscriptions +09:59 +715 -1: test/backfill_test.dart: backfill sqlite real process exit after second chunk write resumes correctly +10:06 +716 -1: test/backfill_test.dart: backfill sqlite real process exit after second chunk commit resumes correctly +10:13 +717 -1: test/backfill_test.dart: backfill postgres chunks pause and resume from a checksummed historical declaration +10:13 +718 -1: test/backfill_test.dart: backfill postgres failed chunk rolls back both writes and cursor, then resumes after repair +10:13 +719 -1: test/backfill_test.dart: backfill postgres failed completion proof preserves finished chunks without replay +10:13 +720 -1: test/backfill_test.dart: backfill postgres frozen upper bound and completion proof detect incompatible new writes +10:13 +721 -1: test/backfill_test.dart: backfill postgres competing runners share committed chunks without duplicate updates +10:13 +722 -1: test/backfill_test.dart: backfill postgres lost chunk commit acknowledgement preserves the committed frontier +10:13 +723 -1: test/backfill_test.dart: backfill postgres old checkpoint tables upgrade on apply; completed reads remain nonmutating +10:13 +724 -1: test/backfill_test.dart: backfill postgres completion conditions reject numeric results instead of truthiness +10:13 +725 -1: test/backfill_test.dart: backfill postgres empty input still requires the completion condition +10:13 +726 -1: test/backfill_test.dart: backfill postgres later ordinary migrations retain group atomicity after a completed backfill +10:13 +727 -1: test/backfill_test.dart: backfill postgres AFTER triggers cannot silently move keys past a committed cursor +10:13 +728 -1: test/backfill_test.dart: backfill postgres corrupt checkpoint dimensions and invalid run limits are rejected +10:14 +729 -1: test/backfill_test.dart: backfill postgres CLI previews non-atomic work and reports a bounded run as incomplete +10:36 +730 -1: test/backfill_test.dart: backfill postgres temporary tables cannot redirect the historical backfill target +10:37 +731 -1: test/backfill_test.dart: backfill postgres temporary objects cannot impersonate durable migration metadata +10:37 +732 -1: test/backfill_test.dart: backfill postgres row security cannot hide unfinished rows from the completion proof +10:37 +733 -1: test/backfill_test.dart: backfill postgres inherited duplicate primary keys cannot advance a cursor +10:37 +734 -1: test/backfill_test.dart: backfill postgres committed chunks invalidate relevant typed query subscriptions +10:37 +735 -1: test/backfill_test.dart: backfill postgres real process exit after second chunk write resumes correctly +10:44 +736 -1: test/backfill_test.dart: backfill postgres real process exit after second chunk commit resumes correctly +10:50 +737 -1: loading test/custom_codec_test.dart +10:50 +737 -1: test/custom_codec_test.dart: (setUpAll) +10:50 +737 -1: test/custom_codec_test.dart: custom codecs sqlite generated custom values round trip through create, select and patch +10:50 +738 -1: test/custom_codec_test.dart: custom codecs sqlite enum labels are stable text and unknown labels fail decoding +10:50 +739 -1: test/custom_codec_test.dart: custom codecs sqlite domain validation and bound parameters apply to writes and predicates +10:50 +740 -1: test/custom_codec_test.dart: custom codecs sqlite JSON documents distinguish SQL NULL, JSON null and string scalars +10:50 +741 -1: test/custom_codec_test.dart: custom codecs sqlite nullable structured codec rejects JSON null instead of masking invalid data +10:50 +742 -1: test/custom_codec_test.dart: custom codecs sqlite custom IDs and domain projections work across joined and batched relations +10:50 +743 -1: test/custom_codec_test.dart: custom codecs sqlite custom values participate in atomic batch writes and native upsert +10:50 +744 -1: test/custom_codec_test.dart: custom codecs sqlite custom IDs and enums survive cursor transport and streaming decoders +10:50 +745 -1: test/custom_codec_test.dart: custom codecs sqlite migration snapshots retain physical storage without executing domain codecs +10:50 +746 -1: test/custom_codec_test.dart: custom codecs postgres generated custom values round trip through create, select and patch +10:50 +747 -1: test/custom_codec_test.dart: custom codecs postgres enum labels are stable text and unknown labels fail decoding +10:50 +748 -1: test/custom_codec_test.dart: custom codecs postgres domain validation and bound parameters apply to writes and predicates +10:50 +749 -1: test/custom_codec_test.dart: custom codecs postgres JSON documents distinguish SQL NULL, JSON null and string scalars +10:50 +750 -1: test/custom_codec_test.dart: custom codecs postgres nullable structured codec rejects JSON null instead of masking invalid data +10:50 +751 -1: test/custom_codec_test.dart: custom codecs postgres custom IDs and domain projections work across joined and batched relations +10:50 +752 -1: test/custom_codec_test.dart: custom codecs postgres custom values participate in atomic batch writes and native upsert +10:50 +753 -1: test/custom_codec_test.dart: custom codecs postgres custom IDs and enums survive cursor transport and streaming decoders +10:50 +754 -1: test/custom_codec_test.dart: custom codecs postgres migration snapshots retain physical storage without executing domain codecs +10:50 +755 -1: test/custom_codec_test.dart: (tearDownAll) +10:50 +755 -1: loading test/plan_test.dart +10:50 +755 -1: test/plan_test.dart: inspect sqlite reports composite key width and validates each potential batch +10:50 +756 -1: test/plan_test.dart: inspect sqlite compiles nested plans without acquiring or decoding +10:50 +757 -1: test/plan_test.dart: inspect sqlite distinguishes same-statement joins and skipped batches +10:50 +758 -1: test/plan_test.dart: inspect sqlite retains CTE, UNION and raw SQL boundaries +10:50 +759 -1: test/plan_test.dart: plan execution sqlite one-key SQL templates match executed SQL and internal slots +10:50 +760 -1: test/plan_test.dart: inspect postgres reports composite key width and validates each potential batch +10:50 +761 -1: test/plan_test.dart: inspect postgres compiles nested plans without acquiring or decoding +10:50 +762 -1: test/plan_test.dart: inspect postgres distinguishes same-statement joins and skipped batches +10:50 +763 -1: test/plan_test.dart: inspect postgres retains CTE, UNION and raw SQL boundaries +10:50 +764 -1: test/plan_test.dart: plan execution postgres one-key SQL templates match executed SQL and internal slots +10:51 +765 -1: loading test/check_test.dart +10:51 +765 -1: test/check_test.dart: CHECK sqlite generated named and unnamed constraints enforce values and permit NULL +10:51 +766 -1: test/check_test.dart: CHECK sqlite import preserves names and SQL then generates a matching snapshot +10:53 +767 -1: test/check_test.dart: CHECK sqlite add, change and remove constraints validate old rows atomically +10:53 +768 -1: test/check_test.dart: CHECK sqlite duplicate unnamed checks consume and drop exactly one catalog entry +10:53 +769 -1: test/check_test.dart: CHECK sqlite explicit checked renames preserve rows, incoming references, indexes and views +10:53 +770 -1: test/check_test.dart: CHECK sqlite verification preserves casts, precedence, literals and trailing comments +10:53 +771 -1: test/check_test.dart: CHECK sqlite quoted Unicode identifiers retain SQLite case distinctions +10:53 +772 -1: test/check_test.dart: CHECK sqlite undeclared CHECK is refused before a rebuild and remains enforced +10:53 +773 -1: test/check_test.dart: CHECK postgres generated named and unnamed constraints enforce values and permit NULL +10:53 +774 -1: test/check_test.dart: CHECK postgres import preserves names and SQL then generates a matching snapshot +10:54 +775 -1: test/check_test.dart: CHECK postgres add, change and remove constraints validate old rows atomically +10:54 +776 -1: test/check_test.dart: CHECK postgres duplicate unnamed checks consume and drop exactly one catalog entry +10:54 +777 -1: test/check_test.dart: CHECK postgres explicit checked renames preserve rows, incoming references, indexes and views +10:54 +778 -1: test/check_test.dart: CHECK postgres verification preserves casts, precedence, literals and trailing comments +10:54 +779 -1: test/check_test.dart: CHECK postgres catalog excludes unvalidated and NO INHERIT constraints +10:54 +780 -1: test/check_test.dart: CHECK postgres verification plans but does not execute volatile row expressions +10:54 +781 -1: test/check_test.dart: dialect overrides only alter the affected backend and preserve old snapshot JSON +10:54 +782 -1: loading test/temporal_test.dart +10:54 +782 -1: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: false +10:54 +783 -1: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: true +10:54 +784 -1: test/temporal_test.dart: Gregorian cycles, era boundaries and full finite date range +10:54 +785 -1: test/temporal_test.dart: local time precision, day endpoint and timestamp arithmetic +10:54 +786 -1: test/temporal_test.dart: PostgreSQL binary registry covers boundaries without DateTime +10:54 +787 -1: test/temporal_test.dart: local temporal sqlite generated writes, defaults, null patches and typed parameters +10:54 +788 -1: test/temporal_test.dart: local temporal sqlite finite extremes and microseconds survive native rows and streams +10:54 +789 -1: test/temporal_test.dart: local temporal sqlite calendar ordering, min/max, grouping and stable date cursors +10:54 +790 -1: test/temporal_test.dart: local temporal sqlite equivalent date keys drive batched relations and unique indexes +10:54 +791 -1: test/temporal_test.dart: local temporal sqlite time and local timestamp comparisons retain calendar semantics +10:54 +792 -1: test/temporal_test.dart: local temporal sqlite reviewed text-to-time migration rolls back duplicate clock values +10:54 +793 -1: test/temporal_test.dart: local temporal sqlite invalid external dates fail typed reads without damaging the connection +10:54 +794 -1: test/temporal_test.dart: local temporal sqlite snapshots, catalog import and regenerated defaults agree +10:57 +795 -1: test/temporal_test.dart: local temporal sqlite historical date keys resume a bounded backfill in calendar order +10:57 +796 -1: test/temporal_test.dart: local temporal postgres catalog retains time-truncating casts in expression defaults +10:57 +797 -1: test/temporal_test.dart: local temporal postgres generated writes, defaults, null patches and typed parameters +10:57 +798 -1: test/temporal_test.dart: local temporal postgres finite extremes and microseconds survive native rows and streams +10:57 +799 -1: test/temporal_test.dart: local temporal postgres calendar ordering, min/max, grouping and stable date cursors +10:57 +800 -1: test/temporal_test.dart: local temporal postgres equivalent date keys drive batched relations and unique indexes +10:57 +801 -1: test/temporal_test.dart: local temporal postgres time and local timestamp comparisons retain calendar semantics +10:57 +802 -1: test/temporal_test.dart: local temporal postgres reviewed text-to-time migration rolls back duplicate clock values +10:57 +803 -1: test/temporal_test.dart: local temporal postgres invalid external dates fail typed reads without damaging the connection +10:57 +804 -1: test/temporal_test.dart: local temporal postgres snapshots, catalog import and regenerated defaults agree +10:58 +805 -1: test/temporal_test.dart: local temporal postgres historical date keys resume a bounded backfill in calendar order +10:58 +806 -1: loading test/unconstrained_relation_test.dart +10:58 +806 -1: test/unconstrained_relation_test.dart: unconstrained sqlite catalogs contain no FK, implicit index or uniqueness promise +10:58 +807 -1: test/unconstrained_relation_test.dart: unconstrained sqlite adding and removing a real FK remains a reviewed physical migration +10:58 +808 -1: test/unconstrained_relation_test.dart: unconstrained sqlite unique joins preserve root rows, NULL components and missing targets +10:58 +809 -1: test/unconstrained_relation_test.dart: unconstrained sqlite required target and filter visibility remain explicit +10:58 +810 -1: test/unconstrained_relation_test.dart: unconstrained sqlite nonunique composite collections batch by tuple and page per parent +10:58 +811 -1: test/unconstrained_relation_test.dart: unconstrained sqlite to-one uses cardinality checks without assuming a unique lookup +10:58 +812 -1: test/unconstrained_relation_test.dart: unconstrained sqlite correlated filters and counts do not materialize related rows +10:58 +813 -1: test/unconstrained_relation_test.dart: unconstrained sqlite self relations and inverse collections share the existing planner +10:58 +814 -1: test/unconstrained_relation_test.dart: unconstrained sqlite delete has no cascade and watches track the joined target +10:58 +815 -1: test/unconstrained_relation_test.dart: unconstrained sqlite transaction reads observe writes and outer rollback restores targets +10:58 +816 -1: test/unconstrained_relation_test.dart: unconstrained sqlite streaming preserves optional and batched relation result shapes +10:58 +817 -1: test/unconstrained_relation_test.dart: unconstrained sqlite floating relation keys retain storage intent and key equality +10:58 +818 -1: test/unconstrained_relation_test.dart: unconstrained postgres catalogs contain no FK, implicit index or uniqueness promise +10:58 +819 -1: test/unconstrained_relation_test.dart: unconstrained postgres adding and removing a real FK remains a reviewed physical migration +10:58 +820 -1: test/unconstrained_relation_test.dart: unconstrained postgres unique joins preserve root rows, NULL components and missing targets +10:58 +821 -1: test/unconstrained_relation_test.dart: unconstrained postgres required target and filter visibility remain explicit +10:58 +822 -1: test/unconstrained_relation_test.dart: unconstrained postgres nonunique composite collections batch by tuple and page per parent +10:58 +823 -1: test/unconstrained_relation_test.dart: unconstrained postgres to-one uses cardinality checks without assuming a unique lookup +10:58 +824 -1: test/unconstrained_relation_test.dart: unconstrained postgres correlated filters and counts do not materialize related rows +10:58 +825 -1: test/unconstrained_relation_test.dart: unconstrained postgres self relations and inverse collections share the existing planner +10:58 +826 -1: test/unconstrained_relation_test.dart: unconstrained postgres delete has no cascade and watches track the joined target +10:58 +827 -1: test/unconstrained_relation_test.dart: unconstrained postgres transaction reads observe writes and outer rollback restores targets +10:58 +828 -1: test/unconstrained_relation_test.dart: unconstrained postgres streaming preserves optional and batched relation result shapes +10:58 +829 -1: test/unconstrained_relation_test.dart: unconstrained postgres floating relation keys retain storage intent and key equality +10:58 +830 -1: loading test/decimal_value_test.dart +10:58 +830 -1: test/decimal_value_test.dart: normalization, scientific notation, equality and hash are exact +10:58 +831 -1: test/decimal_value_test.dart: invalid and approximate inputs fail instead of silently rounding +10:58 +832 -1: test/decimal_value_test.dart: range boundaries are checked without binary numeric conversions +10:58 +833 -1: test/decimal_value_test.dart: addition, subtraction, multiplication and ordering keep every digit +10:58 +834 -1: test/decimal_value_test.dart: division requires an explicit scale and rejects implicit rounding +10:58 +835 -1: test/decimal_value_test.dart: rounding modes handle negative values, ties and negative scales +10:58 +836 -1: test/decimal_value_test.dart: small fixed-scale arithmetic agrees with independent integer arithmetic +10:58 +837 -1: Some tests failed. + +Failing tests: + test/instant_test.dart: historical migration checksum and timestamp tag remain immutable + +Consider enabling the flag chain-stack-traces to receive more detailed exceptions. +For example, 'dart test --chain-stack-traces'. diff --git a/research/validation/dart-migrations.json b/research/validation/dart-migrations.json new file mode 100644 index 00000000..03083675 --- /dev/null +++ b/research/validation/dart-migrations.json @@ -0,0 +1,118 @@ +{ + "capturedAt": "2026-09-15T11:25:08.799101+00:00", + "sourceCommit": "85c4e42bf82924f806d1ce79ba96f1f61af61b1f", + "dart": "3.13.3", + "postgres": "18.4", + "sqlite": "3.53.4", + "status": "passed", + "fullRun": { + "command": "ORM_TEST_POSTGRES= dart test -r expanded", + "exitCode": 1, + "passed": 837, + "failed": 1, + "duration": "10:58", + "failure": "test/instant_test.dart still read the removed 0001_legacy.json fixture. Corrected to use the static Dart migration fingerprint; the follow-up run passes.", + "sourceHashesBeforeFinalToolingChanges": { + "bin/orm.dart": "3b6930c60c81d3365b41078cd631418485f152ddac00e56276c8576d4346c836", + "lib/generate.dart": "e423c67f97cc089f8abb654f421e4f38c5e1a3a500316f7a8edfc7644a61952a", + "lib/src/generate/build.dart": "1e2d60e8c7bf96bdce213e447a03d609892e34536dd140fce0dfdc5e8f876d14", + "lib/src/generate/queries.dart": "00be85e6496c31f9ede18dd85dd8fc2556fa78b2dab28db3e668474153b76aef", + "test/build_runner_test.dart": "639b0d7fef472bda4f361b5de2dca45b97874915893302709b8bbc48f357e149", + "test/generator_test.dart": "01b0acea109071f4bbea2ac15631223e139dddb9fd754d3cbf0365567325cd32", + "test/named_sql_generation_test.dart": "2fb35d3b3e16a85c14530f35677ea0632f585476006c6e82182215b6831cc256", + "test/named_sql_test.dart": "a79ecdb1fb5b98ee79444c5055f1b6f88768727c919a7342709bae2a13fe78c0" + } + }, + "followup": { + "command": "ORM_TEST_POSTGRES= dart test test/instant_test.dart test/generator_test.dart test/named_sql_generation_test.dart test/named_sql_test.dart test/build_runner_test.dart -r expanded", + "exitCode": 0, + "passed": 76, + "duration": "01:33", + "scope": "Rechecks the corrected test, final source-output collision guard and removal of named-query JSON manifests, including live build/watch, freshness checks and native SQL structure validation." + }, + "aot": { + "command": "dart test test/migration_source_test.dart --name 'AOT migration bundle' -r expanded", + "exitCode": 0, + "passed": 1, + "scope": "Compiled CLI bundle after removing all consumer lib/bin Dart sources, pubspec and .dart_tool; offline validation, missing-file read-only rejection, apply/no-replay, schema verify and independent row/history readback." + }, + "backfillAot": { + "command": "dart build cli --target test/support/native_backfill.dart --output .dart_tool/backfill-aot && .dart_tool/backfill-aot/bundle/bin/native_backfill", + "exitCode": 0, + "scope": "Statically imported historical backfill, bounded pause, two concurrent runners, exact-once row update and startup compatibility." + }, + "browser": { + "report": "research/validation/browser.json", + "javascriptChecks": 19, + "wasmChecks": 19 + }, + "logs": { + "full": { + "path": "research/validation/dart-migrations-full.log", + "sha256": "5e50af0d80d77b3e6b4026a8183ab89f7b6a90229cebac226baebd5ba7defdb8" + }, + "followup": { + "path": "research/validation/dart-migrations-followup.log", + "sha256": "02a93a391310adfef2a6b6f0a8c0555c769721cbe3ba75b0dfa63c9e05e07623" + }, + "aot": { + "path": "research/validation/dart-migrations-aot.log", + "sha256": "b2abed7aff420857a9b576317fc0b0c0fd2d32de64a9dc41fd2e383d58344fae" + }, + "backfill-aot": { + "path": "research/validation/dart-migrations-backfill-aot.log", + "sha256": "b2ed0ba7416e8e9669b4dd5017a7d6bfeb54860a6204cd21e4ffb2f55ffdb5ae" + } + }, + "limits": [ + "The full run and focused follow-up are separate invocations; this is not a claim of a single all-green full run.", + "SQL strings and physical rename/conversion names still require migration review and database validation.", + "Internal checksum/checkpoint encoding and machine-readable reports still use JSON; no schema/migration/named-query JSON artifact reader remains." + ], + "example": [ + { + "command": "dart run example/migrate.dart check", + "exitCode": 0, + "report": { + "valid": true, + "migrations": [ + "0001_initial" + ] + } + }, + { + "command": "dart run example/migrate.dart apply", + "exitCode": 0, + "report": { + "applied": [ + "0001_initial" + ] + } + }, + { + "command": "dart run example/migrate.dart verify", + "exitCode": 0, + "report": { + "matches": true, + "differences": [], + "unmanaged": [] + } + }, + { + "command": "dart run example/migrate.dart apply", + "exitCode": 0, + "report": { + "applied": [] + } + } + ], + "android": { + "report": "research/validation/flutter.json", + "checks": [ + 4, + 17, + 11 + ], + "scope": "Real Android API 35 arm64 emulator. Debug version 1 install, release AOT version 2 upgrade retaining data, then distinct-process restart. Both APK ZIP inventories contain no migration JSON assets or Dart snapshot sources." + } +} diff --git a/research/validation/editor-recovery.json b/research/validation/editor-recovery.json new file mode 100644 index 00000000..71f4d70d --- /dev/null +++ b/research/validation/editor-recovery.json @@ -0,0 +1,891 @@ +{ + "capturedAt": "2026-09-15T09:49:44.135320+00:00", + "runtimeSourceCommit": "20ffee7", + "sameSession": { + "command": "dart run tool/benchmark_editor.dart --same-session", + "exitCode": 255, + "attemptedModels": 10, + "completedScales": 0, + "status": "rename_timeout", + "timeoutSeconds": 60, + "failedMethod": "textDocument/rename", + "symbol": "primary_constructor_field", + "completedBeforeTimeout": [ + "five completion probes", + "four exact-code/range diagnostics", + "repair diagnostic", + "Record field rename unavailable" + ], + "log": "research/validation/editor-recovery.log", + "logSha256": "0bff8f1138af759bd52b8c46b9c312f9b6282f1202e20937319c3f47d0420690" + }, + "restartRecovery": { + "recordedAt": "2026-09-15T09:47:50.614525Z", + "runtimeSourceCommit": "20ffee74ea8d8bcc37de43448cf28837503ebfb9", + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "os": "Version 26.6.2 (Build 25G83)", + "cpu": "Apple M3 Max", + "smoke": true, + "sameSession": false, + "samplesPerWarmProbe": 2, + "harnessSha256": { + "tool/benchmark_editor.dart": "d38e9ad45f1b72937a8b054ab02ba7e481cfdbb47426c5b245d60b04706db69b", + "tool/src/editor_server.dart": "00e3cf5e7675166979cdb03a9d333754ee7aeb2874d6940d5d5ab6cff43a9ff8", + "tool/src/build_fixture.dart": "03a5d875d608fd25245b5da268d66ce4c0913ed288ac05270ae9522a68bb0ea9" + }, + "scope": "Direct stdio LSP client against the installed Dart Analysis Server, isolated consumer packages and real generated APIs; no database. Includes client transport/JSON costs, excludes GUI/plugin rendering and dependency download. Each scale uses one fresh server for completion/diagnostics and another for symbol renames. Startup and initial project analysis are measured separately from completion. First completion follows a fresh diagnostic for the incomplete source and is not a cold OS/cache measurement. Warm probes use unchanged documents after two unrecorded warmups. Diagnostic probes serialize changes and await a fresh notification; this SDK omits document versions. Final CLI analysis verifies the edited sources. Symbol-form probes compare language rename behavior only, not three ORM authoring frontends or identical generated APIs from each form.", + "limits": [ + "This capture restarts the server for renames and does not establish mixed-session rename after diagnostic recovery.", + "The named Record field remains unavailable for automatic rename in this SDK. A successful bounded sequence does not establish every IDE/plugin workflow." + ], + "results": [ + { + "models": 10, + "sessionPids": { + "diagnostics": 50960, + "renames": 50961 + }, + "sourceBytes": 1608, + "generatedBytes": 22953, + "initializeMicros": 33482, + "projectReadyMicros": 331734, + "completions": [ + { + "probe": "table_entries", + "analysisReadyMicros": 8780, + "firstRequestMicros": 2558, + "warm": { + "samplesMicros": [ + 1076, + 1142 + ], + "p50Micros": 1076, + "p95Micros": 1142 + }, + "response": { + "isIncomplete": false, + "itemCount": 31, + "items": [ + { + "label": "rows0", + "kind": 10, + "detail": "Rows0TableSet", + "labelDetails": { + "detail": " Rows0TableSet" + }, + "textEdit": { + "newText": "rows0", + "range": { + "end": { + "character": 51, + "line": 3 + }, + "start": { + "character": 51, + "line": 3 + } + } + } + }, + { + "label": "rows9", + "kind": 10, + "detail": "Rows9TableSet", + "labelDetails": { + "detail": " Rows9TableSet" + }, + "textEdit": { + "newText": "rows9", + "range": { + "end": { + "character": 51, + "line": 3 + }, + "start": { + "character": 51, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "rows0", + "rows9" + ], + "forbiddenLabels": [] + }, + { + "probe": "schema_record_fields", + "analysisReadyMicros": 9476, + "firstRequestMicros": 790, + "warm": { + "samplesMicros": [ + 502, + 473 + ], + "p50Micros": 473, + "p95Micros": 502 + }, + "response": { + "isIncomplete": false, + "itemCount": 8, + "items": [ + { + "label": "id", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 39, + "line": 25 + }, + "start": { + "character": 39, + "line": 25 + } + } + } + }, + { + "label": "score", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "score", + "range": { + "end": { + "character": 39, + "line": 25 + }, + "start": { + "character": 39, + "line": 25 + } + } + } + }, + { + "label": "status", + "kind": 6, + "detail": "Status", + "labelDetails": { + "detail": " Status" + }, + "textEdit": { + "newText": "status", + "range": { + "end": { + "character": 39, + "line": 25 + }, + "start": { + "character": 39, + "line": 25 + } + } + } + }, + { + "label": "title", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 39, + "line": 25 + }, + "start": { + "character": 39, + "line": 25 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title", + "score", + "status" + ], + "forbiddenLabels": [] + }, + { + "probe": "query_fields", + "analysisReadyMicros": 8898, + "firstRequestMicros": 1580, + "warm": { + "samplesMicros": [ + 512, + 476 + ], + "p50Micros": 476, + "p95Micros": 512 + }, + "response": { + "isIncomplete": false, + "itemCount": 11, + "items": [ + { + "label": "id", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "title", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "score", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "score", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + }, + { + "label": "status", + "kind": 5, + "detail": "Field", + "labelDetails": { + "detail": " Field" + }, + "textEdit": { + "newText": "status", + "range": { + "end": { + "character": 72, + "line": 3 + }, + "start": { + "character": 72, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title", + "score", + "status" + ], + "forbiddenLabels": [] + }, + { + "probe": "create_arguments", + "analysisReadyMicros": 5181, + "firstRequestMicros": 480, + "warm": { + "samplesMicros": [ + 295, + 246 + ], + "p50Micros": 246, + "p95Micros": 295 + }, + "response": { + "isIncomplete": false, + "itemCount": 5, + "items": [ + { + "label": "title:", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title: ", + "range": { + "end": { + "character": 70, + "line": 3 + }, + "start": { + "character": 70, + "line": 3 + } + } + } + }, + { + "label": "status:", + "kind": 6, + "detail": "Status", + "labelDetails": { + "detail": " Status" + }, + "textEdit": { + "newText": "status: ", + "range": { + "end": { + "character": 70, + "line": 3 + }, + "start": { + "character": 70, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "title:", + "status:" + ], + "forbiddenLabels": [] + }, + { + "probe": "projected_record_fields", + "analysisReadyMicros": 7614, + "firstRequestMicros": 665, + "warm": { + "samplesMicros": [ + 556, + 553 + ], + "p50Micros": 553, + "p95Micros": 556 + }, + "response": { + "isIncomplete": false, + "itemCount": 6, + "items": [ + { + "label": "id", + "kind": 6, + "detail": "int", + "labelDetails": { + "detail": " int" + }, + "textEdit": { + "newText": "id", + "range": { + "end": { + "character": 180, + "line": 3 + }, + "start": { + "character": 180, + "line": 3 + } + } + } + }, + { + "label": "title", + "kind": 6, + "detail": "String", + "labelDetails": { + "detail": " String" + }, + "textEdit": { + "newText": "title", + "range": { + "end": { + "character": 180, + "line": 3 + }, + "start": { + "character": 180, + "line": 3 + } + } + } + } + ] + }, + "requiredLabels": [ + "id", + "title" + ], + "forbiddenLabels": [ + "score", + "status" + ] + } + ], + "diagnostics": [ + { + "probe": "create_type", + "micros": 5178, + "version": 5, + "expectedCode": "argument_type_not_assignable", + "expectedRange": { + "start": { + "line": 3, + "character": 77 + }, + "end": { + "line": 3, + "character": 80 + } + }, + "diagnostics": [ + { + "code": "argument_type_not_assignable", + "message": "The argument type 'int' can't be assigned to the parameter type 'String'. ", + "range": { + "end": { + "character": 80, + "line": 3 + }, + "start": { + "character": 77, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + } + ] + }, + { + "probe": "predicate_type", + "micros": 6052, + "version": 6, + "expectedCode": "argument_type_not_assignable", + "expectedRange": { + "start": { + "line": 3, + "character": 81 + }, + "end": { + "line": 3, + "character": 88 + } + }, + "diagnostics": [ + { + "code": "argument_type_not_assignable", + "message": "The argument type 'String' can't be assigned to the parameter type 'int'. ", + "range": { + "end": { + "character": 88, + "line": 3 + }, + "start": { + "character": 81, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + }, + { + "probe": "selected_result", + "micros": 5228, + "version": 7, + "expectedCode": "invalid_assignment", + "expectedRange": { + "start": { + "line": 3, + "character": 124 + }, + "end": { + "line": 3, + "character": 134 + } + }, + "diagnostics": [ + { + "code": "invalid_assignment", + "message": "A value of type 'String' can't be assigned to a variable of type 'int'.\nTry changing the type of the variable, or casting the right-hand type to 'int'.", + "range": { + "end": { + "character": 134, + "line": 3 + }, + "start": { + "character": 124, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + }, + { + "probe": "missing_selected_field", + "micros": 6120, + "version": 8, + "expectedCode": "undefined_getter", + "expectedRange": { + "start": { + "line": 3, + "character": 169 + }, + "end": { + "line": 3, + "character": 174 + } + }, + "diagnostics": [ + { + "code": "undefined_getter", + "message": "The getter 'score' isn't defined for the type '({int id, String title})'.\nTry importing the library that defines 'score', correcting the name to the name of an existing getter, or defining a getter or field named 'score'.", + "range": { + "end": { + "character": 174, + "line": 3 + }, + "start": { + "character": 169, + "line": 3 + } + }, + "severity": 1, + "source": "dart" + }, + { + "code": "unused_import", + "message": "Unused import: 'models.dart'.\nTry removing the import directive.", + "range": { + "end": { + "character": 20, + "line": 2 + }, + "start": { + "character": 7, + "line": 2 + } + }, + "severity": 2, + "source": "dart" + } + ] + } + ], + "repairMicros": 4914, + "renameServerStartupMicros": 299474, + "symbolRenames": [ + { + "probe": "record_field", + "status": "unavailable", + "micros": 10700, + "prepare": null, + "error": null, + "workspaceEdit": null, + "applied": null + }, + { + "probe": "primary_constructor_field", + "status": "applied", + "micros": 1158280, + "prepare": { + "placeholder": "name", + "range": { + "end": { + "character": 53, + "line": 3 + }, + "start": { + "character": 49, + "line": 3 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { + "character": 53, + "line": 3 + }, + "start": { + "character": 49, + "line": 3 + } + } + }, + { + "newText": "displayName", + "range": { + "end": { + "character": 46, + "line": 12 + }, + "start": { + "character": 42, + "line": 12 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 1 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 2, + "result": "import 'package:orm/orm.dart';\ntypedef RecordUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final name = column(Column('name', Codecs.text));\n}\nString recordLabel(RecordUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRecordUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.name;\n" + } + } + }, + { + "probe": "table_field", + "status": "applied", + "micros": 24433, + "prepare": { + "placeholder": "name", + "range": { + "end": { + "character": 17, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { + "character": 17, + "line": 7 + }, + "start": { + "character": 13, + "line": 7 + } + } + }, + { + "newText": "displayName", + "range": { + "end": { + "character": 54, + "line": 13 + }, + "start": { + "character": 50, + "line": 13 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 2 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 2, + "result": "import 'package:orm/orm.dart';\ntypedef RecordUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final displayName = column(Column('name', Codecs.text));\n}\nString recordLabel(RecordUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRecordUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.displayName;\n" + } + } + }, + { + "probe": "record_alias", + "status": "applied", + "micros": 4755, + "prepare": { + "placeholder": "RecordUser", + "range": { + "end": { + "character": 18, + "line": 1 + }, + "start": { + "character": 8, + "line": 1 + } + } + }, + "error": null, + "workspaceEdit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 18, + "line": 1 + }, + "start": { + "character": 8, + "line": 1 + } + } + }, + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 29, + "line": 9 + }, + "start": { + "character": 19, + "line": 9 + } + } + }, + { + "newText": "RenamedUser", + "range": { + "end": { + "character": 10, + "line": 11 + }, + "start": { + "character": 0, + "line": 11 + } + } + } + ], + "textDocument": { + "uri": "fixture/lib/symbols.dart", + "version": 3 + } + } + ] + }, + "applied": { + "lib/symbols.dart": { + "edits": 3, + "result": "import 'package:orm/orm.dart';\ntypedef RenamedUser = ({int id, String name});\ntypedef OtherRecord = ({int id, String name});\nfinal class ClassUser(final int id, final String displayName);\nfinal class UserTable extends Fields {\n UserTable(super.table);\n late final id = column(Column('id', Codecs.integer));\n late final displayName = column(Column('name', Codecs.text));\n}\nString recordLabel(RenamedUser user) => user.name;\nString otherLabel(OtherRecord user) => user.name;\nRenamedUser recordValue() => (id: 1, name: 'first');\nString classLabel(ClassUser user) => user.displayName;\nExpr tableLabel(UserTable table) => table.displayName;\n" + } + } + } + ], + "ormRule": { + "dartAnalysisMilliseconds": 117, + "dartAnalysisOutput": "Analyzing schema.dart...\nNo issues found!\n\n", + "generatorError": "Key selectors accept direct fields or positional records of fields only. (offset 1644)" + }, + "finalAnalyzeMilliseconds": 461 + } + ] + }, + "limits": [ + "The 10-model same-session run failed before larger scales were attempted.", + "A server restart completes the same bounded rename probes. The underlying SDK/harness interaction is not diagnosed.", + "Record field rename is unavailable even in the clean session; use explicit edits, regeneration and analyzer-guided reference repair.", + "This is direct LSP evidence, not GUI or all-editor certification." + ] +} diff --git a/research/validation/editor-recovery.log b/research/validation/editor-recovery.log new file mode 100644 index 00000000..01dfc817 --- /dev/null +++ b/research/validation/editor-recovery.log @@ -0,0 +1,23 @@ +Running build hooks...Running build hooks...Editor protocol checks for 10 models... + completion: table_entries + completion: schema_record_fields + completion: query_fields + completion: create_arguments + completion: projected_record_fields + diagnostic: create_type + diagnostic: predicate_type + diagnostic: selected_result + diagnostic: missing_selected_field + requesting rename: record_field + rename: record_field (unavailable) + requesting rename: primary_constructor_field +Unhandled exception: +Bad state: Timed out: textDocument/rename {textDocument: {uri: file:///var/folders/gf/46z6v161517c7_h1zl31hj1m0000gn/T/orm-build-X8NKs5/lib/symbols.dart}, position: {line: 3, character: 49}, newName: displayName}; notifications=[{id: 30, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 31, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 32, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 33, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 34, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 35, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 36, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 37, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 38, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}, {id: 39, jsonrpc: 2.0, method: window/workDoneProgress/create, params: {token: ANALYZING}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: begin, title: Analyzing…}}}, {jsonrpc: 2.0, method: $/progress, params: {token: ANALYZING, value: {kind: end}}}]; stderr= +#0 EditorServer.request. (file:///Users/seven/workspace/dart-orm/tool/src/editor_server.dart:99:11) +#1 _RootZone.run (dart:async/zone.dart:1028:54) +#2 Future.timeout. (dart:async/future_impl.dart:1054:34) + +#3 EditorServer.request (file:///Users/seven/workspace/dart-orm/tool/src/editor_server.dart:96:14) + +#4 main (file:///Users/seven/workspace/dart-orm/tool/benchmark_editor.dart:264:22) + diff --git a/research/validation/flutter-android.png b/research/validation/flutter-android.png new file mode 100644 index 00000000..b7421a4a Binary files /dev/null and b/research/validation/flutter-android.png differ diff --git a/research/validation/flutter-web-probe.json b/research/validation/flutter-web-probe.json new file mode 100644 index 00000000..966d34fa --- /dev/null +++ b/research/validation/flutter-web-probe.json @@ -0,0 +1,500 @@ +{ + "date": "2026-09-16", + "runtimeRevision": "a25c29c9a2e3025c5bb29fe3415c846e764d7373", + "scope": "Release Flutter builds with a temporary ORM package copy declaring web-only assets; shipping runtime unchanged.", + "standaloneDartPubGet": "passed offline; package keeps no Flutter SDK dependency", + "probeSha256": "9e99f722fcb0b11f5610d0441fe8baf236e616e326f897848b51c04587397648", + "artifacts": [ + { + "name": "sqlite3.wasm", + "bytes": 748686, + "gzipBytes": 358944, + "sha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "name": "worker.js", + "bytes": 175973, + "gzipBytes": 55940, + "sha256": "390820f8ceefca406e191ace4d594e06840808a7c09823ad031ccd5ebf893d44" + } + ], + "runs": [ + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 95 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 38 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 15 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 20 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 32 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 28 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 3 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 3 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 1 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 3 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 161 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 32 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 100 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 21 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": false, + "flutterFrames": 6, + "baseUri": "http://127.0.0.1:56191/nested/", + "dartUriBase": "http://127.0.0.1:56191/nested/detail/42?phase=recover", + "isolated": false, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/chromium/canvaskit.wasm", + "/nested/canvaskit/chromium/canvaskit.js", + "/nested/main.dart.js", + "/favicon.ico", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/chromium/canvaskit.wasm", + "/nested/canvaskit/chromium/canvaskit.js", + "/nested/main.dart.js", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/report" + ], + "build": "js", + "expectedIsolation": false, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 100 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 38 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 12 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 19 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 27 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 24 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 1 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 160 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 32 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 97 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 12 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": true, + "flutterFrames": 4, + "baseUri": "http://127.0.0.1:56219/nested/", + "dartUriBase": "http://127.0.0.1:56219/nested/detail/42?phase=recover", + "isolated": false, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/favicon.ico", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/report" + ], + "build": "wasm", + "expectedIsolation": false, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 56 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 41 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 12 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 18 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 27 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 23 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 1 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 160 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 31 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 96 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 1 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 14 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": true, + "flutterFrames": 4, + "baseUri": "http://127.0.0.1:56235/nested/", + "dartUriBase": "http://127.0.0.1:56235/nested/detail/42?phase=recover", + "isolated": true, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/favicon.ico", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/canvaskit/skwasm.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/web/worker.js", + "/nested/canvaskit/skwasm.js", + "/nested/assets/packages/orm/assets/web/sqlite3.wasm", + "/report" + ], + "build": "wasm", + "expectedIsolation": true, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + } + ] +} diff --git a/research/validation/flutter.json b/research/validation/flutter.json new file mode 100644 index 00000000..5919ce0a --- /dev/null +++ b/research/validation/flutter.json @@ -0,0 +1,292 @@ +{ + "capturedAt": "2026-09-15T11:25:43.915364Z", + "runtimeSourceCommit": "85c4e42bf82924f806d1ce79ba96f1f61af61b1f", + "flutter": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "dartSdkVersion": "3.13.3" + }, + "device": "emulator-5580", + "abi": "arm64-v8a", + "buildFingerprint": "google/sdk_gphone64_arm64/emu64a:15/AE3A.240806.036/12592187:user/release-keys", + "apkSha256": { + "legacy": "3c949c03fc181f7dba9192175afc4a558a0e4eba071fc45453820ab0393fd2d6", + "current": "db5dbceb9f952a10a52f6d460e66b42fa9e41d05d421b04e329d3c30feec6767" + }, + "sourceSha256": { + "lib/builder.dart": "031bdbdaec51addad1eab835297e9bafe1daec68ca40658f02a53731c1d61c2e", + "lib/sqlite_web.dart": "7c0f9146a4fd851363ec66634e3ddd4199aec66da50017c6490cd0e47c269461", + "lib/orm.dart": "454b2f9030bc1b5f36caa3381cc5ba83be956a151c078b6be3659342a3570724", + "lib/generate.dart": "c8ea4b5ff50fb6a72fc28e27f3db07965afeab0d0228f67f784a01745b200af9", + "lib/sqlite_web_worker.dart": "c36fc527bbca1578338784b6dacee4489790d4eb980dd761e65ba2caba596a23", + "lib/migrate_cli.dart": "fdb509d4e6bccd5d6bb983d2a70c3f51da4aea3951c9e8bce56fa5266f742ff9", + "lib/sqlite.dart": "4190681a93d529cd181900d9c80236b66d784483a1d10a1d9f47ca9bfcb01957", + "lib/schema.dart": "85b88b9f54ecf5b6a0d2e4c772e839dad02d15532fa1db847be78b5e49b71d88", + "lib/postgres.dart": "b97492fe7b4004f3c996e20276448ec70cd147f059ada27901476a0b8251e5e7", + "lib/migrate.dart": "c3050956b5ad19b8b2ae32f47331ef2f3782b60e7dc9891d16be407943d43903", + "lib/src/instant.dart": "060bf3709d06e38a7483ce7c6f5a8b29239012b548136bcaffac1e554c00b518", + "lib/src/temporal.dart": "de55763e61ad70ea471763b01cd1a3fa5f0f5ed7c1e1416323e23dd8de83fa39", + "lib/src/mutation.dart": "e3af296711a57c43680d7b4660b9dd77230f77aa144ce4a9bbc5b4f3166ffb64", + "lib/src/union.dart": "3df7db11b8df824755ec2a8265381082b6fa36b99c093ed3d26c2c58fff6743c", + "lib/src/execution.dart": "9431cdc967f8ccee0ed9424fa872e05a965dff33c44b3be2435778f7252bfd37", + "lib/src/batch.dart": "675130ab5ffc1411cb3a5512ba7273fb0b917b1425ccbb0a9bf3f1faa699d929", + "lib/src/sql.dart": "4f7c1f39f666bd142bdb68eef08d9d0cd2f32807511d80d9f9150464697617bf", + "lib/src/watch.dart": "538ea16142701d135e95035c74a561d1179f5bf8e15a3ba07f80e8507ace3b70", + "lib/src/selection.dart": "038617d98192d42449deac8875af49b15412fa2915bc11569f1e33a29a46dd5f", + "lib/src/database.dart": "9fc5b2ee3753acb3b073cd1f00d1ecfea99ed12df10f1278fb3718be6940ce09", + "lib/src/decimal_average.dart": "05ed8eb87201087db3cc0bdc478fbb9d34054db5baa84c39b204cb7a45d5a117", + "lib/src/sqlite/failure.dart": "64bfb54aea1b7ef2cd7f7c65d06b85df3ef9a6cbc981f2546c5f55f071fea49b", + "lib/src/sqlite/functions.dart": "7ee775e738137457ebfdc2f911c320a5da24704d5e2aab651eabaf433f6646eb", + "lib/src/sqlite/temporal.dart": "0e874da5e0f6aa31d2d23db8edd458306d4e0dec2ce46397f471443186946243", + "lib/src/sqlite/web_wire.dart": "7b4460f2ad3cd922039fa2a50d3825a45dd9a65a7fa1adc114049b89658ae3ef", + "lib/src/sqlite/web_client.dart": "7a45b6affa89787cb8c8da5ca7a97abeb1b3540bd010606979197d1308e50db3", + "lib/src/sqlite/decimal.dart": "b291e5cfc0758fd944e7a09b345f8fa04b3e1679a099f92b9bf43023f57da5f0", + "lib/src/sqlite/worker.dart": "8f67a5a6be6adf3c5b35df7345ac8b01f61dcca6c151a62bd618497d85f5e870", + "lib/src/sqlite/native.dart": "36fe0a2ee78a9b740646b0244a6bf6eae276cfed29d890e799c0dc6b8c8e1830", + "lib/src/decimal_sql.dart": "60fbbea999e892f4166bf86052b73b028e9d68e4c134a0f92b170bd4b2760ed9", + "lib/src/table.dart": "e24ac71001350920de00c61c0134e79c0a955956d6a7950f632aaedf9aee5b68", + "lib/src/decimal.dart": "5693780043a8271953814861f235f3beef5c6ed64dd6d89f33784eccf2101357", + "lib/src/query.dart": "19bd107901c35a0fc80984e69712308899cba3097acb17a4bb29f74a75bdfdcb", + "lib/src/postgres/temporal.dart": "0dffac77da7fc1d744bf6d5c0735c653ba6a13ef451d01001b1135d78c2b90ef", + "lib/src/decimal_division.dart": "2fe5496e9c70bab94d05fdfa6317af6d862c2c8b0b75c175cfd510d82811e4f6", + "lib/src/migrate/sqlite_checks.dart": "fee6d23a9fae37b2208485fcf2ee24d7e104e65b546162833f12bfebf7457ee3", + "lib/src/migrate/recovery.dart": "8567928dbfea9d4b320957b5fb6f4c06aa834b882c9472024ab97c5d3286b5b3", + "lib/src/migrate/catalog.dart": "80c1a396727d57e9c49e37914c076e6fab64a3eb43437b522c82c749cf280359", + "lib/src/migrate/checks.dart": "def060b5110483eaccfd2f38eb59276eb8826f9bc37305574d0cf5f66e30c0b2", + "lib/src/migrate/schema.dart": "ecb46569f83192b4888f8af1aefd33a1d786c0e670d3f1eaeb264a59e4456e81", + "lib/src/migrate/backfill.dart": "2e02997c3cab9214167e25b3e830418384b1c704f534e5200a97519c995562f1", + "lib/src/migrate/snapshot.dart": "7a8d38fdd9d9078deb97fab50e851462c52cf8df606c65be387ff49e9792dfc3", + "lib/src/migrate/history.dart": "3d236ce64e8428e83692d2bce5f0b35a63401cbbba305d8a397bca402eb04563", + "lib/src/migrate/computed.dart": "cf34167f63590e36e72196ff5e6fac03fd9bb2d1dc28c7ef11b06d8ca9f943e8", + "lib/src/migrate/diff.dart": "de168f14e7b91e3cd350532eb5a0901f45310cd516b1f0aa1dd7f89d66a89d5c", + "lib/src/migrate/source.dart": "ee9637f5efa3a85a89573561027bc82930ddac14e5578742cdf1a27f609ceb7e", + "lib/src/migrate/step.dart": "750a02b0a4bfce6a6f3009eb6f72aa46813863da92493a6b23d38601c7d4f69f", + "lib/src/stream.dart": "ce6fd5da4a7dd17b5cdf09aae7eddc893c362c53251b0d627124419a7cc49e37", + "lib/src/generate/migrations.dart": "77ce5275428ea9efc3d0025d78a36607afda50ee83a5af5261e47ab73d263901", + "lib/src/generate/build.dart": "7020b5bf3b753c181c0efbf8afe5927b2f23b2d4c9f9362cfb864addf988083a", + "lib/src/generate/reader.dart": "d851c5d3d433a64a90a024047d64cedb8c8fa42585e49bd781bb3ab1232bcd71", + "lib/src/generate/import.dart": "7752c4e09f740e4cf8c4c61929306b6883ea853ee21fcde7e2427a5c180443a2", + "lib/src/generate/types.dart": "7fb4c9bea6762f9ee2dfa94f94419d883d52eb398c9346c1034b020c248fb86e", + "lib/src/generate/queries.dart": "85abf4adaeaaa7c2a5de47c6a9c953be79c2faf44f574f38a4f2d22004feef57", + "lib/src/generate/emitter.dart": "e676e662d2a2c8518e2f8a8df4045a159469ae4ed84c3df505301cfd04e21b99", + "lib/src/generate/model.dart": "1d0d28739ab18379278901632d55e7bb8b800b9898a8ffd64b58e6d133e5e516", + "lib/src/advanced.dart": "22de5b3bb320261282bca5c8446877e88129b34b68b839cfabf04dd8cbe783af", + "lib/src/named_sql.dart": "906b7bad1a499db8f582ef4e862d05ed5b8f4b08d24ddaaeff2b7555f1fb549d", + "lib/src/transaction.dart": "9e2da548a493b84c52d18acad9cfce65887c0b1c7f63615a2be5733d5ba68538", + "lib/src/relation.dart": "d78428fe2df25031d83500002b152853875f0762bf7875ed1f7725764c0b4acf", + "lib/src/cursor.dart": "3483a7b27b2f741a3782220cd7137c0fb7b6e0c9cf0254f4ddeb0068faf92852", + "lib/src/cte.dart": "e6241688c3648fff50476e3ffc517044ac9bc6c080ad6a2f56f482d4f415f610", + "lib/src/observation.dart": "e9471f15d1efdeb8233a50892b5c0cfeb0bd3b15b6a7790d4363551ae9473da7", + "lib/src/plan.dart": "9a58f35b3490ed2f13f55a1ec7c0c7a45117bf7265adec1bef9adab368d17e25", + "lib/src/codec.dart": "2f1d829d2cf043e76ac123eea6554206d82de0cbcdf8c34848075f21fe6d4765", + "example/flutter/lib/migrations/migrations.g.dart": "74eb85031b3aa459c5c13768926610b4bd9f2c251d7a9d1c6398144ed0531539", + "example/flutter/lib/migrations/m0002_comments.dart": "5fdbdc8ec612618de36649a66b331fa006a17a6d0787a597740d159c2e1cc870", + "example/flutter/lib/migrations/m0001_initial.dart": "71d878de428f54afb0e45f949149c61c0bed8462dbfbdba1056bffa0573b17fe", + "example/flutter/lib/schema.orm.dart": "80f1d1e1f3b21f0882671c58d9e3e2e9a840b8f077af10dc40a1c217feb9c7f5", + "example/flutter/lib/schema.snapshot.dart": "8bb4b6a07f2e2f8c3b5123a2c5e325870ed4baca817ae1a97365f14d1731c58f", + "example/flutter/lib/legacy/seed.dart": "a70c0c5191f9fd6632bb9bcd36a58649d486cd183b9e32ea8ce42238b9e27f3c", + "example/flutter/lib/legacy/schema.orm.dart": "600a281687d9f11ae299de8ecfd79cff1ff243f0343136256a8d263463b57ef9", + "example/flutter/lib/legacy/schema.snapshot.dart": "2f74337b040f831ecaac3d9268011bb27af2cf6728bb84c427ce7d95d14a713e", + "example/flutter/lib/legacy/schema.dart": "7bdd9b8f8cb97747d632a6da573883cde5fc5cfb60ab1ae2a6bf3da2df36f93a", + "example/flutter/lib/schema.dart": "ee6e7c93f615fcdcd60a020339bbfc0099ab48e0128d12ba20f362685802e0d6", + "example/flutter/lib/main.dart": "53b31b2eebcf8faa2eb25a8c6563619b069408e120d1b13c44967fb4053df287", + "example/flutter/lib/acceptance.dart": "4acdc0a8b500d9be09dab2cd02e3725da838d00f8131155d549dc0a4b7458420", + "example/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png": "c7c0c0189145e4e32a401c61c9bdc615754b0264e7afae24e834bb81049eaf81", + "example/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png": "6a7c8f0d703e3682108f9662f813302236240d3f8f638bb391e32bfb96055fef", + "example/flutter/android/app/src/main/res/drawable/launch_background.xml": "94433756fe10005ddd6fc55cc5c0b1e9f3c10b66a08ceac795ece6713d34e28a", + "example/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png": "3c34e1f298d0c9ea3455d46db6b7759c8211a49e9ec6e44b635fc5c87dfb4180", + "example/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png": "4d470bf22d5c17d84edc5f82516d1ba8a1c09559cd761cefb792f86d9f52b540", + "example/flutter/android/app/src/main/res/values-night/styles.xml": "8c5bad5152f217e837519f9c15211d19f39bd45f5f67faf6dd81cb4b299e518f", + "example/flutter/android/app/src/main/res/values/styles.xml": "937d7c82f539c72eb94fd3a3773f29b3d3fef1997213c92cb632298cedf4bda9", + "example/flutter/android/app/src/main/res/drawable-v21/launch_background.xml": "7ca6d5cec2169c5a98661a119db23a6f3399c8fef2256ff04f3a642831282478", + "example/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png": "e14aa40904929bf313fded22cf7e7ffcbf1d1aac4263b5ef1be8bfce650397aa", + "example/flutter/android/app/src/main/AndroidManifest.xml": "6bc850ca03629a837d3dc7b82ba7cbe6c2b969d69ff49a6bea814a651d473c76", + "example/flutter/android/app/src/main/kotlin/dev/orm/orm_flutter/MainActivity.kt": "2c1111a536c7554c4e39eb96dbf1f0a136c51f0dc3a5d9b87c7545f2272a77d8", + "tool/test_flutter.dart": "ce813e2715977ed4202ea7696df7be3789e894dbb290035b9db64052276278f0", + "example/flutter/pubspec.yaml": "8484b780de943ed56d9727cb7db47b75f372efed4f7b48e652fdcdfa911ae286", + "example/flutter/pubspec.lock": "4773a8bd30cbebbc25ed26c51dea715f6018d7e320f4e9a24fcdb93874ae1318", + "example/flutter/.metadata": "ac4f09e51478c44ffef6b566e3616f16a8b21db8b17eb51969b03cb2e1a2842e", + "example/flutter/android/app/build.gradle.kts": "67cc1825bb2528f74eb6d8577f99f22dd9c23fc8bb61d9be9a80d80e7fa9ecfb", + "example/flutter/android/settings.gradle.kts": "016759283e88ade6eba92910583feeef88b962839b957aa414296afd2952d122", + "example/flutter/android/gradle/wrapper/gradle-wrapper.properties": "b690d26223576fe4e63889fad9a00df81945cb870b9476cc5a563152a1a88a74" + }, + "screenshotSha256": "93f445c4187017a497f54c34bfe5ff74014834db28024146e8f867896a9cd054", + "phases": [ + { + "phase": "legacy", + "schemaVersion": 1, + "release": false, + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"android_arm64\"", + "os": "AE3A.240806.036", + "androidApi": 35, + "pid": 6123, + "path": "/data/user/0/dev.orm.orm_flutter/files/acceptance.sqlite", + "checks": [ + "Fresh database", + "Version 1 typed transaction persisted two notes", + "Application schema compatibility", + "Closed database reopened read-only" + ], + "sqlite": "3.53.4", + "legacyRows": [ + [ + 1, + "Written by version 1", + "2026-09-15 01:02:03.123456+00" + ], + [ + 2, + "Keep this across the upgrade", + "2026-09-15 00:00:00.000000+00" + ] + ], + "history": [ + { + "id": "0001_initial", + "checksum": "a5d45b89caaf4503d516f6b43138c4fd40e6ca88114f88ac1c3997af1afef421" + } + ], + "status": "passed", + "apkVersionCode": 1 + }, + { + "phase": "upgrade", + "schemaVersion": 2, + "release": true, + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"android_arm64\"", + "os": "AE3A.240806.036", + "androidApi": 35, + "pid": 6324, + "path": "/data/user/0/dev.orm.orm_flutter/files/acceptance.sqlite", + "checks": [ + "Existing database retained", + "Known execution phase", + "Expected previous migration history", + "Only version 2 migration applied", + "Live schema matches bundled snapshot", + "Original value and timestamp microseconds preserved", + "New field default / persisted update", + "Initial watch snapshot delivered to Flutter", + "Committed relation write refreshed watch", + "Rollback neither persisted nor notified", + "Typed patch refreshed watch", + "Background SQLite computation result", + "Dart timer and Flutter frames advanced during SQLite work", + "Native SQLite cancellation", + "Worker usable after cancellation", + "Application schema compatibility", + "Closed database reopened read-only" + ], + "sqlite": "3.53.4", + "unmanaged": [], + "background": { + "elapsedUs": 335844, + "timerTicks": 20, + "flutterFrames": 12 + }, + "watchSnapshots": 3, + "rows": [ + { + "id": 1, + "text": "Written by version 1", + "done": true, + "comments": [] + }, + { + "id": 2, + "text": "Keep this across the upgrade", + "done": false, + "comments": [] + }, + { + "id": 3, + "text": "Written by version 2", + "done": false, + "comments": [ + "Related in one transaction" + ] + } + ], + "history": [ + { + "id": "0001_initial", + "checksum": "a5d45b89caaf4503d516f6b43138c4fd40e6ca88114f88ac1c3997af1afef421" + }, + { + "id": "0002_comments", + "checksum": "089cd5d7ab42fa7491b8e46ca3b00899b34f926983f6a93f2812cf1b0c9afb92" + } + ], + "status": "passed", + "apkVersionCode": 2 + }, + { + "phase": "reopen", + "schemaVersion": 2, + "release": true, + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"android_arm64\"", + "os": "AE3A.240806.036", + "androidApi": 35, + "pid": 6401, + "path": "/data/user/0/dev.orm.orm_flutter/files/acceptance.sqlite", + "checks": [ + "Existing database retained", + "Known execution phase", + "Expected previous migration history", + "No migration replay", + "Live schema matches bundled snapshot", + "Original value and timestamp microseconds preserved", + "New field default / persisted update", + "Initial watch snapshot delivered to Flutter", + "Relation data survived process restart", + "Application schema compatibility", + "Closed database reopened read-only" + ], + "sqlite": "3.53.4", + "unmanaged": [], + "watchSnapshots": 1, + "rows": [ + { + "id": 1, + "text": "Written by version 1", + "done": true, + "comments": [] + }, + { + "id": 2, + "text": "Keep this across the upgrade", + "done": false, + "comments": [] + }, + { + "id": 3, + "text": "Written by version 2", + "done": false, + "comments": [ + "Related in one transaction" + ] + } + ], + "history": [ + { + "id": "0001_initial", + "checksum": "a5d45b89caaf4503d516f6b43138c4fd40e6ca88114f88ac1c3997af1afef421" + }, + { + "id": "0002_comments", + "checksum": "089cd5d7ab42fa7491b8e46ca3b00899b34f926983f6a93f2812cf1b0c9afb92" + } + ], + "status": "passed", + "apkVersionCode": 2 + } + ], + "limits": [ + "Android arm64 API 35 emulator; not physical hardware, iOS or macOS Flutter.", + "Legacy APK debug; upgraded/restarted APK release AOT.", + "Frame and timer progress demonstrate worker separation, not an FPS benchmark.", + "Force-stop/relaunch is process persistence evidence, not power-loss testing." + ] +} diff --git a/research/validation/migration-targets-backfill-aot.log b/research/validation/migration-targets-backfill-aot.log new file mode 100644 index 00000000..faa73818 --- /dev/null +++ b/research/validation/migration-targets-backfill-aot.log @@ -0,0 +1,4 @@ +Running build hooks...Running build hooks...Running link hooks...Running link hooks...Copying 1 build assets: +package:sqlite3/src/ffi/libsqlite3.g.dart +Generated: /Users/seven/workspace/dart-orm/.dart_tool/target-backfill-aot/bundle/bin/native_backfill +Native AOT: historical backfill declaration, bounded pause, concurrent resume and startup compatibility passed. diff --git a/research/validation/migration-targets-browser-js.log b/research/validation/migration-targets-browser-js.log new file mode 100644 index 00000000..e4e7ece0 --- /dev/null +++ b/research/validation/migration-targets-browser-js.log @@ -0,0 +1,86 @@ +Running build hooks...Running build hooks...{ + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 66 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 37 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 15 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 33 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 25 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 3 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 2 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 3 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 1 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 4 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 4 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 159 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 30 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 92 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 3 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 20 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "js", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" +} diff --git a/research/validation/migration-targets-browser-wasm.log b/research/validation/migration-targets-browser-wasm.log new file mode 100644 index 00000000..d1226e7b --- /dev/null +++ b/research/validation/migration-targets-browser-wasm.log @@ -0,0 +1,86 @@ +Running build hooks...Running build hooks...{ + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 51 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 39 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 13 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 25 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 22 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 2 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 157 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 29 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 87 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 1 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 13 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "wasm", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" +} diff --git a/research/validation/migration-targets-cli.log b/research/validation/migration-targets-cli.log new file mode 100644 index 00000000..b844bcb0 --- /dev/null +++ b/research/validation/migration-targets-cli.log @@ -0,0 +1,5 @@ +CLI registry initialization, regeneration and rejected retarget: passed +{"command": "check", "valid": true, "dialect": "sqlite", "migrations": ["0001_initial"]} +{"command": "apply", "applied": ["0001_initial"]} +{"command": "verify", "matches": true, "differences": [], "unmanaged": []} +{"command": "apply", "applied": []} diff --git a/research/validation/migration-targets-followup.log b/research/validation/migration-targets-followup.log new file mode 100644 index 00000000..ec7ffcfb --- /dev/null +++ b/research/validation/migration-targets-followup.log @@ -0,0 +1,32 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/migration_target_test.dart +00:00 +0: test/migration_target_test.dart: history requires a single engine even when empty or SQL is portable +00:00 +1: test/migration_target_test.dart: unselected expressions cannot change a saved plan, hash or source +00:00 +2: test/migration_target_test.dart: SQLite virtual indexes and computed mode changes ignore PostgreSQL limits +00:00 +3: test/migration_target_test.dart: replacing stored values with computed values requires explicit review +00:00 +4: test/migration_target_test.dart: older PostgreSQL is rejected before locks, journals or partial execution +00:00 +5: test/migration_target_test.dart: registry fixes its engine at initialization and refuses retargeting +00:00 +6: test/migration_target_test.dart: wrong engine fails before any migration SQL for runner and every connected CLI command +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +00:00 +7: test/migration_target_test.dart: PostgreSQL applies only its own generated conversion and rejects a SQLite history before SQL +00:00 +8: loading test/migration_recovery_test.dart +00:00 +8: test/migration_recovery_test.dart: (setUpAll) +00:00 +8: test/migration_recovery_test.dart: SQLite and transaction controls reject recoverable autocommit steps +00:00 +9: test/migration_recovery_test.dart: mixed migrations checkpoint SQL and verify concurrent indexes +00:00 +10: test/migration_recovery_test.dart: same-name wrong-definition indexes are never accepted as completion +00:00 +11: test/migration_recovery_test.dart: failed unique builds leave INVALID indexes and require explicit repair +00:00 +12: test/migration_recovery_test.dart: failed attempts freeze migration checksums and block baseline +00:00 +13: test/migration_recovery_test.dart: failed postconditions report their actual failing stage +00:00 +14: test/migration_recovery_test.dart: recovery probes must produce one boolean value +00:00 +15: test/migration_recovery_test.dart: concurrent runners serialize normal and autocommit steps under one lock +00:01 +16: test/migration_recovery_test.dart: runner lock timeout leaves history untouched and the connection reusable +00:01 +17: test/migration_recovery_test.dart: ordinary SQL inside a mixed migration rolls back only its failed step and resumes +00:01 +18: test/migration_recovery_test.dart: completion checks reject partial and NULLS NOT DISTINCT definitions +00:01 +19: test/migration_recovery_test.dart: real process exit after concurrent DDL resumes without replay +00:11 +20: test/migration_recovery_test.dart: real process exit after transaction commit resumes without replay +00:19 +21: test/migration_recovery_test.dart: (tearDownAll) +00:19 +21: All tests passed! diff --git a/research/validation/migration-targets-full.log b/research/validation/migration-targets-full.log new file mode 100644 index 00000000..39540cd4 --- /dev/null +++ b/research/validation/migration-targets-full.log @@ -0,0 +1,948 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/client_default_test.dart +00:00 +0: test/client_default_test.dart: client defaults sqlite omitted values use typed Dart factories and return stored records +00:00 +1: test/client_default_test.dart: client defaults sqlite explicit values, null and SQL DEFAULT bypass their client factory +00:00 +2: test/client_default_test.dart: client defaults sqlite prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +3: test/client_default_test.dart: client defaults sqlite batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +4: test/client_default_test.dart: client defaults sqlite updates and conflict updates do not reset omitted client values +00:00 +5: test/client_default_test.dart: client defaults sqlite factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +6: test/client_default_test.dart: client defaults sqlite snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +7: test/client_default_test.dart: client defaults sqlite explicit DEFAULT can choose database identity instead of the client value +00:00 +8: test/client_default_test.dart: client defaults postgres omitted values use typed Dart factories and return stored records +00:00 +9: test/client_default_test.dart: client defaults postgres explicit values, null and SQL DEFAULT bypass their client factory +00:00 +10: test/client_default_test.dart: client defaults postgres prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +11: test/client_default_test.dart: client defaults postgres batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +12: test/client_default_test.dart: client defaults postgres updates and conflict updates do not reset omitted client values +00:00 +13: test/client_default_test.dart: client defaults postgres factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +14: test/client_default_test.dart: client defaults postgres snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +15: test/client_default_test.dart: client defaults postgres explicit DEFAULT can choose database identity instead of the client value +00:00 +16: test/client_default_test.dart: generation reads factory references without executing them and supports moved output +00:02 +17: loading test/decimal_precision_test.dart +00:02 +17: test/decimal_precision_test.dart: decimal precision sqlite column coercion rounds signed ties and handles negative and excess scales +00:02 +18: test/decimal_precision_test.dart: decimal precision sqlite writes constrain columns while arithmetic and wider aggregates retain their result precision +00:02 +19: test/decimal_precision_test.dart: decimal precision sqlite batch inserts and upserts use column coercion and roll back as a unit +00:02 +20: test/decimal_precision_test.dart: decimal precision sqlite catalogs snapshots and imported declarations retain precision scale and defaults +00:04 +21: test/decimal_precision_test.dart: decimal precision sqlite reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:04 +22: test/decimal_precision_test.dart: decimal precision sqlite physical renames and changing constrained defaults preserve column contracts +00:04 +23: test/decimal_precision_test.dart: decimal precision sqlite maximum declared precision and scale bounds remain exact +00:04 +24: test/decimal_precision_test.dart: decimal precision sqlite historical backfills retain constrained decimal key declarations +00:04 +25: test/decimal_precision_test.dart: decimal precision postgres column coercion rounds signed ties and handles negative and excess scales +00:04 +26: test/decimal_precision_test.dart: decimal precision postgres writes constrain columns while arithmetic and wider aggregates retain their result precision +00:04 +27: test/decimal_precision_test.dart: decimal precision postgres batch inserts and upserts use column coercion and roll back as a unit +00:04 +28: test/decimal_precision_test.dart: decimal precision postgres catalogs snapshots and imported declarations retain precision scale and defaults +00:05 +29: test/decimal_precision_test.dart: decimal precision postgres reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:05 +30: test/decimal_precision_test.dart: decimal precision postgres physical renames and changing constrained defaults preserve column contracts +00:05 +31: test/decimal_precision_test.dart: decimal precision postgres maximum declared precision and scale bounds remain exact +00:05 +32: test/decimal_precision_test.dart: decimal precision postgres historical backfills retain constrained decimal key declarations +00:05 +33: test/decimal_precision_test.dart: SQLite enforces stored precision and checks default coercion and trusted-schema behavior +00:05 +34: test/decimal_precision_test.dart: generation validates DecimalDigits and retains custom decimal codec types +00:07 +35: test/decimal_precision_test.dart: metadata rejects invalid precisions scales and non-decimal columns +00:07 +36: loading test/selection_test.dart +00:07 +36: test/selection_test.dart: selection sqlite all typed arities defer mapping and preserve left-to-right evaluation +00:07 +37: test/selection_test.dart: selection sqlite first decoder failure prevents later fields and outer mapper +00:07 +38: test/selection_test.dart: selection sqlite dynamic fields snapshot keys and preserve nulls and mapping order +00:07 +39: test/selection_test.dart: selection postgres all typed arities defer mapping and preserve left-to-right evaluation +00:07 +40: test/selection_test.dart: selection postgres first decoder failure prevents later fields and outer mapper +00:07 +41: test/selection_test.dart: selection postgres dynamic fields snapshot keys and preserve nulls and mapping order +00:07 +42: loading test/build_runner_test.dart +00:07 +42: test/build_runner_test.dart: real build_runner build/watch tracks imports, recovers errors and deletes stale outputs +00:37 +43: loading test/schema_version_test.dart +00:37 +43: test/schema_version_test.dart: schema version sqlite invalid ranges fail before SQL; caller transactions stay usable +00:37 +44: test/schema_version_test.dart: schema version sqlite unversioned existing data is rejected without creating history +00:37 +45: test/schema_version_test.dart: schema version sqlite default requires latest; inclusive compatibility range is explicit +00:37 +46: test/schema_version_test.dart: schema version sqlite newer database rejects downgrade without changing data or schema +00:37 +47: test/schema_version_test.dart: schema version sqlite complete applied prefix is checked, including old checksums and missing entries +00:37 +48: test/schema_version_test.dart: schema version sqlite matching version does not claim that the catalog is free of drift +00:37 +49: test/schema_version_test.dart: schema version sqlite verified baseline is compatible without replaying creation +00:37 +50: test/schema_version_test.dart: schema version sqlite borrowed session uses a read snapshot and survives rejected versions +00:37 +51: test/schema_version_test.dart: schema version postgres invalid ranges fail before SQL; caller transactions stay usable +00:37 +52: test/schema_version_test.dart: schema version postgres unversioned existing data is rejected without creating history +00:37 +53: test/schema_version_test.dart: schema version postgres default requires latest; inclusive compatibility range is explicit +00:37 +54: test/schema_version_test.dart: schema version postgres newer database rejects downgrade without changing data or schema +00:37 +55: test/schema_version_test.dart: schema version postgres complete applied prefix is checked, including old checksums and missing entries +00:37 +56: test/schema_version_test.dart: schema version postgres matching version does not claim that the catalog is free of drift +00:37 +57: test/schema_version_test.dart: schema version postgres verified baseline is compatible without replaying creation +00:37 +58: test/schema_version_test.dart: schema version postgres borrowed session uses a read snapshot and survives rejected versions +00:37 +59: test/schema_version_test.dart: schema version postgres unfinished recoverable migration blocks startup until repaired and completed +00:37 +60: test/schema_version_test.dart: schema version postgres catalog and history share a snapshot and the supplied history is frozen +00:38 +61: test/schema_version_test.dart: SQLite version gate works on an explicitly read-only file +00:38 +62: loading test/types_test.dart +00:38 +62: test/types_test.dart: junction-table keys, enum payloads and nested results keep generated types +00:39 +63: test/types_test.dart: computed generated fields are statically readable and never writable +00:40 +64: test/types_test.dart: client-default create parameters preserve domain, nullable and timestamp types +00:40 +65: test/types_test.dart: query-only relation APIs retain result types and expose no graph writes +00:41 +66: test/types_test.dart: calendar APIs reject instants, strings and mixed temporal types +00:41 +67: test/types_test.dart: actual generated APIs reject wrong inputs, results and backend options +00:41 +68: loading test/migration_test.dart +00:41 +68: test/migration_test.dart: (setUpAll) +00:42 +68: test/migration_test.dart: migrations sqlite explicit table and column renames preserve incoming references and data +00:42 +69: test/migration_test.dart: migrations sqlite constraint changes resolve catalog names and retain rows +00:42 +70: test/migration_test.dart: migrations sqlite failed NOT NULL rebuild restores data history and foreign key settings +00:42 +71: test/migration_test.dart: migrations sqlite reviewed conversion expressions preserve values when storage types change +00:42 +72: test/migration_test.dart: migrations sqlite additive changes use ALTER without a table rebuild +00:42 +73: test/migration_test.dart: migrations sqlite reviewed table removal handles restrictive foreign keys atomically +00:42 +74: test/migration_test.dart: migrations sqlite broken migration chain is refused before DDL +00:42 +75: test/migration_test.dart: migrations sqlite catalog distinguishes nullable non-rowid primary keys +00:42 +76: test/migration_test.dart: migrations sqlite rebuild preserves unmanaged index trigger and view definitions +00:42 +77: test/migration_test.dart: migrations sqlite foreign key check rolls back changed parent keys without cascading data away +00:42 +78: test/migration_test.dart: migrations sqlite unmodeled CHECK constraints are refused without losing their definition +00:42 +79: test/migration_test.dart: migrations postgres explicit table and column renames preserve incoming references and data +00:42 +80: test/migration_test.dart: migrations postgres constraint changes resolve catalog names and retain rows +00:42 +81: test/migration_test.dart: migrations postgres failed NOT NULL rebuild restores data history and foreign key settings +00:42 +82: test/migration_test.dart: migrations postgres reviewed conversion expressions preserve values when storage types change +00:42 +83: test/migration_test.dart: migrations postgres additive changes use ALTER without a table rebuild +00:42 +84: test/migration_test.dart: migrations postgres reviewed table removal handles restrictive foreign keys atomically +00:42 +85: test/migration_test.dart: migrations postgres broken migration chain is refused before DDL +00:42 +86: test/migration_test.dart: diff requires explicit renames, casts and backfills +00:42 +87: test/migration_test.dart: (tearDownAll) +00:42 +87: loading test/many_to_many_test.dart +00:42 +87: test/many_to_many_test.dart: many-to-many sqlite two declared FKs and a composite primary key enforce association identity +00:42 +88: test/many_to_many_test.dart: many-to-many sqlite forward projection keeps payload, shared targets and per-user limits in two statements +00:42 +89: test/many_to_many_test.dart: many-to-many sqlite reverse projection applies tie-breaking and offset to each team without changing root pagination +00:42 +90: test/many_to_many_test.dart: many-to-many sqlite nested team rosters deduplicate shared team keys and batch by level +00:42 +91: test/many_to_many_test.dart: many-to-many sqlite association predicates and counts stay in one SQL statement +00:42 +92: test/many_to_many_test.dart: many-to-many sqlite limited parameter capacity chunks associations without changing per-parent limits +00:42 +93: test/many_to_many_test.dart: many-to-many sqlite transaction rollback restores payload changes and new endpoints after an invalid association +00:42 +94: test/many_to_many_test.dart: many-to-many sqlite composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:42 +95: test/many_to_many_test.dart: many-to-many sqlite watch tracks junction payload and opposite endpoint changes after commit +00:42 +96: test/many_to_many_test.dart: many-to-many postgres two declared FKs and a composite primary key enforce association identity +00:42 +97: test/many_to_many_test.dart: many-to-many postgres forward projection keeps payload, shared targets and per-user limits in two statements +00:42 +98: test/many_to_many_test.dart: many-to-many postgres reverse projection applies tie-breaking and offset to each team without changing root pagination +00:42 +99: test/many_to_many_test.dart: many-to-many postgres nested team rosters deduplicate shared team keys and batch by level +00:42 +100: test/many_to_many_test.dart: many-to-many postgres association predicates and counts stay in one SQL statement +00:42 +101: test/many_to_many_test.dart: many-to-many postgres limited parameter capacity chunks associations without changing per-parent limits +00:42 +102: test/many_to_many_test.dart: many-to-many postgres transaction rollback restores payload changes and new endpoints after an invalid association +00:42 +103: test/many_to_many_test.dart: many-to-many postgres composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:43 +104: test/many_to_many_test.dart: many-to-many postgres watch tracks junction payload and opposite endpoint changes after commit +00:43 +105: loading test/migration_target_test.dart +00:43 +105: test/migration_target_test.dart: history requires a single engine even when empty or SQL is portable +00:43 +106: test/migration_target_test.dart: unselected expressions cannot change a saved plan, hash or source +00:43 +107: test/migration_target_test.dart: SQLite virtual indexes and computed mode changes ignore PostgreSQL limits +00:43 +108: test/migration_target_test.dart: replacing stored values with computed values requires explicit review +00:43 +109: test/migration_target_test.dart: older PostgreSQL is rejected before locks, journals or partial execution +00:43 +110: test/migration_target_test.dart: registry fixes its engine at initialization and refuses retargeting +00:43 +111: test/migration_target_test.dart: wrong engine fails before any migration SQL for runner and every connected CLI command +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +00:43 +112: test/migration_target_test.dart: PostgreSQL applies only its own generated conversion and rejects a SQLite history before SQL +00:43 +113: loading test/stream_test.dart +00:43 +113: test/stream_test.dart: sqlite cancellation between batch chunks prevents a partial commit +00:43 +114: test/stream_test.dart: sqlite cancellation interrupts an active cursor fetch +00:43 +115: test/stream_test.dart: sqlite timed-out mutations return no partial write +00:43 +116: test/stream_test.dart: sqlite database close stops a paused cursor and releases the driver +00:43 +117: test/stream_test.dart: sqlite cancellation between stream creation and listening needs no rollback +00:43 +118: test/stream_test.dart: sqlite bounded cursor fetches preserve parameters and typed rows +00:43 +119: test/stream_test.dart: sqlite pause stops fetches and early cancellation releases the lease +00:43 +120: test/stream_test.dart: sqlite await-for provides demand and take closes without exhausting +00:43 +121: test/stream_test.dart: sqlite relation projections load once per root batch +00:43 +122: test/stream_test.dart: sqlite mapper failure closes the cursor and rolls back +00:43 +123: test/stream_test.dart: sqlite early stream end inside a transaction preserves other work +00:43 +124: test/stream_test.dart: sqlite escaping paused stream cannot keep a transaction alive +00:43 +125: test/stream_test.dart: sqlite cancels executing SQL and allows the next statement +00:43 +126: test/stream_test.dart: sqlite statement timeout stops SQL and leaves a usable connection +00:43 +127: test/stream_test.dart: sqlite cancelled statement poisons a transaction even if caught +00:43 +128: test/stream_test.dart: sqlite cancelling a paused stream releases its connection before resume +00:43 +129: test/stream_test.dart: sqlite cancellation before starting performs no SQL +00:43 +130: test/stream_test.dart: postgres cancellation between batch chunks prevents a partial commit +00:43 +131: test/stream_test.dart: postgres cancellation interrupts an active cursor fetch +00:43 +132: test/stream_test.dart: postgres timed-out mutations return no partial write +00:44 +133: test/stream_test.dart: postgres database close stops a paused cursor and releases the driver +00:44 +134: test/stream_test.dart: postgres cancellation between stream creation and listening needs no rollback +00:44 +135: test/stream_test.dart: postgres bounded cursor fetches preserve parameters and typed rows +00:44 +136: test/stream_test.dart: postgres pause stops fetches and early cancellation releases the lease +00:44 +137: test/stream_test.dart: postgres await-for provides demand and take closes without exhausting +00:44 +138: test/stream_test.dart: postgres relation projections load once per root batch +00:44 +139: test/stream_test.dart: postgres mapper failure closes the cursor and rolls back +00:44 +140: test/stream_test.dart: postgres early stream end inside a transaction preserves other work +00:44 +141: test/stream_test.dart: postgres escaping paused stream cannot keep a transaction alive +00:44 +142: test/stream_test.dart: postgres cancels executing SQL and allows the next statement +00:44 +143: test/stream_test.dart: postgres statement timeout stops SQL and leaves a usable connection +00:44 +144: test/stream_test.dart: postgres cancelled statement poisons a transaction even if caught +00:44 +145: test/stream_test.dart: postgres cancelling a paused stream releases its connection before resume +00:44 +146: test/stream_test.dart: postgres cancellation before starting performs no SQL +00:44 +147: test/stream_test.dart: late PostgreSQL cancellation cannot reach the next statement +00:44 +148: test/stream_test.dart: borrowed pool requires cancellation support and retains ownership +00:44 +149: test/stream_test.dart: failed cancellation control discards the physical connection +00:44 +150: loading test/migration_recovery_test.dart +00:45 +150: test/migration_recovery_test.dart: (setUpAll) +00:45 +150: test/migration_recovery_test.dart: SQLite and transaction controls reject recoverable autocommit steps +00:45 +151: test/migration_recovery_test.dart: mixed migrations checkpoint SQL and verify concurrent indexes +00:54 +152: test/migration_recovery_test.dart: same-name wrong-definition indexes are never accepted as completion +00:55 +153: test/migration_recovery_test.dart: failed unique builds leave INVALID indexes and require explicit repair +00:55 +154: test/migration_recovery_test.dart: failed attempts freeze migration checksums and block baseline +00:55 +155: test/migration_recovery_test.dart: failed postconditions report their actual failing stage +00:55 +156: test/migration_recovery_test.dart: recovery probes must produce one boolean value +00:55 +157: test/migration_recovery_test.dart: concurrent runners serialize normal and autocommit steps under one lock +00:55 +158: test/migration_recovery_test.dart: runner lock timeout leaves history untouched and the connection reusable +00:55 +159: test/migration_recovery_test.dart: ordinary SQL inside a mixed migration rolls back only its failed step and resumes +00:55 +160: test/migration_recovery_test.dart: completion checks reject partial and NULLS NOT DISTINCT definitions +00:55 +161: test/migration_recovery_test.dart: real process exit after concurrent DDL resumes without replay +01:00 +161 -1: test/migration_recovery_test.dart: real process exit after concurrent DDL resumes without replay [E] + Expected: <91> + Actual: <255> + + Running build hooks...Running build hooks...Unhandled exception: + OrmException(MIGRATION.TARGET): 0001_initial targets postgres, but this history requires sqlite. + #0 validateMigrations (package:orm/src/migrate/history.dart:362:7) + #1 MigrationHistory.checked (package:orm/src/migrate/source.dart:24:5) + #2 main (file:///private/var/folders/gf/46z6v161517c7_h1zl31hj1m0000gn/T/orm-build-h4pesJ/bin/crash.dart:3:73) + #3 _delayEntrypointInvocation. (dart:isolate-patch/isolate_patch.dart:311:33) + #4 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:192:12) + + + package:matcher expect + test/migration_recovery_test.dart 417:11 main. + +01:00 +161 -1: test/migration_recovery_test.dart: real process exit after transaction commit resumes without replay +01:08 +161 -2: test/migration_recovery_test.dart: real process exit after transaction commit resumes without replay [E] + Expected: <91> + Actual: <255> + + Running build hooks...Running build hooks...Unhandled exception: + OrmException(MIGRATION.TARGET): 0001_initial targets postgres, but this history requires sqlite. + #0 validateMigrations (package:orm/src/migrate/history.dart:362:7) + #1 MigrationHistory.checked (package:orm/src/migrate/source.dart:24:5) + #2 main (file:///private/var/folders/gf/46z6v161517c7_h1zl31hj1m0000gn/T/orm-build-blAlWv/bin/crash.dart:3:73) + #3 _delayEntrypointInvocation. (dart:isolate-patch/isolate_patch.dart:311:33) + #4 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:192:12) + + + package:matcher expect + test/migration_recovery_test.dart 417:11 main. + +01:08 +161 -2: test/migration_recovery_test.dart: (tearDownAll) +01:08 +161 -2: loading test/acquisition_test.dart +01:08 +161 -2: test/acquisition_test.dart: an expired lease cannot enter before a delayed Timer gets its turn +01:08 +162 -2: test/acquisition_test.dart: acquisition sqlite expired raw writes never execute after a late lease becomes available +01:08 +163 -2: test/acquisition_test.dart: acquisition sqlite query cancellation returns while the pool is still occupied +01:08 +164 -2: test/acquisition_test.dart: acquisition sqlite typed mutations and batch transactions propagate acquisition limits +01:08 +165 -2: test/acquisition_test.dart: acquisition sqlite expired transactions and sessions never enter their callbacks +01:08 +166 -2: test/acquisition_test.dart: acquisition sqlite acquisition timeout stops on entry and statement timeout starts afterward +01:08 +167 -2: test/acquisition_test.dart: acquisition sqlite invalid and already-cancelled acquisition options never queue work +01:08 +168 -2: test/acquisition_test.dart: acquisition sqlite stream cancellation and timeout return without opening a cursor +01:08 +169 -2: test/acquisition_test.dart: acquisition sqlite query watch acquisition failure can recover on a later invalidation +01:08 +170 -2: test/acquisition_test.dart: acquisition sqlite close drains abandoned reservations without running their callbacks +01:08 +171 -2: test/acquisition_test.dart: acquisition sqlite abandoned requests preserve pool reuse and subsequent action errors +01:08 +172 -2: test/acquisition_test.dart: PostgreSQL native pool timeout is classified before entering the callback +01:09 +173 -2: test/acquisition_test.dart: PostgreSQL connection establishment timeout does not cap pool waiting +01:09 +174 -2: test/acquisition_test.dart: acquisition postgres expired raw writes never execute after a late lease becomes available +01:09 +175 -2: test/acquisition_test.dart: acquisition postgres query cancellation returns while the pool is still occupied +01:09 +176 -2: test/acquisition_test.dart: acquisition postgres typed mutations and batch transactions propagate acquisition limits +01:09 +177 -2: test/acquisition_test.dart: acquisition postgres expired transactions and sessions never enter their callbacks +01:09 +178 -2: test/acquisition_test.dart: acquisition postgres acquisition timeout stops on entry and statement timeout starts afterward +01:09 +179 -2: test/acquisition_test.dart: acquisition postgres invalid and already-cancelled acquisition options never queue work +01:09 +180 -2: test/acquisition_test.dart: acquisition postgres stream cancellation and timeout return without opening a cursor +01:09 +181 -2: test/acquisition_test.dart: acquisition postgres query watch acquisition failure can recover on a later invalidation +01:10 +182 -2: test/acquisition_test.dart: acquisition postgres close drains abandoned reservations without running their callbacks +01:10 +183 -2: test/acquisition_test.dart: acquisition postgres abandoned requests preserve pool reuse and subsequent action errors +01:10 +184 -2: test/acquisition_test.dart: PostgreSQL timeout during real connection initialization releases the late lease +01:10 +185 -2: loading test/computed_test.dart +01:10 +185 -2: test/computed_test.dart: computed sqlite native expressions preserve quoted identifiers, comments and SQL-like string values +01:10 +186 -2: test/computed_test.dart: computed sqlite generated CRUD, projections, batch and conflict writes compute stored and virtual values +01:10 +187 -2: test/computed_test.dart: computed sqlite catalog/import roundtrip preserves SQL and modes and detects drift +01:12 +188 -2: test/computed_test.dart: computed sqlite ComputedStorage.stored add, expression/type change and drop preserve existing rows +01:12 +189 -2: test/computed_test.dart: computed sqlite ComputedStorage.virtual add, expression/type change and drop preserve existing rows +01:12 +190 -2: test/computed_test.dart: computed sqlite stored to ordinary materialization retains values and permits later writes +01:12 +191 -2: test/computed_test.dart: computed sqlite explicit renamed expressions preserve indexes, checks, references and views +01:12 +192 -2: test/computed_test.dart: computed sqlite invalid recomputation rolls back the table and migration history +01:12 +193 -2: test/computed_test.dart: computed sqlite decimal computed SQL keeps precision coercion and imports exactly once +01:13 +194 -2: test/computed_test.dart: computed postgres native expressions preserve quoted identifiers, comments and SQL-like string values +01:13 +195 -2: test/computed_test.dart: computed postgres generated CRUD, projections, batch and conflict writes compute stored and virtual values +01:13 +196 -2: test/computed_test.dart: computed postgres catalog/import roundtrip preserves SQL and modes and detects drift +01:13 +197 -2: test/computed_test.dart: computed postgres ComputedStorage.stored add, expression/type change and drop preserve existing rows +01:13 +198 -2: test/computed_test.dart: computed postgres ComputedStorage.virtual add, expression/type change and drop preserve existing rows +01:13 +199 -2: test/computed_test.dart: computed postgres stored to ordinary materialization retains values and permits later writes +01:13 +200 -2: test/computed_test.dart: computed postgres explicit renamed expressions preserve indexes, checks, references and views +01:13 +201 -2: test/computed_test.dart: computed postgres invalid recomputation rolls back the table and migration history +01:13 +202 -2: test/computed_test.dart: computed postgres decimal computed SQL keeps precision coercion and imports exactly once +01:14 +203 -2: test/computed_test.dart: mode conversions need explicit plans and historical copy/backfill cannot write computed columns +01:14 +204 -2: loading test/named_sql_test.dart +01:14 +204 -2: test/named_sql_test.dart: template binds repeated names once and preserves quotes/comments +01:14 +205 -2: test/named_sql_test.dart: templates reject ambiguous bindings and incomplete or multiple statements +01:14 +206 -2: test/named_sql_test.dart: generated client is deterministic and checked against its Dart declaration +01:16 +207 -2: test/named_sql_test.dart: sqlite typed parameters, projection and repeated nullable bindings compose +01:16 +208 -2: test/named_sql_test.dart: sqlite native numeric and temporal codecs survive binding and streaming +01:16 +209 -2: test/named_sql_test.dart: sqlite CTEs and unions retain distinct query arguments and field identity +01:16 +210 -2: test/named_sql_test.dart: sqlite transactions see pending writes and rollback normally +01:16 +211 -2: test/named_sql_test.dart: sqlite named sources reject table mutations and require explicit watch reads +01:16 +212 -2: test/named_sql_test.dart: sqlite native checks accept declared shape and preserve prepared-statement state +01:17 +213 -2: test/named_sql_test.dart: sqlite native checks reject syntax, missing tables and missing result aliases +01:17 +214 -2: test/named_sql_test.dart: sqlite native checks do not evaluate application expressions +01:17 +215 -2: test/named_sql_test.dart: postgres typed parameters, projection and repeated nullable bindings compose +01:17 +216 -2: test/named_sql_test.dart: postgres native numeric and temporal codecs survive binding and streaming +01:17 +217 -2: test/named_sql_test.dart: postgres CTEs and unions retain distinct query arguments and field identity +01:17 +218 -2: test/named_sql_test.dart: postgres transactions see pending writes and rollback normally +01:17 +219 -2: test/named_sql_test.dart: postgres named sources reject table mutations and require explicit watch reads +01:17 +220 -2: test/named_sql_test.dart: postgres native checks accept declared shape and preserve prepared-statement state +01:17 +221 -2: test/named_sql_test.dart: postgres native checks reject syntax, missing tables and missing result aliases +01:17 +222 -2: test/named_sql_test.dart: postgres native checks do not evaluate application expressions +01:17 +223 -2: loading test/named_sql_generation_test.dart +01:18 +223 -2: test/named_sql_generation_test.dart: source contracts reject missing/extra parameters and row metadata +01:21 +224 -2: test/named_sql_generation_test.dart: SQL edits and modified generated code fail freshness checks +01:23 +225 -2: test/named_sql_generation_test.dart: asset builder and standalone generator emit identical SQL contracts +01:25 +226 -2: test/named_sql_generation_test.dart: generated APIs reject missing/wrong parameters, result types and unsupported backends +01:25 +227 -2: test/named_sql_generation_test.dart: CLI checks a persistent SQLite database without creating missing files +01:44 +228 -2: loading test/decimal_average_test.dart +01:44 +228 -2: test/decimal_average_test.dart: integer fractions round once and only bound their final result +01:45 +229 -2: test/decimal_average_test.dart: decimal average sqlite empty null and constant inputs retain aggregate semantics +01:45 +230 -2: test/decimal_average_test.dart: decimal average sqlite all rounding modes match exact signed sums across scales +01:45 +231 -2: test/decimal_average_test.dart: decimal average sqlite valid means survive sums exceeding the finite numeric range +01:47 +232 -2: test/decimal_average_test.dart: decimal average sqlite minimum fractions and outermost rounding scales stay exact +01:47 +233 -2: test/decimal_average_test.dart: decimal average sqlite grouping HAVING ordering and CTE references preserve the mean +01:47 +234 -2: test/decimal_average_test.dart: decimal average sqlite illegal window nesting and aggregate assignments fail before SQL +01:47 +235 -2: test/decimal_average_test.dart: decimal average sqlite windows and rounded window means respect partitions and pagination +01:47 +236 -2: test/decimal_average_test.dart: decimal average sqlite correlated means can update values and failures roll back transactions +01:47 +237 -2: test/decimal_average_test.dart: decimal average sqlite batched relationship windows keep per-parent limits +01:47 +238 -2: test/decimal_average_test.dart: decimal average sqlite sliding windows remove values, reset empty frames and reject varying policies +01:47 +239 -2: test/decimal_average_test.dart: decimal average sqlite window means of grouped sums run after HAVING +01:47 +240 -2: test/decimal_average_test.dart: decimal average postgres empty null and constant inputs retain aggregate semantics +01:47 +241 -2: test/decimal_average_test.dart: decimal average postgres all rounding modes match exact signed sums across scales +01:47 +242 -2: test/decimal_average_test.dart: decimal average postgres valid means survive sums exceeding the finite numeric range +01:47 +243 -2: test/decimal_average_test.dart: decimal average postgres minimum fractions and outermost rounding scales stay exact +01:48 +244 -2: test/decimal_average_test.dart: decimal average postgres grouping HAVING ordering and CTE references preserve the mean +01:48 +245 -2: test/decimal_average_test.dart: decimal average postgres illegal window nesting and aggregate assignments fail before SQL +01:48 +246 -2: test/decimal_average_test.dart: decimal average postgres windows and rounded window means respect partitions and pagination +01:48 +247 -2: test/decimal_average_test.dart: decimal average postgres correlated means can update values and failures roll back transactions +01:48 +248 -2: test/decimal_average_test.dart: decimal average postgres batched relationship windows keep per-parent limits +01:48 +249 -2: test/decimal_average_test.dart: decimal average postgres window means of grouped sums run after HAVING +01:48 +250 -2: test/decimal_average_test.dart: decimal average postgres volatile inputs and window ordering are evaluated once per row +01:48 +251 -2: loading test/instant_test.dart +01:48 +251 -2: test/instant_test.dart: strict UTC parsing handles eras, offsets, endpoints and invalid components +01:48 +252 -2: test/instant_test.dart: UTC defaults and local DateTime inputs do not depend on the process timezone +01:49 +253 -2: test/instant_test.dart: historical migration checksum and timestamp tag remain immutable +01:49 +254 -2: test/instant_test.dart: UTC instant sqlite native round trips and streams preserve exact microseconds and common endpoints +01:49 +255 -2: test/instant_test.dart: UTC instant sqlite chronological comparisons, grouping, windows, union and CTEs use instants +01:49 +256 -2: test/instant_test.dart: UTC instant sqlite offset-equivalent keys join and batch through the same unique index +01:49 +257 -2: test/instant_test.dart: UTC instant sqlite current snapshots, physical catalog and imported DateTime declarations agree +01:51 +258 -2: test/instant_test.dart: UTC instant sqlite offset literals compare by value while expression casts remain visible +01:51 +259 -2: test/instant_test.dart: UTC instant sqlite reviewed migration preserves history and repairs legacy timestamp ordering +01:51 +260 -2: test/instant_test.dart: UTC instant sqlite legacy key collisions and invalid calendar values roll back conversion +01:51 +261 -2: test/instant_test.dart: UTC instant sqlite instant primary-key backfills resume across sub-millisecond boundaries +01:51 +262 -2: test/instant_test.dart: UTC instant sqlite external infinity and out-of-DateTime values fail decoding without wrapping +01:51 +263 -2: test/instant_test.dart: UTC instant postgres native round trips and streams preserve exact microseconds and common endpoints +01:51 +264 -2: test/instant_test.dart: UTC instant postgres chronological comparisons, grouping, windows, union and CTEs use instants +01:51 +265 -2: test/instant_test.dart: UTC instant postgres offset-equivalent keys join and batch through the same unique index +01:51 +266 -2: test/instant_test.dart: UTC instant postgres current snapshots, physical catalog and imported DateTime declarations agree +01:52 +267 -2: test/instant_test.dart: UTC instant postgres offset literals compare by value while expression casts remain visible +01:52 +268 -2: test/instant_test.dart: UTC instant postgres reviewed migration preserves history and repairs legacy timestamp ordering +01:52 +269 -2: test/instant_test.dart: UTC instant postgres instant primary-key backfills resume across sub-millisecond boundaries +01:52 +270 -2: test/instant_test.dart: UTC instant postgres external infinity and out-of-DateTime values fail decoding without wrapping +01:52 +271 -2: loading test/codec_test.dart +01:52 +271 -2: test/codec_test.dart: numeric aggregate text decodes without integer rounding or overflow +01:52 +272 -2: test/codec_test.dart: erased codecs keep checked argument types without function getter casts +01:52 +273 -2: test/codec_test.dart: mapped domain codecs preserve one decode and one encode boundary +01:52 +274 -2: test/codec_test.dart: parsed JSON scalars and document presence have unambiguous decoding +01:52 +275 -2: test/codec_test.dart: enum mappings reject duplicates and do not follow mutable caller maps +01:52 +276 -2: loading test/sql_real_test.dart +01:52 +276 -2: test/sql_real_test.dart: sqlite explicitly typed REAL parameters preserve floating storage +01:52 +277 -2: test/sql_real_test.dart: postgres explicitly typed REAL parameters preserve floating storage +01:52 +278 -2: loading test/migration_source_test.dart +01:52 +278 -2: test/migration_source_test.dart: Dart emission preserves storage metadata and every operation without application imports +02:00 +279 -2: test/migration_source_test.dart: project CLI creates Dart history, upgrades a database and preserves recorded fingerprints +03:13 +280 -2: test/migration_source_test.dart: AOT migration bundle works without Dart sources or migration assets +03:24 +281 -2: loading test/generator_test.dart +03:24 +281 -2: test/generator_test.dart: (setUpAll) +03:24 +281 -2: test/generator_test.dart: client and snapshot paths cannot overwrite the declaration +03:24 +282 -2: test/generator_test.dart: record annotations, physical names, relations and snapshots +03:26 +283 -2: test/generator_test.dart: output is deterministic and matches the committed generated client +03:31 +284 -2: test/generator_test.dart: composite keys and more than six fields generate analyzable code +03:32 +285 -2: test/generator_test.dart: computed keys are rejected instead of executing a fake row +03:32 +286 -2: test/generator_test.dart: computed declarations reject conflicting defaults, empty SQL and invalid row identities +03:34 +287 -2: test/generator_test.dart: client defaults reject incompatible, asynchronous, private and ambiguous factories +03:37 +288 -2: test/generator_test.dart: CHECK declarations reject dynamic SQL, empty overrides and duplicate names +03:38 +289 -2: test/generator_test.dart: custom types resolve defining libraries when output moves to another directory +03:38 +290 -2: test/generator_test.dart: nested generic and record types preserve nullable aliases without running codecs +03:39 +291 -2: test/generator_test.dart: invalid custom declaration: wrong_codec +03:39 +292 -2: test/generator_test.dart: invalid custom declaration: nullable_codec +03:40 +293 -2: test/generator_test.dart: invalid custom declaration: erased_extension +03:40 +294 -2: test/generator_test.dart: invalid custom declaration: unknown_storage +03:40 +295 -2: test/generator_test.dart: invalid custom declaration: private_codec +03:40 +296 -2: test/generator_test.dart: invalid custom declaration: private_type +03:41 +297 -2: test/generator_test.dart: invalid custom declaration: inline_codec +03:41 +298 -2: test/generator_test.dart: invalid custom declaration: duplicate_codec +03:41 +299 -2: test/generator_test.dart: invalid custom declaration: missing_codec +03:41 +300 -2: test/generator_test.dart: invalid custom declaration: duplicate_labels +03:41 +301 -2: test/generator_test.dart: invalid custom declaration: duplicate_enum_annotation +03:42 +302 -2: test/generator_test.dart: foreign keys require unique targets +03:42 +303 -2: test/generator_test.dart: query-only edges do not alter physical snapshots or require unique targets +03:42 +304 -2: test/generator_test.dart: constrained and unconstrained edges retain separate migration meaning +03:43 +305 -2: test/generator_test.dart: invalid query-only relation: lookup_computed +03:43 +306 -2: test/generator_test.dart: invalid query-only relation: lookup_codec +03:43 +307 -2: test/generator_test.dart: invalid query-only relation: lookup_inverse +03:43 +308 -2: test/generator_test.dart: invalid query-only relation: lookup_type +03:44 +309 -2: test/generator_test.dart: invalid query-only relation: lookup_delete +03:44 +310 -2: test/generator_test.dart: Dart type errors stop generation +03:44 +311 -2: test/generator_test.dart: duplicate physical names and nullable primary keys are rejected +03:45 +312 -2: test/generator_test.dart: (tearDownAll) +03:45 +312 -2: loading test/transaction_test.dart +03:45 +312 -2: test/transaction_test.dart: transaction control sqlite idle callback expires, rolls back and cannot issue late SQL +03:45 +313 -2: test/transaction_test.dart: transaction control sqlite deadline interrupts active SQL before releasing and reusing the lease +03:45 +314 -2: test/transaction_test.dart: transaction control sqlite interrupted write rolls back and leaves the connection usable +03:45 +315 -2: test/transaction_test.dart: transaction control sqlite explicit transaction cancellation stops an idle callback and active SQL +03:45 +316 -2: test/transaction_test.dart: transaction control sqlite global transaction cancellation also abandons acquisition +03:45 +317 -2: test/transaction_test.dart: transaction control sqlite savepoint inherits deadline, drains and rolls back through raw cleanup +03:45 +318 -2: test/transaction_test.dart: transaction control sqlite deadline closes a paused transactional cursor before rollback +03:45 +319 -2: test/transaction_test.dart: transaction control sqlite deadline interrupts an active transactional cursor fetch +03:45 +320 -2: test/transaction_test.dart: transaction control sqlite transaction clock starts after acquisition and a borrowed session recovers +03:46 +321 -2: test/transaction_test.dart: transaction control sqlite failed rollback is explicit and discards an uncertain connection +03:46 +322 -2: test/transaction_test.dart: transaction control sqlite transaction controls reject adapters without actual cancellation +03:46 +323 -2: test/transaction_test.dart: transaction control sqlite shorter statement limit retains its error and rolls back the transaction +03:46 +324 -2: test/transaction_test.dart: transaction control sqlite monotonic deadline prevents commit after a CPU-bound callback +03:46 +325 -2: test/transaction_test.dart: transaction control sqlite completed scopes remove their timers and cancellation listeners +03:46 +326 -2: test/transaction_test.dart: transaction control sqlite confirmed commit wins a cancellation race; lost acknowledgement stays unknown +03:46 +327 -2: test/transaction_test.dart: transaction control sqlite deferred constraint rejection at COMMIT is a known failure +03:46 +328 -2: test/transaction_test.dart: transaction control sqlite invalid deadlines and already cancelled tokens never begin a transaction +03:46 +329 -2: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=false +03:46 +330 -2: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=true +03:46 +331 -2: test/transaction_test.dart: transaction control postgres idle callback expires, rolls back and cannot issue late SQL +03:46 +332 -2: test/transaction_test.dart: transaction control postgres deadline interrupts active SQL before releasing and reusing the lease +03:46 +333 -2: test/transaction_test.dart: transaction control postgres interrupted write rolls back and leaves the connection usable +03:46 +334 -2: test/transaction_test.dart: transaction control postgres explicit transaction cancellation stops an idle callback and active SQL +03:46 +335 -2: test/transaction_test.dart: transaction control postgres global transaction cancellation also abandons acquisition +03:46 +336 -2: test/transaction_test.dart: transaction control postgres savepoint inherits deadline, drains and rolls back through raw cleanup +03:47 +337 -2: test/transaction_test.dart: transaction control postgres deadline closes a paused transactional cursor before rollback +03:47 +338 -2: test/transaction_test.dart: transaction control postgres deadline interrupts an active transactional cursor fetch +03:47 +339 -2: test/transaction_test.dart: transaction control postgres transaction clock starts after acquisition and a borrowed session recovers +03:47 +340 -2: test/transaction_test.dart: transaction control postgres failed rollback is explicit and discards an uncertain connection +03:47 +341 -2: test/transaction_test.dart: transaction control postgres transaction controls reject adapters without actual cancellation +03:47 +342 -2: test/transaction_test.dart: transaction control postgres shorter statement limit retains its error and rolls back the transaction +03:47 +343 -2: test/transaction_test.dart: transaction control postgres monotonic deadline prevents commit after a CPU-bound callback +03:47 +344 -2: test/transaction_test.dart: transaction control postgres completed scopes remove their timers and cancellation listeners +03:48 +345 -2: test/transaction_test.dart: transaction control postgres confirmed commit wins a cancellation race; lost acknowledgement stays unknown +03:48 +346 -2: test/transaction_test.dart: transaction control postgres deferred constraint rejection at COMMIT is a known failure +03:48 +347 -2: test/transaction_test.dart: transaction control postgres invalid deadlines and already cancelled tokens never begin a transaction +03:48 +348 -2: test/transaction_test.dart: transaction control postgres SQLSTATE 40003 at COMMIT is never classified as a confirmed rejection +03:48 +349 -2: test/transaction_test.dart: transaction control postgres real PostgreSQL serialization failure is classified after rollback +03:48 +350 -2: test/transaction_test.dart: transaction control postgres real PostgreSQL deadlock rolls back exactly one transaction +03:49 +351 -2: test/transaction_test.dart: SQLite COMMIT busy is a known rejection followed by verified rollback +03:49 +352 -2: test/transaction_test.dart: SQLite deadline while BEGIN waits for a writer preserves its connection +03:49 +353 -2: loading test/backfill_keys_test.dart +03:49 +353 -2: test/backfill_keys_test.dart: backfill keys sqlite integer cursors preserve native values across resume +03:49 +354 -2: test/backfill_keys_test.dart: backfill keys sqlite bigint cursors preserve native values across resume +03:49 +355 -2: test/backfill_keys_test.dart: backfill keys sqlite text cursors preserve native values across resume +03:49 +356 -2: test/backfill_keys_test.dart: backfill keys sqlite real cursors preserve native values across resume +03:49 +357 -2: test/backfill_keys_test.dart: backfill keys sqlite boolean cursors preserve native values across resume +03:49 +358 -2: test/backfill_keys_test.dart: backfill keys sqlite timestamp cursors preserve native values across resume +03:49 +359 -2: test/backfill_keys_test.dart: backfill keys sqlite blob cursors preserve native values across resume +03:49 +360 -2: test/backfill_keys_test.dart: backfill keys sqlite composite key batches account for every bound parameter +03:49 +361 -2: test/backfill_keys_test.dart: backfill keys postgres integer cursors preserve native values across resume +03:49 +362 -2: test/backfill_keys_test.dart: backfill keys postgres bigint cursors preserve native values across resume +03:49 +363 -2: test/backfill_keys_test.dart: backfill keys postgres text cursors preserve native values across resume +03:50 +364 -2: test/backfill_keys_test.dart: backfill keys postgres real cursors preserve native values across resume +03:50 +365 -2: test/backfill_keys_test.dart: backfill keys postgres boolean cursors preserve native values across resume +03:50 +366 -2: test/backfill_keys_test.dart: backfill keys postgres timestamp cursors preserve native values across resume +03:50 +367 -2: test/backfill_keys_test.dart: backfill keys postgres blob cursors preserve native values across resume +03:50 +368 -2: test/backfill_keys_test.dart: backfill keys postgres composite key batches account for every bound parameter +03:50 +369 -2: test/backfill_keys_test.dart: invalid key, assignments and completion declarations fail before execution +03:50 +370 -2: loading test/retry_test.dart +03:50 +370 -2: test/retry_test.dart: retry sqlite opt-in retries roll back all writes and use new closed-over views +03:50 +371 -2: test/retry_test.dart: retry sqlite default is one attempt; an explicit attempt limit is exact +03:50 +372 -2: test/retry_test.dart: retry sqlite application and constraint errors and caught statement failures do not replay +03:50 +373 -2: test/retry_test.dart: retry sqlite unknown commit and unconfirmed rollback prohibit replay +03:50 +374 -2: test/retry_test.dart: retry sqlite retry time budget bounds active SQL and does not retry cancellation +03:50 +375 -2: test/retry_test.dart: retry sqlite deadline and cancellation interrupt backoff without another callback +03:50 +376 -2: test/retry_test.dart: retry sqlite time budget includes acquisition and abandons late callbacks +03:50 +377 -2: test/retry_test.dart: retry sqlite transaction timeout is shared across attempts and sessions recover after expiry +03:50 +378 -2: test/retry_test.dart: retry sqlite subscriptions publish only the final committed retry result +03:50 +379 -2: test/retry_test.dart: retry sqlite invalid policies reject before SQL +03:50 +380 -2: test/retry_test.dart: retry postgres opt-in retries roll back all writes and use new closed-over views +03:50 +381 -2: test/retry_test.dart: retry postgres default is one attempt; an explicit attempt limit is exact +03:50 +382 -2: test/retry_test.dart: retry postgres application and constraint errors and caught statement failures do not replay +03:50 +383 -2: test/retry_test.dart: retry postgres unknown commit and unconfirmed rollback prohibit replay +03:50 +384 -2: test/retry_test.dart: retry postgres retry time budget bounds active SQL and does not retry cancellation +03:51 +385 -2: test/retry_test.dart: retry postgres deadline and cancellation interrupt backoff without another callback +03:51 +386 -2: test/retry_test.dart: retry postgres time budget includes acquisition and abandons late callbacks +03:51 +387 -2: test/retry_test.dart: retry postgres transaction timeout is shared across attempts and sessions recover after expiry +03:51 +388 -2: test/retry_test.dart: retry postgres subscriptions publish only the final committed retry result +03:51 +389 -2: test/retry_test.dart: retry postgres invalid policies reject before SQL +03:51 +390 -2: test/retry_test.dart: retry postgres real unknown COMMIT response prohibits opted-in retry +03:51 +391 -2: test/retry_test.dart: retry postgres real serialization conflict repeats the reads and decisions +03:51 +392 -2: test/retry_test.dart: retry postgres real deadlock retries only the aborted callback +03:52 +393 -2: test/retry_test.dart: SQLite WAL snapshot conflict replays reads after rollback +03:52 +394 -2: test/retry_test.dart: SQLite real busy COMMIT: success +03:52 +395 -2: test/retry_test.dart: SQLite real busy COMMIT: exhausted +03:52 +396 -2: test/retry_test.dart: SQLite real busy COMMIT: deadline +03:52 +397 -2: test/retry_test.dart: SQLite real busy COMMIT: cancel +03:52 +398 -2: test/retry_test.dart: SQLite real busy COMMIT: shared-budget +03:53 +399 -2: loading test/builder_test.dart +03:53 +399 -2: test/builder_test.dart: build assets and standalone CLI produce identical client and snapshot +03:56 +400 -2: test/builder_test.dart: multiple explicit schema roots resolve shared public types under lib +03:56 +401 -2: test/builder_test.dart: invalid semantic_error never publishes partial outputs +03:57 +402 -2: test/builder_test.dart: invalid enum_labels never publishes partial outputs +03:58 +403 -2: test/builder_test.dart: invalid no_models never publishes partial outputs +03:58 +404 -2: test/builder_test.dart: invalid part_file never publishes partial outputs +03:59 +405 -2: test/builder_test.dart: unknown options fail instead of silently changing build behavior +03:59 +406 -2: test/builder_test.dart: schema resolution can consume an earlier builder output +04:00 +407 -2: loading test/temporal_precision_test.dart +04:00 +407 -2: test/temporal_precision_test.dart: temporal precision sqlite generated writes, defaults and computed columns use database precision +04:00 +408 -2: test/temporal_precision_test.dart: temporal precision sqlite SQL casts match all precisions on both sides of the PostgreSQL epoch +04:00 +409 -2: test/temporal_precision_test.dart: temporal precision sqlite nulls, CTE references, grouping, streams and wrong scopes retain type semantics +04:00 +410 -2: test/temporal_precision_test.dart: temporal precision sqlite rounded keys work in batch writes, upserts and foreign-key relation loading +04:00 +411 -2: test/temporal_precision_test.dart: temporal precision sqlite catalog import preserves precision, defaults and computed expressions +04:02 +412 -2: test/temporal_precision_test.dart: temporal precision sqlite narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +04:02 +413 -2: test/temporal_precision_test.dart: temporal precision sqlite timestamp defaults and explicit six-digit catalogs retain precision +04:02 +414 -2: test/temporal_precision_test.dart: temporal precision sqlite extended timestamps round in SQL without total-microsecond overflow +04:02 +415 -2: test/temporal_precision_test.dart: temporal precision sqlite raw SQL respects native or managed precision constraints +04:02 +416 -2: test/temporal_precision_test.dart: temporal precision postgres generated writes, defaults and computed columns use database precision +04:02 +417 -2: test/temporal_precision_test.dart: temporal precision postgres SQL casts match all precisions on both sides of the PostgreSQL epoch +04:02 +418 -2: test/temporal_precision_test.dart: temporal precision postgres nulls, CTE references, grouping, streams and wrong scopes retain type semantics +04:02 +419 -2: test/temporal_precision_test.dart: temporal precision postgres rounded keys work in batch writes, upserts and foreign-key relation loading +04:02 +420 -2: test/temporal_precision_test.dart: temporal precision postgres catalog import preserves precision, defaults and computed expressions +04:03 +421 -2: test/temporal_precision_test.dart: temporal precision postgres narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +04:03 +422 -2: test/temporal_precision_test.dart: temporal precision postgres timestamp defaults and explicit six-digit catalogs retain precision +04:03 +423 -2: test/temporal_precision_test.dart: temporal precision postgres extended timestamps round in SQL without total-microsecond overflow +04:03 +424 -2: test/temporal_precision_test.dart: temporal precision postgres raw SQL respects native or managed precision constraints +04:03 +425 -2: test/temporal_precision_test.dart: value rounding keeps extended ranges and rejects overflow/invalid precision +04:03 +426 -2: test/temporal_precision_test.dart: default and explicit six preserve historical snapshots and migration checksums +04:03 +427 -2: test/temporal_precision_test.dart: generator rejects invalid annotations and preserves generated fixture metadata +04:05 +428 -2: loading test/observation_test.dart +04:05 +428 -2: test/observation_test.dart: observation sqlite actual lease queue and synchronous mapping have separate clocks +04:05 +429 -2: test/observation_test.dart: observation sqlite expired acquisition emits one failure and no late SQL +04:05 +430 -2: test/observation_test.dart: observation sqlite cancelled acquisition emits one failure and no late SQL +04:05 +431 -2: test/observation_test.dart: observation sqlite sessions transactions and savepoints inherit hooks and report reused leases +04:05 +432 -2: test/observation_test.dart: observation sqlite nested reads attribute decoding to each statement without repeating SQL +04:05 +433 -2: test/observation_test.dart: observation sqlite SQL and mapper failures belong to their own phases +04:05 +434 -2: test/observation_test.dart: observation sqlite cursor batches and returning writes produce decoding observations +04:05 +435 -2: test/observation_test.dart: observation sqlite observer exceptions preserve successful commits and original mapper errors +04:05 +436 -2: test/observation_test.dart: observation postgres actual lease queue and synchronous mapping have separate clocks +04:06 +437 -2: test/observation_test.dart: observation postgres expired acquisition emits one failure and no late SQL +04:06 +438 -2: test/observation_test.dart: observation postgres cancelled acquisition emits one failure and no late SQL +04:06 +439 -2: test/observation_test.dart: observation postgres sessions transactions and savepoints inherit hooks and report reused leases +04:06 +440 -2: test/observation_test.dart: observation postgres nested reads attribute decoding to each statement without repeating SQL +04:06 +441 -2: test/observation_test.dart: observation postgres SQL and mapper failures belong to their own phases +04:06 +442 -2: test/observation_test.dart: observation postgres cursor batches and returning writes produce decoding observations +04:06 +443 -2: test/observation_test.dart: observation postgres observer exceptions preserve successful commits and original mapper errors +04:06 +444 -2: test/observation_test.dart: synchronous driver acquisition failures are reported once +04:06 +445 -2: loading test/integer_test.dart +04:06 +445 -2: test/integer_test.dart: integer widths sqlite storage bounds, nulls and wider sums keep the normal integer value codec +04:06 +446 -2: test/integer_test.dart: integer widths sqlite relations, union and cursors share int expressions across physical widths +04:06 +447 -2: test/integer_test.dart: integer widths sqlite defaulted and quoted columns retain enforced nullable bounds +04:06 +448 -2: test/integer_test.dart: integer widths sqlite Dart snapshots and imports retain widths without confusing value codecs +04:08 +449 -2: test/integer_test.dart: integer widths sqlite widening and narrowing use reviewed conversions with atomic failure recovery +04:08 +450 -2: test/integer_test.dart: integer widths sqlite column renames preserve width metadata and range constraints +04:08 +451 -2: test/integer_test.dart: integer widths sqlite historical bounded backfills verify widths and resume normally +04:08 +452 -2: test/integer_test.dart: integer widths sqlite changing an identity width preserves generation and rejects out-of-range explicit IDs +04:08 +453 -2: test/integer_test.dart: integer widths sqlite quoted defaults and comments cannot masquerade as managed range checks +04:08 +454 -2: test/integer_test.dart: integer widths sqlite extra weaker expressions remain unmanaged and cannot claim a narrower width +04:08 +455 -2: test/integer_test.dart: integer widths postgres storage bounds, nulls and wider sums keep the normal integer value codec +04:08 +456 -2: test/integer_test.dart: integer widths postgres relations, union and cursors share int expressions across physical widths +04:08 +457 -2: test/integer_test.dart: integer widths postgres defaulted and quoted columns retain enforced nullable bounds +04:08 +458 -2: test/integer_test.dart: integer widths postgres Dart snapshots and imports retain widths without confusing value codecs +04:09 +459 -2: test/integer_test.dart: integer widths postgres widening and narrowing use reviewed conversions with atomic failure recovery +04:09 +460 -2: test/integer_test.dart: integer widths postgres column renames preserve width metadata and range constraints +04:09 +461 -2: test/integer_test.dart: integer widths postgres historical bounded backfills verify widths and resume normally +04:09 +462 -2: test/integer_test.dart: integer widths postgres changing an identity width preserves generation and rejects out-of-range explicit IDs +04:09 +463 -2: test/integer_test.dart: analyzer validates width annotations and integer-backed domain codecs +04:10 +464 -2: test/integer_test.dart: width declarations reject wrong kinds and ranges without changing default snapshots +04:10 +465 -2: loading test/relation_test.dart +04:10 +465 -2: test/relation_test.dart: sqlite automatic unique joins preserve root pagination in one statement +04:10 +466 -2: test/relation_test.dart: sqlite all-null projections still distinguish a present row from absence +04:10 +467 -2: test/relation_test.dart: sqlite explicit batch deduplicates composite keys and skips partial nulls +04:10 +468 -2: test/relation_test.dart: sqlite composite chunks include filter and window parameters with nested joins +04:10 +469 -2: test/relation_test.dart: sqlite nested self joins and two edges to the same table use distinct aliases +04:10 +470 -2: test/relation_test.dart: sqlite batch collection below a joined optional parent loads on the same plan +04:10 +471 -2: test/relation_test.dart: sqlite join refuses unproven cardinality while automatic batch checks it +04:10 +472 -2: test/relation_test.dart: sqlite filters and to-one pagination do not drop root rows +04:10 +473 -2: test/relation_test.dart: sqlite joined records survive CTE projection and cursor decoding +04:10 +474 -2: test/relation_test.dart: sqlite required tests row presence independently of nullable selected values +04:10 +475 -2: test/relation_test.dart: sqlite relation aggregates restore the alias used by a joined selection +04:10 +476 -2: test/relation_test.dart: sqlite reusing an identical edge deduplicates its join +04:10 +477 -2: test/relation_test.dart: sqlite different filters need fresh relation occurrences +04:10 +478 -2: test/relation_test.dart: sqlite large composite batches avoid expression-depth failure +04:10 +479 -2: test/relation_test.dart: postgres automatic unique joins preserve root pagination in one statement +04:11 +480 -2: test/relation_test.dart: postgres all-null projections still distinguish a present row from absence +04:11 +481 -2: test/relation_test.dart: postgres explicit batch deduplicates composite keys and skips partial nulls +04:11 +482 -2: test/relation_test.dart: postgres composite chunks include filter and window parameters with nested joins +04:11 +483 -2: test/relation_test.dart: postgres nested self joins and two edges to the same table use distinct aliases +04:11 +484 -2: test/relation_test.dart: postgres batch collection below a joined optional parent loads on the same plan +04:11 +485 -2: test/relation_test.dart: postgres join refuses unproven cardinality while automatic batch checks it +04:11 +486 -2: test/relation_test.dart: postgres filters and to-one pagination do not drop root rows +04:11 +487 -2: test/relation_test.dart: postgres joined records survive CTE projection and cursor decoding +04:11 +488 -2: test/relation_test.dart: postgres required tests row presence independently of nullable selected values +04:11 +489 -2: test/relation_test.dart: postgres relation aggregates restore the alias used by a joined selection +04:11 +490 -2: test/relation_test.dart: postgres reusing an identical edge deduplicates its join +04:11 +491 -2: test/relation_test.dart: postgres different filters need fresh relation occurrences +04:11 +492 -2: test/relation_test.dart: postgres large composite batches avoid expression-depth failure +04:11 +493 -2: loading test/generated_database_test.dart +04:11 +493 -2: test/generated_database_test.dart: (setUpAll) +04:11 +493 -2: test/generated_database_test.dart: generated sqlite generated client, DDL, relations, dates and patches work together +04:11 +494 -2: test/generated_database_test.dart: generated sqlite planning is read-only and repeat migration is a no-op +04:11 +495 -2: test/generated_database_test.dart: generated sqlite compiled physical snapshot and full managed catalog comparison +04:11 +496 -2: test/generated_database_test.dart: generated sqlite baseline preserves existing rows and verifies keys before recording history +04:11 +497 -2: test/generated_database_test.dart: generated sqlite full verification sees defaults and additional unique constraints +04:11 +498 -2: test/generated_database_test.dart: generated sqlite history tampering and missing applied migrations fail +04:11 +499 -2: test/generated_database_test.dart: generated sqlite failed DDL rolls back schema and history atomically +04:11 +500 -2: test/generated_database_test.dart: generated sqlite actual column drift is detected independently of history +04:11 +501 -2: test/generated_database_test.dart: generated sqlite transaction control cannot be hidden behind SQL comments +04:11 +502 -2: test/generated_database_test.dart: generated sqlite concurrent migration callers serialize against shared history +04:11 +503 -2: test/generated_database_test.dart: generated postgres generated client, DDL, relations, dates and patches work together +04:11 +504 -2: test/generated_database_test.dart: generated postgres planning is read-only and repeat migration is a no-op +04:11 +505 -2: test/generated_database_test.dart: generated postgres compiled physical snapshot and full managed catalog comparison +04:11 +506 -2: test/generated_database_test.dart: generated postgres baseline preserves existing rows and verifies keys before recording history +04:11 +507 -2: test/generated_database_test.dart: generated postgres full verification sees defaults and additional unique constraints +04:11 +508 -2: test/generated_database_test.dart: generated postgres history tampering and missing applied migrations fail +04:11 +509 -2: test/generated_database_test.dart: generated postgres failed DDL rolls back schema and history atomically +04:11 +510 -2: test/generated_database_test.dart: generated postgres actual column drift is detected independently of history +04:11 +511 -2: test/generated_database_test.dart: generated postgres transaction control cannot be hidden behind SQL comments +04:11 +512 -2: test/generated_database_test.dart: generated postgres concurrent migration callers serialize against shared history +04:11 +513 -2: test/generated_database_test.dart: (tearDownAll) +04:11 +513 -2: loading test/import_test.dart +04:12 +513 -2: test/import_test.dart: catalog import sqlite imports records, identities, defaults and constraints then baselines unchanged rows +04:15 +514 -2: test/import_test.dart: catalog import sqlite normalizes reserved, colliding and hostile identifiers without changing physical names +04:16 +515 -2: test/import_test.dart: catalog import sqlite composite and self relations preserve key order and unique indexes +04:17 +516 -2: test/import_test.dart: catalog import sqlite unsupported storage excludes its table while computed SQL imports with a review note +04:17 +517 -2: test/import_test.dart: catalog import sqlite read-only snapshot and selected scope reject temporary shadowing and active transactions +04:17 +518 -2: test/import_test.dart: catalog import sqlite physical storage mapping does not infer domain types from data +04:18 +519 -2: test/import_test.dart: catalog import sqlite views triggers and specialized indexes remain in the review report +04:18 +520 -2: test/import_test.dart: catalog import sqlite nullable primary keys, virtual tables and their storage tables need explicit handling +04:18 +521 -2: test/import_test.dart: catalog import sqlite foreign keys with coercible but different Dart types are reported +04:18 +522 -2: test/import_test.dart: catalog import postgres imports records, identities, defaults and constraints then baselines unchanged rows +04:21 +523 -2: test/import_test.dart: catalog import postgres normalizes reserved, colliding and hostile identifiers without changing physical names +04:22 +524 -2: test/import_test.dart: catalog import postgres composite and self relations preserve key order and unique indexes +04:22 +525 -2: test/import_test.dart: catalog import postgres unsupported storage excludes its table while computed SQL imports with a review note +04:23 +526 -2: test/import_test.dart: catalog import postgres read-only snapshot and selected scope reject temporary shadowing and active transactions +04:23 +527 -2: test/import_test.dart: catalog import postgres physical storage mapping does not infer domain types from data +04:23 +528 -2: test/import_test.dart: catalog import postgres views triggers and specialized indexes remain in the review report +04:23 +529 -2: test/import_test.dart: catalog import postgres policy reports retain roles, commands, mode and row-security state +04:24 +530 -2: test/import_test.dart: catalog import postgres always identity and inherited tables are not flattened into ordinary writable models +04:24 +531 -2: loading test/database_test.dart +04:24 +531 -2: test/database_test.dart: sqlite typed CRUD, exact projection and parameter binding +04:24 +532 -2: test/database_test.dart: sqlite record and DTO mapping run only when decoding +04:24 +533 -2: test/database_test.dart: sqlite immutable filters, empty IN, null and pagination +04:24 +534 -2: test/database_test.dart: sqlite cross-table identity and duplicate assignments rejected before IO +04:24 +535 -2: test/database_test.dart: sqlite commit, rollback, savepoint recovery and escaped session +04:24 +536 -2: test/database_test.dart: sqlite caught statement failures cannot masquerade as a commit +04:24 +537 -2: test/database_test.dart: sqlite recoverable constraint errors stay within their savepoint +04:24 +538 -2: test/database_test.dart: sqlite unawaited work is drained and rolled back +04:24 +539 -2: test/database_test.dart: sqlite close waits for active operations and rejects further use +04:24 +540 -2: test/database_test.dart: sqlite leased sessions retain one connection across transactions and expire +04:24 +541 -2: test/database_test.dart: sqlite native upsert updates atomically using existing and incoming values +04:24 +542 -2: test/database_test.dart: sqlite bulk inserts split at parameter limits and opt into returning +04:24 +543 -2: test/database_test.dart: sqlite a failed later bulk chunk rolls back all previous chunks +04:24 +544 -2: test/database_test.dart: sqlite bulk optional field shapes preserve defaults and explicit null +04:24 +545 -2: test/database_test.dart: sqlite relations CTEs preserve SQL column identity through mapped projections +04:24 +546 -2: test/database_test.dart: sqlite relations explicit aliases support multiple joins and self joins +04:24 +547 -2: test/database_test.dart: sqlite relations left join distinguishes a missing row from an all-null selection +04:24 +548 -2: test/database_test.dart: sqlite relations correlated scalar, IN and EXISTS use nested SQL scopes +04:24 +549 -2: test/database_test.dart: sqlite relations grouping is checked and window functions preserve row cardinality +04:24 +550 -2: test/database_test.dart: sqlite relations root pagination and each parent limit use two queries +04:24 +551 -2: test/database_test.dart: sqlite relations empty roots skip child queries; empty lists remain typed lists +04:24 +552 -2: test/database_test.dart: sqlite relations nested relation projections and optional all-null projection +04:24 +553 -2: test/database_test.dart: sqlite relations correlated any, none, count and every with SQL nulls +04:24 +554 -2: test/database_test.dart: sqlite relations relation parameters bind correctly in projection and filter +04:24 +555 -2: test/database_test.dart: sqlite keyset pagination uses a unique tie breaker in mixed directions +04:24 +556 -2: test/database_test.dart: sqlite keyset pagination handles null order and validates versioned tokens +04:24 +557 -2: test/database_test.dart: postgres typed CRUD, exact projection and parameter binding +04:24 +558 -2: test/database_test.dart: postgres record and DTO mapping run only when decoding +04:24 +559 -2: test/database_test.dart: postgres immutable filters, empty IN, null and pagination +04:24 +560 -2: test/database_test.dart: postgres cross-table identity and duplicate assignments rejected before IO +04:24 +561 -2: test/database_test.dart: postgres commit, rollback, savepoint recovery and escaped session +04:24 +562 -2: test/database_test.dart: postgres caught statement failures cannot masquerade as a commit +04:24 +563 -2: test/database_test.dart: postgres recoverable constraint errors stay within their savepoint +04:24 +564 -2: test/database_test.dart: postgres unawaited work is drained and rolled back +04:24 +565 -2: test/database_test.dart: postgres close waits for active operations and rejects further use +04:24 +566 -2: test/database_test.dart: postgres leased sessions retain one connection across transactions and expire +04:24 +567 -2: test/database_test.dart: postgres native upsert updates atomically using existing and incoming values +04:24 +568 -2: test/database_test.dart: postgres bulk inserts split at parameter limits and opt into returning +04:24 +569 -2: test/database_test.dart: postgres a failed later bulk chunk rolls back all previous chunks +04:24 +570 -2: test/database_test.dart: postgres bulk optional field shapes preserve defaults and explicit null +04:24 +571 -2: test/database_test.dart: postgres relations CTEs preserve SQL column identity through mapped projections +04:24 +572 -2: test/database_test.dart: postgres relations explicit aliases support multiple joins and self joins +04:24 +573 -2: test/database_test.dart: postgres relations left join distinguishes a missing row from an all-null selection +04:24 +574 -2: test/database_test.dart: postgres relations correlated scalar, IN and EXISTS use nested SQL scopes +04:24 +575 -2: test/database_test.dart: postgres relations grouping is checked and window functions preserve row cardinality +04:24 +576 -2: test/database_test.dart: postgres relations root pagination and each parent limit use two queries +04:24 +577 -2: test/database_test.dart: postgres relations empty roots skip child queries; empty lists remain typed lists +04:24 +578 -2: test/database_test.dart: postgres relations nested relation projections and optional all-null projection +04:24 +579 -2: test/database_test.dart: postgres relations correlated any, none, count and every with SQL nulls +04:24 +580 -2: test/database_test.dart: postgres relations relation parameters bind correctly in projection and filter +04:24 +581 -2: test/database_test.dart: postgres keyset pagination uses a unique tie breaker in mixed directions +04:24 +582 -2: test/database_test.dart: postgres keyset pagination handles null order and validates versioned tokens +04:24 +583 -2: loading test/union_test.dart +04:24 +583 -2: test/union_test.dart: union sqlite SQL duplicate and NULL semantics use one statement +04:24 +584 -2: test/union_test.dart: union sqlite heterogeneous records map only after SQL union and final pagination +04:24 +585 -2: test/union_test.dart: union sqlite operand limits, order, parameters and final filter stay scoped +04:24 +586 -2: test/union_test.dart: union sqlite repeated SQL expressions preserve positional columns +04:24 +587 -2: test/union_test.dart: union sqlite nested sets preserve association, CTE exports and streaming +04:24 +588 -2: test/union_test.dart: union sqlite same CTE names in distinct operands stay in separate scopes +04:24 +589 -2: test/union_test.dart: union sqlite SQL scalar, IN subqueries, aggregate and zero-row results compose +04:24 +590 -2: test/union_test.dart: union sqlite grouped branches and window branches remain SQL queries +04:24 +591 -2: test/union_test.dart: union sqlite bound constant projections retain Dart types +04:24 +592 -2: test/union_test.dart: union sqlite Dart maps and relation selections are rejected before execution +04:24 +593 -2: test/union_test.dart: union sqlite codecs and nullability cannot silently change across operands +04:24 +594 -2: test/union_test.dart: union sqlite shared domain codecs work, and nullable wrappers retain decoding semantics +04:24 +595 -2: test/union_test.dart: union sqlite export references and outer join nullability are guarded +04:24 +596 -2: test/union_test.dart: union sqlite compound mutations and mixed sessions fail before SQL +04:24 +597 -2: test/union_test.dart: union sqlite all record arities use typed SQL decoding +04:24 +598 -2: test/union_test.dart: union sqlite subscription observes committed changes in both operands +04:24 +599 -2: test/union_test.dart: union postgres SQL duplicate and NULL semantics use one statement +04:24 +600 -2: test/union_test.dart: union postgres heterogeneous records map only after SQL union and final pagination +04:24 +601 -2: test/union_test.dart: union postgres operand limits, order, parameters and final filter stay scoped +04:24 +602 -2: test/union_test.dart: union postgres repeated SQL expressions preserve positional columns +04:24 +603 -2: test/union_test.dart: union postgres nested sets preserve association, CTE exports and streaming +04:24 +604 -2: test/union_test.dart: union postgres same CTE names in distinct operands stay in separate scopes +04:25 +605 -2: test/union_test.dart: union postgres SQL scalar, IN subqueries, aggregate and zero-row results compose +04:25 +606 -2: test/union_test.dart: union postgres grouped branches and window branches remain SQL queries +04:25 +607 -2: test/union_test.dart: union postgres bound constant projections retain Dart types +04:25 +608 -2: test/union_test.dart: union postgres Dart maps and relation selections are rejected before execution +04:25 +609 -2: test/union_test.dart: union postgres codecs and nullability cannot silently change across operands +04:25 +610 -2: test/union_test.dart: union postgres shared domain codecs work, and nullable wrappers retain decoding semantics +04:25 +611 -2: test/union_test.dart: union postgres export references and outer join nullability are guarded +04:25 +612 -2: test/union_test.dart: union postgres compound mutations and mixed sessions fail before SQL +04:25 +613 -2: test/union_test.dart: union postgres all record arities use typed SQL decoding +04:25 +614 -2: test/union_test.dart: union postgres subscription observes committed changes in both operands +04:25 +615 -2: loading test/import_cli_test.dart +04:25 +615 -2: test/import_cli_test.dart: CLI sqlite imports reviews generates and baselines existing data without replacing files +05:12 +616 -2: test/import_cli_test.dart: CLI postgres imports reviews generates and baselines existing data without replacing files +05:55 +617 -2: loading test/decimal_division_test.dart +05:55 +617 -2: test/decimal_division_test.dart: decimal division sqlite signed modes and scales match exact integer arithmetic +05:55 +618 -2: test/decimal_division_test.dart: decimal division sqlite exact defaults reject lost digits and zero divisors, NULL propagates +05:55 +619 -2: test/decimal_division_test.dart: decimal division sqlite precision exceeds native division scale without double rounding +05:55 +620 -2: test/decimal_division_test.dart: decimal division sqlite huge remainders avoid overflowing intermediate scaled products +06:17 +621 -2: test/decimal_division_test.dart: decimal division sqlite outermost integer scale and result overflow are explicit +06:18 +622 -2: test/decimal_division_test.dart: decimal division sqlite aggregates windows CTEs predicates and unions compose +06:18 +623 -2: test/decimal_division_test.dart: decimal division sqlite expression assignments and transaction rollback preserve values +06:18 +624 -2: test/decimal_division_test.dart: decimal division sqlite window filtering ordering DISTINCT and pagination keep their SQL order +06:18 +625 -2: test/decimal_division_test.dart: decimal division sqlite windows over grouped aggregates run after HAVING +06:18 +626 -2: test/decimal_division_test.dart: decimal division sqlite window results compose through CTEs sets and correlated subqueries +06:18 +627 -2: test/decimal_division_test.dart: decimal division sqlite batched relations retain per-parent window pagination +06:18 +628 -2: test/decimal_division_test.dart: decimal division postgres signed modes and scales match exact integer arithmetic +06:18 +629 -2: test/decimal_division_test.dart: decimal division postgres exact defaults reject lost digits and zero divisors, NULL propagates +06:18 +630 -2: test/decimal_division_test.dart: decimal division postgres precision exceeds native division scale without double rounding +06:18 +631 -2: test/decimal_division_test.dart: decimal division postgres huge remainders avoid overflowing intermediate scaled products +06:38 +632 -2: test/decimal_division_test.dart: decimal division postgres outermost integer scale and result overflow are explicit +06:38 +633 -2: test/decimal_division_test.dart: decimal division postgres aggregates windows CTEs predicates and unions compose +06:38 +634 -2: test/decimal_division_test.dart: decimal division postgres expression assignments and transaction rollback preserve values +06:38 +635 -2: test/decimal_division_test.dart: decimal division postgres window filtering ordering DISTINCT and pagination keep their SQL order +06:39 +636 -2: test/decimal_division_test.dart: decimal division postgres windows over grouped aggregates run after HAVING +06:39 +637 -2: test/decimal_division_test.dart: decimal division postgres window results compose through CTEs sets and correlated subqueries +06:39 +638 -2: test/decimal_division_test.dart: decimal division postgres batched relations retain per-parent window pagination +06:39 +639 -2: test/decimal_division_test.dart: decimal division postgres volatile operands are evaluated exactly once each +06:39 +640 -2: loading test/cli_test.dart +06:39 +640 -2: test/cli_test.dart: Dart CLI generates, reviews, upgrades and verifies constraints without overwriting history +07:48 +641 -2: test/cli_test.dart: Dart CLI baselines existing rows; read-only connections and invalid options reject writes +08:35 +642 -2: test/cli_test.dart: Dart PostgreSQL configuration supports checked nontransactional recovery operations +09:28 +643 -2: loading test/decimal_test.dart +09:28 +643 -2: test/decimal_test.dart: exact decimal sqlite generated writes, defaults, patches and projections preserve all digits +09:28 +644 -2: test/decimal_test.dart: exact decimal sqlite numeric ordering, predicates, IN, distinct and stable cursors +09:28 +645 -2: test/decimal_test.dart: exact decimal sqlite sums, min/max, grouped aggregates and sliding windows stay exact +09:28 +646 -2: test/decimal_test.dart: exact decimal sqlite computed CTEs, union results, subqueries and streaming keep decimal semantics +09:28 +647 -2: test/decimal_test.dart: exact decimal sqlite numeric primary keys, foreign keys, joined and batched relations +09:28 +648 -2: test/decimal_test.dart: exact decimal sqlite snapshots, catalog verification and imported declarations retain decimal storage +09:30 +649 -2: test/decimal_test.dart: exact decimal sqlite aggregate intermediate overflow may cancel before a valid final result +09:30 +650 -2: test/decimal_test.dart: exact decimal sqlite historical decimal keys resume backfills without lexical ordering or precision loss +09:30 +651 -2: test/decimal_test.dart: exact decimal sqlite reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +09:30 +652 -2: test/decimal_test.dart: exact decimal sqlite invalid external values fail decoding; later valid queries still work +09:30 +653 -2: test/decimal_test.dart: exact decimal postgres generated writes, defaults, patches and projections preserve all digits +09:30 +654 -2: test/decimal_test.dart: exact decimal postgres numeric ordering, predicates, IN, distinct and stable cursors +09:30 +655 -2: test/decimal_test.dart: exact decimal postgres sums, min/max, grouped aggregates and sliding windows stay exact +09:30 +656 -2: test/decimal_test.dart: exact decimal postgres computed CTEs, union results, subqueries and streaming keep decimal semantics +09:30 +657 -2: test/decimal_test.dart: exact decimal postgres numeric primary keys, foreign keys, joined and batched relations +09:30 +658 -2: test/decimal_test.dart: exact decimal postgres snapshots, catalog verification and imported declarations retain decimal storage +09:31 +659 -2: test/decimal_test.dart: exact decimal postgres aggregate intermediate overflow may cancel before a valid final result +09:31 +660 -2: test/decimal_test.dart: exact decimal postgres historical decimal keys resume backfills without lexical ordering or precision loss +09:31 +661 -2: test/decimal_test.dart: exact decimal postgres reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +09:31 +662 -2: test/decimal_test.dart: exact decimal postgres invalid external values fail decoding; later valid queries still work +09:31 +663 -2: test/decimal_test.dart: decimal SQL rejects drivers without exact operations before execution +09:31 +664 -2: test/decimal_test.dart: SQLite recognizes only column decimal collations, and detects drift +09:31 +665 -2: loading test/watch_test.dart +09:31 +665 -2: test/watch_test.dart: independent native SQLite workers require explicit notification +09:31 +666 -2: test/watch_test.dart: watch sqlite lazy initial snapshot and relevant table invalidation +09:31 +667 -2: test/watch_test.dart: watch sqlite transactions publish one snapshot after commit and none on rollback +09:31 +668 -2: test/watch_test.dart: watch sqlite savepoint changes merge on release and disappear on rollback +09:31 +669 -2: test/watch_test.dart: watch sqlite failed statements, zero-row writes and conflict no-ops do not refresh +09:31 +670 -2: test/watch_test.dart: watch sqlite batch writes and upsert returning publish committed changes +09:31 +671 -2: test/watch_test.dart: watch sqlite joined and nested batch relations track child writes +09:31 +672 -2: test/watch_test.dart: watch sqlite relation predicates, SQL subqueries and CTE sources are dependencies +09:31 +673 -2: test/watch_test.dart: watch sqlite declared cascade deletion invalidates a child-only query +09:31 +674 -2: test/watch_test.dart: watch sqlite CASCADE tracks declared transitive deletion effects +09:31 +675 -2: test/watch_test.dart: watch sqlite SET NULL tracks declared transitive deletion effects +09:31 +676 -2: test/watch_test.dart: watch sqlite explicit JOINs and joined CTE definitions track both physical sources +09:31 +677 -2: test/watch_test.dart: watch sqlite raw SQL declares changed and read tables; explicit invalidation follows transactions +09:31 +678 -2: test/watch_test.dart: watch sqlite paused listeners coalesce writes and stop fetching until resumed +09:31 +679 -2: test/watch_test.dart: watch sqlite invalidation during a read suppresses the stale snapshot and refetches +09:31 +680 -2: test/watch_test.dart: watch sqlite decoding errors are reported and a later write can recover +09:31 +681 -2: test/watch_test.dart: watch sqlite separate subscriptions and views of one driver receive changes +09:31 +682 -2: test/watch_test.dart: watch sqlite watching leased sessions is rejected and cancelled tokens close the stream +09:31 +683 -2: test/watch_test.dart: watch sqlite database close finishes watchers even when a listener is paused +09:31 +684 -2: test/watch_test.dart: watch sqlite cancelling an active watched query interrupts SQL and releases its lease +09:31 +685 -2: test/watch_test.dart: watch postgres lazy initial snapshot and relevant table invalidation +09:31 +686 -2: test/watch_test.dart: watch postgres transactions publish one snapshot after commit and none on rollback +09:31 +687 -2: test/watch_test.dart: watch postgres savepoint changes merge on release and disappear on rollback +09:31 +688 -2: test/watch_test.dart: watch postgres failed statements, zero-row writes and conflict no-ops do not refresh +09:31 +689 -2: test/watch_test.dart: watch postgres batch writes and upsert returning publish committed changes +09:31 +690 -2: test/watch_test.dart: watch postgres joined and nested batch relations track child writes +09:31 +691 -2: test/watch_test.dart: watch postgres relation predicates, SQL subqueries and CTE sources are dependencies +09:31 +692 -2: test/watch_test.dart: watch postgres declared cascade deletion invalidates a child-only query +09:31 +693 -2: test/watch_test.dart: watch postgres CASCADE tracks declared transitive deletion effects +09:31 +694 -2: test/watch_test.dart: watch postgres SET NULL tracks declared transitive deletion effects +09:31 +695 -2: test/watch_test.dart: watch postgres explicit JOINs and joined CTE definitions track both physical sources +09:31 +696 -2: test/watch_test.dart: watch postgres raw SQL declares changed and read tables; explicit invalidation follows transactions +09:32 +697 -2: test/watch_test.dart: watch postgres paused listeners coalesce writes and stop fetching until resumed +09:32 +698 -2: test/watch_test.dart: watch postgres invalidation during a read suppresses the stale snapshot and refetches +09:32 +699 -2: test/watch_test.dart: watch postgres decoding errors are reported and a later write can recover +09:32 +700 -2: test/watch_test.dart: watch postgres separate subscriptions and views of one driver receive changes +09:32 +701 -2: test/watch_test.dart: watch postgres watching leased sessions is rejected and cancelled tokens close the stream +09:32 +702 -2: test/watch_test.dart: watch postgres database close finishes watchers even when a listener is paused +09:32 +703 -2: test/watch_test.dart: watch postgres cancelling an active watched query interrupts SQL and releases its lease +09:32 +704 -2: test/watch_test.dart: watch postgres lost commit acknowledgement triggers a conservative re-read +09:32 +705 -2: test/watch_test.dart: watch postgres independent connections require an explicit external-write notification +09:32 +706 -2: loading test/backfill_test.dart +09:32 +706 -2: test/backfill_test.dart: backfill sqlite chunks pause and resume from a checksummed historical declaration +09:32 +707 -2: test/backfill_test.dart: backfill sqlite failed chunk rolls back both writes and cursor, then resumes after repair +09:32 +708 -2: test/backfill_test.dart: backfill sqlite failed completion proof preserves finished chunks without replay +09:32 +709 -2: test/backfill_test.dart: backfill sqlite frozen upper bound and completion proof detect incompatible new writes +09:32 +710 -2: test/backfill_test.dart: backfill sqlite competing runners share committed chunks without duplicate updates +09:32 +711 -2: test/backfill_test.dart: backfill sqlite lost chunk commit acknowledgement preserves the committed frontier +09:32 +712 -2: test/backfill_test.dart: backfill sqlite old checkpoint tables upgrade on apply; completed reads remain nonmutating +09:32 +713 -2: test/backfill_test.dart: backfill sqlite completion conditions reject numeric results instead of truthiness +09:32 +714 -2: test/backfill_test.dart: backfill sqlite empty input still requires the completion condition +09:32 +715 -2: test/backfill_test.dart: backfill sqlite later ordinary migrations retain group atomicity after a completed backfill +09:32 +716 -2: test/backfill_test.dart: backfill sqlite AFTER triggers cannot silently move keys past a committed cursor +09:32 +717 -2: test/backfill_test.dart: backfill sqlite corrupt checkpoint dimensions and invalid run limits are rejected +09:32 +718 -2: test/backfill_test.dart: backfill sqlite rebuild, chunked backfill and required-column rebuild preserve FK enforcement +09:32 +719 -2: test/backfill_test.dart: backfill sqlite CLI previews non-atomic work and reports a bounded run as incomplete +09:58 +720 -2: test/backfill_test.dart: backfill sqlite temporary tables cannot redirect the historical backfill target +09:58 +721 -2: test/backfill_test.dart: backfill sqlite temporary objects cannot impersonate durable migration metadata +09:58 +722 -2: test/backfill_test.dart: backfill sqlite committed chunks invalidate relevant typed query subscriptions +09:58 +723 -2: test/backfill_test.dart: backfill sqlite real process exit after second chunk write resumes correctly +10:20 +724 -2: test/backfill_test.dart: backfill sqlite real process exit after second chunk commit resumes correctly +10:42 +725 -2: test/backfill_test.dart: backfill postgres chunks pause and resume from a checksummed historical declaration +10:42 +726 -2: test/backfill_test.dart: backfill postgres failed chunk rolls back both writes and cursor, then resumes after repair +10:42 +727 -2: test/backfill_test.dart: backfill postgres failed completion proof preserves finished chunks without replay +10:42 +728 -2: test/backfill_test.dart: backfill postgres frozen upper bound and completion proof detect incompatible new writes +10:42 +729 -2: test/backfill_test.dart: backfill postgres competing runners share committed chunks without duplicate updates +10:42 +730 -2: test/backfill_test.dart: backfill postgres lost chunk commit acknowledgement preserves the committed frontier +10:42 +731 -2: test/backfill_test.dart: backfill postgres old checkpoint tables upgrade on apply; completed reads remain nonmutating +10:42 +732 -2: test/backfill_test.dart: backfill postgres completion conditions reject numeric results instead of truthiness +10:42 +733 -2: test/backfill_test.dart: backfill postgres empty input still requires the completion condition +10:42 +734 -2: test/backfill_test.dart: backfill postgres later ordinary migrations retain group atomicity after a completed backfill +10:42 +735 -2: test/backfill_test.dart: backfill postgres AFTER triggers cannot silently move keys past a committed cursor +10:42 +736 -2: test/backfill_test.dart: backfill postgres corrupt checkpoint dimensions and invalid run limits are rejected +10:42 +737 -2: test/backfill_test.dart: backfill postgres CLI previews non-atomic work and reports a bounded run as incomplete +11:10 +738 -2: test/backfill_test.dart: backfill postgres temporary tables cannot redirect the historical backfill target +11:10 +739 -2: test/backfill_test.dart: backfill postgres temporary objects cannot impersonate durable migration metadata +11:10 +740 -2: test/backfill_test.dart: backfill postgres row security cannot hide unfinished rows from the completion proof +11:10 +741 -2: test/backfill_test.dart: backfill postgres inherited duplicate primary keys cannot advance a cursor +11:10 +742 -2: test/backfill_test.dart: backfill postgres committed chunks invalidate relevant typed query subscriptions +11:10 +743 -2: test/backfill_test.dart: backfill postgres real process exit after second chunk write resumes correctly +11:25 +744 -2: test/backfill_test.dart: backfill postgres real process exit after second chunk commit resumes correctly +11:43 +745 -2: loading test/custom_codec_test.dart +11:43 +745 -2: test/custom_codec_test.dart: (setUpAll) +11:43 +745 -2: test/custom_codec_test.dart: custom codecs sqlite generated custom values round trip through create, select and patch +11:43 +746 -2: test/custom_codec_test.dart: custom codecs sqlite enum labels are stable text and unknown labels fail decoding +11:43 +747 -2: test/custom_codec_test.dart: custom codecs sqlite domain validation and bound parameters apply to writes and predicates +11:43 +748 -2: test/custom_codec_test.dart: custom codecs sqlite JSON documents distinguish SQL NULL, JSON null and string scalars +11:43 +749 -2: test/custom_codec_test.dart: custom codecs sqlite nullable structured codec rejects JSON null instead of masking invalid data +11:43 +750 -2: test/custom_codec_test.dart: custom codecs sqlite custom IDs and domain projections work across joined and batched relations +11:43 +751 -2: test/custom_codec_test.dart: custom codecs sqlite custom values participate in atomic batch writes and native upsert +11:43 +752 -2: test/custom_codec_test.dart: custom codecs sqlite custom IDs and enums survive cursor transport and streaming decoders +11:43 +753 -2: test/custom_codec_test.dart: custom codecs sqlite migration snapshots retain physical storage without executing domain codecs +11:43 +754 -2: test/custom_codec_test.dart: custom codecs postgres generated custom values round trip through create, select and patch +11:43 +755 -2: test/custom_codec_test.dart: custom codecs postgres enum labels are stable text and unknown labels fail decoding +11:43 +756 -2: test/custom_codec_test.dart: custom codecs postgres domain validation and bound parameters apply to writes and predicates +11:43 +757 -2: test/custom_codec_test.dart: custom codecs postgres JSON documents distinguish SQL NULL, JSON null and string scalars +11:43 +758 -2: test/custom_codec_test.dart: custom codecs postgres nullable structured codec rejects JSON null instead of masking invalid data +11:44 +759 -2: test/custom_codec_test.dart: custom codecs postgres custom IDs and domain projections work across joined and batched relations +11:44 +760 -2: test/custom_codec_test.dart: custom codecs postgres custom values participate in atomic batch writes and native upsert +11:44 +761 -2: test/custom_codec_test.dart: custom codecs postgres custom IDs and enums survive cursor transport and streaming decoders +11:44 +762 -2: test/custom_codec_test.dart: custom codecs postgres migration snapshots retain physical storage without executing domain codecs +11:44 +763 -2: test/custom_codec_test.dart: (tearDownAll) +11:44 +763 -2: loading test/plan_test.dart +11:44 +763 -2: test/plan_test.dart: inspect sqlite reports composite key width and validates each potential batch +11:44 +764 -2: test/plan_test.dart: inspect sqlite compiles nested plans without acquiring or decoding +11:44 +765 -2: test/plan_test.dart: inspect sqlite distinguishes same-statement joins and skipped batches +11:44 +766 -2: test/plan_test.dart: inspect sqlite retains CTE, UNION and raw SQL boundaries +11:44 +767 -2: test/plan_test.dart: plan execution sqlite one-key SQL templates match executed SQL and internal slots +11:44 +768 -2: test/plan_test.dart: inspect postgres reports composite key width and validates each potential batch +11:44 +769 -2: test/plan_test.dart: inspect postgres compiles nested plans without acquiring or decoding +11:44 +770 -2: test/plan_test.dart: inspect postgres distinguishes same-statement joins and skipped batches +11:44 +771 -2: test/plan_test.dart: inspect postgres retains CTE, UNION and raw SQL boundaries +11:44 +772 -2: test/plan_test.dart: plan execution postgres one-key SQL templates match executed SQL and internal slots +11:44 +773 -2: loading test/check_test.dart +11:44 +773 -2: test/check_test.dart: CHECK sqlite generated named and unnamed constraints enforce values and permit NULL +11:44 +774 -2: test/check_test.dart: CHECK sqlite import preserves names and SQL then generates a matching snapshot +11:46 +775 -2: test/check_test.dart: CHECK sqlite add, change and remove constraints validate old rows atomically +11:46 +776 -2: test/check_test.dart: CHECK sqlite duplicate unnamed checks consume and drop exactly one catalog entry +11:46 +777 -2: test/check_test.dart: CHECK sqlite explicit checked renames preserve rows, incoming references, indexes and views +11:46 +778 -2: test/check_test.dart: CHECK sqlite verification preserves casts, precedence, literals and trailing comments +11:46 +779 -2: test/check_test.dart: CHECK sqlite quoted Unicode identifiers retain SQLite case distinctions +11:46 +780 -2: test/check_test.dart: CHECK sqlite undeclared CHECK is refused before a rebuild and remains enforced +11:46 +781 -2: test/check_test.dart: CHECK postgres generated named and unnamed constraints enforce values and permit NULL +11:46 +782 -2: test/check_test.dart: CHECK postgres import preserves names and SQL then generates a matching snapshot +11:47 +783 -2: test/check_test.dart: CHECK postgres add, change and remove constraints validate old rows atomically +11:47 +784 -2: test/check_test.dart: CHECK postgres duplicate unnamed checks consume and drop exactly one catalog entry +11:47 +785 -2: test/check_test.dart: CHECK postgres explicit checked renames preserve rows, incoming references, indexes and views +11:47 +786 -2: test/check_test.dart: CHECK postgres verification preserves casts, precedence, literals and trailing comments +11:47 +787 -2: test/check_test.dart: CHECK postgres catalog excludes unvalidated and NO INHERIT constraints +11:47 +788 -2: test/check_test.dart: CHECK postgres verification plans but does not execute volatile row expressions +11:47 +789 -2: test/check_test.dart: dialect overrides only alter the affected backend and preserve old snapshot JSON +11:47 +790 -2: loading test/temporal_test.dart +11:47 +790 -2: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: false +11:47 +791 -2: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: true +11:47 +792 -2: test/temporal_test.dart: Gregorian cycles, era boundaries and full finite date range +11:47 +793 -2: test/temporal_test.dart: local time precision, day endpoint and timestamp arithmetic +11:47 +794 -2: test/temporal_test.dart: PostgreSQL binary registry covers boundaries without DateTime +11:47 +795 -2: test/temporal_test.dart: local temporal sqlite generated writes, defaults, null patches and typed parameters +11:47 +796 -2: test/temporal_test.dart: local temporal sqlite finite extremes and microseconds survive native rows and streams +11:47 +797 -2: test/temporal_test.dart: local temporal sqlite calendar ordering, min/max, grouping and stable date cursors +11:47 +798 -2: test/temporal_test.dart: local temporal sqlite equivalent date keys drive batched relations and unique indexes +11:47 +799 -2: test/temporal_test.dart: local temporal sqlite time and local timestamp comparisons retain calendar semantics +11:47 +800 -2: test/temporal_test.dart: local temporal sqlite reviewed text-to-time migration rolls back duplicate clock values +11:47 +801 -2: test/temporal_test.dart: local temporal sqlite invalid external dates fail typed reads without damaging the connection +11:47 +802 -2: test/temporal_test.dart: local temporal sqlite snapshots, catalog import and regenerated defaults agree +11:49 +803 -2: test/temporal_test.dart: local temporal sqlite historical date keys resume a bounded backfill in calendar order +11:49 +804 -2: test/temporal_test.dart: local temporal postgres catalog retains time-truncating casts in expression defaults +11:49 +805 -2: test/temporal_test.dart: local temporal postgres generated writes, defaults, null patches and typed parameters +11:49 +806 -2: test/temporal_test.dart: local temporal postgres finite extremes and microseconds survive native rows and streams +11:49 +807 -2: test/temporal_test.dart: local temporal postgres calendar ordering, min/max, grouping and stable date cursors +11:49 +808 -2: test/temporal_test.dart: local temporal postgres equivalent date keys drive batched relations and unique indexes +11:50 +809 -2: test/temporal_test.dart: local temporal postgres time and local timestamp comparisons retain calendar semantics +11:50 +810 -2: test/temporal_test.dart: local temporal postgres reviewed text-to-time migration rolls back duplicate clock values +11:50 +811 -2: test/temporal_test.dart: local temporal postgres invalid external dates fail typed reads without damaging the connection +11:50 +812 -2: test/temporal_test.dart: local temporal postgres snapshots, catalog import and regenerated defaults agree +11:50 +813 -2: test/temporal_test.dart: local temporal postgres historical date keys resume a bounded backfill in calendar order +11:50 +814 -2: loading test/unconstrained_relation_test.dart +11:50 +814 -2: test/unconstrained_relation_test.dart: unconstrained sqlite catalogs contain no FK, implicit index or uniqueness promise +11:50 +815 -2: test/unconstrained_relation_test.dart: unconstrained sqlite adding and removing a real FK remains a reviewed physical migration +11:50 +816 -2: test/unconstrained_relation_test.dart: unconstrained sqlite unique joins preserve root rows, NULL components and missing targets +11:50 +817 -2: test/unconstrained_relation_test.dart: unconstrained sqlite required target and filter visibility remain explicit +11:50 +818 -2: test/unconstrained_relation_test.dart: unconstrained sqlite nonunique composite collections batch by tuple and page per parent +11:50 +819 -2: test/unconstrained_relation_test.dart: unconstrained sqlite to-one uses cardinality checks without assuming a unique lookup +11:50 +820 -2: test/unconstrained_relation_test.dart: unconstrained sqlite correlated filters and counts do not materialize related rows +11:50 +821 -2: test/unconstrained_relation_test.dart: unconstrained sqlite self relations and inverse collections share the existing planner +11:50 +822 -2: test/unconstrained_relation_test.dart: unconstrained sqlite delete has no cascade and watches track the joined target +11:50 +823 -2: test/unconstrained_relation_test.dart: unconstrained sqlite transaction reads observe writes and outer rollback restores targets +11:50 +824 -2: test/unconstrained_relation_test.dart: unconstrained sqlite streaming preserves optional and batched relation result shapes +11:50 +825 -2: test/unconstrained_relation_test.dart: unconstrained sqlite floating relation keys retain storage intent and key equality +11:50 +826 -2: test/unconstrained_relation_test.dart: unconstrained postgres catalogs contain no FK, implicit index or uniqueness promise +11:51 +827 -2: test/unconstrained_relation_test.dart: unconstrained postgres adding and removing a real FK remains a reviewed physical migration +11:51 +828 -2: test/unconstrained_relation_test.dart: unconstrained postgres unique joins preserve root rows, NULL components and missing targets +11:51 +829 -2: test/unconstrained_relation_test.dart: unconstrained postgres required target and filter visibility remain explicit +11:51 +830 -2: test/unconstrained_relation_test.dart: unconstrained postgres nonunique composite collections batch by tuple and page per parent +11:51 +831 -2: test/unconstrained_relation_test.dart: unconstrained postgres to-one uses cardinality checks without assuming a unique lookup +11:51 +832 -2: test/unconstrained_relation_test.dart: unconstrained postgres correlated filters and counts do not materialize related rows +11:51 +833 -2: test/unconstrained_relation_test.dart: unconstrained postgres self relations and inverse collections share the existing planner +11:51 +834 -2: test/unconstrained_relation_test.dart: unconstrained postgres delete has no cascade and watches track the joined target +11:51 +835 -2: test/unconstrained_relation_test.dart: unconstrained postgres transaction reads observe writes and outer rollback restores targets +11:51 +836 -2: test/unconstrained_relation_test.dart: unconstrained postgres streaming preserves optional and batched relation result shapes +11:51 +837 -2: test/unconstrained_relation_test.dart: unconstrained postgres floating relation keys retain storage intent and key equality +11:51 +838 -2: loading test/decimal_value_test.dart +11:51 +838 -2: test/decimal_value_test.dart: normalization, scientific notation, equality and hash are exact +11:51 +839 -2: test/decimal_value_test.dart: invalid and approximate inputs fail instead of silently rounding +11:51 +840 -2: test/decimal_value_test.dart: range boundaries are checked without binary numeric conversions +11:51 +841 -2: test/decimal_value_test.dart: addition, subtraction, multiplication and ordering keep every digit +11:51 +842 -2: test/decimal_value_test.dart: division requires an explicit scale and rejects implicit rounding +11:51 +843 -2: test/decimal_value_test.dart: rounding modes handle negative values, ties and negative scales +11:51 +844 -2: test/decimal_value_test.dart: small fixed-scale arithmetic agrees with independent integer arithmetic +11:51 +845 -2: Some tests failed. + +Failing tests: + test/migration_recovery_test.dart: real process exit after concurrent DDL resumes without replay + test/migration_recovery_test.dart: real process exit after transaction commit resumes without replay + +Consider enabling the flag chain-stack-traces to receive more detailed exceptions. +For example, 'dart test --chain-stack-traces'. diff --git a/research/validation/migration-targets.md b/research/validation/migration-targets.md new file mode 100644 index 00000000..008c320b --- /dev/null +++ b/research/validation/migration-targets.md @@ -0,0 +1,70 @@ +# Single-engine migration validation + +Captured 2026-09-15T14:19:28.276397+00:00. Dart 3.13.3, +macOS arm64, native SQLite through sqlite3 3.6.0, and a disposable PostgreSQL 18.4 +instance. The instance and test directories were owned by this run and cleaned up. + +Runtime source digest: `a3c1161cff2726a66456095a2cbf86f3154cc99aa0a38d860c3e333f466a332e`. SHA-256 over lexically sorted +Dart paths under `lib`/`bin` plus `pubspec.yaml`/`pubspec.lock`, each encoded as +UTF-8 path, NUL, file bytes, NUL. Runtime sources were unchanged during these runs. + +| Validation | Result | Evidence | +| --- | --- | --- | +| Complete native suite with `ORM_TEST_POSTGRES` set | 845 passed, 2 failed, 11:51 | [Full log](migration-targets-full.log) | +| Migration target and PostgreSQL recovery suites after fixing fixture configuration | 21 passed, 00:19, exit 0 | [Follow-up](migration-targets-followup.log) | +| Consumer migration source roundtrip and standalone AOT bundle after deleting consumer sources/assets | Passed in full run | `migration_source_test.dart` in full log | +| Statically registered historical backfill in native AOT | Pause, concurrent resume, exactly-once updates, startup compatibility passed | [AOT log](migration-targets-backfill-aot.log) | +| Real Chrome JavaScript | 19 checks passed | [JS log](migration-targets-browser-js.log) | +| Real Chrome Dart WASM | 19 checks passed | [WASM log](migration-targets-browser-wasm.log) | +| Public registry CLI and example entrypoint | Target initialization/preservation/rejected switch; check/apply/verify/no replay passed | [CLI log](migration-targets-cli.log) | +| `dart analyze` and `git diff --check` | No issues | Final working tree | + +The full run and follow-up are separate invocations. The two failures were real +process-exit tests in `migration_recovery_test.dart`: their test project used the +SQLite registry default for PostgreSQL migration files. The new target check +rejected them before execution. The fixture now initializes a PostgreSQL registry; +both concurrent-DDL and transaction-commit exit/recovery cases pass in the follow-up. +The follow-up also covers the final empty-history connection rejection and unused +empty-expression assertions. This is not a claim of a single all-green full run. + +Commands (Dart binary from the verified SDK; local `DEVELOPER_DIR` points to the +installed CommandLineTools): + +```sh +ORM_TEST_POSTGRES= dart test --reporter expanded +ORM_TEST_POSTGRES= dart test test/migration_target_test.dart test/migration_recovery_test.dart --reporter expanded +dart run tool/test_browser.dart +dart run tool/test_browser.dart --wasm +dart build cli --target test/support/native_backfill.dart --output .dart_tool/target-backfill-aot +.dart_tool/target-backfill-aot/bundle/bin/native_backfill +dart analyze +``` + +The CLI smoke initialized a temporary registry using +`dart run bin/orm.dart migration registry --dialect postgres`, rebuilt +it without changing its target, and verified a SQLite retarget failed without +changing its source. `example/migrate.dart check`, `apply`, `verify`, `apply` used +an isolated `ORM_SQLITE_PATH`; the final apply returned an empty list. + +Covered boundaries: + +- A migration has one engine and one step list. Engine identity is checksummed; + mixed histories and mismatched connections fail before migration SQL. +- Empty histories retain a target. Registry rebuilding preserves it and neither + rewrites fixed fingerprints nor accepts another engine. +- Only selected CHECK/computed SQL enters frozen history. Unused overrides cannot + change the plan, fingerprint or emitted migration source. +- SQLite virtual-column indexes, stored/virtual transitions and materialization + work without PostgreSQL restrictions. Replacing ordinary values with computed + values requires explicit destructive-change review. +- PostgreSQL uses its own conversion expression, constraint operations, concurrent + indexes and recovery logic. SQLite retains rebuild/foreign-key/rollback behavior. +- PostgreSQL execution checks server version before locks or journal changes. + Rejection of a 17.x response is a driver-double unit check; real PostgreSQL + execution was verified on 18.4, not on an installed 17.x server. + +No Android/Apple-device/Flutter runtime was rerun for this correction. The Flutter +example's Dart migration history and shared runtime were updated; the earlier +Android capture applies to its recorded revision. There is no cross-engine data +transfer or distributed transaction guarantee. Reviewed SQL, deployment downtime +and database objects outside the modeled schema still require application review. diff --git a/research/validation/native.json b/research/validation/native.json new file mode 100644 index 00000000..7c8b9281 --- /dev/null +++ b/research/validation/native.json @@ -0,0 +1,21 @@ +{ + "capturedAt": "2026-09-15T10:04:34.415029+00:00", + "sourceBase": "20ffee7", + "command": "ORM_TEST_POSTGRES= dart test -r expanded", + "exitCode": 0, + "passed": 835, + "elapsed": "07:44", + "dart": "3.13.3", + "postgres": "18.4", + "sqlite": "3.53.4", + "log": "research/validation/native.log", + "logSha256": "3fa6b4160ba28d46fa9ea1937b95cdca562659307b61f805d36fd6e780255b71", + "sources": { + "lib/src/migrate/catalog.dart": "80c1a396727d57e9c49e37914c076e6fab64a3eb43437b522c82c749cf280359", + "test/import_test.dart": "3627b4e57c7493a1757e90529942c02b2554add29c7f128d3a63708e446a5942", + "pubspec.yaml": "0a6bfe003864c3599c11b4acc208e611af71135904bea531edac67760bc559be", + "pubspec.lock": "a17ed225d38e69c125be83a4a60d5574e61190408e33b191b23de735d8af5416", + "dart_test.yaml": "fb685b478e815629ba593e1d74975bc2aabc50293bbd27cb30f62f7e4ae8f5ab" + }, + "scope": "Complete native test suite, serialized on macOS; real SQLite and PostgreSQL are enabled. Includes generator/CLI/build_runner processes, transactions/streaming, migrations/recovery, catalog/import and type checks. Browser and Flutter are separate captures." +} diff --git a/research/validation/native.log b/research/validation/native.log new file mode 100644 index 00000000..99f4e272 --- /dev/null +++ b/research/validation/native.log @@ -0,0 +1,889 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/client_default_test.dart +00:00 +0: test/client_default_test.dart: client defaults sqlite omitted values use typed Dart factories and return stored records +00:00 +1: test/client_default_test.dart: client defaults sqlite explicit values, null and SQL DEFAULT bypass their client factory +00:00 +2: test/client_default_test.dart: client defaults sqlite prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +3: test/client_default_test.dart: client defaults sqlite batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +4: test/client_default_test.dart: client defaults sqlite updates and conflict updates do not reset omitted client values +00:00 +5: test/client_default_test.dart: client defaults sqlite factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +6: test/client_default_test.dart: client defaults sqlite snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +7: test/client_default_test.dart: client defaults sqlite explicit DEFAULT can choose database identity instead of the client value +00:00 +8: test/client_default_test.dart: client defaults postgres omitted values use typed Dart factories and return stored records +00:00 +9: test/client_default_test.dart: client defaults postgres explicit values, null and SQL DEFAULT bypass their client factory +00:00 +10: test/client_default_test.dart: client defaults postgres prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +11: test/client_default_test.dart: client defaults postgres batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +12: test/client_default_test.dart: client defaults postgres updates and conflict updates do not reset omitted client values +00:00 +13: test/client_default_test.dart: client defaults postgres factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +14: test/client_default_test.dart: client defaults postgres snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +15: test/client_default_test.dart: client defaults postgres explicit DEFAULT can choose database identity instead of the client value +00:00 +16: test/client_default_test.dart: generation reads factory references without executing them and supports moved output +00:02 +17: loading test/decimal_precision_test.dart +00:02 +17: test/decimal_precision_test.dart: decimal precision sqlite column coercion rounds signed ties and handles negative and excess scales +00:02 +18: test/decimal_precision_test.dart: decimal precision sqlite writes constrain columns while arithmetic and wider aggregates retain their result precision +00:02 +19: test/decimal_precision_test.dart: decimal precision sqlite batch inserts and upserts use column coercion and roll back as a unit +00:02 +20: test/decimal_precision_test.dart: decimal precision sqlite catalogs snapshots and imported declarations retain precision scale and defaults +00:04 +21: test/decimal_precision_test.dart: decimal precision sqlite reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:04 +22: test/decimal_precision_test.dart: decimal precision sqlite physical renames and changing constrained defaults preserve column contracts +00:04 +23: test/decimal_precision_test.dart: decimal precision sqlite maximum declared precision and scale bounds remain exact +00:04 +24: test/decimal_precision_test.dart: decimal precision sqlite historical backfills retain constrained decimal key declarations +00:04 +25: test/decimal_precision_test.dart: decimal precision postgres column coercion rounds signed ties and handles negative and excess scales +00:04 +26: test/decimal_precision_test.dart: decimal precision postgres writes constrain columns while arithmetic and wider aggregates retain their result precision +00:04 +27: test/decimal_precision_test.dart: decimal precision postgres batch inserts and upserts use column coercion and roll back as a unit +00:04 +28: test/decimal_precision_test.dart: decimal precision postgres catalogs snapshots and imported declarations retain precision scale and defaults +00:05 +29: test/decimal_precision_test.dart: decimal precision postgres reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:05 +30: test/decimal_precision_test.dart: decimal precision postgres physical renames and changing constrained defaults preserve column contracts +00:05 +31: test/decimal_precision_test.dart: decimal precision postgres maximum declared precision and scale bounds remain exact +00:05 +32: test/decimal_precision_test.dart: decimal precision postgres historical backfills retain constrained decimal key declarations +00:05 +33: test/decimal_precision_test.dart: SQLite enforces stored precision and checks default coercion and trusted-schema behavior +00:05 +34: test/decimal_precision_test.dart: generation validates DecimalDigits and retains custom decimal codec types +00:07 +35: test/decimal_precision_test.dart: metadata rejects invalid precisions scales and non-decimal columns +00:07 +36: loading test/selection_test.dart +00:07 +36: test/selection_test.dart: selection sqlite all typed arities defer mapping and preserve left-to-right evaluation +00:07 +37: test/selection_test.dart: selection sqlite first decoder failure prevents later fields and outer mapper +00:07 +38: test/selection_test.dart: selection sqlite dynamic fields snapshot keys and preserve nulls and mapping order +00:07 +39: test/selection_test.dart: selection postgres all typed arities defer mapping and preserve left-to-right evaluation +00:07 +40: test/selection_test.dart: selection postgres first decoder failure prevents later fields and outer mapper +00:07 +41: test/selection_test.dart: selection postgres dynamic fields snapshot keys and preserve nulls and mapping order +00:07 +42: loading test/build_runner_test.dart +00:07 +42: test/build_runner_test.dart: real build_runner build/watch tracks imports, recovers errors and deletes stale outputs +00:31 +43: loading test/schema_version_test.dart +00:31 +43: test/schema_version_test.dart: schema version sqlite invalid ranges fail before SQL; caller transactions stay usable +00:31 +44: test/schema_version_test.dart: schema version sqlite unversioned existing data is rejected without creating history +00:31 +45: test/schema_version_test.dart: schema version sqlite default requires latest; inclusive compatibility range is explicit +00:31 +46: test/schema_version_test.dart: schema version sqlite newer database rejects downgrade without changing data or schema +00:31 +47: test/schema_version_test.dart: schema version sqlite complete applied prefix is checked, including old checksums and missing entries +00:31 +48: test/schema_version_test.dart: schema version sqlite matching version does not claim that the catalog is free of drift +00:31 +49: test/schema_version_test.dart: schema version sqlite verified baseline is compatible without replaying creation +00:31 +50: test/schema_version_test.dart: schema version sqlite borrowed session uses a read snapshot and survives rejected versions +00:31 +51: test/schema_version_test.dart: schema version postgres invalid ranges fail before SQL; caller transactions stay usable +00:31 +52: test/schema_version_test.dart: schema version postgres unversioned existing data is rejected without creating history +00:31 +53: test/schema_version_test.dart: schema version postgres default requires latest; inclusive compatibility range is explicit +00:31 +54: test/schema_version_test.dart: schema version postgres newer database rejects downgrade without changing data or schema +00:31 +55: test/schema_version_test.dart: schema version postgres complete applied prefix is checked, including old checksums and missing entries +00:32 +56: test/schema_version_test.dart: schema version postgres matching version does not claim that the catalog is free of drift +00:32 +57: test/schema_version_test.dart: schema version postgres verified baseline is compatible without replaying creation +00:32 +58: test/schema_version_test.dart: schema version postgres borrowed session uses a read snapshot and survives rejected versions +00:32 +59: test/schema_version_test.dart: schema version postgres unfinished recoverable migration blocks startup until repaired and completed +00:32 +60: test/schema_version_test.dart: schema version postgres catalog and history share a snapshot and the supplied history is frozen +00:32 +61: test/schema_version_test.dart: SQLite version gate works on an explicitly read-only file +00:32 +62: loading test/types_test.dart +00:32 +62: test/types_test.dart: junction-table keys, enum payloads and nested results keep generated types +00:33 +63: test/types_test.dart: computed generated fields are statically readable and never writable +00:34 +64: test/types_test.dart: client-default create parameters preserve domain, nullable and timestamp types +00:34 +65: test/types_test.dart: query-only relation APIs retain result types and expose no graph writes +00:35 +66: test/types_test.dart: calendar APIs reject instants, strings and mixed temporal types +00:35 +67: test/types_test.dart: actual generated APIs reject wrong inputs, results and backend options +00:36 +68: loading test/migration_test.dart +00:36 +68: test/migration_test.dart: (setUpAll) +00:36 +68: test/migration_test.dart: migrations sqlite explicit table and column renames preserve incoming references and data +00:36 +69: test/migration_test.dart: migrations sqlite constraint changes resolve catalog names and retain rows +00:36 +70: test/migration_test.dart: migrations sqlite failed NOT NULL rebuild restores data history and foreign key settings +00:36 +71: test/migration_test.dart: migrations sqlite reviewed conversion expressions preserve values when storage types change +00:36 +72: test/migration_test.dart: migrations sqlite additive changes use ALTER without a table rebuild +00:36 +73: test/migration_test.dart: migrations sqlite reviewed table removal handles restrictive foreign keys atomically +00:36 +74: test/migration_test.dart: migrations sqlite broken migration chain is refused before DDL +00:36 +75: test/migration_test.dart: migrations sqlite catalog distinguishes nullable non-rowid primary keys +00:36 +76: test/migration_test.dart: migrations sqlite rebuild preserves unmanaged index trigger and view definitions +00:36 +77: test/migration_test.dart: migrations sqlite foreign key check rolls back changed parent keys without cascading data away +00:36 +78: test/migration_test.dart: migrations sqlite unmodeled CHECK constraints are refused without losing their definition +00:36 +79: test/migration_test.dart: migrations postgres explicit table and column renames preserve incoming references and data +00:36 +80: test/migration_test.dart: migrations postgres constraint changes resolve catalog names and retain rows +00:36 +81: test/migration_test.dart: migrations postgres failed NOT NULL rebuild restores data history and foreign key settings +00:36 +82: test/migration_test.dart: migrations postgres reviewed conversion expressions preserve values when storage types change +00:36 +83: test/migration_test.dart: migrations postgres additive changes use ALTER without a table rebuild +00:36 +84: test/migration_test.dart: migrations postgres reviewed table removal handles restrictive foreign keys atomically +00:36 +85: test/migration_test.dart: migrations postgres broken migration chain is refused before DDL +00:36 +86: test/migration_test.dart: diff requires explicit renames, casts and backfills +00:36 +87: test/migration_test.dart: (tearDownAll) +00:36 +87: loading test/many_to_many_test.dart +00:36 +87: test/many_to_many_test.dart: many-to-many sqlite two declared FKs and a composite primary key enforce association identity +00:36 +88: test/many_to_many_test.dart: many-to-many sqlite forward projection keeps payload, shared targets and per-user limits in two statements +00:36 +89: test/many_to_many_test.dart: many-to-many sqlite reverse projection applies tie-breaking and offset to each team without changing root pagination +00:36 +90: test/many_to_many_test.dart: many-to-many sqlite nested team rosters deduplicate shared team keys and batch by level +00:36 +91: test/many_to_many_test.dart: many-to-many sqlite association predicates and counts stay in one SQL statement +00:36 +92: test/many_to_many_test.dart: many-to-many sqlite limited parameter capacity chunks associations without changing per-parent limits +00:36 +93: test/many_to_many_test.dart: many-to-many sqlite transaction rollback restores payload changes and new endpoints after an invalid association +00:36 +94: test/many_to_many_test.dart: many-to-many sqlite composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:36 +95: test/many_to_many_test.dart: many-to-many sqlite watch tracks junction payload and opposite endpoint changes after commit +00:36 +96: test/many_to_many_test.dart: many-to-many postgres two declared FKs and a composite primary key enforce association identity +00:37 +97: test/many_to_many_test.dart: many-to-many postgres forward projection keeps payload, shared targets and per-user limits in two statements +00:37 +98: test/many_to_many_test.dart: many-to-many postgres reverse projection applies tie-breaking and offset to each team without changing root pagination +00:37 +99: test/many_to_many_test.dart: many-to-many postgres nested team rosters deduplicate shared team keys and batch by level +00:37 +100: test/many_to_many_test.dart: many-to-many postgres association predicates and counts stay in one SQL statement +00:37 +101: test/many_to_many_test.dart: many-to-many postgres limited parameter capacity chunks associations without changing per-parent limits +00:37 +102: test/many_to_many_test.dart: many-to-many postgres transaction rollback restores payload changes and new endpoints after an invalid association +00:37 +103: test/many_to_many_test.dart: many-to-many postgres composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:37 +104: test/many_to_many_test.dart: many-to-many postgres watch tracks junction payload and opposite endpoint changes after commit +00:37 +105: loading test/stream_test.dart +00:37 +105: test/stream_test.dart: sqlite cancellation between batch chunks prevents a partial commit +00:37 +106: test/stream_test.dart: sqlite cancellation interrupts an active cursor fetch +00:37 +107: test/stream_test.dart: sqlite timed-out mutations return no partial write +00:37 +108: test/stream_test.dart: sqlite database close stops a paused cursor and releases the driver +00:37 +109: test/stream_test.dart: sqlite cancellation between stream creation and listening needs no rollback +00:37 +110: test/stream_test.dart: sqlite bounded cursor fetches preserve parameters and typed rows +00:37 +111: test/stream_test.dart: sqlite pause stops fetches and early cancellation releases the lease +00:37 +112: test/stream_test.dart: sqlite await-for provides demand and take closes without exhausting +00:37 +113: test/stream_test.dart: sqlite relation projections load once per root batch +00:37 +114: test/stream_test.dart: sqlite mapper failure closes the cursor and rolls back +00:37 +115: test/stream_test.dart: sqlite early stream end inside a transaction preserves other work +00:37 +116: test/stream_test.dart: sqlite escaping paused stream cannot keep a transaction alive +00:37 +117: test/stream_test.dart: sqlite cancels executing SQL and allows the next statement +00:37 +118: test/stream_test.dart: sqlite statement timeout stops SQL and leaves a usable connection +00:37 +119: test/stream_test.dart: sqlite cancelled statement poisons a transaction even if caught +00:37 +120: test/stream_test.dart: sqlite cancelling a paused stream releases its connection before resume +00:37 +121: test/stream_test.dart: sqlite cancellation before starting performs no SQL +00:37 +122: test/stream_test.dart: postgres cancellation between batch chunks prevents a partial commit +00:37 +123: test/stream_test.dart: postgres cancellation interrupts an active cursor fetch +00:37 +124: test/stream_test.dart: postgres timed-out mutations return no partial write +00:37 +125: test/stream_test.dart: postgres database close stops a paused cursor and releases the driver +00:37 +126: test/stream_test.dart: postgres cancellation between stream creation and listening needs no rollback +00:37 +127: test/stream_test.dart: postgres bounded cursor fetches preserve parameters and typed rows +00:37 +128: test/stream_test.dart: postgres pause stops fetches and early cancellation releases the lease +00:37 +129: test/stream_test.dart: postgres await-for provides demand and take closes without exhausting +00:38 +130: test/stream_test.dart: postgres relation projections load once per root batch +00:38 +131: test/stream_test.dart: postgres mapper failure closes the cursor and rolls back +00:38 +132: test/stream_test.dart: postgres early stream end inside a transaction preserves other work +00:38 +133: test/stream_test.dart: postgres escaping paused stream cannot keep a transaction alive +00:38 +134: test/stream_test.dart: postgres cancels executing SQL and allows the next statement +00:38 +135: test/stream_test.dart: postgres statement timeout stops SQL and leaves a usable connection +00:38 +136: test/stream_test.dart: postgres cancelled statement poisons a transaction even if caught +00:38 +137: test/stream_test.dart: postgres cancelling a paused stream releases its connection before resume +00:38 +138: test/stream_test.dart: postgres cancellation before starting performs no SQL +00:38 +139: test/stream_test.dart: late PostgreSQL cancellation cannot reach the next statement +00:38 +140: test/stream_test.dart: borrowed pool requires cancellation support and retains ownership +00:38 +141: test/stream_test.dart: failed cancellation control discards the physical connection +00:38 +142: loading test/migration_recovery_test.dart +00:38 +142: test/migration_recovery_test.dart: (setUpAll) +00:38 +142: test/migration_recovery_test.dart: SQLite and transaction controls reject recoverable autocommit steps +00:38 +143: test/migration_recovery_test.dart: mixed migrations checkpoint SQL and verify concurrent indexes +00:48 +144: test/migration_recovery_test.dart: same-name wrong-definition indexes are never accepted as completion +00:48 +145: test/migration_recovery_test.dart: failed unique builds leave INVALID indexes and require explicit repair +00:48 +146: test/migration_recovery_test.dart: failed attempts freeze migration checksums and block baseline +00:48 +147: test/migration_recovery_test.dart: failed postconditions report their actual failing stage +00:48 +148: test/migration_recovery_test.dart: recovery probes must produce one boolean value +00:48 +149: test/migration_recovery_test.dart: concurrent runners serialize normal and autocommit steps under one lock +00:49 +150: test/migration_recovery_test.dart: runner lock timeout leaves history untouched and the connection reusable +00:49 +151: test/migration_recovery_test.dart: ordinary SQL inside a mixed migration rolls back only its failed step and resumes +00:49 +152: test/migration_recovery_test.dart: completion checks reject partial and NULLS NOT DISTINCT definitions +00:49 +153: test/migration_recovery_test.dart: real process exit after concurrent DDL resumes without replay +00:50 +154: test/migration_recovery_test.dart: real process exit after transaction commit resumes without replay +00:51 +155: test/migration_recovery_test.dart: (tearDownAll) +00:51 +155: loading test/acquisition_test.dart +00:51 +155: test/acquisition_test.dart: an expired lease cannot enter before a delayed Timer gets its turn +00:51 +156: test/acquisition_test.dart: acquisition sqlite expired raw writes never execute after a late lease becomes available +00:51 +157: test/acquisition_test.dart: acquisition sqlite query cancellation returns while the pool is still occupied +00:51 +158: test/acquisition_test.dart: acquisition sqlite typed mutations and batch transactions propagate acquisition limits +00:51 +159: test/acquisition_test.dart: acquisition sqlite expired transactions and sessions never enter their callbacks +00:51 +160: test/acquisition_test.dart: acquisition sqlite acquisition timeout stops on entry and statement timeout starts afterward +00:51 +161: test/acquisition_test.dart: acquisition sqlite invalid and already-cancelled acquisition options never queue work +00:51 +162: test/acquisition_test.dart: acquisition sqlite stream cancellation and timeout return without opening a cursor +00:51 +163: test/acquisition_test.dart: acquisition sqlite query watch acquisition failure can recover on a later invalidation +00:51 +164: test/acquisition_test.dart: acquisition sqlite close drains abandoned reservations without running their callbacks +00:51 +165: test/acquisition_test.dart: acquisition sqlite abandoned requests preserve pool reuse and subsequent action errors +00:51 +166: test/acquisition_test.dart: PostgreSQL native pool timeout is classified before entering the callback +00:52 +167: test/acquisition_test.dart: PostgreSQL connection establishment timeout does not cap pool waiting +00:52 +168: test/acquisition_test.dart: acquisition postgres expired raw writes never execute after a late lease becomes available +00:52 +169: test/acquisition_test.dart: acquisition postgres query cancellation returns while the pool is still occupied +00:52 +170: test/acquisition_test.dart: acquisition postgres typed mutations and batch transactions propagate acquisition limits +00:52 +171: test/acquisition_test.dart: acquisition postgres expired transactions and sessions never enter their callbacks +00:52 +172: test/acquisition_test.dart: acquisition postgres acquisition timeout stops on entry and statement timeout starts afterward +00:52 +173: test/acquisition_test.dart: acquisition postgres invalid and already-cancelled acquisition options never queue work +00:52 +174: test/acquisition_test.dart: acquisition postgres stream cancellation and timeout return without opening a cursor +00:52 +175: test/acquisition_test.dart: acquisition postgres query watch acquisition failure can recover on a later invalidation +00:53 +176: test/acquisition_test.dart: acquisition postgres close drains abandoned reservations without running their callbacks +00:53 +177: test/acquisition_test.dart: acquisition postgres abandoned requests preserve pool reuse and subsequent action errors +00:53 +178: test/acquisition_test.dart: PostgreSQL timeout during real connection initialization releases the late lease +00:53 +179: loading test/computed_test.dart +00:53 +179: test/computed_test.dart: computed sqlite native expressions preserve quoted identifiers, comments and SQL-like string values +00:53 +180: test/computed_test.dart: computed sqlite generated CRUD, projections, batch and conflict writes compute stored and virtual values +00:53 +181: test/computed_test.dart: computed sqlite catalog/import roundtrip preserves SQL and modes and detects drift +00:55 +182: test/computed_test.dart: computed sqlite ComputedStorage.stored add, expression/type change and drop preserve existing rows +00:55 +183: test/computed_test.dart: computed sqlite ComputedStorage.virtual add, expression/type change and drop preserve existing rows +00:55 +184: test/computed_test.dart: computed sqlite stored to ordinary materialization retains values and permits later writes +00:55 +185: test/computed_test.dart: computed sqlite explicit renamed expressions preserve indexes, checks, references and views +00:55 +186: test/computed_test.dart: computed sqlite invalid recomputation rolls back the table and migration history +00:55 +187: test/computed_test.dart: computed sqlite decimal computed SQL keeps precision coercion and imports exactly once +00:56 +188: test/computed_test.dart: computed postgres native expressions preserve quoted identifiers, comments and SQL-like string values +00:56 +189: test/computed_test.dart: computed postgres generated CRUD, projections, batch and conflict writes compute stored and virtual values +00:56 +190: test/computed_test.dart: computed postgres catalog/import roundtrip preserves SQL and modes and detects drift +00:56 +191: test/computed_test.dart: computed postgres ComputedStorage.stored add, expression/type change and drop preserve existing rows +00:56 +192: test/computed_test.dart: computed postgres ComputedStorage.virtual add, expression/type change and drop preserve existing rows +00:56 +193: test/computed_test.dart: computed postgres stored to ordinary materialization retains values and permits later writes +00:56 +194: test/computed_test.dart: computed postgres explicit renamed expressions preserve indexes, checks, references and views +00:56 +195: test/computed_test.dart: computed postgres invalid recomputation rolls back the table and migration history +00:56 +196: test/computed_test.dart: computed postgres decimal computed SQL keeps precision coercion and imports exactly once +00:57 +197: test/computed_test.dart: mode conversions need explicit plans and historical copy/backfill cannot write computed columns +00:57 +198: loading test/named_sql_test.dart +00:57 +198: test/named_sql_test.dart: template binds repeated names once and preserves quotes/comments +00:57 +199: test/named_sql_test.dart: templates reject ambiguous bindings and incomplete or multiple statements +00:57 +200: test/named_sql_test.dart: generated client and manifest are deterministic and fresh +00:59 +201: test/named_sql_test.dart: sqlite typed parameters, projection and repeated nullable bindings compose +00:59 +202: test/named_sql_test.dart: sqlite native numeric and temporal codecs survive binding and streaming +00:59 +203: test/named_sql_test.dart: sqlite CTEs and unions retain distinct query arguments and field identity +00:59 +204: test/named_sql_test.dart: sqlite transactions see pending writes and rollback normally +00:59 +205: test/named_sql_test.dart: sqlite named sources reject table mutations and require explicit watch reads +00:59 +206: test/named_sql_test.dart: sqlite native checks accept declared shape and preserve prepared-statement state +01:00 +207: test/named_sql_test.dart: sqlite native checks reject syntax, missing tables and missing result aliases +01:00 +208: test/named_sql_test.dart: sqlite native checks do not evaluate application expressions +01:00 +209: test/named_sql_test.dart: postgres typed parameters, projection and repeated nullable bindings compose +01:00 +210: test/named_sql_test.dart: postgres native numeric and temporal codecs survive binding and streaming +01:00 +211: test/named_sql_test.dart: postgres CTEs and unions retain distinct query arguments and field identity +01:00 +212: test/named_sql_test.dart: postgres transactions see pending writes and rollback normally +01:00 +213: test/named_sql_test.dart: postgres named sources reject table mutations and require explicit watch reads +01:00 +214: test/named_sql_test.dart: postgres native checks accept declared shape and preserve prepared-statement state +01:00 +215: test/named_sql_test.dart: postgres native checks reject syntax, missing tables and missing result aliases +01:00 +216: test/named_sql_test.dart: postgres native checks do not evaluate application expressions +01:00 +217: loading test/named_sql_generation_test.dart +01:00 +217: test/named_sql_generation_test.dart: source contracts reject missing/extra parameters and row metadata +01:04 +218: test/named_sql_generation_test.dart: SQL edits and modified generated code fail freshness checks +01:05 +219: test/named_sql_generation_test.dart: asset builder and standalone generator emit identical SQL contracts +01:06 +220: test/named_sql_generation_test.dart: generated APIs reject missing/wrong parameters, result types and unsupported backends +01:07 +221: test/named_sql_generation_test.dart: CLI checks a persistent SQLite database without creating missing files +01:25 +222: loading test/decimal_average_test.dart +01:25 +222: test/decimal_average_test.dart: integer fractions round once and only bound their final result +01:26 +223: test/decimal_average_test.dart: decimal average sqlite empty null and constant inputs retain aggregate semantics +01:26 +224: test/decimal_average_test.dart: decimal average sqlite all rounding modes match exact signed sums across scales +01:26 +225: test/decimal_average_test.dart: decimal average sqlite valid means survive sums exceeding the finite numeric range +01:28 +226: test/decimal_average_test.dart: decimal average sqlite minimum fractions and outermost rounding scales stay exact +01:28 +227: test/decimal_average_test.dart: decimal average sqlite grouping HAVING ordering and CTE references preserve the mean +01:28 +228: test/decimal_average_test.dart: decimal average sqlite illegal window nesting and aggregate assignments fail before SQL +01:28 +229: test/decimal_average_test.dart: decimal average sqlite windows and rounded window means respect partitions and pagination +01:28 +230: test/decimal_average_test.dart: decimal average sqlite correlated means can update values and failures roll back transactions +01:28 +231: test/decimal_average_test.dart: decimal average sqlite batched relationship windows keep per-parent limits +01:28 +232: test/decimal_average_test.dart: decimal average sqlite sliding windows remove values, reset empty frames and reject varying policies +01:28 +233: test/decimal_average_test.dart: decimal average sqlite window means of grouped sums run after HAVING +01:28 +234: test/decimal_average_test.dart: decimal average postgres empty null and constant inputs retain aggregate semantics +01:28 +235: test/decimal_average_test.dart: decimal average postgres all rounding modes match exact signed sums across scales +01:28 +236: test/decimal_average_test.dart: decimal average postgres valid means survive sums exceeding the finite numeric range +01:29 +237: test/decimal_average_test.dart: decimal average postgres minimum fractions and outermost rounding scales stay exact +01:29 +238: test/decimal_average_test.dart: decimal average postgres grouping HAVING ordering and CTE references preserve the mean +01:29 +239: test/decimal_average_test.dart: decimal average postgres illegal window nesting and aggregate assignments fail before SQL +01:29 +240: test/decimal_average_test.dart: decimal average postgres windows and rounded window means respect partitions and pagination +01:29 +241: test/decimal_average_test.dart: decimal average postgres correlated means can update values and failures roll back transactions +01:29 +242: test/decimal_average_test.dart: decimal average postgres batched relationship windows keep per-parent limits +01:29 +243: test/decimal_average_test.dart: decimal average postgres window means of grouped sums run after HAVING +01:29 +244: test/decimal_average_test.dart: decimal average postgres volatile inputs and window ordering are evaluated once per row +01:29 +245: loading test/instant_test.dart +01:29 +245: test/instant_test.dart: strict UTC parsing handles eras, offsets, endpoints and invalid components +01:29 +246: test/instant_test.dart: UTC defaults and local DateTime inputs do not depend on the process timezone +01:30 +247: test/instant_test.dart: historical migration checksum and timestamp tag remain immutable +01:30 +248: test/instant_test.dart: UTC instant sqlite native round trips and streams preserve exact microseconds and common endpoints +01:30 +249: test/instant_test.dart: UTC instant sqlite chronological comparisons, grouping, windows, union and CTEs use instants +01:30 +250: test/instant_test.dart: UTC instant sqlite offset-equivalent keys join and batch through the same unique index +01:30 +251: test/instant_test.dart: UTC instant sqlite current snapshots, physical catalog and imported DateTime declarations agree +01:32 +252: test/instant_test.dart: UTC instant sqlite offset literals compare by value while expression casts remain visible +01:32 +253: test/instant_test.dart: UTC instant sqlite reviewed migration preserves history and repairs legacy timestamp ordering +01:32 +254: test/instant_test.dart: UTC instant sqlite legacy key collisions and invalid calendar values roll back conversion +01:32 +255: test/instant_test.dart: UTC instant sqlite instant primary-key backfills resume across sub-millisecond boundaries +01:32 +256: test/instant_test.dart: UTC instant sqlite external infinity and out-of-DateTime values fail decoding without wrapping +01:32 +257: test/instant_test.dart: UTC instant postgres native round trips and streams preserve exact microseconds and common endpoints +01:32 +258: test/instant_test.dart: UTC instant postgres chronological comparisons, grouping, windows, union and CTEs use instants +01:32 +259: test/instant_test.dart: UTC instant postgres offset-equivalent keys join and batch through the same unique index +01:32 +260: test/instant_test.dart: UTC instant postgres current snapshots, physical catalog and imported DateTime declarations agree +01:33 +261: test/instant_test.dart: UTC instant postgres offset literals compare by value while expression casts remain visible +01:33 +262: test/instant_test.dart: UTC instant postgres reviewed migration preserves history and repairs legacy timestamp ordering +01:33 +263: test/instant_test.dart: UTC instant postgres instant primary-key backfills resume across sub-millisecond boundaries +01:33 +264: test/instant_test.dart: UTC instant postgres external infinity and out-of-DateTime values fail decoding without wrapping +01:33 +265: loading test/codec_test.dart +01:33 +265: test/codec_test.dart: numeric aggregate text decodes without integer rounding or overflow +01:33 +266: test/codec_test.dart: erased codecs keep checked argument types without function getter casts +01:33 +267: test/codec_test.dart: mapped domain codecs preserve one decode and one encode boundary +01:33 +268: test/codec_test.dart: parsed JSON scalars and document presence have unambiguous decoding +01:33 +269: test/codec_test.dart: enum mappings reject duplicates and do not follow mutable caller maps +01:33 +270: loading test/sql_real_test.dart +01:33 +270: test/sql_real_test.dart: sqlite explicitly typed REAL parameters preserve floating storage +01:33 +271: test/sql_real_test.dart: postgres explicitly typed REAL parameters preserve floating storage +01:33 +272: loading test/generator_test.dart +01:33 +272: test/generator_test.dart: (setUpAll) +01:33 +272: test/generator_test.dart: record annotations, physical names, relations and snapshots +01:35 +273: test/generator_test.dart: output is deterministic and matches the committed generated client +01:40 +274: test/generator_test.dart: composite keys and more than six fields generate analyzable code +01:40 +275: test/generator_test.dart: computed keys are rejected instead of executing a fake row +01:41 +276: test/generator_test.dart: computed declarations reject conflicting defaults, empty SQL and invalid row identities +01:43 +277: test/generator_test.dart: client defaults reject incompatible, asynchronous, private and ambiguous factories +01:45 +278: test/generator_test.dart: CHECK declarations reject dynamic SQL, empty overrides and duplicate names +01:47 +279: test/generator_test.dart: custom types resolve defining libraries when output moves to another directory +01:47 +280: test/generator_test.dart: nested generic and record types preserve nullable aliases without running codecs +01:48 +281: test/generator_test.dart: invalid custom declaration: wrong_codec +01:48 +282: test/generator_test.dart: invalid custom declaration: nullable_codec +01:48 +283: test/generator_test.dart: invalid custom declaration: erased_extension +01:48 +284: test/generator_test.dart: invalid custom declaration: unknown_storage +01:49 +285: test/generator_test.dart: invalid custom declaration: private_codec +01:49 +286: test/generator_test.dart: invalid custom declaration: private_type +01:49 +287: test/generator_test.dart: invalid custom declaration: inline_codec +01:49 +288: test/generator_test.dart: invalid custom declaration: duplicate_codec +01:50 +289: test/generator_test.dart: invalid custom declaration: missing_codec +01:50 +290: test/generator_test.dart: invalid custom declaration: duplicate_labels +01:50 +291: test/generator_test.dart: invalid custom declaration: duplicate_enum_annotation +01:50 +292: test/generator_test.dart: foreign keys require unique targets +01:51 +293: test/generator_test.dart: query-only edges do not alter physical snapshots or require unique targets +01:51 +294: test/generator_test.dart: constrained and unconstrained edges retain separate migration meaning +01:51 +295: test/generator_test.dart: invalid query-only relation: lookup_computed +01:51 +296: test/generator_test.dart: invalid query-only relation: lookup_codec +01:52 +297: test/generator_test.dart: invalid query-only relation: lookup_inverse +01:52 +298: test/generator_test.dart: invalid query-only relation: lookup_type +01:52 +299: test/generator_test.dart: invalid query-only relation: lookup_delete +01:52 +300: test/generator_test.dart: Dart type errors stop generation +01:53 +301: test/generator_test.dart: duplicate physical names and nullable primary keys are rejected +01:53 +302: test/generator_test.dart: (tearDownAll) +01:53 +302: loading test/transaction_test.dart +01:53 +302: test/transaction_test.dart: transaction control sqlite idle callback expires, rolls back and cannot issue late SQL +01:53 +303: test/transaction_test.dart: transaction control sqlite deadline interrupts active SQL before releasing and reusing the lease +01:53 +304: test/transaction_test.dart: transaction control sqlite interrupted write rolls back and leaves the connection usable +01:53 +305: test/transaction_test.dart: transaction control sqlite explicit transaction cancellation stops an idle callback and active SQL +01:54 +306: test/transaction_test.dart: transaction control sqlite global transaction cancellation also abandons acquisition +01:54 +307: test/transaction_test.dart: transaction control sqlite savepoint inherits deadline, drains and rolls back through raw cleanup +01:54 +308: test/transaction_test.dart: transaction control sqlite deadline closes a paused transactional cursor before rollback +01:54 +309: test/transaction_test.dart: transaction control sqlite deadline interrupts an active transactional cursor fetch +01:54 +310: test/transaction_test.dart: transaction control sqlite transaction clock starts after acquisition and a borrowed session recovers +01:54 +311: test/transaction_test.dart: transaction control sqlite failed rollback is explicit and discards an uncertain connection +01:54 +312: test/transaction_test.dart: transaction control sqlite transaction controls reject adapters without actual cancellation +01:54 +313: test/transaction_test.dart: transaction control sqlite shorter statement limit retains its error and rolls back the transaction +01:54 +314: test/transaction_test.dart: transaction control sqlite monotonic deadline prevents commit after a CPU-bound callback +01:54 +315: test/transaction_test.dart: transaction control sqlite completed scopes remove their timers and cancellation listeners +01:54 +316: test/transaction_test.dart: transaction control sqlite confirmed commit wins a cancellation race; lost acknowledgement stays unknown +01:54 +317: test/transaction_test.dart: transaction control sqlite deferred constraint rejection at COMMIT is a known failure +01:54 +318: test/transaction_test.dart: transaction control sqlite invalid deadlines and already cancelled tokens never begin a transaction +01:54 +319: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=false +01:54 +320: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=true +01:54 +321: test/transaction_test.dart: transaction control postgres idle callback expires, rolls back and cannot issue late SQL +01:55 +322: test/transaction_test.dart: transaction control postgres deadline interrupts active SQL before releasing and reusing the lease +01:55 +323: test/transaction_test.dart: transaction control postgres interrupted write rolls back and leaves the connection usable +01:55 +324: test/transaction_test.dart: transaction control postgres explicit transaction cancellation stops an idle callback and active SQL +01:55 +325: test/transaction_test.dart: transaction control postgres global transaction cancellation also abandons acquisition +01:55 +326: test/transaction_test.dart: transaction control postgres savepoint inherits deadline, drains and rolls back through raw cleanup +01:55 +327: test/transaction_test.dart: transaction control postgres deadline closes a paused transactional cursor before rollback +01:55 +328: test/transaction_test.dart: transaction control postgres deadline interrupts an active transactional cursor fetch +01:55 +329: test/transaction_test.dart: transaction control postgres transaction clock starts after acquisition and a borrowed session recovers +01:56 +330: test/transaction_test.dart: transaction control postgres failed rollback is explicit and discards an uncertain connection +01:56 +331: test/transaction_test.dart: transaction control postgres transaction controls reject adapters without actual cancellation +01:56 +332: test/transaction_test.dart: transaction control postgres shorter statement limit retains its error and rolls back the transaction +01:56 +333: test/transaction_test.dart: transaction control postgres monotonic deadline prevents commit after a CPU-bound callback +01:56 +334: test/transaction_test.dart: transaction control postgres completed scopes remove their timers and cancellation listeners +01:56 +335: test/transaction_test.dart: transaction control postgres confirmed commit wins a cancellation race; lost acknowledgement stays unknown +01:56 +336: test/transaction_test.dart: transaction control postgres deferred constraint rejection at COMMIT is a known failure +01:56 +337: test/transaction_test.dart: transaction control postgres invalid deadlines and already cancelled tokens never begin a transaction +01:56 +338: test/transaction_test.dart: transaction control postgres SQLSTATE 40003 at COMMIT is never classified as a confirmed rejection +01:56 +339: test/transaction_test.dart: transaction control postgres real PostgreSQL serialization failure is classified after rollback +01:56 +340: test/transaction_test.dart: transaction control postgres real PostgreSQL deadlock rolls back exactly one transaction +01:57 +341: test/transaction_test.dart: SQLite COMMIT busy is a known rejection followed by verified rollback +01:57 +342: test/transaction_test.dart: SQLite deadline while BEGIN waits for a writer preserves its connection +01:57 +343: loading test/backfill_keys_test.dart +01:57 +343: test/backfill_keys_test.dart: backfill keys sqlite integer cursors preserve native values across resume +01:58 +344: test/backfill_keys_test.dart: backfill keys sqlite bigint cursors preserve native values across resume +01:58 +345: test/backfill_keys_test.dart: backfill keys sqlite text cursors preserve native values across resume +01:58 +346: test/backfill_keys_test.dart: backfill keys sqlite real cursors preserve native values across resume +01:58 +347: test/backfill_keys_test.dart: backfill keys sqlite boolean cursors preserve native values across resume +01:58 +348: test/backfill_keys_test.dart: backfill keys sqlite timestamp cursors preserve native values across resume +01:58 +349: test/backfill_keys_test.dart: backfill keys sqlite blob cursors preserve native values across resume +01:58 +350: test/backfill_keys_test.dart: backfill keys sqlite composite key batches account for every bound parameter +01:58 +351: test/backfill_keys_test.dart: backfill keys postgres integer cursors preserve native values across resume +01:58 +352: test/backfill_keys_test.dart: backfill keys postgres bigint cursors preserve native values across resume +01:58 +353: test/backfill_keys_test.dart: backfill keys postgres text cursors preserve native values across resume +01:58 +354: test/backfill_keys_test.dart: backfill keys postgres real cursors preserve native values across resume +01:58 +355: test/backfill_keys_test.dart: backfill keys postgres boolean cursors preserve native values across resume +01:58 +356: test/backfill_keys_test.dart: backfill keys postgres timestamp cursors preserve native values across resume +01:58 +357: test/backfill_keys_test.dart: backfill keys postgres blob cursors preserve native values across resume +01:58 +358: test/backfill_keys_test.dart: backfill keys postgres composite key batches account for every bound parameter +01:58 +359: test/backfill_keys_test.dart: invalid key, assignments and completion declarations fail before execution +01:58 +360: loading test/retry_test.dart +01:58 +360: test/retry_test.dart: retry sqlite opt-in retries roll back all writes and use new closed-over views +01:58 +361: test/retry_test.dart: retry sqlite default is one attempt; an explicit attempt limit is exact +01:58 +362: test/retry_test.dart: retry sqlite application and constraint errors and caught statement failures do not replay +01:58 +363: test/retry_test.dart: retry sqlite unknown commit and unconfirmed rollback prohibit replay +01:58 +364: test/retry_test.dart: retry sqlite retry time budget bounds active SQL and does not retry cancellation +01:58 +365: test/retry_test.dart: retry sqlite deadline and cancellation interrupt backoff without another callback +01:58 +366: test/retry_test.dart: retry sqlite time budget includes acquisition and abandons late callbacks +01:59 +367: test/retry_test.dart: retry sqlite transaction timeout is shared across attempts and sessions recover after expiry +01:59 +368: test/retry_test.dart: retry sqlite subscriptions publish only the final committed retry result +01:59 +369: test/retry_test.dart: retry sqlite invalid policies reject before SQL +01:59 +370: test/retry_test.dart: retry postgres opt-in retries roll back all writes and use new closed-over views +01:59 +371: test/retry_test.dart: retry postgres default is one attempt; an explicit attempt limit is exact +01:59 +372: test/retry_test.dart: retry postgres application and constraint errors and caught statement failures do not replay +01:59 +373: test/retry_test.dart: retry postgres unknown commit and unconfirmed rollback prohibit replay +01:59 +374: test/retry_test.dart: retry postgres retry time budget bounds active SQL and does not retry cancellation +01:59 +375: test/retry_test.dart: retry postgres deadline and cancellation interrupt backoff without another callback +01:59 +376: test/retry_test.dart: retry postgres time budget includes acquisition and abandons late callbacks +01:59 +377: test/retry_test.dart: retry postgres transaction timeout is shared across attempts and sessions recover after expiry +01:59 +378: test/retry_test.dart: retry postgres subscriptions publish only the final committed retry result +01:59 +379: test/retry_test.dart: retry postgres invalid policies reject before SQL +01:59 +380: test/retry_test.dart: retry postgres real unknown COMMIT response prohibits opted-in retry +01:59 +381: test/retry_test.dart: retry postgres real serialization conflict repeats the reads and decisions +01:59 +382: test/retry_test.dart: retry postgres real deadlock retries only the aborted callback +02:00 +383: test/retry_test.dart: SQLite WAL snapshot conflict replays reads after rollback +02:00 +384: test/retry_test.dart: SQLite real busy COMMIT: success +02:00 +385: test/retry_test.dart: SQLite real busy COMMIT: exhausted +02:01 +386: test/retry_test.dart: SQLite real busy COMMIT: deadline +02:01 +387: test/retry_test.dart: SQLite real busy COMMIT: cancel +02:01 +388: test/retry_test.dart: SQLite real busy COMMIT: shared-budget +02:01 +389: loading test/builder_test.dart +02:01 +389: test/builder_test.dart: build assets and standalone CLI produce identical client and snapshot +02:04 +390: test/builder_test.dart: multiple explicit schema roots resolve shared public types under lib +02:04 +391: test/builder_test.dart: invalid semantic_error never publishes partial outputs +02:05 +392: test/builder_test.dart: invalid enum_labels never publishes partial outputs +02:06 +393: test/builder_test.dart: invalid no_models never publishes partial outputs +02:06 +394: test/builder_test.dart: invalid part_file never publishes partial outputs +02:07 +395: test/builder_test.dart: unknown options fail instead of silently changing build behavior +02:07 +396: test/builder_test.dart: schema resolution can consume an earlier builder output +02:08 +397: loading test/temporal_precision_test.dart +02:08 +397: test/temporal_precision_test.dart: temporal precision sqlite generated writes, defaults and computed columns use database precision +02:08 +398: test/temporal_precision_test.dart: temporal precision sqlite SQL casts match all precisions on both sides of the PostgreSQL epoch +02:08 +399: test/temporal_precision_test.dart: temporal precision sqlite nulls, CTE references, grouping, streams and wrong scopes retain type semantics +02:08 +400: test/temporal_precision_test.dart: temporal precision sqlite rounded keys work in batch writes, upserts and foreign-key relation loading +02:08 +401: test/temporal_precision_test.dart: temporal precision sqlite catalog import preserves precision, defaults and computed expressions +02:10 +402: test/temporal_precision_test.dart: temporal precision sqlite narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +02:10 +403: test/temporal_precision_test.dart: temporal precision sqlite timestamp defaults and explicit six-digit catalogs retain precision +02:10 +404: test/temporal_precision_test.dart: temporal precision sqlite extended timestamps round in SQL without total-microsecond overflow +02:10 +405: test/temporal_precision_test.dart: temporal precision sqlite raw SQL respects native or managed precision constraints +02:10 +406: test/temporal_precision_test.dart: temporal precision postgres generated writes, defaults and computed columns use database precision +02:10 +407: test/temporal_precision_test.dart: temporal precision postgres SQL casts match all precisions on both sides of the PostgreSQL epoch +02:10 +408: test/temporal_precision_test.dart: temporal precision postgres nulls, CTE references, grouping, streams and wrong scopes retain type semantics +02:10 +409: test/temporal_precision_test.dart: temporal precision postgres rounded keys work in batch writes, upserts and foreign-key relation loading +02:10 +410: test/temporal_precision_test.dart: temporal precision postgres catalog import preserves precision, defaults and computed expressions +02:11 +411: test/temporal_precision_test.dart: temporal precision postgres narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +02:11 +412: test/temporal_precision_test.dart: temporal precision postgres timestamp defaults and explicit six-digit catalogs retain precision +02:11 +413: test/temporal_precision_test.dart: temporal precision postgres extended timestamps round in SQL without total-microsecond overflow +02:11 +414: test/temporal_precision_test.dart: temporal precision postgres raw SQL respects native or managed precision constraints +02:11 +415: test/temporal_precision_test.dart: value rounding keeps extended ranges and rejects overflow/invalid precision +02:11 +416: test/temporal_precision_test.dart: default and explicit six preserve historical snapshots and migration checksums +02:11 +417: test/temporal_precision_test.dart: generator rejects invalid annotations and preserves generated fixture metadata +02:13 +418: loading test/observation_test.dart +02:13 +418: test/observation_test.dart: observation sqlite actual lease queue and synchronous mapping have separate clocks +02:13 +419: test/observation_test.dart: observation sqlite expired acquisition emits one failure and no late SQL +02:13 +420: test/observation_test.dart: observation sqlite cancelled acquisition emits one failure and no late SQL +02:13 +421: test/observation_test.dart: observation sqlite sessions transactions and savepoints inherit hooks and report reused leases +02:13 +422: test/observation_test.dart: observation sqlite nested reads attribute decoding to each statement without repeating SQL +02:13 +423: test/observation_test.dart: observation sqlite SQL and mapper failures belong to their own phases +02:13 +424: test/observation_test.dart: observation sqlite cursor batches and returning writes produce decoding observations +02:13 +425: test/observation_test.dart: observation sqlite observer exceptions preserve successful commits and original mapper errors +02:13 +426: test/observation_test.dart: observation postgres actual lease queue and synchronous mapping have separate clocks +02:14 +427: test/observation_test.dart: observation postgres expired acquisition emits one failure and no late SQL +02:14 +428: test/observation_test.dart: observation postgres cancelled acquisition emits one failure and no late SQL +02:14 +429: test/observation_test.dart: observation postgres sessions transactions and savepoints inherit hooks and report reused leases +02:14 +430: test/observation_test.dart: observation postgres nested reads attribute decoding to each statement without repeating SQL +02:14 +431: test/observation_test.dart: observation postgres SQL and mapper failures belong to their own phases +02:14 +432: test/observation_test.dart: observation postgres cursor batches and returning writes produce decoding observations +02:14 +433: test/observation_test.dart: observation postgres observer exceptions preserve successful commits and original mapper errors +02:14 +434: test/observation_test.dart: synchronous driver acquisition failures are reported once +02:14 +435: loading test/integer_test.dart +02:14 +435: test/integer_test.dart: integer widths sqlite storage bounds, nulls and wider sums keep the normal integer value codec +02:14 +436: test/integer_test.dart: integer widths sqlite relations, union and cursors share int expressions across physical widths +02:14 +437: test/integer_test.dart: integer widths sqlite defaulted and quoted columns retain enforced nullable bounds +02:14 +438: test/integer_test.dart: integer widths sqlite JSON history and imports retain widths without confusing value codecs +02:16 +439: test/integer_test.dart: integer widths sqlite widening and narrowing use reviewed conversions with atomic failure recovery +02:16 +440: test/integer_test.dart: integer widths sqlite column renames preserve width metadata and range constraints +02:16 +441: test/integer_test.dart: integer widths sqlite historical bounded backfills verify widths and resume normally +02:16 +442: test/integer_test.dart: integer widths sqlite changing an identity width preserves generation and rejects out-of-range explicit IDs +02:16 +443: test/integer_test.dart: integer widths sqlite quoted defaults and comments cannot masquerade as managed range checks +02:16 +444: test/integer_test.dart: integer widths sqlite extra weaker expressions remain unmanaged and cannot claim a narrower width +02:16 +445: test/integer_test.dart: integer widths postgres storage bounds, nulls and wider sums keep the normal integer value codec +02:16 +446: test/integer_test.dart: integer widths postgres relations, union and cursors share int expressions across physical widths +02:16 +447: test/integer_test.dart: integer widths postgres defaulted and quoted columns retain enforced nullable bounds +02:16 +448: test/integer_test.dart: integer widths postgres JSON history and imports retain widths without confusing value codecs +02:17 +449: test/integer_test.dart: integer widths postgres widening and narrowing use reviewed conversions with atomic failure recovery +02:17 +450: test/integer_test.dart: integer widths postgres column renames preserve width metadata and range constraints +02:17 +451: test/integer_test.dart: integer widths postgres historical bounded backfills verify widths and resume normally +02:17 +452: test/integer_test.dart: integer widths postgres changing an identity width preserves generation and rejects out-of-range explicit IDs +02:17 +453: test/integer_test.dart: analyzer validates width annotations and integer-backed domain codecs +02:18 +454: test/integer_test.dart: width declarations reject wrong kinds and ranges without changing default snapshots +02:18 +455: loading test/relation_test.dart +02:18 +455: test/relation_test.dart: sqlite automatic unique joins preserve root pagination in one statement +02:18 +456: test/relation_test.dart: sqlite all-null projections still distinguish a present row from absence +02:18 +457: test/relation_test.dart: sqlite explicit batch deduplicates composite keys and skips partial nulls +02:18 +458: test/relation_test.dart: sqlite composite chunks include filter and window parameters with nested joins +02:18 +459: test/relation_test.dart: sqlite nested self joins and two edges to the same table use distinct aliases +02:18 +460: test/relation_test.dart: sqlite batch collection below a joined optional parent loads on the same plan +02:18 +461: test/relation_test.dart: sqlite join refuses unproven cardinality while automatic batch checks it +02:18 +462: test/relation_test.dart: sqlite filters and to-one pagination do not drop root rows +02:19 +463: test/relation_test.dart: sqlite joined records survive CTE projection and cursor decoding +02:19 +464: test/relation_test.dart: sqlite required tests row presence independently of nullable selected values +02:19 +465: test/relation_test.dart: sqlite relation aggregates restore the alias used by a joined selection +02:19 +466: test/relation_test.dart: sqlite reusing an identical edge deduplicates its join +02:19 +467: test/relation_test.dart: sqlite different filters need fresh relation occurrences +02:19 +468: test/relation_test.dart: sqlite large composite batches avoid expression-depth failure +02:19 +469: test/relation_test.dart: postgres automatic unique joins preserve root pagination in one statement +02:19 +470: test/relation_test.dart: postgres all-null projections still distinguish a present row from absence +02:19 +471: test/relation_test.dart: postgres explicit batch deduplicates composite keys and skips partial nulls +02:19 +472: test/relation_test.dart: postgres composite chunks include filter and window parameters with nested joins +02:19 +473: test/relation_test.dart: postgres nested self joins and two edges to the same table use distinct aliases +02:19 +474: test/relation_test.dart: postgres batch collection below a joined optional parent loads on the same plan +02:19 +475: test/relation_test.dart: postgres join refuses unproven cardinality while automatic batch checks it +02:19 +476: test/relation_test.dart: postgres filters and to-one pagination do not drop root rows +02:19 +477: test/relation_test.dart: postgres joined records survive CTE projection and cursor decoding +02:19 +478: test/relation_test.dart: postgres required tests row presence independently of nullable selected values +02:19 +479: test/relation_test.dart: postgres relation aggregates restore the alias used by a joined selection +02:19 +480: test/relation_test.dart: postgres reusing an identical edge deduplicates its join +02:19 +481: test/relation_test.dart: postgres different filters need fresh relation occurrences +02:19 +482: test/relation_test.dart: postgres large composite batches avoid expression-depth failure +02:19 +483: loading test/generated_database_test.dart +02:19 +483: test/generated_database_test.dart: (setUpAll) +02:19 +483: test/generated_database_test.dart: generated sqlite generated client, DDL, relations, dates and patches work together +02:19 +484: test/generated_database_test.dart: generated sqlite planning is read-only and repeat migration is a no-op +02:19 +485: test/generated_database_test.dart: generated sqlite snapshot round trip and full managed catalog comparison +02:19 +486: test/generated_database_test.dart: generated sqlite baseline preserves existing rows and verifies keys before recording history +02:19 +487: test/generated_database_test.dart: generated sqlite full verification sees defaults and additional unique constraints +02:19 +488: test/generated_database_test.dart: generated sqlite history tampering and missing applied migrations fail +02:19 +489: test/generated_database_test.dart: generated sqlite failed DDL rolls back schema and history atomically +02:19 +490: test/generated_database_test.dart: generated sqlite actual column drift is detected independently of history +02:19 +491: test/generated_database_test.dart: generated sqlite transaction control cannot be hidden behind SQL comments +02:19 +492: test/generated_database_test.dart: generated sqlite concurrent migration callers serialize against shared history +02:19 +493: test/generated_database_test.dart: generated postgres generated client, DDL, relations, dates and patches work together +02:19 +494: test/generated_database_test.dart: generated postgres planning is read-only and repeat migration is a no-op +02:19 +495: test/generated_database_test.dart: generated postgres snapshot round trip and full managed catalog comparison +02:19 +496: test/generated_database_test.dart: generated postgres baseline preserves existing rows and verifies keys before recording history +02:19 +497: test/generated_database_test.dart: generated postgres full verification sees defaults and additional unique constraints +02:19 +498: test/generated_database_test.dart: generated postgres history tampering and missing applied migrations fail +02:19 +499: test/generated_database_test.dart: generated postgres failed DDL rolls back schema and history atomically +02:19 +500: test/generated_database_test.dart: generated postgres actual column drift is detected independently of history +02:19 +501: test/generated_database_test.dart: generated postgres transaction control cannot be hidden behind SQL comments +02:19 +502: test/generated_database_test.dart: generated postgres concurrent migration callers serialize against shared history +02:19 +503: test/generated_database_test.dart: (tearDownAll) +02:19 +503: loading test/import_test.dart +02:20 +503: test/import_test.dart: catalog import sqlite imports records, identities, defaults and constraints then baselines unchanged rows +02:25 +504: test/import_test.dart: catalog import sqlite normalizes reserved, colliding and hostile identifiers without changing physical names +02:26 +505: test/import_test.dart: catalog import sqlite composite and self relations preserve key order and unique indexes +02:27 +506: test/import_test.dart: catalog import sqlite unsupported storage excludes its table while computed SQL imports with a review note +02:27 +507: test/import_test.dart: catalog import sqlite read-only snapshot and selected scope reject temporary shadowing and active transactions +02:27 +508: test/import_test.dart: catalog import sqlite physical storage mapping does not infer domain types from data +02:28 +509: test/import_test.dart: catalog import sqlite views triggers and specialized indexes remain in the review report +02:28 +510: test/import_test.dart: catalog import sqlite nullable primary keys, virtual tables and their storage tables need explicit handling +02:28 +511: test/import_test.dart: catalog import sqlite foreign keys with coercible but different Dart types are reported +02:28 +512: test/import_test.dart: catalog import postgres imports records, identities, defaults and constraints then baselines unchanged rows +02:30 +513: test/import_test.dart: catalog import postgres normalizes reserved, colliding and hostile identifiers without changing physical names +02:30 +514: test/import_test.dart: catalog import postgres composite and self relations preserve key order and unique indexes +02:31 +515: test/import_test.dart: catalog import postgres unsupported storage excludes its table while computed SQL imports with a review note +02:31 +516: test/import_test.dart: catalog import postgres read-only snapshot and selected scope reject temporary shadowing and active transactions +02:31 +517: test/import_test.dart: catalog import postgres physical storage mapping does not infer domain types from data +02:32 +518: test/import_test.dart: catalog import postgres views triggers and specialized indexes remain in the review report +02:32 +519: test/import_test.dart: catalog import postgres policy reports retain roles, commands, mode and row-security state +02:32 +520: test/import_test.dart: catalog import postgres always identity and inherited tables are not flattened into ordinary writable models +02:32 +521: loading test/database_test.dart +02:32 +521: test/database_test.dart: sqlite typed CRUD, exact projection and parameter binding +02:32 +522: test/database_test.dart: sqlite record and DTO mapping run only when decoding +02:32 +523: test/database_test.dart: sqlite immutable filters, empty IN, null and pagination +02:32 +524: test/database_test.dart: sqlite cross-table identity and duplicate assignments rejected before IO +02:32 +525: test/database_test.dart: sqlite commit, rollback, savepoint recovery and escaped session +02:33 +526: test/database_test.dart: sqlite caught statement failures cannot masquerade as a commit +02:33 +527: test/database_test.dart: sqlite recoverable constraint errors stay within their savepoint +02:33 +528: test/database_test.dart: sqlite unawaited work is drained and rolled back +02:33 +529: test/database_test.dart: sqlite close waits for active operations and rejects further use +02:33 +530: test/database_test.dart: sqlite leased sessions retain one connection across transactions and expire +02:33 +531: test/database_test.dart: sqlite native upsert updates atomically using existing and incoming values +02:33 +532: test/database_test.dart: sqlite bulk inserts split at parameter limits and opt into returning +02:33 +533: test/database_test.dart: sqlite a failed later bulk chunk rolls back all previous chunks +02:33 +534: test/database_test.dart: sqlite bulk optional field shapes preserve defaults and explicit null +02:33 +535: test/database_test.dart: sqlite relations CTEs preserve SQL column identity through mapped projections +02:33 +536: test/database_test.dart: sqlite relations explicit aliases support multiple joins and self joins +02:33 +537: test/database_test.dart: sqlite relations left join distinguishes a missing row from an all-null selection +02:33 +538: test/database_test.dart: sqlite relations correlated scalar, IN and EXISTS use nested SQL scopes +02:33 +539: test/database_test.dart: sqlite relations grouping is checked and window functions preserve row cardinality +02:33 +540: test/database_test.dart: sqlite relations root pagination and each parent limit use two queries +02:33 +541: test/database_test.dart: sqlite relations empty roots skip child queries; empty lists remain typed lists +02:33 +542: test/database_test.dart: sqlite relations nested relation projections and optional all-null projection +02:33 +543: test/database_test.dart: sqlite relations correlated any, none, count and every with SQL nulls +02:33 +544: test/database_test.dart: sqlite relations relation parameters bind correctly in projection and filter +02:33 +545: test/database_test.dart: sqlite keyset pagination uses a unique tie breaker in mixed directions +02:33 +546: test/database_test.dart: sqlite keyset pagination handles null order and validates versioned tokens +02:33 +547: test/database_test.dart: postgres typed CRUD, exact projection and parameter binding +02:33 +548: test/database_test.dart: postgres record and DTO mapping run only when decoding +02:33 +549: test/database_test.dart: postgres immutable filters, empty IN, null and pagination +02:33 +550: test/database_test.dart: postgres cross-table identity and duplicate assignments rejected before IO +02:33 +551: test/database_test.dart: postgres commit, rollback, savepoint recovery and escaped session +02:33 +552: test/database_test.dart: postgres caught statement failures cannot masquerade as a commit +02:33 +553: test/database_test.dart: postgres recoverable constraint errors stay within their savepoint +02:33 +554: test/database_test.dart: postgres unawaited work is drained and rolled back +02:33 +555: test/database_test.dart: postgres close waits for active operations and rejects further use +02:33 +556: test/database_test.dart: postgres leased sessions retain one connection across transactions and expire +02:33 +557: test/database_test.dart: postgres native upsert updates atomically using existing and incoming values +02:33 +558: test/database_test.dart: postgres bulk inserts split at parameter limits and opt into returning +02:33 +559: test/database_test.dart: postgres a failed later bulk chunk rolls back all previous chunks +02:33 +560: test/database_test.dart: postgres bulk optional field shapes preserve defaults and explicit null +02:33 +561: test/database_test.dart: postgres relations CTEs preserve SQL column identity through mapped projections +02:33 +562: test/database_test.dart: postgres relations explicit aliases support multiple joins and self joins +02:33 +563: test/database_test.dart: postgres relations left join distinguishes a missing row from an all-null selection +02:33 +564: test/database_test.dart: postgres relations correlated scalar, IN and EXISTS use nested SQL scopes +02:33 +565: test/database_test.dart: postgres relations grouping is checked and window functions preserve row cardinality +02:33 +566: test/database_test.dart: postgres relations root pagination and each parent limit use two queries +02:33 +567: test/database_test.dart: postgres relations empty roots skip child queries; empty lists remain typed lists +02:33 +568: test/database_test.dart: postgres relations nested relation projections and optional all-null projection +02:33 +569: test/database_test.dart: postgres relations correlated any, none, count and every with SQL nulls +02:33 +570: test/database_test.dart: postgres relations relation parameters bind correctly in projection and filter +02:33 +571: test/database_test.dart: postgres keyset pagination uses a unique tie breaker in mixed directions +02:33 +572: test/database_test.dart: postgres keyset pagination handles null order and validates versioned tokens +02:33 +573: loading test/union_test.dart +02:33 +573: test/union_test.dart: union sqlite SQL duplicate and NULL semantics use one statement +02:33 +574: test/union_test.dart: union sqlite heterogeneous records map only after SQL union and final pagination +02:33 +575: test/union_test.dart: union sqlite operand limits, order, parameters and final filter stay scoped +02:33 +576: test/union_test.dart: union sqlite repeated SQL expressions preserve positional columns +02:33 +577: test/union_test.dart: union sqlite nested sets preserve association, CTE exports and streaming +02:33 +578: test/union_test.dart: union sqlite same CTE names in distinct operands stay in separate scopes +02:33 +579: test/union_test.dart: union sqlite SQL scalar, IN subqueries, aggregate and zero-row results compose +02:33 +580: test/union_test.dart: union sqlite grouped branches and window branches remain SQL queries +02:33 +581: test/union_test.dart: union sqlite bound constant projections retain Dart types +02:33 +582: test/union_test.dart: union sqlite Dart maps and relation selections are rejected before execution +02:33 +583: test/union_test.dart: union sqlite codecs and nullability cannot silently change across operands +02:33 +584: test/union_test.dart: union sqlite shared domain codecs work, and nullable wrappers retain decoding semantics +02:33 +585: test/union_test.dart: union sqlite export references and outer join nullability are guarded +02:33 +586: test/union_test.dart: union sqlite compound mutations and mixed sessions fail before SQL +02:33 +587: test/union_test.dart: union sqlite all record arities use typed SQL decoding +02:33 +588: test/union_test.dart: union sqlite subscription observes committed changes in both operands +02:33 +589: test/union_test.dart: union postgres SQL duplicate and NULL semantics use one statement +02:33 +590: test/union_test.dart: union postgres heterogeneous records map only after SQL union and final pagination +02:33 +591: test/union_test.dart: union postgres operand limits, order, parameters and final filter stay scoped +02:33 +592: test/union_test.dart: union postgres repeated SQL expressions preserve positional columns +02:33 +593: test/union_test.dart: union postgres nested sets preserve association, CTE exports and streaming +02:33 +594: test/union_test.dart: union postgres same CTE names in distinct operands stay in separate scopes +02:33 +595: test/union_test.dart: union postgres SQL scalar, IN subqueries, aggregate and zero-row results compose +02:33 +596: test/union_test.dart: union postgres grouped branches and window branches remain SQL queries +02:33 +597: test/union_test.dart: union postgres bound constant projections retain Dart types +02:33 +598: test/union_test.dart: union postgres Dart maps and relation selections are rejected before execution +02:33 +599: test/union_test.dart: union postgres codecs and nullability cannot silently change across operands +02:33 +600: test/union_test.dart: union postgres shared domain codecs work, and nullable wrappers retain decoding semantics +02:33 +601: test/union_test.dart: union postgres export references and outer join nullability are guarded +02:33 +602: test/union_test.dart: union postgres compound mutations and mixed sessions fail before SQL +02:33 +603: test/union_test.dart: union postgres all record arities use typed SQL decoding +02:33 +604: test/union_test.dart: union postgres subscription observes committed changes in both operands +02:33 +605: loading test/import_cli_test.dart +02:34 +605: test/import_cli_test.dart: CLI sqlite imports reviews generates and baselines existing data without replacing files +03:17 +606: test/import_cli_test.dart: CLI postgres imports reviews generates and baselines existing data without replacing files +03:54 +607: loading test/decimal_division_test.dart +03:55 +607: test/decimal_division_test.dart: decimal division sqlite signed modes and scales match exact integer arithmetic +03:55 +608: test/decimal_division_test.dart: decimal division sqlite exact defaults reject lost digits and zero divisors, NULL propagates +03:55 +609: test/decimal_division_test.dart: decimal division sqlite precision exceeds native division scale without double rounding +03:55 +610: test/decimal_division_test.dart: decimal division sqlite huge remainders avoid overflowing intermediate scaled products +04:16 +611: test/decimal_division_test.dart: decimal division sqlite outermost integer scale and result overflow are explicit +04:17 +612: test/decimal_division_test.dart: decimal division sqlite aggregates windows CTEs predicates and unions compose +04:17 +613: test/decimal_division_test.dart: decimal division sqlite expression assignments and transaction rollback preserve values +04:17 +614: test/decimal_division_test.dart: decimal division sqlite window filtering ordering DISTINCT and pagination keep their SQL order +04:17 +615: test/decimal_division_test.dart: decimal division sqlite windows over grouped aggregates run after HAVING +04:17 +616: test/decimal_division_test.dart: decimal division sqlite window results compose through CTEs sets and correlated subqueries +04:17 +617: test/decimal_division_test.dart: decimal division sqlite batched relations retain per-parent window pagination +04:17 +618: test/decimal_division_test.dart: decimal division postgres signed modes and scales match exact integer arithmetic +04:18 +619: test/decimal_division_test.dart: decimal division postgres exact defaults reject lost digits and zero divisors, NULL propagates +04:18 +620: test/decimal_division_test.dart: decimal division postgres precision exceeds native division scale without double rounding +04:18 +621: test/decimal_division_test.dart: decimal division postgres huge remainders avoid overflowing intermediate scaled products +04:38 +622: test/decimal_division_test.dart: decimal division postgres outermost integer scale and result overflow are explicit +04:38 +623: test/decimal_division_test.dart: decimal division postgres aggregates windows CTEs predicates and unions compose +04:38 +624: test/decimal_division_test.dart: decimal division postgres expression assignments and transaction rollback preserve values +04:38 +625: test/decimal_division_test.dart: decimal division postgres window filtering ordering DISTINCT and pagination keep their SQL order +04:38 +626: test/decimal_division_test.dart: decimal division postgres windows over grouped aggregates run after HAVING +04:38 +627: test/decimal_division_test.dart: decimal division postgres window results compose through CTEs sets and correlated subqueries +04:38 +628: test/decimal_division_test.dart: decimal division postgres batched relations retain per-parent window pagination +04:38 +629: test/decimal_division_test.dart: decimal division postgres volatile operands are evaluated exactly once each +04:38 +630: loading test/cli_test.dart +04:38 +630: test/cli_test.dart: CLI generates checks reviews applies and verifies versioned migration files +05:49 +631: test/cli_test.dart: CLI baselines existing SQLite rows and read-only options reject writes +06:07 +632: test/cli_test.dart: CLI uses PostgreSQL-specific connection configuration for apply and verification +06:48 +633: loading test/decimal_test.dart +06:48 +633: test/decimal_test.dart: exact decimal sqlite generated writes, defaults, patches and projections preserve all digits +06:48 +634: test/decimal_test.dart: exact decimal sqlite numeric ordering, predicates, IN, distinct and stable cursors +06:48 +635: test/decimal_test.dart: exact decimal sqlite sums, min/max, grouped aggregates and sliding windows stay exact +06:48 +636: test/decimal_test.dart: exact decimal sqlite computed CTEs, union results, subqueries and streaming keep decimal semantics +06:48 +637: test/decimal_test.dart: exact decimal sqlite numeric primary keys, foreign keys, joined and batched relations +06:48 +638: test/decimal_test.dart: exact decimal sqlite snapshots, catalog verification and imported declarations retain decimal storage +06:50 +639: test/decimal_test.dart: exact decimal sqlite aggregate intermediate overflow may cancel before a valid final result +06:50 +640: test/decimal_test.dart: exact decimal sqlite historical decimal keys resume backfills without lexical ordering or precision loss +06:50 +641: test/decimal_test.dart: exact decimal sqlite reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +06:50 +642: test/decimal_test.dart: exact decimal sqlite invalid external values fail decoding; later valid queries still work +06:50 +643: test/decimal_test.dart: exact decimal postgres generated writes, defaults, patches and projections preserve all digits +06:50 +644: test/decimal_test.dart: exact decimal postgres numeric ordering, predicates, IN, distinct and stable cursors +06:50 +645: test/decimal_test.dart: exact decimal postgres sums, min/max, grouped aggregates and sliding windows stay exact +06:50 +646: test/decimal_test.dart: exact decimal postgres computed CTEs, union results, subqueries and streaming keep decimal semantics +06:50 +647: test/decimal_test.dart: exact decimal postgres numeric primary keys, foreign keys, joined and batched relations +06:50 +648: test/decimal_test.dart: exact decimal postgres snapshots, catalog verification and imported declarations retain decimal storage +06:51 +649: test/decimal_test.dart: exact decimal postgres aggregate intermediate overflow may cancel before a valid final result +06:51 +650: test/decimal_test.dart: exact decimal postgres historical decimal keys resume backfills without lexical ordering or precision loss +06:51 +651: test/decimal_test.dart: exact decimal postgres reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +06:51 +652: test/decimal_test.dart: exact decimal postgres invalid external values fail decoding; later valid queries still work +06:51 +653: test/decimal_test.dart: decimal SQL rejects drivers without exact operations before execution +06:51 +654: test/decimal_test.dart: SQLite recognizes only column decimal collations, and detects drift +06:51 +655: loading test/watch_test.dart +06:51 +655: test/watch_test.dart: independent native SQLite workers require explicit notification +06:51 +656: test/watch_test.dart: watch sqlite lazy initial snapshot and relevant table invalidation +06:51 +657: test/watch_test.dart: watch sqlite transactions publish one snapshot after commit and none on rollback +06:51 +658: test/watch_test.dart: watch sqlite savepoint changes merge on release and disappear on rollback +06:51 +659: test/watch_test.dart: watch sqlite failed statements, zero-row writes and conflict no-ops do not refresh +06:51 +660: test/watch_test.dart: watch sqlite batch writes and upsert returning publish committed changes +06:51 +661: test/watch_test.dart: watch sqlite joined and nested batch relations track child writes +06:51 +662: test/watch_test.dart: watch sqlite relation predicates, SQL subqueries and CTE sources are dependencies +06:51 +663: test/watch_test.dart: watch sqlite declared cascade deletion invalidates a child-only query +06:51 +664: test/watch_test.dart: watch sqlite CASCADE tracks declared transitive deletion effects +06:51 +665: test/watch_test.dart: watch sqlite SET NULL tracks declared transitive deletion effects +06:51 +666: test/watch_test.dart: watch sqlite explicit JOINs and joined CTE definitions track both physical sources +06:51 +667: test/watch_test.dart: watch sqlite raw SQL declares changed and read tables; explicit invalidation follows transactions +06:51 +668: test/watch_test.dart: watch sqlite paused listeners coalesce writes and stop fetching until resumed +06:51 +669: test/watch_test.dart: watch sqlite invalidation during a read suppresses the stale snapshot and refetches +06:51 +670: test/watch_test.dart: watch sqlite decoding errors are reported and a later write can recover +06:51 +671: test/watch_test.dart: watch sqlite separate subscriptions and views of one driver receive changes +06:51 +672: test/watch_test.dart: watch sqlite watching leased sessions is rejected and cancelled tokens close the stream +06:51 +673: test/watch_test.dart: watch sqlite database close finishes watchers even when a listener is paused +06:51 +674: test/watch_test.dart: watch sqlite cancelling an active watched query interrupts SQL and releases its lease +06:51 +675: test/watch_test.dart: watch postgres lazy initial snapshot and relevant table invalidation +06:51 +676: test/watch_test.dart: watch postgres transactions publish one snapshot after commit and none on rollback +06:51 +677: test/watch_test.dart: watch postgres savepoint changes merge on release and disappear on rollback +06:51 +678: test/watch_test.dart: watch postgres failed statements, zero-row writes and conflict no-ops do not refresh +06:51 +679: test/watch_test.dart: watch postgres batch writes and upsert returning publish committed changes +06:51 +680: test/watch_test.dart: watch postgres joined and nested batch relations track child writes +06:51 +681: test/watch_test.dart: watch postgres relation predicates, SQL subqueries and CTE sources are dependencies +06:52 +682: test/watch_test.dart: watch postgres declared cascade deletion invalidates a child-only query +06:52 +683: test/watch_test.dart: watch postgres CASCADE tracks declared transitive deletion effects +06:52 +684: test/watch_test.dart: watch postgres SET NULL tracks declared transitive deletion effects +06:52 +685: test/watch_test.dart: watch postgres explicit JOINs and joined CTE definitions track both physical sources +06:52 +686: test/watch_test.dart: watch postgres raw SQL declares changed and read tables; explicit invalidation follows transactions +06:52 +687: test/watch_test.dart: watch postgres paused listeners coalesce writes and stop fetching until resumed +06:52 +688: test/watch_test.dart: watch postgres invalidation during a read suppresses the stale snapshot and refetches +06:52 +689: test/watch_test.dart: watch postgres decoding errors are reported and a later write can recover +06:52 +690: test/watch_test.dart: watch postgres separate subscriptions and views of one driver receive changes +06:52 +691: test/watch_test.dart: watch postgres watching leased sessions is rejected and cancelled tokens close the stream +06:52 +692: test/watch_test.dart: watch postgres database close finishes watchers even when a listener is paused +06:52 +693: test/watch_test.dart: watch postgres cancelling an active watched query interrupts SQL and releases its lease +06:52 +694: test/watch_test.dart: watch postgres lost commit acknowledgement triggers a conservative re-read +06:52 +695: test/watch_test.dart: watch postgres independent connections require an explicit external-write notification +06:52 +696: loading test/backfill_test.dart +06:52 +696: test/backfill_test.dart: backfill sqlite chunks pause and resume from a checksummed historical declaration +06:52 +697: test/backfill_test.dart: backfill sqlite failed chunk rolls back both writes and cursor, then resumes after repair +06:52 +698: test/backfill_test.dart: backfill sqlite failed completion proof preserves finished chunks without replay +06:52 +699: test/backfill_test.dart: backfill sqlite frozen upper bound and completion proof detect incompatible new writes +06:52 +700: test/backfill_test.dart: backfill sqlite competing runners share committed chunks without duplicate updates +06:52 +701: test/backfill_test.dart: backfill sqlite lost chunk commit acknowledgement preserves the committed frontier +06:52 +702: test/backfill_test.dart: backfill sqlite old checkpoint tables upgrade on apply; completed reads remain nonmutating +06:52 +703: test/backfill_test.dart: backfill sqlite completion conditions reject numeric results instead of truthiness +06:52 +704: test/backfill_test.dart: backfill sqlite empty input still requires the completion condition +06:52 +705: test/backfill_test.dart: backfill sqlite later ordinary migrations retain group atomicity after a completed backfill +06:52 +706: test/backfill_test.dart: backfill sqlite AFTER triggers cannot silently move keys past a committed cursor +06:52 +707: test/backfill_test.dart: backfill sqlite corrupt checkpoint dimensions and invalid run limits are rejected +06:52 +708: test/backfill_test.dart: backfill sqlite rebuild, chunked backfill and required-column rebuild preserve FK enforcement +06:52 +709: test/backfill_test.dart: backfill sqlite CLI previews non-atomic work and reports a bounded run as incomplete +07:11 +710: test/backfill_test.dart: backfill sqlite temporary tables cannot redirect the historical backfill target +07:11 +711: test/backfill_test.dart: backfill sqlite temporary objects cannot impersonate durable migration metadata +07:11 +712: test/backfill_test.dart: backfill sqlite committed chunks invalidate relevant typed query subscriptions +07:11 +713: test/backfill_test.dart: backfill sqlite real process exit after second chunk write resumes correctly +07:13 +714: test/backfill_test.dart: backfill sqlite real process exit after second chunk commit resumes correctly +07:14 +715: test/backfill_test.dart: backfill postgres chunks pause and resume from a checksummed historical declaration +07:15 +716: test/backfill_test.dart: backfill postgres failed chunk rolls back both writes and cursor, then resumes after repair +07:15 +717: test/backfill_test.dart: backfill postgres failed completion proof preserves finished chunks without replay +07:15 +718: test/backfill_test.dart: backfill postgres frozen upper bound and completion proof detect incompatible new writes +07:15 +719: test/backfill_test.dart: backfill postgres competing runners share committed chunks without duplicate updates +07:15 +720: test/backfill_test.dart: backfill postgres lost chunk commit acknowledgement preserves the committed frontier +07:15 +721: test/backfill_test.dart: backfill postgres old checkpoint tables upgrade on apply; completed reads remain nonmutating +07:15 +722: test/backfill_test.dart: backfill postgres completion conditions reject numeric results instead of truthiness +07:15 +723: test/backfill_test.dart: backfill postgres empty input still requires the completion condition +07:15 +724: test/backfill_test.dart: backfill postgres later ordinary migrations retain group atomicity after a completed backfill +07:15 +725: test/backfill_test.dart: backfill postgres AFTER triggers cannot silently move keys past a committed cursor +07:15 +726: test/backfill_test.dart: backfill postgres corrupt checkpoint dimensions and invalid run limits are rejected +07:15 +727: test/backfill_test.dart: backfill postgres CLI previews non-atomic work and reports a bounded run as incomplete +07:33 +728: test/backfill_test.dart: backfill postgres temporary tables cannot redirect the historical backfill target +07:33 +729: test/backfill_test.dart: backfill postgres temporary objects cannot impersonate durable migration metadata +07:33 +730: test/backfill_test.dart: backfill postgres row security cannot hide unfinished rows from the completion proof +07:33 +731: test/backfill_test.dart: backfill postgres inherited duplicate primary keys cannot advance a cursor +07:33 +732: test/backfill_test.dart: backfill postgres committed chunks invalidate relevant typed query subscriptions +07:34 +733: test/backfill_test.dart: backfill postgres real process exit after second chunk write resumes correctly +07:35 +734: test/backfill_test.dart: backfill postgres real process exit after second chunk commit resumes correctly +07:36 +735: loading test/custom_codec_test.dart +07:36 +735: test/custom_codec_test.dart: (setUpAll) +07:36 +735: test/custom_codec_test.dart: custom codecs sqlite generated custom values round trip through create, select and patch +07:36 +736: test/custom_codec_test.dart: custom codecs sqlite enum labels are stable text and unknown labels fail decoding +07:36 +737: test/custom_codec_test.dart: custom codecs sqlite domain validation and bound parameters apply to writes and predicates +07:36 +738: test/custom_codec_test.dart: custom codecs sqlite JSON documents distinguish SQL NULL, JSON null and string scalars +07:36 +739: test/custom_codec_test.dart: custom codecs sqlite nullable structured codec rejects JSON null instead of masking invalid data +07:36 +740: test/custom_codec_test.dart: custom codecs sqlite custom IDs and domain projections work across joined and batched relations +07:36 +741: test/custom_codec_test.dart: custom codecs sqlite custom values participate in atomic batch writes and native upsert +07:36 +742: test/custom_codec_test.dart: custom codecs sqlite custom IDs and enums survive cursor transport and streaming decoders +07:36 +743: test/custom_codec_test.dart: custom codecs sqlite migration snapshots retain physical storage without executing domain codecs +07:36 +744: test/custom_codec_test.dart: custom codecs postgres generated custom values round trip through create, select and patch +07:36 +745: test/custom_codec_test.dart: custom codecs postgres enum labels are stable text and unknown labels fail decoding +07:37 +746: test/custom_codec_test.dart: custom codecs postgres domain validation and bound parameters apply to writes and predicates +07:37 +747: test/custom_codec_test.dart: custom codecs postgres JSON documents distinguish SQL NULL, JSON null and string scalars +07:37 +748: test/custom_codec_test.dart: custom codecs postgres nullable structured codec rejects JSON null instead of masking invalid data +07:37 +749: test/custom_codec_test.dart: custom codecs postgres custom IDs and domain projections work across joined and batched relations +07:37 +750: test/custom_codec_test.dart: custom codecs postgres custom values participate in atomic batch writes and native upsert +07:37 +751: test/custom_codec_test.dart: custom codecs postgres custom IDs and enums survive cursor transport and streaming decoders +07:37 +752: test/custom_codec_test.dart: custom codecs postgres migration snapshots retain physical storage without executing domain codecs +07:37 +753: test/custom_codec_test.dart: (tearDownAll) +07:37 +753: loading test/plan_test.dart +07:37 +753: test/plan_test.dart: inspect sqlite reports composite key width and validates each potential batch +07:37 +754: test/plan_test.dart: inspect sqlite compiles nested plans without acquiring or decoding +07:37 +755: test/plan_test.dart: inspect sqlite distinguishes same-statement joins and skipped batches +07:37 +756: test/plan_test.dart: inspect sqlite retains CTE, UNION and raw SQL boundaries +07:37 +757: test/plan_test.dart: plan execution sqlite one-key SQL templates match executed SQL and internal slots +07:37 +758: test/plan_test.dart: inspect postgres reports composite key width and validates each potential batch +07:37 +759: test/plan_test.dart: inspect postgres compiles nested plans without acquiring or decoding +07:37 +760: test/plan_test.dart: inspect postgres distinguishes same-statement joins and skipped batches +07:37 +761: test/plan_test.dart: inspect postgres retains CTE, UNION and raw SQL boundaries +07:37 +762: test/plan_test.dart: plan execution postgres one-key SQL templates match executed SQL and internal slots +07:37 +763: loading test/check_test.dart +07:37 +763: test/check_test.dart: CHECK sqlite generated named and unnamed constraints enforce values and permit NULL +07:37 +764: test/check_test.dart: CHECK sqlite import preserves names and SQL then generates a matching snapshot +07:39 +765: test/check_test.dart: CHECK sqlite add, change and remove constraints validate old rows atomically +07:39 +766: test/check_test.dart: CHECK sqlite duplicate unnamed checks consume and drop exactly one catalog entry +07:39 +767: test/check_test.dart: CHECK sqlite explicit checked renames preserve rows, incoming references, indexes and views +07:39 +768: test/check_test.dart: CHECK sqlite verification preserves casts, precedence, literals and trailing comments +07:39 +769: test/check_test.dart: CHECK sqlite quoted Unicode identifiers retain SQLite case distinctions +07:39 +770: test/check_test.dart: CHECK sqlite undeclared CHECK is refused before a rebuild and remains enforced +07:39 +771: test/check_test.dart: CHECK postgres generated named and unnamed constraints enforce values and permit NULL +07:39 +772: test/check_test.dart: CHECK postgres import preserves names and SQL then generates a matching snapshot +07:40 +773: test/check_test.dart: CHECK postgres add, change and remove constraints validate old rows atomically +07:40 +774: test/check_test.dart: CHECK postgres duplicate unnamed checks consume and drop exactly one catalog entry +07:40 +775: test/check_test.dart: CHECK postgres explicit checked renames preserve rows, incoming references, indexes and views +07:40 +776: test/check_test.dart: CHECK postgres verification preserves casts, precedence, literals and trailing comments +07:40 +777: test/check_test.dart: CHECK postgres catalog excludes unvalidated and NO INHERIT constraints +07:40 +778: test/check_test.dart: CHECK postgres verification plans but does not execute volatile row expressions +07:40 +779: test/check_test.dart: dialect overrides only alter the affected backend and preserve old snapshot JSON +07:40 +780: loading test/temporal_test.dart +07:40 +780: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: false +07:40 +781: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: true +07:40 +782: test/temporal_test.dart: Gregorian cycles, era boundaries and full finite date range +07:40 +783: test/temporal_test.dart: local time precision, day endpoint and timestamp arithmetic +07:40 +784: test/temporal_test.dart: PostgreSQL binary registry covers boundaries without DateTime +07:40 +785: test/temporal_test.dart: local temporal sqlite generated writes, defaults, null patches and typed parameters +07:40 +786: test/temporal_test.dart: local temporal sqlite finite extremes and microseconds survive native rows and streams +07:40 +787: test/temporal_test.dart: local temporal sqlite calendar ordering, min/max, grouping and stable date cursors +07:40 +788: test/temporal_test.dart: local temporal sqlite equivalent date keys drive batched relations and unique indexes +07:40 +789: test/temporal_test.dart: local temporal sqlite time and local timestamp comparisons retain calendar semantics +07:40 +790: test/temporal_test.dart: local temporal sqlite reviewed text-to-time migration rolls back duplicate clock values +07:40 +791: test/temporal_test.dart: local temporal sqlite invalid external dates fail typed reads without damaging the connection +07:40 +792: test/temporal_test.dart: local temporal sqlite snapshots, catalog import and regenerated defaults agree +07:42 +793: test/temporal_test.dart: local temporal sqlite historical date keys resume a bounded backfill in calendar order +07:42 +794: test/temporal_test.dart: local temporal postgres catalog retains time-truncating casts in expression defaults +07:43 +795: test/temporal_test.dart: local temporal postgres generated writes, defaults, null patches and typed parameters +07:43 +796: test/temporal_test.dart: local temporal postgres finite extremes and microseconds survive native rows and streams +07:43 +797: test/temporal_test.dart: local temporal postgres calendar ordering, min/max, grouping and stable date cursors +07:43 +798: test/temporal_test.dart: local temporal postgres equivalent date keys drive batched relations and unique indexes +07:43 +799: test/temporal_test.dart: local temporal postgres time and local timestamp comparisons retain calendar semantics +07:43 +800: test/temporal_test.dart: local temporal postgres reviewed text-to-time migration rolls back duplicate clock values +07:43 +801: test/temporal_test.dart: local temporal postgres invalid external dates fail typed reads without damaging the connection +07:43 +802: test/temporal_test.dart: local temporal postgres snapshots, catalog import and regenerated defaults agree +07:43 +803: test/temporal_test.dart: local temporal postgres historical date keys resume a bounded backfill in calendar order +07:43 +804: loading test/unconstrained_relation_test.dart +07:43 +804: test/unconstrained_relation_test.dart: unconstrained sqlite catalogs contain no FK, implicit index or uniqueness promise +07:44 +805: test/unconstrained_relation_test.dart: unconstrained sqlite adding and removing a real FK remains a reviewed physical migration +07:44 +806: test/unconstrained_relation_test.dart: unconstrained sqlite unique joins preserve root rows, NULL components and missing targets +07:44 +807: test/unconstrained_relation_test.dart: unconstrained sqlite required target and filter visibility remain explicit +07:44 +808: test/unconstrained_relation_test.dart: unconstrained sqlite nonunique composite collections batch by tuple and page per parent +07:44 +809: test/unconstrained_relation_test.dart: unconstrained sqlite to-one uses cardinality checks without assuming a unique lookup +07:44 +810: test/unconstrained_relation_test.dart: unconstrained sqlite correlated filters and counts do not materialize related rows +07:44 +811: test/unconstrained_relation_test.dart: unconstrained sqlite self relations and inverse collections share the existing planner +07:44 +812: test/unconstrained_relation_test.dart: unconstrained sqlite delete has no cascade and watches track the joined target +07:44 +813: test/unconstrained_relation_test.dart: unconstrained sqlite transaction reads observe writes and outer rollback restores targets +07:44 +814: test/unconstrained_relation_test.dart: unconstrained sqlite streaming preserves optional and batched relation result shapes +07:44 +815: test/unconstrained_relation_test.dart: unconstrained sqlite floating relation keys retain storage intent and key equality +07:44 +816: test/unconstrained_relation_test.dart: unconstrained postgres catalogs contain no FK, implicit index or uniqueness promise +07:44 +817: test/unconstrained_relation_test.dart: unconstrained postgres adding and removing a real FK remains a reviewed physical migration +07:44 +818: test/unconstrained_relation_test.dart: unconstrained postgres unique joins preserve root rows, NULL components and missing targets +07:44 +819: test/unconstrained_relation_test.dart: unconstrained postgres required target and filter visibility remain explicit +07:44 +820: test/unconstrained_relation_test.dart: unconstrained postgres nonunique composite collections batch by tuple and page per parent +07:44 +821: test/unconstrained_relation_test.dart: unconstrained postgres to-one uses cardinality checks without assuming a unique lookup +07:44 +822: test/unconstrained_relation_test.dart: unconstrained postgres correlated filters and counts do not materialize related rows +07:44 +823: test/unconstrained_relation_test.dart: unconstrained postgres self relations and inverse collections share the existing planner +07:44 +824: test/unconstrained_relation_test.dart: unconstrained postgres delete has no cascade and watches track the joined target +07:44 +825: test/unconstrained_relation_test.dart: unconstrained postgres transaction reads observe writes and outer rollback restores targets +07:44 +826: test/unconstrained_relation_test.dart: unconstrained postgres streaming preserves optional and batched relation result shapes +07:44 +827: test/unconstrained_relation_test.dart: unconstrained postgres floating relation keys retain storage intent and key equality +07:44 +828: loading test/decimal_value_test.dart +07:44 +828: test/decimal_value_test.dart: normalization, scientific notation, equality and hash are exact +07:44 +829: test/decimal_value_test.dart: invalid and approximate inputs fail instead of silently rounding +07:44 +830: test/decimal_value_test.dart: range boundaries are checked without binary numeric conversions +07:44 +831: test/decimal_value_test.dart: addition, subtraction, multiplication and ordering keep every digit +07:44 +832: test/decimal_value_test.dart: division requires an explicit scale and rejects implicit rounding +07:44 +833: test/decimal_value_test.dart: rounding modes handle negative values, ties and negative scales +07:44 +834: test/decimal_value_test.dart: small fixed-scale arithmetic agrees with independent integer arithmetic +07:44 +835: All tests passed! diff --git a/research/validation/sqlite-refactor-analyze.log b/research/validation/sqlite-refactor-analyze.log new file mode 100644 index 00000000..50990799 --- /dev/null +++ b/research/validation/sqlite-refactor-analyze.log @@ -0,0 +1,2 @@ +Analyzing dart-orm... +No issues found! diff --git a/research/validation/sqlite-refactor-assets-check.log b/research/validation/sqlite-refactor-assets-check.log new file mode 100644 index 00000000..5baccb1c --- /dev/null +++ b/research/validation/sqlite-refactor-assets-check.log @@ -0,0 +1 @@ +Running build hooks...Running build hooks...SQLite web source, worker and WASM fingerprints match. diff --git a/research/validation/sqlite-refactor-browser-js.json b/research/validation/sqlite-refactor-browser-js.json new file mode 100644 index 00000000..f89d99de --- /dev/null +++ b/research/validation/sqlite-refactor-browser-js.json @@ -0,0 +1,94 @@ +{ + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 64 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 41 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 16 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 21 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 19 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 33 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 26 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 4 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 2 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 1 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 3 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 156 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 29 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 33 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 98 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 22 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "js", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" +} diff --git a/research/validation/sqlite-refactor-browser-wasm.json b/research/validation/sqlite-refactor-browser-wasm.json new file mode 100644 index 00000000..48fb576c --- /dev/null +++ b/research/validation/sqlite-refactor-browser-wasm.json @@ -0,0 +1,94 @@ +{ + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 57 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 39 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 13 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 19 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 19 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 27 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 23 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 2 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 155 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 30 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 34 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 99 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 1 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 13 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "wasm", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" +} diff --git a/research/validation/sqlite-refactor-flutter-example.log b/research/validation/sqlite-refactor-flutter-example.log new file mode 100644 index 00000000..022746bd --- /dev/null +++ b/research/validation/sqlite-refactor-flutter-example.log @@ -0,0 +1,10 @@ + +┌─ New feature ────────────────────────────────────────────────────────────────────────────┐ +│ WebAssembly compilation is new. Understand the details before deploying to production. │ +│ See https://flutter.dev/to/wasm for more information. │ +└──────────────────────────────────────────────────────────────────────────────────────────┘ +Compiling lib/main.dart for the Web... +Expected to find fonts for (MaterialIcons, packages/cupertino_icons/CupertinoIcons), but found (MaterialIcons). This usually means you are referring to font families in an IconData class but not including them in the assets section of your pubspec.yaml, are missing the package that would include them, or are missing "uses-material-design: true". +Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 7928 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app. +Compiling lib/main.dart for the Web... 18.8s +✓ Built build/web diff --git a/research/validation/sqlite-refactor-flutter-web.json b/research/validation/sqlite-refactor-flutter-web.json new file mode 100644 index 00000000..c5399e61 --- /dev/null +++ b/research/validation/sqlite-refactor-flutter-web.json @@ -0,0 +1,509 @@ +[ + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 91 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 39 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 14 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 21 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 18 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 30 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 26 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 3 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 3 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 158 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 31 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 36 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 102 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 19 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": false, + "flutterFrames": 5, + "baseUri": "http://127.0.0.1:64609/nested/", + "dartUriBase": "http://127.0.0.1:64609/nested/detail/42?phase=recover", + "isolated": false, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/chromium/canvaskit.wasm", + "/nested/canvaskit/chromium/canvaskit.js", + "/nested/main.dart.js", + "/favicon.ico", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/mismatched-worker.js", + "/mismatched-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/chromium/canvaskit.wasm", + "/nested/canvaskit/chromium/canvaskit.js", + "/nested/main.dart.js", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/report" + ], + "build": "js", + "expectedIsolation": false, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 85 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 40 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 12 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 19 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 27 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 24 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 1 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 157 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 32 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 35 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 104 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 12 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": true, + "flutterFrames": 3, + "baseUri": "http://127.0.0.1:64638/nested/", + "dartUriBase": "http://127.0.0.1:64638/nested/detail/42?phase=recover", + "isolated": false, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/nested/main.dart.wasm", + "/favicon.ico", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/mismatched-worker.js", + "/mismatched-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/report" + ], + "build": "wasm", + "expectedIsolation": false, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 54 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 40 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 13 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 21 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 21 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 27 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 26 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 0 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 1 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 158 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 31 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 35 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 103 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 1 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 15 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": true, + "flutterFrames": 4, + "baseUri": "http://127.0.0.1:64655/nested/", + "dartUriBase": "http://127.0.0.1:64655/nested/detail/42?phase=recover", + "isolated": true, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/favicon.ico", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/canvaskit/skwasm.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/mismatched-worker.js", + "/mismatched-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "/nested/canvaskit/skwasm.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/report" + ], + "build": "wasm", + "expectedIsolation": true, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + } +] diff --git a/research/validation/sqlite-refactor-focused.log b/research/validation/sqlite-refactor-focused.log new file mode 100644 index 00000000..01feeca0 --- /dev/null +++ b/research/validation/sqlite-refactor-focused.log @@ -0,0 +1,8 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/sqlite_options_test.dart +00:00 +0: test/sqlite_options_test.dart: persistent uses the explicit native path and retains committed data +00:00 +1: test/sqlite_options_test.dart: native persistent storage never guesses a path or falls back to memory +00:00 +2: test/sqlite_options_test.dart: all driver close callers wait for active work and shutdown +00:00 +3: loading test/public_api_test.dart +00:00 +3: test/public_api_test.dart: generated clients expose row types and an immutable schema +00:00 +4: test/public_api_test.dart: entrypoint exports and runtime dependency boundaries stay explicit +00:06 +5: All tests passed! diff --git a/research/validation/sqlite-refactor-native.log b/research/validation/sqlite-refactor-native.log new file mode 100644 index 00000000..25289e8a --- /dev/null +++ b/research/validation/sqlite-refactor-native.log @@ -0,0 +1,941 @@ +Running build hooks...Running build hooks...00:00 +0: loading test/client_default_test.dart +00:00 +0: test/client_default_test.dart: client defaults sqlite omitted values use typed Dart factories and return stored records +00:00 +1: test/client_default_test.dart: client defaults sqlite explicit values, null and SQL DEFAULT bypass their client factory +00:00 +2: test/client_default_test.dart: client defaults sqlite prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +3: test/client_default_test.dart: client defaults sqlite batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +4: test/client_default_test.dart: client defaults sqlite updates and conflict updates do not reset omitted client values +00:00 +5: test/client_default_test.dart: client defaults sqlite factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +6: test/client_default_test.dart: client defaults sqlite snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +7: test/client_default_test.dart: client defaults sqlite explicit DEFAULT can choose database identity instead of the client value +00:00 +8: test/client_default_test.dart: client defaults postgres omitted values use typed Dart factories and return stored records +00:00 +9: test/client_default_test.dart: client defaults postgres explicit values, null and SQL DEFAULT bypass their client factory +00:00 +10: test/client_default_test.dart: client defaults postgres prepared mutations, repeated compilation and conflicts retain one generated value +00:00 +11: test/client_default_test.dart: client defaults postgres batch insert prepares defaults once per omitted row and preserves explicit values +00:00 +12: test/client_default_test.dart: client defaults postgres updates and conflict updates do not reset omitted client values +00:00 +13: test/client_default_test.dart: client defaults postgres factory errors issue no writes, and rollback does not reverse Dart side effects +00:00 +14: test/client_default_test.dart: client defaults postgres snapshots, migrations, catalogs and imports never store or run a client factory +00:00 +15: test/client_default_test.dart: client defaults postgres explicit DEFAULT can choose database identity instead of the client value +00:00 +16: test/client_default_test.dart: generation reads factory references without executing them and supports moved output +00:02 +17: loading test/decimal_precision_test.dart +00:02 +17: test/decimal_precision_test.dart: decimal precision sqlite column coercion rounds signed ties and handles negative and excess scales +00:02 +18: test/decimal_precision_test.dart: decimal precision sqlite writes constrain columns while arithmetic and wider aggregates retain their result precision +00:02 +19: test/decimal_precision_test.dart: decimal precision sqlite batch inserts and upserts use column coercion and roll back as a unit +00:02 +20: test/decimal_precision_test.dart: decimal precision sqlite catalogs snapshots and imported declarations retain precision scale and defaults +00:04 +21: test/decimal_precision_test.dart: decimal precision sqlite reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:04 +22: test/decimal_precision_test.dart: decimal precision sqlite physical renames and changing constrained defaults preserve column contracts +00:04 +23: test/decimal_precision_test.dart: decimal precision sqlite maximum declared precision and scale bounds remain exact +00:04 +24: test/decimal_precision_test.dart: decimal precision sqlite historical backfills retain constrained decimal key declarations +00:04 +25: test/decimal_precision_test.dart: decimal precision postgres column coercion rounds signed ties and handles negative and excess scales +00:04 +26: test/decimal_precision_test.dart: decimal precision postgres writes constrain columns while arithmetic and wider aggregates retain their result precision +00:05 +27: test/decimal_precision_test.dart: decimal precision postgres batch inserts and upserts use column coercion and roll back as a unit +00:05 +28: test/decimal_precision_test.dart: decimal precision postgres catalogs snapshots and imported declarations retain precision scale and defaults +00:05 +29: test/decimal_precision_test.dart: decimal precision postgres reviewed scale reduction detects rounded duplicate keys and rolls back schema history +00:05 +30: test/decimal_precision_test.dart: decimal precision postgres physical renames and changing constrained defaults preserve column contracts +00:05 +31: test/decimal_precision_test.dart: decimal precision postgres maximum declared precision and scale bounds remain exact +00:05 +32: test/decimal_precision_test.dart: decimal precision postgres historical backfills retain constrained decimal key declarations +00:05 +33: test/decimal_precision_test.dart: SQLite enforces stored precision and checks default coercion and trusted-schema behavior +00:05 +34: test/decimal_precision_test.dart: generation validates DecimalDigits and retains custom decimal codec types +00:08 +35: test/decimal_precision_test.dart: metadata rejects invalid precisions scales and non-decimal columns +00:08 +36: loading test/selection_test.dart +00:08 +36: test/selection_test.dart: selection sqlite all typed arities defer mapping and preserve left-to-right evaluation +00:08 +37: test/selection_test.dart: selection sqlite first decoder failure prevents later fields and outer mapper +00:08 +38: test/selection_test.dart: selection sqlite dynamic fields snapshot keys and preserve nulls and mapping order +00:08 +39: test/selection_test.dart: selection postgres all typed arities defer mapping and preserve left-to-right evaluation +00:08 +40: test/selection_test.dart: selection postgres first decoder failure prevents later fields and outer mapper +00:08 +41: test/selection_test.dart: selection postgres dynamic fields snapshot keys and preserve nulls and mapping order +00:08 +42: loading test/build_runner_test.dart +00:08 +42: test/build_runner_test.dart: real build_runner build/watch tracks imports, recovers errors and deletes stale outputs +00:34 +43: loading test/schema_version_test.dart +00:34 +43: test/schema_version_test.dart: schema version sqlite invalid ranges fail before SQL; caller transactions stay usable +00:34 +44: test/schema_version_test.dart: schema version sqlite unversioned existing data is rejected without creating history +00:34 +45: test/schema_version_test.dart: schema version sqlite default requires latest; inclusive compatibility range is explicit +00:34 +46: test/schema_version_test.dart: schema version sqlite newer database rejects downgrade without changing data or schema +00:34 +47: test/schema_version_test.dart: schema version sqlite complete applied prefix is checked, including old checksums and missing entries +00:34 +48: test/schema_version_test.dart: schema version sqlite matching version does not claim that the catalog is free of drift +00:34 +49: test/schema_version_test.dart: schema version sqlite verified baseline is compatible without replaying creation +00:34 +50: test/schema_version_test.dart: schema version sqlite borrowed session uses a read snapshot and survives rejected versions +00:34 +51: test/schema_version_test.dart: schema version postgres invalid ranges fail before SQL; caller transactions stay usable +00:34 +52: test/schema_version_test.dart: schema version postgres unversioned existing data is rejected without creating history +00:34 +53: test/schema_version_test.dart: schema version postgres default requires latest; inclusive compatibility range is explicit +00:34 +54: test/schema_version_test.dart: schema version postgres newer database rejects downgrade without changing data or schema +00:34 +55: test/schema_version_test.dart: schema version postgres complete applied prefix is checked, including old checksums and missing entries +00:34 +56: test/schema_version_test.dart: schema version postgres matching version does not claim that the catalog is free of drift +00:34 +57: test/schema_version_test.dart: schema version postgres verified baseline is compatible without replaying creation +00:34 +58: test/schema_version_test.dart: schema version postgres borrowed session uses a read snapshot and survives rejected versions +00:34 +59: test/schema_version_test.dart: schema version postgres unfinished recoverable migration blocks startup until repaired and completed +00:34 +60: test/schema_version_test.dart: schema version postgres catalog and history share a snapshot and the supplied history is frozen +00:34 +61: test/schema_version_test.dart: SQLite version gate works on an explicitly read-only file +00:34 +62: loading test/types_test.dart +00:34 +62: test/types_test.dart: junction-table keys, enum payloads and nested results keep generated types +00:36 +63: test/types_test.dart: computed generated fields are statically readable and never writable +00:37 +64: test/types_test.dart: client-default create parameters preserve domain, nullable and timestamp types +00:37 +65: test/types_test.dart: query-only relation APIs retain result types and expose no graph writes +00:37 +66: test/types_test.dart: calendar APIs reject instants, strings and mixed temporal types +00:38 +67: test/types_test.dart: actual generated APIs reject wrong inputs, results and backend options +00:38 +68: loading test/migration_test.dart +00:38 +68: test/migration_test.dart: (setUpAll) +00:38 +68: test/migration_test.dart: migrations sqlite explicit table and column renames preserve incoming references and data +00:38 +69: test/migration_test.dart: migrations sqlite constraint changes resolve catalog names and retain rows +00:38 +70: test/migration_test.dart: migrations sqlite failed NOT NULL rebuild restores data history and foreign key settings +00:38 +71: test/migration_test.dart: migrations sqlite reviewed conversion expressions preserve values when storage types change +00:38 +72: test/migration_test.dart: migrations sqlite additive changes use ALTER without a table rebuild +00:38 +73: test/migration_test.dart: migrations sqlite reviewed table removal handles restrictive foreign keys atomically +00:38 +74: test/migration_test.dart: migrations sqlite broken migration chain is refused before DDL +00:38 +75: test/migration_test.dart: migrations sqlite catalog distinguishes nullable non-rowid primary keys +00:38 +76: test/migration_test.dart: migrations sqlite rebuild preserves unmanaged index trigger and view definitions +00:38 +77: test/migration_test.dart: migrations sqlite foreign key check rolls back changed parent keys without cascading data away +00:38 +78: test/migration_test.dart: migrations sqlite unmodeled CHECK constraints are refused without losing their definition +00:38 +79: test/migration_test.dart: migrations postgres explicit table and column renames preserve incoming references and data +00:38 +80: test/migration_test.dart: migrations postgres constraint changes resolve catalog names and retain rows +00:38 +81: test/migration_test.dart: migrations postgres failed NOT NULL rebuild restores data history and foreign key settings +00:39 +82: test/migration_test.dart: migrations postgres reviewed conversion expressions preserve values when storage types change +00:39 +83: test/migration_test.dart: migrations postgres additive changes use ALTER without a table rebuild +00:39 +84: test/migration_test.dart: migrations postgres reviewed table removal handles restrictive foreign keys atomically +00:39 +85: test/migration_test.dart: migrations postgres broken migration chain is refused before DDL +00:39 +86: test/migration_test.dart: diff requires explicit renames, casts and backfills +00:39 +87: test/migration_test.dart: (tearDownAll) +00:39 +87: loading test/many_to_many_test.dart +00:39 +87: test/many_to_many_test.dart: many-to-many sqlite two declared FKs and a composite primary key enforce association identity +00:39 +88: test/many_to_many_test.dart: many-to-many sqlite forward projection keeps payload, shared targets and per-user limits in two statements +00:39 +89: test/many_to_many_test.dart: many-to-many sqlite reverse projection applies tie-breaking and offset to each team without changing root pagination +00:39 +90: test/many_to_many_test.dart: many-to-many sqlite nested team rosters deduplicate shared team keys and batch by level +00:39 +91: test/many_to_many_test.dart: many-to-many sqlite association predicates and counts stay in one SQL statement +00:39 +92: test/many_to_many_test.dart: many-to-many sqlite limited parameter capacity chunks associations without changing per-parent limits +00:39 +93: test/many_to_many_test.dart: many-to-many sqlite transaction rollback restores payload changes and new endpoints after an invalid association +00:39 +94: test/many_to_many_test.dart: many-to-many sqlite composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:39 +95: test/many_to_many_test.dart: many-to-many sqlite watch tracks junction payload and opposite endpoint changes after commit +00:39 +96: test/many_to_many_test.dart: many-to-many postgres two declared FKs and a composite primary key enforce association identity +00:39 +97: test/many_to_many_test.dart: many-to-many postgres forward projection keeps payload, shared targets and per-user limits in two statements +00:39 +98: test/many_to_many_test.dart: many-to-many postgres reverse projection applies tie-breaking and offset to each team without changing root pagination +00:39 +99: test/many_to_many_test.dart: many-to-many postgres nested team rosters deduplicate shared team keys and batch by level +00:39 +100: test/many_to_many_test.dart: many-to-many postgres association predicates and counts stay in one SQL statement +00:39 +101: test/many_to_many_test.dart: many-to-many postgres limited parameter capacity chunks associations without changing per-parent limits +00:39 +102: test/many_to_many_test.dart: many-to-many postgres transaction rollback restores payload changes and new endpoints after an invalid association +00:39 +103: test/many_to_many_test.dart: many-to-many postgres composite-key conflict updates preserve membership dates and unlinking keeps endpoints +00:39 +104: test/many_to_many_test.dart: many-to-many postgres watch tracks junction payload and opposite endpoint changes after commit +00:39 +105: loading test/migration_target_test.dart +00:39 +105: test/migration_target_test.dart: history requires a single engine even when empty or SQL is portable +00:39 +106: test/migration_target_test.dart: unselected expressions cannot change a saved plan, hash or source +00:39 +107: test/migration_target_test.dart: SQLite virtual indexes and computed mode changes ignore PostgreSQL limits +00:39 +108: test/migration_target_test.dart: replacing stored values with computed values requires explicit review +00:39 +109: test/migration_target_test.dart: older PostgreSQL is rejected before locks, journals or partial execution +00:39 +110: test/migration_target_test.dart: registry fixes its engine at initialization and refuses retargeting +00:39 +111: test/migration_target_test.dart: wrong engine fails before any migration SQL for runner and every connected CLI command +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +OrmException(MIGRATION.TARGET): History targets postgres, connection is sqlite. +00:40 +112: test/migration_target_test.dart: PostgreSQL applies only its own generated conversion and rejects a SQLite history before SQL +00:40 +113: loading test/stream_test.dart +00:40 +113: test/stream_test.dart: sqlite cancellation between batch chunks prevents a partial commit +00:40 +114: test/stream_test.dart: sqlite cancellation interrupts an active cursor fetch +00:40 +115: test/stream_test.dart: sqlite timed-out mutations return no partial write +00:40 +116: test/stream_test.dart: sqlite database close stops a paused cursor and releases the driver +00:40 +117: test/stream_test.dart: sqlite cancellation between stream creation and listening needs no rollback +00:40 +118: test/stream_test.dart: sqlite bounded cursor fetches preserve parameters and typed rows +00:40 +119: test/stream_test.dart: sqlite pause stops fetches and early cancellation releases the lease +00:40 +120: test/stream_test.dart: sqlite await-for provides demand and take closes without exhausting +00:40 +121: test/stream_test.dart: sqlite relation projections load once per root batch +00:40 +122: test/stream_test.dart: sqlite mapper failure closes the cursor and rolls back +00:40 +123: test/stream_test.dart: sqlite early stream end inside a transaction preserves other work +00:40 +124: test/stream_test.dart: sqlite escaping paused stream cannot keep a transaction alive +00:40 +125: test/stream_test.dart: sqlite cancels executing SQL and allows the next statement +00:40 +126: test/stream_test.dart: sqlite statement timeout stops SQL and leaves a usable connection +00:40 +127: test/stream_test.dart: sqlite cancelled statement poisons a transaction even if caught +00:40 +128: test/stream_test.dart: sqlite cancelling a paused stream releases its connection before resume +00:40 +129: test/stream_test.dart: sqlite cancellation before starting performs no SQL +00:40 +130: test/stream_test.dart: postgres cancellation between batch chunks prevents a partial commit +00:40 +131: test/stream_test.dart: postgres cancellation interrupts an active cursor fetch +00:40 +132: test/stream_test.dart: postgres timed-out mutations return no partial write +00:40 +133: test/stream_test.dart: postgres database close stops a paused cursor and releases the driver +00:40 +134: test/stream_test.dart: postgres cancellation between stream creation and listening needs no rollback +00:40 +135: test/stream_test.dart: postgres bounded cursor fetches preserve parameters and typed rows +00:40 +136: test/stream_test.dart: postgres pause stops fetches and early cancellation releases the lease +00:40 +137: test/stream_test.dart: postgres await-for provides demand and take closes without exhausting +00:40 +138: test/stream_test.dart: postgres relation projections load once per root batch +00:40 +139: test/stream_test.dart: postgres mapper failure closes the cursor and rolls back +00:40 +140: test/stream_test.dart: postgres early stream end inside a transaction preserves other work +00:40 +141: test/stream_test.dart: postgres escaping paused stream cannot keep a transaction alive +00:40 +142: test/stream_test.dart: postgres cancels executing SQL and allows the next statement +00:41 +143: test/stream_test.dart: postgres statement timeout stops SQL and leaves a usable connection +00:41 +144: test/stream_test.dart: postgres cancelled statement poisons a transaction even if caught +00:41 +145: test/stream_test.dart: postgres cancelling a paused stream releases its connection before resume +00:41 +146: test/stream_test.dart: postgres cancellation before starting performs no SQL +00:41 +147: test/stream_test.dart: late PostgreSQL cancellation cannot reach the next statement +00:41 +148: test/stream_test.dart: borrowed pool requires cancellation support and retains ownership +00:41 +149: test/stream_test.dart: failed cancellation control discards the physical connection +00:41 +150: loading test/migration_recovery_test.dart +00:41 +150: test/migration_recovery_test.dart: (setUpAll) +00:41 +150: test/migration_recovery_test.dart: SQLite and transaction controls reject recoverable autocommit steps +00:41 +151: test/migration_recovery_test.dart: mixed migrations checkpoint SQL and verify concurrent indexes +00:51 +152: test/migration_recovery_test.dart: same-name wrong-definition indexes are never accepted as completion +00:51 +153: test/migration_recovery_test.dart: failed unique builds leave INVALID indexes and require explicit repair +00:51 +154: test/migration_recovery_test.dart: failed attempts freeze migration checksums and block baseline +00:51 +155: test/migration_recovery_test.dart: failed postconditions report their actual failing stage +00:51 +156: test/migration_recovery_test.dart: recovery probes must produce one boolean value +00:51 +157: test/migration_recovery_test.dart: concurrent runners serialize normal and autocommit steps under one lock +00:51 +158: test/migration_recovery_test.dart: runner lock timeout leaves history untouched and the connection reusable +00:51 +159: test/migration_recovery_test.dart: ordinary SQL inside a mixed migration rolls back only its failed step and resumes +00:52 +160: test/migration_recovery_test.dart: completion checks reject partial and NULLS NOT DISTINCT definitions +00:52 +161: test/migration_recovery_test.dart: real process exit after concurrent DDL resumes without replay +00:54 +162: test/migration_recovery_test.dart: real process exit after transaction commit resumes without replay +00:56 +163: test/migration_recovery_test.dart: (tearDownAll) +00:56 +163: loading test/acquisition_test.dart +00:56 +163: test/acquisition_test.dart: an expired lease cannot enter before a delayed Timer gets its turn +00:56 +164: test/acquisition_test.dart: acquisition sqlite expired raw writes never execute after a late lease becomes available +00:56 +165: test/acquisition_test.dart: acquisition sqlite query cancellation returns while the pool is still occupied +00:56 +166: test/acquisition_test.dart: acquisition sqlite typed mutations and batch transactions propagate acquisition limits +00:56 +167: test/acquisition_test.dart: acquisition sqlite expired transactions and sessions never enter their callbacks +00:56 +168: test/acquisition_test.dart: acquisition sqlite acquisition timeout stops on entry and statement timeout starts afterward +00:56 +169: test/acquisition_test.dart: acquisition sqlite invalid and already-cancelled acquisition options never queue work +00:56 +170: test/acquisition_test.dart: acquisition sqlite stream cancellation and timeout return without opening a cursor +00:56 +171: test/acquisition_test.dart: acquisition sqlite query watch acquisition failure can recover on a later invalidation +00:56 +172: test/acquisition_test.dart: acquisition sqlite close drains abandoned reservations without running their callbacks +00:56 +173: test/acquisition_test.dart: acquisition sqlite abandoned requests preserve pool reuse and subsequent action errors +00:56 +174: test/acquisition_test.dart: PostgreSQL native pool timeout is classified before entering the callback +00:57 +175: test/acquisition_test.dart: PostgreSQL connection establishment timeout does not cap pool waiting +00:57 +176: test/acquisition_test.dart: acquisition postgres expired raw writes never execute after a late lease becomes available +00:57 +177: test/acquisition_test.dart: acquisition postgres query cancellation returns while the pool is still occupied +00:57 +178: test/acquisition_test.dart: acquisition postgres typed mutations and batch transactions propagate acquisition limits +00:57 +179: test/acquisition_test.dart: acquisition postgres expired transactions and sessions never enter their callbacks +00:57 +180: test/acquisition_test.dart: acquisition postgres acquisition timeout stops on entry and statement timeout starts afterward +00:57 +181: test/acquisition_test.dart: acquisition postgres invalid and already-cancelled acquisition options never queue work +00:57 +182: test/acquisition_test.dart: acquisition postgres stream cancellation and timeout return without opening a cursor +00:57 +183: test/acquisition_test.dart: acquisition postgres query watch acquisition failure can recover on a later invalidation +00:58 +184: test/acquisition_test.dart: acquisition postgres close drains abandoned reservations without running their callbacks +00:58 +185: test/acquisition_test.dart: acquisition postgres abandoned requests preserve pool reuse and subsequent action errors +00:58 +186: test/acquisition_test.dart: PostgreSQL timeout during real connection initialization releases the late lease +00:58 +187: loading test/computed_test.dart +00:58 +187: test/computed_test.dart: computed sqlite native expressions preserve quoted identifiers, comments and SQL-like string values +00:58 +188: test/computed_test.dart: computed sqlite generated CRUD, projections, batch and conflict writes compute stored and virtual values +00:58 +189: test/computed_test.dart: computed sqlite catalog/import roundtrip preserves SQL and modes and detects drift +01:00 +190: test/computed_test.dart: computed sqlite ComputedStorage.stored add, expression/type change and drop preserve existing rows +01:00 +191: test/computed_test.dart: computed sqlite ComputedStorage.virtual add, expression/type change and drop preserve existing rows +01:00 +192: test/computed_test.dart: computed sqlite stored to ordinary materialization retains values and permits later writes +01:00 +193: test/computed_test.dart: computed sqlite explicit renamed expressions preserve indexes, checks, references and views +01:00 +194: test/computed_test.dart: computed sqlite invalid recomputation rolls back the table and migration history +01:00 +195: test/computed_test.dart: computed sqlite decimal computed SQL keeps precision coercion and imports exactly once +01:01 +196: test/computed_test.dart: computed postgres native expressions preserve quoted identifiers, comments and SQL-like string values +01:01 +197: test/computed_test.dart: computed postgres generated CRUD, projections, batch and conflict writes compute stored and virtual values +01:01 +198: test/computed_test.dart: computed postgres catalog/import roundtrip preserves SQL and modes and detects drift +01:01 +199: test/computed_test.dart: computed postgres ComputedStorage.stored add, expression/type change and drop preserve existing rows +01:01 +200: test/computed_test.dart: computed postgres ComputedStorage.virtual add, expression/type change and drop preserve existing rows +01:01 +201: test/computed_test.dart: computed postgres stored to ordinary materialization retains values and permits later writes +01:01 +202: test/computed_test.dart: computed postgres explicit renamed expressions preserve indexes, checks, references and views +01:01 +203: test/computed_test.dart: computed postgres invalid recomputation rolls back the table and migration history +01:01 +204: test/computed_test.dart: computed postgres decimal computed SQL keeps precision coercion and imports exactly once +01:02 +205: test/computed_test.dart: mode conversions need explicit plans and historical copy/backfill cannot write computed columns +01:02 +206: loading test/named_sql_test.dart +01:02 +206: test/named_sql_test.dart: template binds repeated names once and preserves quotes/comments +01:02 +207: test/named_sql_test.dart: templates reject ambiguous bindings and incomplete or multiple statements +01:02 +208: test/named_sql_test.dart: generated client is deterministic and checked against its Dart declaration +01:04 +209: test/named_sql_test.dart: sqlite typed parameters, projection and repeated nullable bindings compose +01:04 +210: test/named_sql_test.dart: sqlite native numeric and temporal codecs survive binding and streaming +01:04 +211: test/named_sql_test.dart: sqlite CTEs and unions retain distinct query arguments and field identity +01:04 +212: test/named_sql_test.dart: sqlite transactions see pending writes and rollback normally +01:04 +213: test/named_sql_test.dart: sqlite named sources reject table mutations and require explicit watch reads +01:04 +214: test/named_sql_test.dart: sqlite native checks accept declared shape and preserve prepared-statement state +01:05 +215: test/named_sql_test.dart: sqlite native checks reject syntax, missing tables and missing result aliases +01:05 +216: test/named_sql_test.dart: sqlite native checks do not evaluate application expressions +01:05 +217: test/named_sql_test.dart: postgres typed parameters, projection and repeated nullable bindings compose +01:05 +218: test/named_sql_test.dart: postgres native numeric and temporal codecs survive binding and streaming +01:05 +219: test/named_sql_test.dart: postgres CTEs and unions retain distinct query arguments and field identity +01:05 +220: test/named_sql_test.dart: postgres transactions see pending writes and rollback normally +01:05 +221: test/named_sql_test.dart: postgres named sources reject table mutations and require explicit watch reads +01:05 +222: test/named_sql_test.dart: postgres native checks accept declared shape and preserve prepared-statement state +01:05 +223: test/named_sql_test.dart: postgres native checks reject syntax, missing tables and missing result aliases +01:05 +224: test/named_sql_test.dart: postgres native checks do not evaluate application expressions +01:05 +225: loading test/named_sql_generation_test.dart +01:05 +225: test/named_sql_generation_test.dart: source contracts reject missing/extra parameters and row metadata +01:10 +226: test/named_sql_generation_test.dart: SQL edits and modified generated code fail freshness checks +01:12 +227: test/named_sql_generation_test.dart: asset builder and standalone generator emit identical SQL contracts +01:13 +228: test/named_sql_generation_test.dart: generated APIs reject missing/wrong parameters, result types and unsupported backends +01:13 +229: test/named_sql_generation_test.dart: CLI checks a persistent SQLite database without creating missing files +01:32 +230: loading test/decimal_average_test.dart +01:32 +230: test/decimal_average_test.dart: integer fractions round once and only bound their final result +01:33 +231: test/decimal_average_test.dart: decimal average sqlite empty null and constant inputs retain aggregate semantics +01:33 +232: test/decimal_average_test.dart: decimal average sqlite all rounding modes match exact signed sums across scales +01:33 +233: test/decimal_average_test.dart: decimal average sqlite valid means survive sums exceeding the finite numeric range +01:35 +234: test/decimal_average_test.dart: decimal average sqlite minimum fractions and outermost rounding scales stay exact +01:35 +235: test/decimal_average_test.dart: decimal average sqlite grouping HAVING ordering and CTE references preserve the mean +01:35 +236: test/decimal_average_test.dart: decimal average sqlite illegal window nesting and aggregate assignments fail before SQL +01:35 +237: test/decimal_average_test.dart: decimal average sqlite windows and rounded window means respect partitions and pagination +01:35 +238: test/decimal_average_test.dart: decimal average sqlite correlated means can update values and failures roll back transactions +01:35 +239: test/decimal_average_test.dart: decimal average sqlite batched relationship windows keep per-parent limits +01:35 +240: test/decimal_average_test.dart: decimal average sqlite sliding windows remove values, reset empty frames and reject varying policies +01:35 +241: test/decimal_average_test.dart: decimal average sqlite window means of grouped sums run after HAVING +01:35 +242: test/decimal_average_test.dart: decimal average postgres empty null and constant inputs retain aggregate semantics +01:35 +243: test/decimal_average_test.dart: decimal average postgres all rounding modes match exact signed sums across scales +01:35 +244: test/decimal_average_test.dart: decimal average postgres valid means survive sums exceeding the finite numeric range +01:35 +245: test/decimal_average_test.dart: decimal average postgres minimum fractions and outermost rounding scales stay exact +01:35 +246: test/decimal_average_test.dart: decimal average postgres grouping HAVING ordering and CTE references preserve the mean +01:35 +247: test/decimal_average_test.dart: decimal average postgres illegal window nesting and aggregate assignments fail before SQL +01:35 +248: test/decimal_average_test.dart: decimal average postgres windows and rounded window means respect partitions and pagination +01:35 +249: test/decimal_average_test.dart: decimal average postgres correlated means can update values and failures roll back transactions +01:35 +250: test/decimal_average_test.dart: decimal average postgres batched relationship windows keep per-parent limits +01:35 +251: test/decimal_average_test.dart: decimal average postgres window means of grouped sums run after HAVING +01:35 +252: test/decimal_average_test.dart: decimal average postgres volatile inputs and window ordering are evaluated once per row +01:35 +253: loading test/public_api_test.dart +01:35 +253: test/public_api_test.dart: generated clients expose row types and an immutable schema +01:35 +254: test/public_api_test.dart: entrypoint exports and runtime dependency boundaries stay explicit +01:41 +255: loading test/instant_test.dart +01:41 +255: test/instant_test.dart: strict UTC parsing handles eras, offsets, endpoints and invalid components +01:41 +256: test/instant_test.dart: UTC defaults and local DateTime inputs do not depend on the process timezone +01:42 +257: test/instant_test.dart: historical migration checksum and timestamp tag remain immutable +01:42 +258: test/instant_test.dart: UTC instant sqlite native round trips and streams preserve exact microseconds and common endpoints +01:42 +259: test/instant_test.dart: UTC instant sqlite chronological comparisons, grouping, windows, union and CTEs use instants +01:42 +260: test/instant_test.dart: UTC instant sqlite offset-equivalent keys join and batch through the same unique index +01:42 +261: test/instant_test.dart: UTC instant sqlite current snapshots, physical catalog and imported DateTime declarations agree +01:44 +262: test/instant_test.dart: UTC instant sqlite offset literals compare by value while expression casts remain visible +01:44 +263: test/instant_test.dart: UTC instant sqlite reviewed migration preserves history and repairs legacy timestamp ordering +01:44 +264: test/instant_test.dart: UTC instant sqlite legacy key collisions and invalid calendar values roll back conversion +01:44 +265: test/instant_test.dart: UTC instant sqlite instant primary-key backfills resume across sub-millisecond boundaries +01:44 +266: test/instant_test.dart: UTC instant sqlite external infinity and out-of-DateTime values fail decoding without wrapping +01:44 +267: test/instant_test.dart: UTC instant postgres native round trips and streams preserve exact microseconds and common endpoints +01:45 +268: test/instant_test.dart: UTC instant postgres chronological comparisons, grouping, windows, union and CTEs use instants +01:45 +269: test/instant_test.dart: UTC instant postgres offset-equivalent keys join and batch through the same unique index +01:45 +270: test/instant_test.dart: UTC instant postgres current snapshots, physical catalog and imported DateTime declarations agree +01:45 +271: test/instant_test.dart: UTC instant postgres offset literals compare by value while expression casts remain visible +01:45 +272: test/instant_test.dart: UTC instant postgres reviewed migration preserves history and repairs legacy timestamp ordering +01:45 +273: test/instant_test.dart: UTC instant postgres instant primary-key backfills resume across sub-millisecond boundaries +01:45 +274: test/instant_test.dart: UTC instant postgres external infinity and out-of-DateTime values fail decoding without wrapping +01:45 +275: loading test/codec_test.dart +01:45 +275: test/codec_test.dart: numeric aggregate text decodes without integer rounding or overflow +01:45 +276: test/codec_test.dart: erased codecs keep checked argument types without function getter casts +01:45 +277: test/codec_test.dart: mapped domain codecs preserve one decode and one encode boundary +01:45 +278: test/codec_test.dart: parsed JSON scalars and document presence have unambiguous decoding +01:45 +279: test/codec_test.dart: enum mappings reject duplicates and do not follow mutable caller maps +01:45 +280: loading test/sql_real_test.dart +01:46 +280: test/sql_real_test.dart: sqlite explicitly typed REAL parameters preserve floating storage +01:46 +281: test/sql_real_test.dart: postgres explicitly typed REAL parameters preserve floating storage +01:46 +282: loading test/migration_source_test.dart +01:46 +282: test/migration_source_test.dart: Dart emission preserves storage metadata and every operation without application imports +01:48 +283: test/migration_source_test.dart: project CLI creates Dart history, upgrades a database and preserves recorded fingerprints +02:58 +284: test/migration_source_test.dart: AOT migration bundle works without Dart sources or migration assets +03:05 +285: loading test/generator_test.dart +03:06 +285: test/generator_test.dart: (setUpAll) +03:06 +285: test/generator_test.dart: target capabilities are checked when choosing a migration engine +03:08 +286: test/generator_test.dart: unusable extension getters and generated symbol collisions fail at generation +03:09 +287: test/generator_test.dart: client and snapshot paths cannot overwrite the declaration +03:09 +288: test/generator_test.dart: record annotations, physical names, relations and snapshots +03:10 +289: test/generator_test.dart: output is deterministic and matches the committed generated client +03:14 +290: test/generator_test.dart: composite keys and more than six fields generate analyzable code +03:15 +291: test/generator_test.dart: computed keys are rejected instead of executing a fake row +03:15 +292: test/generator_test.dart: computed declarations reject conflicting defaults, empty SQL and invalid row identities +03:16 +293: test/generator_test.dart: client defaults reject incompatible, asynchronous, private and ambiguous factories +03:19 +294: test/generator_test.dart: CHECK declarations reject dynamic SQL, empty SQL and duplicate names +03:20 +295: test/generator_test.dart: custom types resolve defining libraries when output moves to another directory +03:20 +296: test/generator_test.dart: nested generic and record types preserve nullable aliases without running codecs +03:21 +297: test/generator_test.dart: invalid custom declaration: wrong_codec +03:21 +298: test/generator_test.dart: invalid custom declaration: nullable_codec +03:21 +299: test/generator_test.dart: invalid custom declaration: erased_extension +03:22 +300: test/generator_test.dart: invalid custom declaration: unknown_storage +03:22 +301: test/generator_test.dart: invalid custom declaration: private_codec +03:22 +302: test/generator_test.dart: invalid custom declaration: private_type +03:22 +303: test/generator_test.dart: invalid custom declaration: inline_codec +03:23 +304: test/generator_test.dart: invalid custom declaration: duplicate_codec +03:23 +305: test/generator_test.dart: invalid custom declaration: missing_codec +03:23 +306: test/generator_test.dart: invalid custom declaration: duplicate_labels +03:23 +307: test/generator_test.dart: invalid custom declaration: duplicate_enum_annotation +03:24 +308: test/generator_test.dart: foreign keys require unique targets +03:24 +309: test/generator_test.dart: query-only edges do not alter physical snapshots or require unique targets +03:24 +310: test/generator_test.dart: constrained and unconstrained edges retain separate migration meaning +03:25 +311: test/generator_test.dart: invalid query-only relation: lookup_computed +03:25 +312: test/generator_test.dart: invalid query-only relation: lookup_codec +03:25 +313: test/generator_test.dart: invalid query-only relation: lookup_inverse +03:25 +314: test/generator_test.dart: invalid query-only relation: lookup_type +03:26 +315: test/generator_test.dart: invalid query-only relation: lookup_delete +03:26 +316: test/generator_test.dart: Dart type errors stop generation +03:26 +317: test/generator_test.dart: duplicate physical names and nullable primary keys are rejected +03:26 +318: test/generator_test.dart: (tearDownAll) +03:26 +318: loading test/transaction_test.dart +03:27 +318: test/transaction_test.dart: transaction control sqlite idle callback expires, rolls back and cannot issue late SQL +03:27 +319: test/transaction_test.dart: transaction control sqlite deadline interrupts active SQL before releasing and reusing the lease +03:27 +320: test/transaction_test.dart: transaction control sqlite interrupted write rolls back and leaves the connection usable +03:27 +321: test/transaction_test.dart: transaction control sqlite explicit transaction cancellation stops an idle callback and active SQL +03:27 +322: test/transaction_test.dart: transaction control sqlite global transaction cancellation also abandons acquisition +03:27 +323: test/transaction_test.dart: transaction control sqlite savepoint inherits deadline, drains and rolls back through raw cleanup +03:27 +324: test/transaction_test.dart: transaction control sqlite deadline closes a paused transactional cursor before rollback +03:27 +325: test/transaction_test.dart: transaction control sqlite deadline interrupts an active transactional cursor fetch +03:27 +326: test/transaction_test.dart: transaction control sqlite transaction clock starts after acquisition and a borrowed session recovers +03:27 +327: test/transaction_test.dart: transaction control sqlite failed rollback is explicit and discards an uncertain connection +03:28 +328: test/transaction_test.dart: transaction control sqlite transaction controls reject adapters without actual cancellation +03:28 +329: test/transaction_test.dart: transaction control sqlite shorter statement limit retains its error and rolls back the transaction +03:28 +330: test/transaction_test.dart: transaction control sqlite monotonic deadline prevents commit after a CPU-bound callback +03:28 +331: test/transaction_test.dart: transaction control sqlite completed scopes remove their timers and cancellation listeners +03:28 +332: test/transaction_test.dart: transaction control sqlite confirmed commit wins a cancellation race; lost acknowledgement stays unknown +03:28 +333: test/transaction_test.dart: transaction control sqlite deferred constraint rejection at COMMIT is a known failure +03:28 +334: test/transaction_test.dart: transaction control sqlite invalid deadlines and already cancelled tokens never begin a transaction +03:28 +335: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=false +03:28 +336: test/transaction_test.dart: transaction control sqlite automatic SQLite rollback prevents late autocommit writes, nested=true +03:28 +337: test/transaction_test.dart: transaction control postgres idle callback expires, rolls back and cannot issue late SQL +03:28 +338: test/transaction_test.dart: transaction control postgres deadline interrupts active SQL before releasing and reusing the lease +03:28 +339: test/transaction_test.dart: transaction control postgres interrupted write rolls back and leaves the connection usable +03:28 +340: test/transaction_test.dart: transaction control postgres explicit transaction cancellation stops an idle callback and active SQL +03:28 +341: test/transaction_test.dart: transaction control postgres global transaction cancellation also abandons acquisition +03:28 +342: test/transaction_test.dart: transaction control postgres savepoint inherits deadline, drains and rolls back through raw cleanup +03:29 +343: test/transaction_test.dart: transaction control postgres deadline closes a paused transactional cursor before rollback +03:29 +344: test/transaction_test.dart: transaction control postgres deadline interrupts an active transactional cursor fetch +03:29 +345: test/transaction_test.dart: transaction control postgres transaction clock starts after acquisition and a borrowed session recovers +03:29 +346: test/transaction_test.dart: transaction control postgres failed rollback is explicit and discards an uncertain connection +03:29 +347: test/transaction_test.dart: transaction control postgres transaction controls reject adapters without actual cancellation +03:29 +348: test/transaction_test.dart: transaction control postgres shorter statement limit retains its error and rolls back the transaction +03:29 +349: test/transaction_test.dart: transaction control postgres monotonic deadline prevents commit after a CPU-bound callback +03:29 +350: test/transaction_test.dart: transaction control postgres completed scopes remove their timers and cancellation listeners +03:29 +351: test/transaction_test.dart: transaction control postgres confirmed commit wins a cancellation race; lost acknowledgement stays unknown +03:29 +352: test/transaction_test.dart: transaction control postgres deferred constraint rejection at COMMIT is a known failure +03:29 +353: test/transaction_test.dart: transaction control postgres invalid deadlines and already cancelled tokens never begin a transaction +03:29 +354: test/transaction_test.dart: transaction control postgres SQLSTATE 40003 at COMMIT is never classified as a confirmed rejection +03:29 +355: test/transaction_test.dart: transaction control postgres real PostgreSQL serialization failure is classified after rollback +03:29 +356: test/transaction_test.dart: transaction control postgres real PostgreSQL deadlock rolls back exactly one transaction +03:30 +357: test/transaction_test.dart: SQLite COMMIT busy is a known rejection followed by verified rollback +03:31 +358: test/transaction_test.dart: SQLite deadline while BEGIN waits for a writer preserves its connection +03:31 +359: loading test/backfill_keys_test.dart +03:31 +359: test/backfill_keys_test.dart: backfill keys sqlite integer cursors preserve native values across resume +03:31 +360: test/backfill_keys_test.dart: backfill keys sqlite bigint cursors preserve native values across resume +03:31 +361: test/backfill_keys_test.dart: backfill keys sqlite text cursors preserve native values across resume +03:31 +362: test/backfill_keys_test.dart: backfill keys sqlite real cursors preserve native values across resume +03:31 +363: test/backfill_keys_test.dart: backfill keys sqlite boolean cursors preserve native values across resume +03:31 +364: test/backfill_keys_test.dart: backfill keys sqlite timestamp cursors preserve native values across resume +03:31 +365: test/backfill_keys_test.dart: backfill keys sqlite blob cursors preserve native values across resume +03:31 +366: test/backfill_keys_test.dart: backfill keys sqlite composite key batches account for every bound parameter +03:31 +367: test/backfill_keys_test.dart: backfill keys postgres integer cursors preserve native values across resume +03:31 +368: test/backfill_keys_test.dart: backfill keys postgres bigint cursors preserve native values across resume +03:31 +369: test/backfill_keys_test.dart: backfill keys postgres text cursors preserve native values across resume +03:31 +370: test/backfill_keys_test.dart: backfill keys postgres real cursors preserve native values across resume +03:31 +371: test/backfill_keys_test.dart: backfill keys postgres boolean cursors preserve native values across resume +03:31 +372: test/backfill_keys_test.dart: backfill keys postgres timestamp cursors preserve native values across resume +03:32 +373: test/backfill_keys_test.dart: backfill keys postgres blob cursors preserve native values across resume +03:32 +374: test/backfill_keys_test.dart: backfill keys postgres composite key batches account for every bound parameter +03:32 +375: test/backfill_keys_test.dart: invalid key, assignments and completion declarations fail before execution +03:32 +376: loading test/retry_test.dart +03:32 +376: test/retry_test.dart: retry sqlite opt-in retries roll back all writes and use new closed-over views +03:32 +377: test/retry_test.dart: retry sqlite default is one attempt; an explicit attempt limit is exact +03:32 +378: test/retry_test.dart: retry sqlite application and constraint errors and caught statement failures do not replay +03:32 +379: test/retry_test.dart: retry sqlite unknown commit and unconfirmed rollback prohibit replay +03:32 +380: test/retry_test.dart: retry sqlite retry time budget bounds active SQL and does not retry cancellation +03:32 +381: test/retry_test.dart: retry sqlite deadline and cancellation interrupt backoff without another callback +03:32 +382: test/retry_test.dart: retry sqlite time budget includes acquisition and abandons late callbacks +03:32 +383: test/retry_test.dart: retry sqlite transaction timeout is shared across attempts and sessions recover after expiry +03:32 +384: test/retry_test.dart: retry sqlite subscriptions publish only the final committed retry result +03:32 +385: test/retry_test.dart: retry sqlite invalid policies reject before SQL +03:32 +386: test/retry_test.dart: retry postgres opt-in retries roll back all writes and use new closed-over views +03:32 +387: test/retry_test.dart: retry postgres default is one attempt; an explicit attempt limit is exact +03:32 +388: test/retry_test.dart: retry postgres application and constraint errors and caught statement failures do not replay +03:32 +389: test/retry_test.dart: retry postgres unknown commit and unconfirmed rollback prohibit replay +03:32 +390: test/retry_test.dart: retry postgres retry time budget bounds active SQL and does not retry cancellation +03:32 +391: test/retry_test.dart: retry postgres deadline and cancellation interrupt backoff without another callback +03:33 +392: test/retry_test.dart: retry postgres time budget includes acquisition and abandons late callbacks +03:33 +393: test/retry_test.dart: retry postgres transaction timeout is shared across attempts and sessions recover after expiry +03:33 +394: test/retry_test.dart: retry postgres subscriptions publish only the final committed retry result +03:33 +395: test/retry_test.dart: retry postgres invalid policies reject before SQL +03:33 +396: test/retry_test.dart: retry postgres real unknown COMMIT response prohibits opted-in retry +03:33 +397: test/retry_test.dart: retry postgres real serialization conflict repeats the reads and decisions +03:33 +398: test/retry_test.dart: retry postgres real deadlock retries only the aborted callback +03:34 +399: test/retry_test.dart: SQLite WAL snapshot conflict replays reads after rollback +03:34 +400: test/retry_test.dart: SQLite real busy COMMIT: success +03:34 +401: test/retry_test.dart: SQLite real busy COMMIT: exhausted +03:34 +402: test/retry_test.dart: SQLite real busy COMMIT: deadline +03:34 +403: test/retry_test.dart: SQLite real busy COMMIT: cancel +03:34 +404: test/retry_test.dart: SQLite real busy COMMIT: shared-budget +03:34 +405: loading test/builder_test.dart +03:34 +405: test/builder_test.dart: build assets and standalone CLI produce identical client and snapshot +03:37 +406: test/builder_test.dart: multiple explicit schema roots resolve shared public types under lib +03:38 +407: test/builder_test.dart: invalid semantic_error never publishes partial outputs +03:39 +408: test/builder_test.dart: invalid enum_labels never publishes partial outputs +03:39 +409: test/builder_test.dart: invalid no_models never publishes partial outputs +03:40 +410: test/builder_test.dart: invalid part_file never publishes partial outputs +03:41 +411: test/builder_test.dart: unknown options fail instead of silently changing build behavior +03:41 +412: test/builder_test.dart: schema resolution can consume an earlier builder output +03:42 +413: loading test/temporal_precision_test.dart +03:42 +413: test/temporal_precision_test.dart: temporal precision sqlite generated writes, defaults and computed columns use database precision +03:42 +414: test/temporal_precision_test.dart: temporal precision sqlite SQL casts match all precisions on both sides of the PostgreSQL epoch +03:42 +415: test/temporal_precision_test.dart: temporal precision sqlite nulls, CTE references, grouping, streams and wrong scopes retain type semantics +03:42 +416: test/temporal_precision_test.dart: temporal precision sqlite rounded keys work in batch writes, upserts and foreign-key relation loading +03:42 +417: test/temporal_precision_test.dart: temporal precision sqlite catalog import preserves precision, defaults and computed expressions +03:44 +418: test/temporal_precision_test.dart: temporal precision sqlite narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +03:44 +419: test/temporal_precision_test.dart: temporal precision sqlite timestamp defaults and explicit six-digit catalogs retain precision +03:44 +420: test/temporal_precision_test.dart: temporal precision sqlite extended timestamps round in SQL without total-microsecond overflow +03:44 +421: test/temporal_precision_test.dart: temporal precision sqlite raw SQL respects native or managed precision constraints +03:44 +422: test/temporal_precision_test.dart: temporal precision postgres generated writes, defaults and computed columns use database precision +03:44 +423: test/temporal_precision_test.dart: temporal precision postgres SQL casts match all precisions on both sides of the PostgreSQL epoch +03:44 +424: test/temporal_precision_test.dart: temporal precision postgres nulls, CTE references, grouping, streams and wrong scopes retain type semantics +03:44 +425: test/temporal_precision_test.dart: temporal precision postgres rounded keys work in batch writes, upserts and foreign-key relation loading +03:44 +426: test/temporal_precision_test.dart: temporal precision postgres catalog import preserves precision, defaults and computed expressions +03:45 +427: test/temporal_precision_test.dart: temporal precision postgres narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history +03:45 +428: test/temporal_precision_test.dart: temporal precision postgres timestamp defaults and explicit six-digit catalogs retain precision +03:45 +429: test/temporal_precision_test.dart: temporal precision postgres extended timestamps round in SQL without total-microsecond overflow +03:45 +430: test/temporal_precision_test.dart: temporal precision postgres raw SQL respects native or managed precision constraints +03:45 +431: test/temporal_precision_test.dart: value rounding keeps extended ranges and rejects overflow/invalid precision +03:45 +432: test/temporal_precision_test.dart: default and explicit six preserve historical snapshots and migration checksums +03:45 +433: test/temporal_precision_test.dart: generator rejects invalid annotations and preserves generated fixture metadata +03:47 +434: loading test/observation_test.dart +03:47 +434: test/observation_test.dart: observation sqlite actual lease queue and synchronous mapping have separate clocks +03:47 +435: test/observation_test.dart: observation sqlite expired acquisition emits one failure and no late SQL +03:47 +436: test/observation_test.dart: observation sqlite cancelled acquisition emits one failure and no late SQL +03:47 +437: test/observation_test.dart: observation sqlite sessions transactions and savepoints inherit hooks and report reused leases +03:47 +438: test/observation_test.dart: observation sqlite nested reads attribute decoding to each statement without repeating SQL +03:47 +439: test/observation_test.dart: observation sqlite SQL and mapper failures belong to their own phases +03:47 +440: test/observation_test.dart: observation sqlite cursor batches and returning writes produce decoding observations +03:47 +441: test/observation_test.dart: observation sqlite observer exceptions preserve successful commits and original mapper errors +03:47 +442: test/observation_test.dart: observation postgres actual lease queue and synchronous mapping have separate clocks +03:47 +443: test/observation_test.dart: observation postgres expired acquisition emits one failure and no late SQL +03:47 +444: test/observation_test.dart: observation postgres cancelled acquisition emits one failure and no late SQL +03:47 +445: test/observation_test.dart: observation postgres sessions transactions and savepoints inherit hooks and report reused leases +03:47 +446: test/observation_test.dart: observation postgres nested reads attribute decoding to each statement without repeating SQL +03:47 +447: test/observation_test.dart: observation postgres SQL and mapper failures belong to their own phases +03:48 +448: test/observation_test.dart: observation postgres cursor batches and returning writes produce decoding observations +03:48 +449: test/observation_test.dart: observation postgres observer exceptions preserve successful commits and original mapper errors +03:48 +450: test/observation_test.dart: synchronous driver acquisition failures are reported once +03:48 +451: loading test/integer_test.dart +03:48 +451: test/integer_test.dart: integer widths sqlite storage bounds, nulls and wider sums keep the normal integer value codec +03:48 +452: test/integer_test.dart: integer widths sqlite relations, union and cursors share int expressions across physical widths +03:48 +453: test/integer_test.dart: integer widths sqlite defaulted and quoted columns retain enforced nullable bounds +03:48 +454: test/integer_test.dart: integer widths sqlite Dart snapshots and imports retain widths without confusing value codecs +03:50 +455: test/integer_test.dart: integer widths sqlite widening and narrowing use reviewed conversions with atomic failure recovery +03:50 +456: test/integer_test.dart: integer widths sqlite column renames preserve width metadata and range constraints +03:50 +457: test/integer_test.dart: integer widths sqlite historical bounded backfills verify widths and resume normally +03:50 +458: test/integer_test.dart: integer widths sqlite changing an identity width preserves generation and rejects out-of-range explicit IDs +03:50 +459: test/integer_test.dart: integer widths sqlite quoted defaults and comments cannot masquerade as managed range checks +03:50 +460: test/integer_test.dart: integer widths sqlite extra weaker expressions remain unmanaged and cannot claim a narrower width +03:50 +461: test/integer_test.dart: integer widths postgres storage bounds, nulls and wider sums keep the normal integer value codec +03:50 +462: test/integer_test.dart: integer widths postgres relations, union and cursors share int expressions across physical widths +03:50 +463: test/integer_test.dart: integer widths postgres defaulted and quoted columns retain enforced nullable bounds +03:50 +464: test/integer_test.dart: integer widths postgres Dart snapshots and imports retain widths without confusing value codecs +03:50 +465: test/integer_test.dart: integer widths postgres widening and narrowing use reviewed conversions with atomic failure recovery +03:51 +466: test/integer_test.dart: integer widths postgres column renames preserve width metadata and range constraints +03:51 +467: test/integer_test.dart: integer widths postgres historical bounded backfills verify widths and resume normally +03:51 +468: test/integer_test.dart: integer widths postgres changing an identity width preserves generation and rejects out-of-range explicit IDs +03:51 +469: test/integer_test.dart: analyzer validates width annotations and integer-backed domain codecs +03:52 +470: test/integer_test.dart: width declarations reject wrong kinds and ranges without changing default snapshots +03:52 +471: loading test/relation_test.dart +03:52 +471: test/relation_test.dart: sqlite automatic unique joins preserve root pagination in one statement +03:52 +472: test/relation_test.dart: sqlite all-null projections still distinguish a present row from absence +03:52 +473: test/relation_test.dart: sqlite explicit batch deduplicates composite keys and skips partial nulls +03:52 +474: test/relation_test.dart: sqlite composite chunks include filter and window parameters with nested joins +03:52 +475: test/relation_test.dart: sqlite nested self joins and two edges to the same table use distinct aliases +03:52 +476: test/relation_test.dart: sqlite batch collection below a joined optional parent loads on the same plan +03:52 +477: test/relation_test.dart: sqlite join refuses unproven cardinality while automatic batch checks it +03:52 +478: test/relation_test.dart: sqlite filters and to-one pagination do not drop root rows +03:52 +479: test/relation_test.dart: sqlite joined records survive CTE projection and cursor decoding +03:52 +480: test/relation_test.dart: sqlite required tests row presence independently of nullable selected values +03:52 +481: test/relation_test.dart: sqlite relation aggregates restore the alias used by a joined selection +03:52 +482: test/relation_test.dart: sqlite reusing an identical edge deduplicates its join +03:52 +483: test/relation_test.dart: sqlite different filters need fresh relation occurrences +03:52 +484: test/relation_test.dart: sqlite large composite batches avoid expression-depth failure +03:52 +485: test/relation_test.dart: postgres automatic unique joins preserve root pagination in one statement +03:52 +486: test/relation_test.dart: postgres all-null projections still distinguish a present row from absence +03:52 +487: test/relation_test.dart: postgres explicit batch deduplicates composite keys and skips partial nulls +03:52 +488: test/relation_test.dart: postgres composite chunks include filter and window parameters with nested joins +03:52 +489: test/relation_test.dart: postgres nested self joins and two edges to the same table use distinct aliases +03:52 +490: test/relation_test.dart: postgres batch collection below a joined optional parent loads on the same plan +03:52 +491: test/relation_test.dart: postgres join refuses unproven cardinality while automatic batch checks it +03:52 +492: test/relation_test.dart: postgres filters and to-one pagination do not drop root rows +03:53 +493: test/relation_test.dart: postgres joined records survive CTE projection and cursor decoding +03:53 +494: test/relation_test.dart: postgres required tests row presence independently of nullable selected values +03:53 +495: test/relation_test.dart: postgres relation aggregates restore the alias used by a joined selection +03:53 +496: test/relation_test.dart: postgres reusing an identical edge deduplicates its join +03:53 +497: test/relation_test.dart: postgres different filters need fresh relation occurrences +03:53 +498: test/relation_test.dart: postgres large composite batches avoid expression-depth failure +03:53 +499: loading test/schema_boundary_test.dart +03:53 +499: test/schema_boundary_test.dart: schema rejects malformed names, keys and local FK definitions offline +03:53 +500: test/schema_boundary_test.dart: identifier case is resolved for the selected database +03:53 +501: test/schema_boundary_test.dart: generated sqlite schema executes its native computed-key contract +03:55 +502: test/schema_boundary_test.dart: generated postgres schema executes its native computed-key contract +03:56 +503: loading test/generated_database_test.dart +03:56 +503: test/generated_database_test.dart: (setUpAll) +03:56 +503: test/generated_database_test.dart: generated sqlite generated client, DDL, relations, dates and patches work together +03:56 +504: test/generated_database_test.dart: generated sqlite planning is read-only and repeat migration is a no-op +03:56 +505: test/generated_database_test.dart: generated sqlite compiled physical snapshot and full managed catalog comparison +03:56 +506: test/generated_database_test.dart: generated sqlite baseline preserves existing rows and verifies keys before recording history +03:56 +507: test/generated_database_test.dart: generated sqlite full verification sees defaults and additional unique constraints +03:56 +508: test/generated_database_test.dart: generated sqlite history tampering and missing applied migrations fail +03:56 +509: test/generated_database_test.dart: generated sqlite failed DDL rolls back schema and history atomically +03:56 +510: test/generated_database_test.dart: generated sqlite actual column drift is detected independently of history +03:56 +511: test/generated_database_test.dart: generated sqlite transaction control cannot be hidden behind SQL comments +03:56 +512: test/generated_database_test.dart: generated sqlite concurrent migration callers serialize against shared history +03:56 +513: test/generated_database_test.dart: generated postgres generated client, DDL, relations, dates and patches work together +03:56 +514: test/generated_database_test.dart: generated postgres planning is read-only and repeat migration is a no-op +03:56 +515: test/generated_database_test.dart: generated postgres compiled physical snapshot and full managed catalog comparison +03:56 +516: test/generated_database_test.dart: generated postgres baseline preserves existing rows and verifies keys before recording history +03:56 +517: test/generated_database_test.dart: generated postgres full verification sees defaults and additional unique constraints +03:56 +518: test/generated_database_test.dart: generated postgres history tampering and missing applied migrations fail +03:56 +519: test/generated_database_test.dart: generated postgres failed DDL rolls back schema and history atomically +03:56 +520: test/generated_database_test.dart: generated postgres actual column drift is detected independently of history +03:56 +521: test/generated_database_test.dart: generated postgres transaction control cannot be hidden behind SQL comments +03:56 +522: test/generated_database_test.dart: generated postgres concurrent migration callers serialize against shared history +03:56 +523: test/generated_database_test.dart: (tearDownAll) +03:56 +523: loading test/import_test.dart +03:57 +523: test/import_test.dart: catalog import sqlite imports records, identities, defaults and constraints then baselines unchanged rows +04:00 +524: test/import_test.dart: catalog import sqlite normalizes reserved, colliding and hostile identifiers without changing physical names +04:01 +525: test/import_test.dart: catalog import sqlite composite and self relations preserve key order and unique indexes +04:02 +526: test/import_test.dart: catalog import sqlite unsupported storage excludes its table while computed SQL imports with a review note +04:02 +527: test/import_test.dart: catalog import sqlite read-only snapshot and selected scope reject temporary shadowing and active transactions +04:02 +528: test/import_test.dart: catalog import sqlite physical storage mapping does not infer domain types from data +04:03 +529: test/import_test.dart: catalog import sqlite views triggers and specialized indexes remain in the review report +04:03 +530: test/import_test.dart: catalog import sqlite nullable primary keys, virtual tables and their storage tables need explicit handling +04:03 +531: test/import_test.dart: catalog import sqlite foreign keys with coercible but different Dart types are reported +04:04 +532: test/import_test.dart: catalog import postgres imports records, identities, defaults and constraints then baselines unchanged rows +04:05 +533: test/import_test.dart: catalog import postgres normalizes reserved, colliding and hostile identifiers without changing physical names +04:06 +534: test/import_test.dart: catalog import postgres composite and self relations preserve key order and unique indexes +04:06 +535: test/import_test.dart: catalog import postgres unsupported storage excludes its table while computed SQL imports with a review note +04:07 +536: test/import_test.dart: catalog import postgres read-only snapshot and selected scope reject temporary shadowing and active transactions +04:07 +537: test/import_test.dart: catalog import postgres physical storage mapping does not infer domain types from data +04:07 +538: test/import_test.dart: catalog import postgres views triggers and specialized indexes remain in the review report +04:07 +539: test/import_test.dart: catalog import postgres policy reports retain roles, commands, mode and row-security state +04:07 +540: test/import_test.dart: catalog import postgres always identity and inherited tables are not flattened into ordinary writable models +04:08 +541: loading test/database_test.dart +04:08 +541: test/database_test.dart: sqlite typed CRUD, exact projection and parameter binding +04:08 +542: test/database_test.dart: sqlite record and DTO mapping run only when decoding +04:08 +543: test/database_test.dart: sqlite immutable filters, empty IN, null and pagination +04:08 +544: test/database_test.dart: sqlite cross-table identity and duplicate assignments rejected before IO +04:08 +545: test/database_test.dart: sqlite commit, rollback, savepoint recovery and escaped session +04:08 +546: test/database_test.dart: sqlite caught statement failures cannot masquerade as a commit +04:08 +547: test/database_test.dart: sqlite recoverable constraint errors stay within their savepoint +04:08 +548: test/database_test.dart: sqlite unawaited work is drained and rolled back +04:08 +549: test/database_test.dart: sqlite close waits for active operations and rejects further use +04:08 +550: test/database_test.dart: sqlite leased sessions retain one connection across transactions and expire +04:08 +551: test/database_test.dart: sqlite native upsert updates atomically using existing and incoming values +04:08 +552: test/database_test.dart: sqlite bulk inserts split at parameter limits and opt into returning +04:08 +553: test/database_test.dart: sqlite a failed later bulk chunk rolls back all previous chunks +04:08 +554: test/database_test.dart: sqlite bulk optional field shapes preserve defaults and explicit null +04:08 +555: test/database_test.dart: sqlite relations CTEs preserve SQL column identity through mapped projections +04:08 +556: test/database_test.dart: sqlite relations explicit aliases support multiple joins and self joins +04:08 +557: test/database_test.dart: sqlite relations left join distinguishes a missing row from an all-null selection +04:08 +558: test/database_test.dart: sqlite relations correlated scalar, IN and EXISTS use nested SQL scopes +04:08 +559: test/database_test.dart: sqlite relations grouping is checked and window functions preserve row cardinality +04:08 +560: test/database_test.dart: sqlite relations root pagination and each parent limit use two queries +04:08 +561: test/database_test.dart: sqlite relations empty roots skip child queries; empty lists remain typed lists +04:08 +562: test/database_test.dart: sqlite relations nested relation projections and optional all-null projection +04:08 +563: test/database_test.dart: sqlite relations correlated any, none, count and every with SQL nulls +04:08 +564: test/database_test.dart: sqlite relations relation parameters bind correctly in projection and filter +04:08 +565: test/database_test.dart: sqlite keyset pagination uses a unique tie breaker in mixed directions +04:08 +566: test/database_test.dart: sqlite keyset pagination handles null order and validates versioned tokens +04:08 +567: test/database_test.dart: postgres typed CRUD, exact projection and parameter binding +04:08 +568: test/database_test.dart: postgres record and DTO mapping run only when decoding +04:08 +569: test/database_test.dart: postgres immutable filters, empty IN, null and pagination +04:08 +570: test/database_test.dart: postgres cross-table identity and duplicate assignments rejected before IO +04:08 +571: test/database_test.dart: postgres commit, rollback, savepoint recovery and escaped session +04:08 +572: test/database_test.dart: postgres caught statement failures cannot masquerade as a commit +04:08 +573: test/database_test.dart: postgres recoverable constraint errors stay within their savepoint +04:08 +574: test/database_test.dart: postgres unawaited work is drained and rolled back +04:08 +575: test/database_test.dart: postgres close waits for active operations and rejects further use +04:08 +576: test/database_test.dart: postgres leased sessions retain one connection across transactions and expire +04:08 +577: test/database_test.dart: postgres native upsert updates atomically using existing and incoming values +04:08 +578: test/database_test.dart: postgres bulk inserts split at parameter limits and opt into returning +04:08 +579: test/database_test.dart: postgres a failed later bulk chunk rolls back all previous chunks +04:08 +580: test/database_test.dart: postgres bulk optional field shapes preserve defaults and explicit null +04:08 +581: test/database_test.dart: postgres relations CTEs preserve SQL column identity through mapped projections +04:08 +582: test/database_test.dart: postgres relations explicit aliases support multiple joins and self joins +04:08 +583: test/database_test.dart: postgres relations left join distinguishes a missing row from an all-null selection +04:08 +584: test/database_test.dart: postgres relations correlated scalar, IN and EXISTS use nested SQL scopes +04:08 +585: test/database_test.dart: postgres relations grouping is checked and window functions preserve row cardinality +04:08 +586: test/database_test.dart: postgres relations root pagination and each parent limit use two queries +04:08 +587: test/database_test.dart: postgres relations empty roots skip child queries; empty lists remain typed lists +04:08 +588: test/database_test.dart: postgres relations nested relation projections and optional all-null projection +04:08 +589: test/database_test.dart: postgres relations correlated any, none, count and every with SQL nulls +04:08 +590: test/database_test.dart: postgres relations relation parameters bind correctly in projection and filter +04:08 +591: test/database_test.dart: postgres keyset pagination uses a unique tie breaker in mixed directions +04:08 +592: test/database_test.dart: postgres keyset pagination handles null order and validates versioned tokens +04:08 +593: loading test/union_test.dart +04:08 +593: test/union_test.dart: union sqlite SQL duplicate and NULL semantics use one statement +04:08 +594: test/union_test.dart: union sqlite heterogeneous records map only after SQL union and final pagination +04:08 +595: test/union_test.dart: union sqlite operand limits, order, parameters and final filter stay scoped +04:08 +596: test/union_test.dart: union sqlite repeated SQL expressions preserve positional columns +04:08 +597: test/union_test.dart: union sqlite nested sets preserve association, CTE exports and streaming +04:08 +598: test/union_test.dart: union sqlite same CTE names in distinct operands stay in separate scopes +04:08 +599: test/union_test.dart: union sqlite SQL scalar, IN subqueries, aggregate and zero-row results compose +04:08 +600: test/union_test.dart: union sqlite grouped branches and window branches remain SQL queries +04:08 +601: test/union_test.dart: union sqlite bound constant projections retain Dart types +04:08 +602: test/union_test.dart: union sqlite Dart maps and relation selections are rejected before execution +04:08 +603: test/union_test.dart: union sqlite codecs and nullability cannot silently change across operands +04:08 +604: test/union_test.dart: union sqlite shared domain codecs work, and nullable wrappers retain decoding semantics +04:08 +605: test/union_test.dart: union sqlite export references and outer join nullability are guarded +04:08 +606: test/union_test.dart: union sqlite compound mutations and mixed sessions fail before SQL +04:08 +607: test/union_test.dart: union sqlite all record arities use typed SQL decoding +04:08 +608: test/union_test.dart: union sqlite subscription observes committed changes in both operands +04:08 +609: test/union_test.dart: union postgres SQL duplicate and NULL semantics use one statement +04:08 +610: test/union_test.dart: union postgres heterogeneous records map only after SQL union and final pagination +04:08 +611: test/union_test.dart: union postgres operand limits, order, parameters and final filter stay scoped +04:08 +612: test/union_test.dart: union postgres repeated SQL expressions preserve positional columns +04:08 +613: test/union_test.dart: union postgres nested sets preserve association, CTE exports and streaming +04:08 +614: test/union_test.dart: union postgres same CTE names in distinct operands stay in separate scopes +04:09 +615: test/union_test.dart: union postgres SQL scalar, IN subqueries, aggregate and zero-row results compose +04:09 +616: test/union_test.dart: union postgres grouped branches and window branches remain SQL queries +04:09 +617: test/union_test.dart: union postgres bound constant projections retain Dart types +04:09 +618: test/union_test.dart: union postgres Dart maps and relation selections are rejected before execution +04:09 +619: test/union_test.dart: union postgres codecs and nullability cannot silently change across operands +04:09 +620: test/union_test.dart: union postgres shared domain codecs work, and nullable wrappers retain decoding semantics +04:09 +621: test/union_test.dart: union postgres export references and outer join nullability are guarded +04:09 +622: test/union_test.dart: union postgres compound mutations and mixed sessions fail before SQL +04:09 +623: test/union_test.dart: union postgres all record arities use typed SQL decoding +04:09 +624: test/union_test.dart: union postgres subscription observes committed changes in both operands +04:09 +625: loading test/import_cli_test.dart +04:09 +625: test/import_cli_test.dart: CLI sqlite imports reviews generates and baselines existing data without replacing files +04:51 +626: test/import_cli_test.dart: CLI postgres imports reviews generates and baselines existing data without replacing files +05:28 +627: loading test/decimal_division_test.dart +05:29 +627: test/decimal_division_test.dart: decimal division sqlite signed modes and scales match exact integer arithmetic +05:29 +628: test/decimal_division_test.dart: decimal division sqlite exact defaults reject lost digits and zero divisors, NULL propagates +05:29 +629: test/decimal_division_test.dart: decimal division sqlite precision exceeds native division scale without double rounding +05:29 +630: test/decimal_division_test.dart: decimal division sqlite huge remainders avoid overflowing intermediate scaled products +05:50 +631: test/decimal_division_test.dart: decimal division sqlite outermost integer scale and result overflow are explicit +05:51 +632: test/decimal_division_test.dart: decimal division sqlite aggregates windows CTEs predicates and unions compose +05:51 +633: test/decimal_division_test.dart: decimal division sqlite expression assignments and transaction rollback preserve values +05:51 +634: test/decimal_division_test.dart: decimal division sqlite window filtering ordering DISTINCT and pagination keep their SQL order +05:51 +635: test/decimal_division_test.dart: decimal division sqlite windows over grouped aggregates run after HAVING +05:51 +636: test/decimal_division_test.dart: decimal division sqlite window results compose through CTEs sets and correlated subqueries +05:51 +637: test/decimal_division_test.dart: decimal division sqlite batched relations retain per-parent window pagination +05:51 +638: test/decimal_division_test.dart: decimal division postgres signed modes and scales match exact integer arithmetic +05:51 +639: test/decimal_division_test.dart: decimal division postgres exact defaults reject lost digits and zero divisors, NULL propagates +05:51 +640: test/decimal_division_test.dart: decimal division postgres precision exceeds native division scale without double rounding +05:51 +641: test/decimal_division_test.dart: decimal division postgres huge remainders avoid overflowing intermediate scaled products +06:11 +642: test/decimal_division_test.dart: decimal division postgres outermost integer scale and result overflow are explicit +06:12 +643: test/decimal_division_test.dart: decimal division postgres aggregates windows CTEs predicates and unions compose +06:12 +644: test/decimal_division_test.dart: decimal division postgres expression assignments and transaction rollback preserve values +06:12 +645: test/decimal_division_test.dart: decimal division postgres window filtering ordering DISTINCT and pagination keep their SQL order +06:12 +646: test/decimal_division_test.dart: decimal division postgres windows over grouped aggregates run after HAVING +06:12 +647: test/decimal_division_test.dart: decimal division postgres window results compose through CTEs sets and correlated subqueries +06:12 +648: test/decimal_division_test.dart: decimal division postgres batched relations retain per-parent window pagination +06:12 +649: test/decimal_division_test.dart: decimal division postgres volatile operands are evaluated exactly once each +06:12 +650: loading test/query_boundary_test.dart +06:12 +650: test/query_boundary_test.dart: one failing cancellation listener cannot block other operations +06:12 +651: test/query_boundary_test.dart: sqlite explicit Backend construction rejects wrong transaction options before SQL +06:12 +652: test/query_boundary_test.dart: sqlite every close caller waits until the same pending lease is drained +06:12 +653: test/query_boundary_test.dart: sqlite subqueries and CTEs cannot escape their database or transaction +06:12 +654: test/query_boundary_test.dart: sqlite expired query cannot be embedded in its former parent +06:12 +655: test/query_boundary_test.dart: sqlite optional presence covers only its own left join +06:12 +656: test/query_boundary_test.dart: sqlite nested CTE decoding retains its nullable presence guards +06:12 +657: test/query_boundary_test.dart: sqlite mutation predicates and assignments reject aggregate/window SQL before execution +06:12 +658: test/query_boundary_test.dart: sqlite returning rejects empty selections and joined relationships +06:12 +659: test/query_boundary_test.dart: postgres explicit Backend construction rejects wrong transaction options before SQL +06:12 +660: test/query_boundary_test.dart: postgres every close caller waits until the same pending lease is drained +06:12 +661: test/query_boundary_test.dart: postgres subqueries and CTEs cannot escape their database or transaction +06:12 +662: test/query_boundary_test.dart: postgres expired query cannot be embedded in its former parent +06:12 +663: test/query_boundary_test.dart: postgres optional presence covers only its own left join +06:12 +664: test/query_boundary_test.dart: postgres nested CTE decoding retains its nullable presence guards +06:12 +665: test/query_boundary_test.dart: postgres mutation predicates and assignments reject aggregate/window SQL before execution +06:12 +666: test/query_boundary_test.dart: postgres returning rejects empty selections and joined relationships +06:12 +667: loading test/cli_test.dart +06:12 +667: test/cli_test.dart: Dart CLI generates, reviews, upgrades and verifies constraints without overwriting history +07:11 +668: test/cli_test.dart: Dart CLI baselines existing rows; read-only connections and invalid options reject writes +07:34 +669: test/cli_test.dart: Dart PostgreSQL configuration supports checked nontransactional recovery operations +08:12 +670: loading test/decimal_test.dart +08:12 +670: test/decimal_test.dart: exact decimal sqlite generated writes, defaults, patches and projections preserve all digits +08:12 +671: test/decimal_test.dart: exact decimal sqlite numeric ordering, predicates, IN, distinct and stable cursors +08:12 +672: test/decimal_test.dart: exact decimal sqlite sums, min/max, grouped aggregates and sliding windows stay exact +08:12 +673: test/decimal_test.dart: exact decimal sqlite computed CTEs, union results, subqueries and streaming keep decimal semantics +08:12 +674: test/decimal_test.dart: exact decimal sqlite numeric primary keys, foreign keys, joined and batched relations +08:12 +675: test/decimal_test.dart: exact decimal sqlite snapshots, catalog verification and imported declarations retain decimal storage +08:14 +676: test/decimal_test.dart: exact decimal sqlite aggregate intermediate overflow may cancel before a valid final result +08:14 +677: test/decimal_test.dart: exact decimal sqlite historical decimal keys resume backfills without lexical ordering or precision loss +08:14 +678: test/decimal_test.dart: exact decimal sqlite reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +08:14 +679: test/decimal_test.dart: exact decimal sqlite invalid external values fail decoding; later valid queries still work +08:14 +680: test/decimal_test.dart: exact decimal postgres generated writes, defaults, patches and projections preserve all digits +08:14 +681: test/decimal_test.dart: exact decimal postgres numeric ordering, predicates, IN, distinct and stable cursors +08:14 +682: test/decimal_test.dart: exact decimal postgres sums, min/max, grouped aggregates and sliding windows stay exact +08:14 +683: test/decimal_test.dart: exact decimal postgres computed CTEs, union results, subqueries and streaming keep decimal semantics +08:14 +684: test/decimal_test.dart: exact decimal postgres numeric primary keys, foreign keys, joined and batched relations +08:14 +685: test/decimal_test.dart: exact decimal postgres snapshots, catalog verification and imported declarations retain decimal storage +08:15 +686: test/decimal_test.dart: exact decimal postgres aggregate intermediate overflow may cancel before a valid final result +08:15 +687: test/decimal_test.dart: exact decimal postgres historical decimal keys resume backfills without lexical ordering or precision loss +08:15 +688: test/decimal_test.dart: exact decimal postgres reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back +08:15 +689: test/decimal_test.dart: exact decimal postgres invalid external values fail decoding; later valid queries still work +08:15 +690: test/decimal_test.dart: decimal SQL rejects drivers without exact operations before execution +08:15 +691: test/decimal_test.dart: SQLite recognizes only column decimal collations, and detects drift +08:15 +692: loading test/watch_test.dart +08:15 +692: test/watch_test.dart: independent native SQLite workers require explicit notification +08:15 +693: test/watch_test.dart: watch sqlite lazy initial snapshot and relevant table invalidation +08:15 +694: test/watch_test.dart: watch sqlite transactions publish one snapshot after commit and none on rollback +08:15 +695: test/watch_test.dart: watch sqlite savepoint changes merge on release and disappear on rollback +08:15 +696: test/watch_test.dart: watch sqlite failed statements, zero-row writes and conflict no-ops do not refresh +08:15 +697: test/watch_test.dart: watch sqlite batch writes and upsert returning publish committed changes +08:15 +698: test/watch_test.dart: watch sqlite joined and nested batch relations track child writes +08:15 +699: test/watch_test.dart: watch sqlite relation predicates, SQL subqueries and CTE sources are dependencies +08:15 +700: test/watch_test.dart: watch sqlite declared cascade deletion invalidates a child-only query +08:15 +701: test/watch_test.dart: watch sqlite CASCADE tracks declared transitive deletion effects +08:15 +702: test/watch_test.dart: watch sqlite SET NULL tracks declared transitive deletion effects +08:15 +703: test/watch_test.dart: watch sqlite explicit JOINs and joined CTE definitions track both physical sources +08:15 +704: test/watch_test.dart: watch sqlite raw SQL declares changed and read tables; explicit invalidation follows transactions +08:15 +705: test/watch_test.dart: watch sqlite paused listeners coalesce writes and stop fetching until resumed +08:15 +706: test/watch_test.dart: watch sqlite invalidation during a read suppresses the stale snapshot and refetches +08:15 +707: test/watch_test.dart: watch sqlite decoding errors are reported and a later write can recover +08:15 +708: test/watch_test.dart: watch sqlite separate subscriptions and views of one driver receive changes +08:15 +709: test/watch_test.dart: watch sqlite watching leased sessions is rejected and cancelled tokens close the stream +08:15 +710: test/watch_test.dart: watch sqlite database close finishes watchers even when a listener is paused +08:15 +711: test/watch_test.dart: watch sqlite cancelling an active watched query interrupts SQL and releases its lease +08:15 +712: test/watch_test.dart: watch postgres lazy initial snapshot and relevant table invalidation +08:16 +713: test/watch_test.dart: watch postgres transactions publish one snapshot after commit and none on rollback +08:16 +714: test/watch_test.dart: watch postgres savepoint changes merge on release and disappear on rollback +08:16 +715: test/watch_test.dart: watch postgres failed statements, zero-row writes and conflict no-ops do not refresh +08:16 +716: test/watch_test.dart: watch postgres batch writes and upsert returning publish committed changes +08:16 +717: test/watch_test.dart: watch postgres joined and nested batch relations track child writes +08:16 +718: test/watch_test.dart: watch postgres relation predicates, SQL subqueries and CTE sources are dependencies +08:16 +719: test/watch_test.dart: watch postgres declared cascade deletion invalidates a child-only query +08:16 +720: test/watch_test.dart: watch postgres CASCADE tracks declared transitive deletion effects +08:16 +721: test/watch_test.dart: watch postgres SET NULL tracks declared transitive deletion effects +08:16 +722: test/watch_test.dart: watch postgres explicit JOINs and joined CTE definitions track both physical sources +08:16 +723: test/watch_test.dart: watch postgres raw SQL declares changed and read tables; explicit invalidation follows transactions +08:16 +724: test/watch_test.dart: watch postgres paused listeners coalesce writes and stop fetching until resumed +08:16 +725: test/watch_test.dart: watch postgres invalidation during a read suppresses the stale snapshot and refetches +08:16 +726: test/watch_test.dart: watch postgres decoding errors are reported and a later write can recover +08:16 +727: test/watch_test.dart: watch postgres separate subscriptions and views of one driver receive changes +08:16 +728: test/watch_test.dart: watch postgres watching leased sessions is rejected and cancelled tokens close the stream +08:16 +729: test/watch_test.dart: watch postgres database close finishes watchers even when a listener is paused +08:16 +730: test/watch_test.dart: watch postgres cancelling an active watched query interrupts SQL and releases its lease +08:16 +731: test/watch_test.dart: watch postgres lost commit acknowledgement triggers a conservative re-read +08:16 +732: test/watch_test.dart: watch postgres independent connections require an explicit external-write notification +08:16 +733: loading test/backfill_test.dart +08:16 +733: test/backfill_test.dart: backfill sqlite chunks pause and resume from a checksummed historical declaration +08:16 +734: test/backfill_test.dart: backfill sqlite failed chunk rolls back both writes and cursor, then resumes after repair +08:16 +735: test/backfill_test.dart: backfill sqlite failed completion proof preserves finished chunks without replay +08:16 +736: test/backfill_test.dart: backfill sqlite frozen upper bound and completion proof detect incompatible new writes +08:16 +737: test/backfill_test.dart: backfill sqlite competing runners share committed chunks without duplicate updates +08:16 +738: test/backfill_test.dart: backfill sqlite lost chunk commit acknowledgement preserves the committed frontier +08:16 +739: test/backfill_test.dart: backfill sqlite old checkpoint tables upgrade on apply; completed reads remain nonmutating +08:16 +740: test/backfill_test.dart: backfill sqlite completion conditions reject numeric results instead of truthiness +08:16 +741: test/backfill_test.dart: backfill sqlite empty input still requires the completion condition +08:16 +742: test/backfill_test.dart: backfill sqlite later ordinary migrations retain group atomicity after a completed backfill +08:16 +743: test/backfill_test.dart: backfill sqlite AFTER triggers cannot silently move keys past a committed cursor +08:16 +744: test/backfill_test.dart: backfill sqlite corrupt checkpoint dimensions and invalid run limits are rejected +08:16 +745: test/backfill_test.dart: backfill sqlite rebuild, chunked backfill and required-column rebuild preserve FK enforcement +08:16 +746: test/backfill_test.dart: backfill sqlite CLI previews non-atomic work and reports a bounded run as incomplete +08:35 +747: test/backfill_test.dart: backfill sqlite temporary tables cannot redirect the historical backfill target +08:36 +748: test/backfill_test.dart: backfill sqlite temporary objects cannot impersonate durable migration metadata +08:36 +749: test/backfill_test.dart: backfill sqlite committed chunks invalidate relevant typed query subscriptions +08:36 +750: test/backfill_test.dart: backfill sqlite real process exit after second chunk write resumes correctly +08:38 +751: test/backfill_test.dart: backfill sqlite real process exit after second chunk commit resumes correctly +08:41 +752: test/backfill_test.dart: backfill postgres chunks pause and resume from a checksummed historical declaration +08:41 +753: test/backfill_test.dart: backfill postgres failed chunk rolls back both writes and cursor, then resumes after repair +08:41 +754: test/backfill_test.dart: backfill postgres failed completion proof preserves finished chunks without replay +08:41 +755: test/backfill_test.dart: backfill postgres frozen upper bound and completion proof detect incompatible new writes +08:41 +756: test/backfill_test.dart: backfill postgres competing runners share committed chunks without duplicate updates +08:41 +757: test/backfill_test.dart: backfill postgres lost chunk commit acknowledgement preserves the committed frontier +08:41 +758: test/backfill_test.dart: backfill postgres old checkpoint tables upgrade on apply; completed reads remain nonmutating +08:41 +759: test/backfill_test.dart: backfill postgres completion conditions reject numeric results instead of truthiness +08:41 +760: test/backfill_test.dart: backfill postgres empty input still requires the completion condition +08:41 +761: test/backfill_test.dart: backfill postgres later ordinary migrations retain group atomicity after a completed backfill +08:41 +762: test/backfill_test.dart: backfill postgres AFTER triggers cannot silently move keys past a committed cursor +08:41 +763: test/backfill_test.dart: backfill postgres corrupt checkpoint dimensions and invalid run limits are rejected +08:41 +764: test/backfill_test.dart: backfill postgres CLI previews non-atomic work and reports a bounded run as incomplete +08:59 +765: test/backfill_test.dart: backfill postgres temporary tables cannot redirect the historical backfill target +08:59 +766: test/backfill_test.dart: backfill postgres temporary objects cannot impersonate durable migration metadata +08:59 +767: test/backfill_test.dart: backfill postgres row security cannot hide unfinished rows from the completion proof +08:59 +768: test/backfill_test.dart: backfill postgres inherited duplicate primary keys cannot advance a cursor +08:59 +769: test/backfill_test.dart: backfill postgres committed chunks invalidate relevant typed query subscriptions +08:59 +770: test/backfill_test.dart: backfill postgres real process exit after second chunk write resumes correctly +09:02 +771: test/backfill_test.dart: backfill postgres real process exit after second chunk commit resumes correctly +09:04 +772: loading test/custom_codec_test.dart +09:04 +772: test/custom_codec_test.dart: (setUpAll) +09:04 +772: test/custom_codec_test.dart: custom codecs sqlite generated custom values round trip through create, select and patch +09:04 +773: test/custom_codec_test.dart: custom codecs sqlite enum labels are stable text and unknown labels fail decoding +09:04 +774: test/custom_codec_test.dart: custom codecs sqlite domain validation and bound parameters apply to writes and predicates +09:04 +775: test/custom_codec_test.dart: custom codecs sqlite JSON documents distinguish SQL NULL, JSON null and string scalars +09:04 +776: test/custom_codec_test.dart: custom codecs sqlite nullable structured codec rejects JSON null instead of masking invalid data +09:04 +777: test/custom_codec_test.dart: custom codecs sqlite custom IDs and domain projections work across joined and batched relations +09:04 +778: test/custom_codec_test.dart: custom codecs sqlite custom values participate in atomic batch writes and native upsert +09:04 +779: test/custom_codec_test.dart: custom codecs sqlite custom IDs and enums survive cursor transport and streaming decoders +09:04 +780: test/custom_codec_test.dart: custom codecs sqlite migration snapshots retain physical storage without executing domain codecs +09:04 +781: test/custom_codec_test.dart: custom codecs postgres generated custom values round trip through create, select and patch +09:04 +782: test/custom_codec_test.dart: custom codecs postgres enum labels are stable text and unknown labels fail decoding +09:04 +783: test/custom_codec_test.dart: custom codecs postgres domain validation and bound parameters apply to writes and predicates +09:04 +784: test/custom_codec_test.dart: custom codecs postgres JSON documents distinguish SQL NULL, JSON null and string scalars +09:04 +785: test/custom_codec_test.dart: custom codecs postgres nullable structured codec rejects JSON null instead of masking invalid data +09:04 +786: test/custom_codec_test.dart: custom codecs postgres custom IDs and domain projections work across joined and batched relations +09:04 +787: test/custom_codec_test.dart: custom codecs postgres custom values participate in atomic batch writes and native upsert +09:04 +788: test/custom_codec_test.dart: custom codecs postgres custom IDs and enums survive cursor transport and streaming decoders +09:04 +789: test/custom_codec_test.dart: custom codecs postgres migration snapshots retain physical storage without executing domain codecs +09:04 +790: test/custom_codec_test.dart: (tearDownAll) +09:04 +790: loading test/sqlite_options_test.dart +09:04 +790: test/sqlite_options_test.dart: persistent uses the explicit native path and retains committed data +09:05 +791: test/sqlite_options_test.dart: native persistent storage never guesses a path or falls back to memory +09:05 +792: test/sqlite_options_test.dart: all driver close callers wait for active work and shutdown +09:05 +793: loading test/plan_test.dart +09:05 +793: test/plan_test.dart: inspect sqlite reports composite key width and validates each potential batch +09:05 +794: test/plan_test.dart: inspect sqlite compiles nested plans without acquiring or decoding +09:05 +795: test/plan_test.dart: inspect sqlite distinguishes same-statement joins and skipped batches +09:05 +796: test/plan_test.dart: inspect sqlite retains CTE, UNION and raw SQL boundaries +09:05 +797: test/plan_test.dart: plan execution sqlite one-key SQL templates match executed SQL and internal slots +09:05 +798: test/plan_test.dart: inspect postgres reports composite key width and validates each potential batch +09:05 +799: test/plan_test.dart: inspect postgres compiles nested plans without acquiring or decoding +09:05 +800: test/plan_test.dart: inspect postgres distinguishes same-statement joins and skipped batches +09:05 +801: test/plan_test.dart: inspect postgres retains CTE, UNION and raw SQL boundaries +09:05 +802: test/plan_test.dart: plan execution postgres one-key SQL templates match executed SQL and internal slots +09:05 +803: loading test/check_test.dart +09:05 +803: test/check_test.dart: CHECK sqlite generated named and unnamed constraints enforce values and permit NULL +09:05 +804: test/check_test.dart: CHECK sqlite import preserves names and SQL then generates a matching snapshot +09:07 +805: test/check_test.dart: CHECK sqlite add, change and remove constraints validate old rows atomically +09:07 +806: test/check_test.dart: CHECK sqlite duplicate unnamed checks consume and drop exactly one catalog entry +09:07 +807: test/check_test.dart: CHECK sqlite explicit checked renames preserve rows, incoming references, indexes and views +09:07 +808: test/check_test.dart: CHECK sqlite verification preserves casts, precedence, literals and trailing comments +09:07 +809: test/check_test.dart: CHECK sqlite quoted Unicode identifiers retain SQLite case distinctions +09:07 +810: test/check_test.dart: CHECK sqlite undeclared CHECK is refused before a rebuild and remains enforced +09:07 +811: test/check_test.dart: CHECK postgres generated named and unnamed constraints enforce values and permit NULL +09:07 +812: test/check_test.dart: CHECK postgres import preserves names and SQL then generates a matching snapshot +09:08 +813: test/check_test.dart: CHECK postgres add, change and remove constraints validate old rows atomically +09:08 +814: test/check_test.dart: CHECK postgres duplicate unnamed checks consume and drop exactly one catalog entry +09:08 +815: test/check_test.dart: CHECK postgres explicit checked renames preserve rows, incoming references, indexes and views +09:08 +816: test/check_test.dart: CHECK postgres verification preserves casts, precedence, literals and trailing comments +09:08 +817: test/check_test.dart: CHECK postgres catalog excludes unvalidated and NO INHERIT constraints +09:08 +818: test/check_test.dart: CHECK postgres verification plans but does not execute volatile row expressions +09:08 +819: test/check_test.dart: dialect overrides only alter the affected backend and preserve old snapshot JSON +09:08 +820: loading test/temporal_test.dart +09:08 +820: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: false +09:08 +821: test/temporal_test.dart: borrowed PostgreSQL pool local calendar capability: true +09:08 +822: test/temporal_test.dart: Gregorian cycles, era boundaries and full finite date range +09:08 +823: test/temporal_test.dart: local time precision, day endpoint and timestamp arithmetic +09:08 +824: test/temporal_test.dart: PostgreSQL binary registry covers boundaries without DateTime +09:08 +825: test/temporal_test.dart: local temporal sqlite generated writes, defaults, null patches and typed parameters +09:08 +826: test/temporal_test.dart: local temporal sqlite finite extremes and microseconds survive native rows and streams +09:08 +827: test/temporal_test.dart: local temporal sqlite calendar ordering, min/max, grouping and stable date cursors +09:08 +828: test/temporal_test.dart: local temporal sqlite equivalent date keys drive batched relations and unique indexes +09:08 +829: test/temporal_test.dart: local temporal sqlite time and local timestamp comparisons retain calendar semantics +09:08 +830: test/temporal_test.dart: local temporal sqlite reviewed text-to-time migration rolls back duplicate clock values +09:08 +831: test/temporal_test.dart: local temporal sqlite invalid external dates fail typed reads without damaging the connection +09:08 +832: test/temporal_test.dart: local temporal sqlite snapshots, catalog import and regenerated defaults agree +09:10 +833: test/temporal_test.dart: local temporal sqlite historical date keys resume a bounded backfill in calendar order +09:10 +834: test/temporal_test.dart: local temporal postgres catalog retains time-truncating casts in expression defaults +09:10 +835: test/temporal_test.dart: local temporal postgres generated writes, defaults, null patches and typed parameters +09:10 +836: test/temporal_test.dart: local temporal postgres finite extremes and microseconds survive native rows and streams +09:10 +837: test/temporal_test.dart: local temporal postgres calendar ordering, min/max, grouping and stable date cursors +09:10 +838: test/temporal_test.dart: local temporal postgres equivalent date keys drive batched relations and unique indexes +09:10 +839: test/temporal_test.dart: local temporal postgres time and local timestamp comparisons retain calendar semantics +09:10 +840: test/temporal_test.dart: local temporal postgres reviewed text-to-time migration rolls back duplicate clock values +09:11 +841: test/temporal_test.dart: local temporal postgres invalid external dates fail typed reads without damaging the connection +09:11 +842: test/temporal_test.dart: local temporal postgres snapshots, catalog import and regenerated defaults agree +09:11 +843: test/temporal_test.dart: local temporal postgres historical date keys resume a bounded backfill in calendar order +09:11 +844: loading test/unconstrained_relation_test.dart +09:11 +844: test/unconstrained_relation_test.dart: unconstrained sqlite catalogs contain no FK, implicit index or uniqueness promise +09:11 +845: test/unconstrained_relation_test.dart: unconstrained sqlite adding and removing a real FK remains a reviewed physical migration +09:11 +846: test/unconstrained_relation_test.dart: unconstrained sqlite unique joins preserve root rows, NULL components and missing targets +09:11 +847: test/unconstrained_relation_test.dart: unconstrained sqlite required target and filter visibility remain explicit +09:11 +848: test/unconstrained_relation_test.dart: unconstrained sqlite nonunique composite collections batch by tuple and page per parent +09:11 +849: test/unconstrained_relation_test.dart: unconstrained sqlite to-one uses cardinality checks without assuming a unique lookup +09:11 +850: test/unconstrained_relation_test.dart: unconstrained sqlite correlated filters and counts do not materialize related rows +09:11 +851: test/unconstrained_relation_test.dart: unconstrained sqlite self relations and inverse collections share the existing planner +09:11 +852: test/unconstrained_relation_test.dart: unconstrained sqlite delete has no cascade and watches track the joined target +09:11 +853: test/unconstrained_relation_test.dart: unconstrained sqlite transaction reads observe writes and outer rollback restores targets +09:11 +854: test/unconstrained_relation_test.dart: unconstrained sqlite streaming preserves optional and batched relation result shapes +09:11 +855: test/unconstrained_relation_test.dart: unconstrained sqlite floating relation keys retain storage intent and key equality +09:11 +856: test/unconstrained_relation_test.dart: unconstrained postgres catalogs contain no FK, implicit index or uniqueness promise +09:12 +857: test/unconstrained_relation_test.dart: unconstrained postgres adding and removing a real FK remains a reviewed physical migration +09:12 +858: test/unconstrained_relation_test.dart: unconstrained postgres unique joins preserve root rows, NULL components and missing targets +09:12 +859: test/unconstrained_relation_test.dart: unconstrained postgres required target and filter visibility remain explicit +09:12 +860: test/unconstrained_relation_test.dart: unconstrained postgres nonunique composite collections batch by tuple and page per parent +09:12 +861: test/unconstrained_relation_test.dart: unconstrained postgres to-one uses cardinality checks without assuming a unique lookup +09:12 +862: test/unconstrained_relation_test.dart: unconstrained postgres correlated filters and counts do not materialize related rows +09:12 +863: test/unconstrained_relation_test.dart: unconstrained postgres self relations and inverse collections share the existing planner +09:12 +864: test/unconstrained_relation_test.dart: unconstrained postgres delete has no cascade and watches track the joined target +09:12 +865: test/unconstrained_relation_test.dart: unconstrained postgres transaction reads observe writes and outer rollback restores targets +09:12 +866: test/unconstrained_relation_test.dart: unconstrained postgres streaming preserves optional and batched relation result shapes +09:12 +867: test/unconstrained_relation_test.dart: unconstrained postgres floating relation keys retain storage intent and key equality +09:12 +868: loading test/decimal_value_test.dart +09:12 +868: test/decimal_value_test.dart: normalization, scientific notation, equality and hash are exact +09:12 +869: test/decimal_value_test.dart: invalid and approximate inputs fail instead of silently rounding +09:12 +870: test/decimal_value_test.dart: range boundaries are checked without binary numeric conversions +09:12 +871: test/decimal_value_test.dart: addition, subtraction, multiplication and ordering keep every digit +09:12 +872: test/decimal_value_test.dart: division requires an explicit scale and rejects implicit rounding +09:12 +873: test/decimal_value_test.dart: rounding modes handle negative values, ties and negative scales +09:12 +874: test/decimal_value_test.dart: small fixed-scale arithmetic agrees with independent integer arithmetic +09:12 +875: All tests passed! diff --git a/research/validation/sqlite-refactor-web-assets.log b/research/validation/sqlite-refactor-web-assets.log new file mode 100644 index 00000000..3e374046 --- /dev/null +++ b/research/validation/sqlite-refactor-web-assets.log @@ -0,0 +1 @@ +Running build hooks...Running build hooks...Copied SQLite browser assets to .dart_tool/sqlite-assets-export diff --git a/research/validation/sqlite-refactor.json b/research/validation/sqlite-refactor.json new file mode 100644 index 00000000..99f9f59f --- /dev/null +++ b/research/validation/sqlite-refactor.json @@ -0,0 +1,40 @@ +{ + "date": "2026-09-16", + "handwrittenRuntimeSha256": "d2b6a0a87cac75d0157cb631ad90e699c3fdf13be3e51f9fce876615bda50c24", + "runtimeHashInputs": "Sorted lib and bin Dart sources excluding generated web_build.dart, plus pubspec.yaml and pubspec.lock; path NUL bytes NUL.", + "native": { + "passed": true, + "tests": 875, + "duration": "09:12", + "postgres": "18.4", + "finalFocusedTests": 5 + }, + "packagedAssets": [ + { + "path": "assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "sha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4", + "bytes": 748686 + }, + { + "path": "assets/sqlite/worker-8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845.js", + "sha256": "8230ef505870d53bc4e4ebfc3714d5b12b742e1351ccc5153d5d27c9d3045845", + "bytes": 176630 + } + ], + "flutter": { + "version": "3.47.4", + "dart": "3.13.3", + "webExampleWasmBuild": "passed", + "androidAssetBundle": "passed; no SQLite Web assets; not a device runtime test" + }, + "web": { + "chrome": "153", + "dartJs": 21, + "dartWasm": 21, + "flutterJs": 21, + "flutterWasmWithoutIsolation": 21, + "flutterWasmWithIsolation": 21 + }, + "analyze": "passed", + "cliWebAssetExport": "passed" +} diff --git a/research/validation/sqlite-refactor.md b/research/validation/sqlite-refactor.md new file mode 100644 index 00000000..880d80e6 --- /dev/null +++ b/research/validation/sqlite-refactor.md @@ -0,0 +1,70 @@ +# Unified SQLite refactor validation + +Captured 2026-09-16 on Dart 3.13.3, Flutter 3.47.4 and Chrome 153. The +[machine-readable summary](sqlite-refactor.json) records the handwritten runtime +fingerprint and final packaged asset hashes. + +The SQLite entrypoint now selects native or browser transport internally. Both +use the same sqlite3 statement configuration, execution and cursor implementation. +The public Web-only driver entry has been removed. Flutter consumes package-owned +assets by default; ordinary Dart Web apps export them with `orm web-assets`. +Resources have content-hash filenames, a startup protocol/build handshake and +WASM subresource integrity verification. + +| Validation | Result | +| --- | --- | +| Complete native suite, real SQLite and PostgreSQL 18.4 enabled | **875 passed in 09:12**, exit 0 | +| Final focused native persistence/close/export checks | **5 passed**, exit 0 | +| Plain Dart JS / Dart WASM in Chrome | **21 / 21 passed** | +| Flutter release JS, no isolation headers | **21 passed** | +| Flutter release WASM, no isolation headers | **21 passed**, actual WASM confirmed | +| Flutter release WASM, isolation headers | **21 passed**, actual WASM confirmed | +| Repository Flutter example, `build web --wasm` | Passed | +| Android arm64 debug asset bundle | Built; SQLite Web assets absent | +| CLI Web asset export and packaged fingerprint checks | Passed | +| Static analysis and whitespace diff check | Passed | + +The complete native run covers the shared executor and new persistent-path/close +behavior. Final packaging removed the unused worker source-map reference; focused +native checks and all Web configurations were then rerun against those artifacts. +The negative Web scenario separately rejects an old protocol, a different build +with the current protocol, a wrong WASM integrity value, and native file storage. + +Flutter tests consume unchanged package asset declarations and default connection +configuration. They start on `/nested/detail/42` with ``, +retain the Flutter widget tree, and assert animation ticks advance during a long +worker SQL query. The captured checks also verify typed relationships and +projections, transactions/savepoints, bounded cursors, commit notifications, +numeric/temporal precision, exclusive OPFS ownership, page reload with an open +transaction, data recovery and subsequent migrations. + +Evidence: + +- [Native full run](sqlite-refactor-native.log) and [focused checks](sqlite-refactor-focused.log). +- [Plain Dart JS](sqlite-refactor-browser-js.json) and [Dart WASM](sqlite-refactor-browser-wasm.json). +- [Three Flutter Web configurations](sqlite-refactor-flutter-web.json), including + actual compilation mode, isolation state and asset request paths. +- [Asset verification](sqlite-refactor-assets-check.log), [CLI export](sqlite-refactor-web-assets.log), + [analysis](sqlite-refactor-analyze.log) and [example build](sqlite-refactor-flutter-example.log). + +Reproduce from the repository root, with a cached/installed Flutter SDK: + +```sh +dart run tool/build_sqlite_web.dart --check +dart run tool/test_browser.dart +dart run tool/test_browser.dart --wasm +dart run tool/test_flutter_web.dart /absolute/path/to/flutter +dart run orm web-assets +ORM_TEST_POSTGRES=postgresql://postgres@127.0.0.1:65439/postgres dart test +``` + +On this host, `DEVELOPER_DIR=/Library/Developer/CommandLineTools` selects the +available command-line toolchain. Native-asset Dart commands were run sequentially. +The temporary PostgreSQL instance was stopped and removed; browser profiles and +local servers were disposed by the runners. + +The Android result establishes asset filtering, not APK installation or execution. +Android/Apple runtime, Safari/Firefox, Flutter hot restart with an open connection, +CDN/CSP hosting, multi-tab database sharing, offline upgrade deployment and +throughput remain outside this capture. The material-icon font warning in Flutter +release output does not fail compilation. No new performance claim is made. diff --git a/research/validation/standalone-browser.json b/research/validation/standalone-browser.json new file mode 100644 index 00000000..972d6afd --- /dev/null +++ b/research/validation/standalone-browser.json @@ -0,0 +1,198 @@ +{ + "capturedAt": "2026-09-19T13:39:24.071851+00:00", + "runtimeSourceCommit": "3aef4e7905f6997ff2fa9c181709bdddbdcc64ad", + "reports": [ + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 65 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 42 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 16 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 20 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 18 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 37 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 28 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 4 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 2 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 1 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 3 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 158 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 30 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 34 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 95 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 21 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "js", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 60 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 40 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 13 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 20 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 18 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 27 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 23 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 1 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 2 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 158 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 29 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 33 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 93 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 12 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "dartCompilation": "wasm", + "dartSdk": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"macos_arm64\"", + "sqlitePackage": "3.6.0", + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + } + ], + "limits": [ + "Chrome 153 on macOS only; other browsers and WebViews were not validated.", + "Dart JS and Dart WASM are distinct runtime executions, not Flutter builds." + ] +} diff --git a/research/validation/standalone-flutter-android.png b/research/validation/standalone-flutter-android.png new file mode 100644 index 00000000..5b9b4be0 Binary files /dev/null and b/research/validation/standalone-flutter-android.png differ diff --git a/research/validation/standalone-flutter-web.json b/research/validation/standalone-flutter-web.json new file mode 100644 index 00000000..1cce28d5 --- /dev/null +++ b/research/validation/standalone-flutter-web.json @@ -0,0 +1,517 @@ +{ + "capturedAt": "2026-09-19T13:39:24.071851+00:00", + "runtimeSourceCommit": "3aef4e7905f6997ff2fa9c181709bdddbdcc64ad", + "reports": [ + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 92 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 40 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 15 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 20 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 18 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 32 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 27 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 2 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 1 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 3 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 160 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 30 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 35 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 100 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 2 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 20 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": false, + "flutterFrames": 5, + "baseUri": "http://127.0.0.1:64406/nested/", + "dartUriBase": "http://127.0.0.1:64406/nested/detail/42?phase=recover", + "isolated": false, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/chromium/canvaskit.wasm", + "/nested/canvaskit/chromium/canvaskit.js", + "/favicon.ico", + "/nested/main.dart.js", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/mismatched-worker.js", + "/mismatched-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/chromium/canvaskit.wasm", + "/nested/canvaskit/chromium/canvaskit.js", + "/nested/main.dart.js", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/report" + ], + "build": "js", + "expectedIsolation": false, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 94 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 41 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 13 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 18 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 28 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 24 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 1 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 3 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 158 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 32 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 33 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 96 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 1 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 14 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": true, + "flutterFrames": 5, + "baseUri": "http://127.0.0.1:64426/nested/", + "dartUriBase": "http://127.0.0.1:64426/nested/detail/42?phase=recover", + "isolated": false, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/favicon.ico", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/mismatched-worker.js", + "/mismatched-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/report" + ], + "build": "wasm", + "expectedIsolation": false, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + }, + { + "passed": true, + "checks": [ + { + "name": "many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts", + "milliseconds": 56 + }, + { + "name": "temporal column precision preserves epoch ties, extended ranges, defaults and keys", + "milliseconds": 38 + }, + { + "name": "memory migrations and verified foreign keys", + "milliseconds": 12 + }, + { + "name": "query ownership, optional guards and write validation survive JS and WASM", + "milliseconds": 19 + }, + { + "name": "client factories distinguish omission, explicit null and prepared batch values", + "milliseconds": 17 + }, + { + "name": "computed stored and virtual fields recompute across CRUD and migrations", + "milliseconds": 27 + }, + { + "name": "generated CHECK enforcement and atomic constraint migrations", + "milliseconds": 23 + }, + { + "name": "generated records, projections and typed relation batches", + "milliseconds": 2 + }, + { + "name": "rollback, savepoints and transaction lifetime", + "milliseconds": 1 + }, + { + "name": "stream cursor batches and early release", + "milliseconds": 1 + }, + { + "name": "commit invalidation and watch snapshots", + "milliseconds": 0 + }, + { + "name": "exact BigInt, Decimal, blobs and calendar transport", + "milliseconds": 2 + }, + { + "name": "explicit unsupported cancellation", + "milliseconds": 0 + }, + { + "name": "large real values retain floating storage", + "milliseconds": 2 + }, + { + "name": "instant microseconds are exact or explicitly rejected", + "milliseconds": 0 + }, + { + "name": "worker does not block the browser event loop", + "milliseconds": 158 + }, + { + "name": "startup failures are bounded and release failed workers", + "milliseconds": 30 + }, + { + "name": "unified entry rejects native paths and mismatched browser assets", + "milliseconds": 33 + }, + { + "name": "OPFS commit survives a closed worker and new connection", + "milliseconds": 95 + }, + { + "name": "page reload recovers committed rows and rolls back interrupted transaction", + "milliseconds": 1 + }, + { + "name": "persistent schema upgrades after reload preserve existing data", + "milliseconds": 13 + } + ], + "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.0.0 Safari/537.36", + "actualWasm": true, + "flutterFrames": 4, + "baseUri": "http://127.0.0.1:64446/nested/", + "dartUriBase": "http://127.0.0.1:64446/nested/detail/42?phase=recover", + "isolated": true, + "requests": [ + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/favicon.ico", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/canvaskit/skwasm.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/missing.wasm", + "/missing-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/mismatched-worker.js", + "/mismatched-worker.js", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/detail/42", + "/nested/flutter_bootstrap.js", + "/nested/canvaskit/skwasm.wasm", + "/nested/canvaskit/skwasm.js", + "/nested/main.dart.mjs", + "/nested/main.dart.wasm", + "/nested/assets/FontManifest.json", + "/nested/assets/fonts/MaterialIcons-Regular.otf", + "/nested/assets/fonts/fallback/Roboto-Regular.ttf", + "/nested/assets/packages/orm/assets/sqlite/worker-cdda086086b0550d51668eb4ef26e21d27c54d769a16f5defdd45b3ec14043ce.js", + "/nested/assets/packages/orm/assets/sqlite/sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm", + "/nested/canvaskit/skwasm.js", + "/report" + ], + "build": "wasm", + "expectedIsolation": true, + "flutterSdk": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "frameworkCommitDate": "2026-09-10 15:25:10 -0700", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "engineCommitDate": "2026-09-03 16:07:13.000Z", + "engineContentHash": "0e228ec8c8d2abc9fcf1d053e8a40665bb859ec7", + "engineBuildDate": "2026-09-04 00:39:13.722", + "dartSdkVersion": "3.13.3", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.4" + }, + "sqliteWasmSha256": "13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4" + } + ], + "limits": [ + "Flutter release JS and WASM on Chrome 153; other browsers were not validated.", + "Animation frame progress is responsiveness evidence, not a frame-rate benchmark." + ] +} diff --git a/research/validation/standalone-flutter.json b/research/validation/standalone-flutter.json new file mode 100644 index 00000000..821e34f4 --- /dev/null +++ b/research/validation/standalone-flutter.json @@ -0,0 +1,329 @@ +{ + "capturedAt": "2026-09-19T12:55:23.887394Z", + "runtimeSourceCommit": "7a54553933ae1d768576a54a4234b00b6bfcf97e", + "flutter": { + "frameworkVersion": "3.47.4", + "channel": "stable", + "frameworkRevision": "9584c6713b324636289d067944a46fd6b49df14b", + "engineRevision": "06a2e2a110089dff50fe635cffd2a61e1b24fbcd", + "dartSdkVersion": "3.13.3" + }, + "device": "emulator-5582", + "abi": "arm64-v8a", + "buildFingerprint": "google/sdk_gphone64_arm64/emu64a:15/AE3A.240806.036/12592187:user/release-keys", + "apkSha256": { + "legacy": "87d5a37f8f4fc639f5b8e4609f6f900574eff032f594ea991fc29c5aae945705", + "current": "301e3abd15ce011dc659f4b16b114c3c15d722d30efd2340e1d6db417165aa6a" + }, + "sourceSha256": { + "lib/mariadb.dart": "2bae2c18bd9e5008e4899fd182ee6d73276e35169fdb2080d97ed212fcb42dc6", + "lib/builder.dart": "ca071eeeb6bc697c76518e973841722fb11822137c987d32aa0c2a04fb82802c", + "lib/drivers/mariadb.dart": "a3a61b50493ccba032badae1cf259824c68caf3c016aefc29fc996e92e53680a", + "lib/drivers/sqlite.dart": "9aecf50170c3731eacc71778f5c30185675c35bc5b3eb70ffece8ca96cd19d83", + "lib/drivers/postgres.dart": "9d9f30c2bba598c136e42c728c76ece32758d77bf32ccf2e6d437c3a8b2b7f7d", + "lib/drivers/mysql.dart": "5e5363a2bac9b680129d95ce2896eaee63dabd623abec373cd16ea7c51e7dd27", + "lib/values.dart": "db77f9169d78c2a19b8b13e1d066d94cf2a625188df6e2c42bc9065edb9af0be", + "lib/orm.dart": "606673c3c0fd0104663174979b456b2cbc15ca5e3aa55d74240adb5ccaa7ce21", + "lib/generate.dart": "db47ac06f250bc74bef1b670a2d114b99f0e6e5d01eb111a5e36d8887a102b2b", + "lib/sqlite_web_worker.dart": "0f4092c9528303f50f75f48c8d050e83461f795995af74b3fb97776724e9c19e", + "lib/migrate_cli.dart": "5b052713fc4769c7e29949a9c079738c771a31a6c7c52a6b4c7bfb30e4b3cf71", + "lib/sql.dart": "d86c7a6ff9165923edcf22ff6af5bc82c2d3d6a3086ba52417fc10b8fe952b3e", + "lib/sqlite.dart": "4447db13526e4b41f6d1e6057fb24b8d2f39299926ab02acfb6fc5210c8e6f8d", + "lib/schema.dart": "0e93f61e58ed1a8e6b8bb8f642ebcc63355351e4bb2121586443f0b7fb79fa0f", + "lib/postgres.dart": "97369ee6578e9f4ebd6bbd317dd4916b2f70df6d6f3475642adcaed3b1e31a86", + "lib/driver.dart": "b62e3ced5e0b8186e26c776e7c1d1bbea33b4334feb2d5e676733c9fd4e2fa53", + "lib/mysql.dart": "1dc98a260b895e0a025730e993b2128b44a379d5f4bc82f00416937e3e682a85", + "lib/migrate.dart": "cafddc611d9e1e16ad767d1d536f18810911898283d77a590f38c5754df264b0", + "lib/cli.dart": "d99abbee9ee2aacd81f7acbfa6b4808081fcb3298650acb5334d5db75474972e", + "lib/runtime.dart": "f70339493f0bed80babf5126d6560a804c0e6b67e533b3525a56fc81f79a1c9b", + "lib/schema_model.dart": "9271ae3bb44651caf687791b143dc8b594cd9ab22b1905b9e60ad8aea7113033", + "lib/src/instant.dart": "9b07f4a3daa73f5e5c1edb8b61ef0af6e18e55d72e263244e280b75fcf76d881", + "lib/src/temporal.dart": "f8e5d85edff8ac00a7f583e20f6bd28024e7e8513e77751e1d3a1d6d4413a3b6", + "lib/src/mutation.dart": "bff962d888cb753d2f50071772e4b98149432ac2620654488e100b4c53e2b7a6", + "lib/src/union.dart": "099e0845c027b0f6c017f69d1b5e182642729b3d39199c2e110cea72adb94ee2", + "lib/src/execution.dart": "abed3bbd3f34d17e148ed6c691a02aa308b17b1392b90a3a9bb72c172d3fc56c", + "lib/src/temporal_sql.dart": "fa0a4ea98197184d1c412c23448de46a2132970e590ad01a503dde4619fac7c4", + "lib/src/batch.dart": "aa25b00a337a86721511e7f11ac60934d153b2aec6340649c66f6d422cf69c91", + "lib/src/generate.dart": "cf86addb049826c963f4e6b79b45c7571839655836150b663c6f89cce493e67b", + "lib/src/runtime/mysql_transaction.dart": "958d991149e5a48f8542e0f55f1e167a510a90bded00690373d1c0c197a50d10", + "lib/src/runtime/session_connection.dart": "6d82dfe9eaafb1d5f6877f6e791987834666a98e162996bc07182aa8d344b74c", + "lib/src/runtime/database.dart": "7dbf07b4f6f2e27bed329fa1258ee3f3e49ff59714070926dcb1a3bb00bdafbb", + "lib/src/runtime/stream.dart": "3a0874d49e8761d55d194aadfafc7ccaf573eb8a593b317cd6f8a432545dc99e", + "lib/src/runtime/options.dart": "a6ccc7adcb63776fcc30d30acffc79618a002ddddb48a166963863a90b06549b", + "lib/src/sql.dart": "502e216814be3315c2532affd2a3fe501f9b121401ebc6873623970dd22d8aab", + "lib/src/watch.dart": "e7c03459b30dd1f84b2c589bf553696caf0dcaf942b2e1ee8ba15a638fca6cda", + "lib/src/selection.dart": "aa291734891a6fb5688369578dcc6ce199311e84789b7343f1003a1a2e02f7ac", + "lib/src/database.dart": "289c3fe1526767f986cc25d352bd9b6e8d1358e9fc388e31d91ac9d2fac1ec79", + "lib/src/decimal_average.dart": "5b29af955d294a0e7ed721ab124ce59072744ad22cb3d91fb00432a8c3ba3f0c", + "lib/src/sqlite/failure.dart": "eebf787d150938d8b3e66ce401e74ac364e963a0e04e2e1234f38cf99ed94b15", + "lib/src/sqlite/functions.dart": "cb451eb4cefc2d16e02b943ad59e41a1a0cbad794d7832755a4da7a38e1cd54c", + "lib/src/sqlite/temporal.dart": "0e874da5e0f6aa31d2d23db8edd458306d4e0dec2ce46397f471443186946243", + "lib/src/sqlite/web_build.dart": "59102e97a97ea829aa4cd9a3efdaf90bfe021ea63bacdeaf03f037adf0dddcc9", + "lib/src/sqlite/execution.dart": "05cffc662b52b0856e328d3c7b44d6fe7595770c0a3530a81200d6df14be13f9", + "lib/src/sqlite/web_wire.dart": "ecf8c03d3521ecc32a7449d6ef515be382e1bdd9e8d571d30914a93a1fb024b4", + "lib/src/sqlite/web_client.dart": "db62611ea09f0022ca6964d644ab2ea49e309c4530e581f7c44ede11af5a791c", + "lib/src/sqlite/native_client.dart": "aef0e510bdf6d2c9eaf4520f1f07e1f3a40db8f94abadb5f4f70e31b0bca8910", + "lib/src/sqlite/web.dart": "06eed380551685e4b06e3567533da2fb4d69c8025148d94d1b4ac29c5ff7cc3b", + "lib/src/sqlite/decimal.dart": "b291e5cfc0758fd944e7a09b345f8fa04b3e1679a099f92b9bf43023f57da5f0", + "lib/src/sqlite/native_server.dart": "24bdd930b4821cdf71129137826b05aea054426d96f3567bb2e682d1e8b89e40", + "lib/src/sqlite/native.dart": "94462da92cc021e5d2604031a2c3f4974c34ddd0a9a12a3d877ab60dc8e73a15", + "lib/src/sqlite/unsupported.dart": "f52c9b9709d54924856ca19e17a2d71bc39e0bfafefbb7967b496a721c5bc820", + "lib/src/sqlite/opened.dart": "1454c87229040cf6bc0a95c44bca27a98f791cf25154e7a91722842a1b2e80fd", + "lib/src/sqlite/assets_io.dart": "9ad78c5bb7b7de019ffe5a5b4454938e9d84bbbe9f52f171adecb738b2235912", + "lib/src/sqlite/options.dart": "32fa83feab3ea9543d749e1cfc9eefcccbc5af7d7b8477498ab0954132eba024", + "lib/src/cli/init.dart": "b9d9f8da97bc9f04e09498f26a6ec1a4076e1779d849cd2414a50d311c2e4f26", + "lib/src/cli/commands.dart": "1d0a3c84ef902a36cbb8543524ec2c91a278526c05819fc9788f784f71542044", + "lib/src/decimal_sql.dart": "c1d098a543f3e91064fc8f239b672f12d0523cbd66ff18a0277797271b0cf860", + "lib/src/table.dart": "e85d28ecdfdfa9335767403c26c657e6374a6a3d94dbb8c4057d4f3595435839", + "lib/src/decimal.dart": "87ab6b8a959d5b38bcff27099997ab420cd038cfe411c5c27348d5f299f7643f", + "lib/src/query.dart": "2f4f296796ad65f40540986fbb2c0a7cfbe7549193f4916830c6398ff56f1772", + "lib/src/sql_context.dart": "4cb07dd5038608a85fcadaf548cac6c9c063751bb59ff54ad55a7660ddebe8ba", + "lib/src/postgres/temporal.dart": "6a62d65c2fe5f5968c3c73c40ae456c1ebea44723fd9ba2f0b4a4adb850d33ba", + "lib/src/decimal_division.dart": "96c209cfe4ff9ef03c31709d0dacfa03a9f0166a1d1761ddad4dd924d84a1a91", + "lib/src/migrate/sqlite_checks.dart": "fee6d23a9fae37b2208485fcf2ee24d7e104e65b546162833f12bfebf7457ee3", + "lib/src/migrate/recovery.dart": "df5c9286ca29e3a30ced034e4a2b4c2da7a350d44c6e82cd2d05cdaf7ac87d40", + "lib/src/migrate/catalog.dart": "33071f75cf0965d9074e9d684eecdaa2149182d115cbf8e6bb8d3c8798947265", + "lib/src/migrate/checks.dart": "b523d87cabcfc07eeba2f0295677643752721c37d06824136893bf28164f24c4", + "lib/src/migrate/schema.dart": "d6bdb39a1942dde23e46c24742f8d384d7e3346aa4f60607c326e9ab29721622", + "lib/src/migrate/mysql_catalog.dart": "5b2fb5f741ab5716a421aeadc41fc79539777febd26455345487fe4726c3c749", + "lib/src/migrate/backfill.dart": "a469d869cc8e9eadd17a6341821160d18785bb6400520ac963a60f981726fe49", + "lib/src/migrate/snapshot.dart": "fdf9a58007c77a0dbdcef226077853f8a1327824eff1dd95aaa3fb07b6e94ca4", + "lib/src/migrate/history.dart": "474069de738a00768dc8550ec56534355fb9b4492297bb92677014bef892e9c9", + "lib/src/migrate/mysql_diff.dart": "13b36c45be46d40bf0cc662b436abc8c399564de701b1521ef929a221637ec46", + "lib/src/migrate/computed.dart": "2a1ae36964dee810cee2206f0a57e66090947aaed8ab630947df78886d807443", + "lib/src/migrate/diff.dart": "0c8b6a838b5989de494f09cfb48afc75e8c269b56b3b82fa84daadc1d1032d7a", + "lib/src/migrate/mysql_schema.dart": "e32f9f327bbc63f4b4ceb899c88858ae823428ac53ccace5691b0078c7bd1fa3", + "lib/src/migrate/source.dart": "c084e1b0cd4f3959e34c523db616022ae923a6760ae65be233911a02750ed3c7", + "lib/src/migrate/mysql_recovery.dart": "607695357b1c81f7410a94cd4a52b165474f7348890e88906a612b59a6189ccd", + "lib/src/migrate/step.dart": "8b50c01b26c232e1ca15ad79d4505f74e51693b3ccdccc948bd49bd98276c82a", + "lib/src/query_stream.dart": "d3c33a10cc12c01a95ca313716aefe40334af858944b3bd221be3d672a880bd3", + "lib/src/mysql/values.dart": "b36071d1595441815a59fb2a6b272696013e4cb94c90224872699515fd0328d4", + "lib/src/mysql/connection.dart": "5874e90d91301afe8389f6a6c559dfc056e19570b9fd83b6d7927b64facdf117", + "lib/src/generate/migrations.dart": "0666ae1fc02a5045b42c85e0d2bf37c2e3d02ea9df941f8dc2bb8a3c7ee242cd", + "lib/src/generate/build.dart": "414c9d82e079f84e8429a24d1a112dc6681e4cc01fdd6c7f1dfa3f184ec491d9", + "lib/src/generate/reader.dart": "ab07465c77f3babe1bc43d6dc141a96dcdc56b0d9be755ce5281b1e7663d2bcf", + "lib/src/generate/import.dart": "25a8f9ca4447969daffdef14f5df79c801be34edbf428de2e928a79aa1ab62f3", + "lib/src/generate/types.dart": "ca2c1c2e7810505b18344d140d1b80b550c376afb2b2d7c53d81ac2c6c2dd702", + "lib/src/generate/queries.dart": "7257452e965abdc65602c7d7388a39ed3b151db81a3a174098ec794498c31311", + "lib/src/generate/emitter.dart": "e0015c2dd7f4a7555400f13ef5752afad01576a859379bdcf264cc400d76dfe9", + "lib/src/generate/model.dart": "3145d02a3e6cd0e2f7fd6cdfdb128d1179b9fbe411112da4a2a76027b6d3b6a4", + "lib/src/advanced.dart": "9a0eb4a251864700bb7c860571c06b7bd4e0d85f36ace982f4a5702624dd2684", + "lib/src/named_sql.dart": "334534389cdcd77686362573ae1c286a086bbc493fe727cd040ee51ed70d8c98", + "lib/src/transaction.dart": "f8deb6af0522967accb5a413dfa13879348cb34430b960eab323161ad527bf8e", + "lib/src/relation.dart": "62f348fdc1352fc18996a14817535801b7f8bd619712191bcd860839fdcfe06c", + "lib/src/cursor.dart": "53920951dbb57241d900aca2354daa1b7b87b0b3c84a35e176ddd3b936a5f1d2", + "lib/src/query_reads.dart": "2fe8a1b35f9350c999410eafa870d1c8cbe0268b6d2dd138b2ce5967d8964784", + "lib/src/cte.dart": "cdb8d3086f1f4a5766ce8bd2ac1eaa375fed4283f393bbf94fc33ae29550ff3d", + "lib/src/observation.dart": "8a94800ac1306dd7d8f92f307790c6cb372998649ae59b492488282a20daf292", + "lib/src/plan.dart": "2bdcfa546b8d8633758521ae16dcdcf220932c818370304fa39c700a2101e0b5", + "lib/src/codec.dart": "2efb7aaa13e964a8d17010f40f6f0493e4e77d5840217f7a49f5eb7de4aa723b", + "example/flutter/lib/migrations/migrations.g.dart": "9afb98066f3a62fb2ed3f91e222bd85808f8cd03833b6039b14599bab989806c", + "example/flutter/lib/migrations/m0002_comments.dart": "e95a9e7f16cbb8aaeb53bffafac9bde27d4f05d022b54294caa42283b9468ac0", + "example/flutter/lib/migrations/m0001_initial.dart": "6250ec81d58c25fe6e1b3b99babdcf3b92940c3c60150e46c9d4f8883adebf76", + "example/flutter/lib/schema.orm.dart": "6de2c15529d947aa53fe9a12a930c2aad05391c31e128aeddcf7f6e61cc1f167", + "example/flutter/lib/schema.snapshot.dart": "4ef35433653ac189e16593acc95fa0b09993348824318e8d746609446ca1c913", + "example/flutter/lib/legacy/seed.dart": "5a7956abcaed08b5decac59abc2e15565e6674a1675734a6e261aa0c43d522b9", + "example/flutter/lib/legacy/schema.orm.dart": "36711deab8b639a82c8167046a54b24731710c893ed667a3cc4033d0b2f17471", + "example/flutter/lib/legacy/schema.snapshot.dart": "0dbf13fc969585f5f160fbd77c487e25e8ebf8605865444c9528215ff82cc3f6", + "example/flutter/lib/legacy/schema.dart": "7bdd9b8f8cb97747d632a6da573883cde5fc5cfb60ab1ae2a6bf3da2df36f93a", + "example/flutter/lib/schema.dart": "ee6e7c93f615fcdcd60a020339bbfc0099ab48e0128d12ba20f362685802e0d6", + "example/flutter/lib/main.dart": "2f12f6c0f930a883fd1961da5006ede0674df6e5b6a6f7218a1d4eca5dedf122", + "example/flutter/lib/acceptance.dart": "1a100e938db747835d134fc32cf9893c1e5cd02967b00f8bd15bf1116647536a", + "example/flutter/lib/acceptance_web.dart": "5c749e3b9112ea8b8beb3bc98c8fdc76f92c643c3bd31f51bba2704de10ccd07", + "example/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png": "c7c0c0189145e4e32a401c61c9bdc615754b0264e7afae24e834bb81049eaf81", + "example/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png": "6a7c8f0d703e3682108f9662f813302236240d3f8f638bb391e32bfb96055fef", + "example/flutter/android/app/src/main/res/drawable/launch_background.xml": "94433756fe10005ddd6fc55cc5c0b1e9f3c10b66a08ceac795ece6713d34e28a", + "example/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png": "3c34e1f298d0c9ea3455d46db6b7759c8211a49e9ec6e44b635fc5c87dfb4180", + "example/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png": "4d470bf22d5c17d84edc5f82516d1ba8a1c09559cd761cefb792f86d9f52b540", + "example/flutter/android/app/src/main/res/values-night/styles.xml": "8c5bad5152f217e837519f9c15211d19f39bd45f5f67faf6dd81cb4b299e518f", + "example/flutter/android/app/src/main/res/values/styles.xml": "937d7c82f539c72eb94fd3a3773f29b3d3fef1997213c92cb632298cedf4bda9", + "example/flutter/android/app/src/main/res/drawable-v21/launch_background.xml": "7ca6d5cec2169c5a98661a119db23a6f3399c8fef2256ff04f3a642831282478", + "example/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png": "e14aa40904929bf313fded22cf7e7ffcbf1d1aac4263b5ef1be8bfce650397aa", + "example/flutter/android/app/src/main/AndroidManifest.xml": "6bc850ca03629a837d3dc7b82ba7cbe6c2b969d69ff49a6bea814a651d473c76", + "example/flutter/android/app/src/main/kotlin/dev/orm/orm_flutter/MainActivity.kt": "2c1111a536c7554c4e39eb96dbf1f0a136c51f0dc3a5d9b87c7545f2272a77d8", + "tool/test_flutter.dart": "ce813e2715977ed4202ea7696df7be3789e894dbb290035b9db64052276278f0", + "example/flutter/pubspec.yaml": "af8680b8727885fcd3b8142ca7aea7cde1af6b1c8bee95339c1f09c88ea76632", + "example/flutter/pubspec.lock": "793378bc564741af8e6bc43b774386cafd912ba662201f6e478361f1b09f3840", + "example/flutter/.metadata": "ac4f09e51478c44ffef6b566e3616f16a8b21db8b17eb51969b03cb2e1a2842e", + "example/flutter/android/app/build.gradle.kts": "67cc1825bb2528f74eb6d8577f99f22dd9c23fc8bb61d9be9a80d80e7fa9ecfb", + "example/flutter/android/settings.gradle.kts": "016759283e88ade6eba92910583feeef88b962839b957aa414296afd2952d122", + "example/flutter/android/gradle/wrapper/gradle-wrapper.properties": "b690d26223576fe4e63889fad9a00df81945cb870b9476cc5a563152a1a88a74" + }, + "screenshotSha256": "d12a2ed7e46a352e7eba24cf139b7deb98c887c137a1e89e6bd65d1f239c8b91", + "phases": [ + { + "phase": "legacy", + "schemaVersion": 1, + "release": false, + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"android_arm64\"", + "os": "AE3A.240806.036", + "androidApi": 35, + "pid": 5147, + "path": "/data/user/0/dev.orm.orm_flutter/files/acceptance.sqlite", + "checks": [ + "Fresh database", + "Version 1 typed transaction persisted two notes", + "Application schema compatibility", + "Closed database reopened read-only" + ], + "sqlite": "3.53.4", + "legacyRows": [ + [ + 1, + "Written by version 1", + "2026-09-15 01:02:03.123456+00" + ], + [ + 2, + "Keep this across the upgrade", + "2026-09-15 00:00:00.000000+00" + ] + ], + "history": [ + { + "id": "0001_initial", + "checksum": "eb8478c032ecf4fd28752a34ee1b74b503ae8708c0f6ef851856c63ac02c2252" + } + ], + "status": "passed", + "apkVersionCode": 1 + }, + { + "phase": "upgrade", + "schemaVersion": 2, + "release": true, + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"android_arm64\"", + "os": "AE3A.240806.036", + "androidApi": 35, + "pid": 5316, + "path": "/data/user/0/dev.orm.orm_flutter/files/acceptance.sqlite", + "checks": [ + "Existing database retained", + "Known execution phase", + "Expected previous migration history", + "Only version 2 migration applied", + "Live schema matches bundled snapshot", + "Original value and timestamp microseconds preserved", + "New field default / persisted update", + "Initial watch snapshot delivered to Flutter", + "Committed relation write refreshed watch", + "Rollback neither persisted nor notified", + "Typed patch refreshed watch", + "Background SQLite computation result", + "Dart timer and Flutter frames advanced during SQLite work", + "Native SQLite cancellation", + "Worker usable after cancellation", + "Application schema compatibility", + "Closed database reopened read-only" + ], + "sqlite": "3.53.4", + "unmanaged": [], + "background": { + "elapsedUs": 285090, + "timerTicks": 17, + "flutterFrames": 8 + }, + "watchSnapshots": 3, + "rows": [ + { + "id": 1, + "text": "Written by version 1", + "done": true, + "comments": [] + }, + { + "id": 2, + "text": "Keep this across the upgrade", + "done": false, + "comments": [] + }, + { + "id": 3, + "text": "Written by version 2", + "done": false, + "comments": [ + "Related in one transaction" + ] + } + ], + "history": [ + { + "id": "0001_initial", + "checksum": "eb8478c032ecf4fd28752a34ee1b74b503ae8708c0f6ef851856c63ac02c2252" + }, + { + "id": "0002_comments", + "checksum": "2967a6b4178d2061acad6451e497c7709f82d674a190caa938a765a32accec26" + } + ], + "status": "passed", + "apkVersionCode": 2 + }, + { + "phase": "reopen", + "schemaVersion": 2, + "release": true, + "dart": "3.13.3 (stable) (Tue Sep 1 01:07:17 2026 -0700) on \"android_arm64\"", + "os": "AE3A.240806.036", + "androidApi": 35, + "pid": 5390, + "path": "/data/user/0/dev.orm.orm_flutter/files/acceptance.sqlite", + "checks": [ + "Existing database retained", + "Known execution phase", + "Expected previous migration history", + "No migration replay", + "Live schema matches bundled snapshot", + "Original value and timestamp microseconds preserved", + "New field default / persisted update", + "Initial watch snapshot delivered to Flutter", + "Relation data survived process restart", + "Application schema compatibility", + "Closed database reopened read-only" + ], + "sqlite": "3.53.4", + "unmanaged": [], + "watchSnapshots": 1, + "rows": [ + { + "id": 1, + "text": "Written by version 1", + "done": true, + "comments": [] + }, + { + "id": 2, + "text": "Keep this across the upgrade", + "done": false, + "comments": [] + }, + { + "id": 3, + "text": "Written by version 2", + "done": false, + "comments": [ + "Related in one transaction" + ] + } + ], + "history": [ + { + "id": "0001_initial", + "checksum": "eb8478c032ecf4fd28752a34ee1b74b503ae8708c0f6ef851856c63ac02c2252" + }, + { + "id": "0002_comments", + "checksum": "2967a6b4178d2061acad6451e497c7709f82d674a190caa938a765a32accec26" + } + ], + "status": "passed", + "apkVersionCode": 2 + } + ], + "limits": [ + "Android arm64 API 35 emulator; not physical hardware, iOS or macOS Flutter.", + "Legacy APK debug; upgraded/restarted APK release AOT.", + "Frame and timer progress demonstrate worker separation, not an FPS benchmark.", + "Force-stop/relaunch is process persistence evidence, not power-loss testing." + ] +} diff --git a/research/validation/standalone-native.json b/research/validation/standalone-native.json new file mode 100644 index 00000000..2181374b --- /dev/null +++ b/research/validation/standalone-native.json @@ -0,0 +1,206 @@ +{ + "capturedAt": "2026-09-19T12:57:23.694966+00:00", + "runtimeSourceCommit": "7a54553933ae1d768576a54a4234b00b6bfcf97e", + "versions": { + "dart": "3.13.3", + "flutter": "3.47.4", + "sqlite": "3.53.4", + "postgres": "18.4", + "mysql": "8.4.11", + "mariadb": "11.8.9", + "chrome": "153" + }, + "native": { + "initialFullSuite": { + "passed": 1119, + "failed": 5, + "duration": "11:50", + "note": "Older CLI JSON expectations and new temporary-table regressions; corrected afterwards." + }, + "finalSessionRegression": { + "passed": 282, + "failed": 0 + }, + "finalCliAndMigrationRegression": { + "passed": 71, + "failed": 0 + }, + "analysis": "No issues found", + "flutterAnalysis": "No issues found" + }, + "localLogDigests": { + "redesign-full.log": { + "sha256": "fa62e954dcd084555aa1d247add8cb4257bdc57f9697d0ea21bd2a85f040ec20" + }, + "redesign-fixes-final.log": { + "sha256": "145c1c2142b550ddba83d1a702dad9e0ea90764016f4069a95420770cb5b658a" + }, + "redesign-session-final.log": { + "sha256": "d8d2eb7b170bbca8ff3059ec5e7c1193aecc9fb09e2af7d25a21fc949533d5ab" + }, + "redesign-analyze-final.log": { + "sha256": "34a0d218671a357b7042bd3be43d0134efd7f8d5b56c5d8c0ab98096c99a331e" + }, + "redesign-flutter-analyze.log": { + "sha256": "40bbb3c2256cfd52d7e7ec89ba44c939374e2f26b6c4aa4324a9146b9ba581a0" + }, + "redesign-postgres-review.log": { + "sha256": "2cdf97fac79070d66670313e16d39b7c333f8326f6060f4114312ba83547a479" + }, + "redesign-version-final.log": { + "sha256": "4eea5b0361952e1c9f644d7ba88b6b288badc637be96d8e70cb42f2affe7c90b" + }, + "redesign-review-analyze.log": { + "sha256": "34a0d218671a357b7042bd3be43d0134efd7f8d5b56c5d8c0ab98096c99a331e" + }, + "linux-sqlite-final.log": { + "sha256": "186f12bb79c650f42e513ed9b73758d8a40b5157cd3cf75f975f6f27e6a07e15" + }, + "sqlite-platform-host.log": { + "sha256": "81cc6734856da124e92eb0a5649a9a53548dcb2e8adb558f5f5131b163b5a8a7" + }, + "redesign-linux-pg-fixtures.log": { + "sha256": "0ba05b46decc78fceb636726374cefb1417101fece8f0bc5311dc6c1a23fe639" + }, + "mysql-statement-timeout.log": { + "sha256": "4d4b114f0189333e7b4adb00ff329f4816c6281cff31b0c1ef44f5fa01ce3860" + }, + "redesign-final-analyze.log": { + "sha256": "34a0d218671a357b7042bd3be43d0134efd7f8d5b56c5d8c0ab98096c99a331e" + }, + "linux-sqlite-capabilities-final.log": { + "sha256": "13a02dd8b95df6ffa238a966089c2b16622d81482e0643e8d1bcf3610ea1eb6d" + }, + "sqlite-capabilities-host-final.log": { + "sha256": "e9d51ff36d51b734d67555b280a29acb8fb029f419af92c1660f3a8ed05e50f3" + }, + "ci-35446420359.log": { + "sha256": "5a40050575da4445c076f65a1ab7f025e2614569efa8e442ad496f7d31f58054" + }, + "ci-35448372437.log": { + "sha256": "f28d359e9d376ff16bd55247014df42c79235613d65b261e10466182ca0217f0" + } + }, + "limits": [ + "The final focused invocations are not described as a second complete suite; GitHub CI runs the whole suite as a merge gate.", + "Fresh platform records independently pin the runtime source and build inputs." + ], + "reviewRegression": { + "capturedAt": "2026-09-19T13:23:27.946868+00:00", + "runtimeSourceCommit": "46265f836e49a248c7e6a64dbc646fada929e79f", + "postgres": { + "passed": 176, + "failed": 0, + "newProxyRegressions": 4 + }, + "mysqlDriverAndVersionGate": { + "passed": 40, + "failed": 0 + }, + "analysis": "No issues found" + }, + "portabilityRegression": { + "capturedAt": "2026-09-19T13:33:22.424576+00:00", + "runtimeSourceCommit": "46265f836e49a248c7e6a64dbc646fada929e79f", + "linux": { + "platform": "linux_arm64", + "dart": "3.13.3", + "passed": 21, + "skipped": 23, + "skipReason": "8 statement-interruption scenarios and 15 bounded retries require sqlite3_interrupt, absent from the default Linux asset. Explicit rejection is verified.", + "aotBundles": [ + "native_execution", + "native_retry" + ] + }, + "macosAndPostgres": { + "passed": 84, + "skipped": 4, + "skipReason": "Inverse unsupported-capability checks on drivers that support interruption." + }, + "postgresFixtures": { + "passed": 48, + "failed": 0 + } + }, + "statementTimeoutRegression": { + "capturedAt": "2026-09-19T13:38:04.781896+00:00", + "runtimeSourceCommit": "3aef4e7905f6997ff2fa9c181709bdddbdcc64ad", + "mysqlAndMariadb": { + "passed": 140, + "failed": 0 + }, + "analysis": "No issues found", + "independentCodeReview": "No additional concrete defects found" + }, + "linuxCompleteAttempt": { + "headCommit": "326f41e842acc840e15b4f8cd28bf159df803995", + "runUrl": "https://github.com/medz/dart-orm/actions/runs/35446420359", + "passed": 1129, + "failed": 17, + "skipped": 25, + "note": "All 17 failures were remaining Linux SQLite interruption assumptions in transaction/acquisition/watch tests. Runtime source was unchanged by their correction. The final PR CI run is the complete merge gate." + }, + "finalCapabilityRegression": { + "capturedAt": "2026-09-19T14:19:05.153644+00:00", + "runtimeSourceCommit": "3aef4e7905f6997ff2fa9c181709bdddbdcc64ad", + "linux": { + "passed": 41, + "skipped": 14, + "skipReason": "13 transaction interruption and 1 active watched-query interruption scenarios." + }, + "macosAndPostgres": { + "passed": 108, + "skipped": 4, + "skipReason": "Inverse unsupported-capability tests." + } + }, + "linuxNativeAcceptance": { + "headCommit": "e933576a790f579477e9c8a1980eb74febe8742a", + "runtimeSourceCommit": "3aef4e7905f6997ff2fa9c181709bdddbdcc64ad", + "runUrl": "https://github.com/medz/dart-orm/actions/runs/35448372437", + "passed": 1136, + "failed": 0, + "skipped": 41, + "skipReason": "Native interruption-dependent scenarios and inverse unsupported-capability checks.", + "browserJs": { + "scenariosPassed": 21, + "runnerSucceeded": false, + "reason": "Chrome profile deletion raced with exiting child processes (ENOTEMPTY)." + }, + "note": "Complete native stage succeeded. The overall workflow failed during browser runner cleanup, so the final corrected workflow remains the merge gate." + }, + "browserCleanupRegression": { + "runtimeSourceCommit": "3aef4e7905f6997ff2fa9c181709bdddbdcc64ad", + "helperTestsPassed": 5, + "chromeJs": { + "passed": 21, + "runnerExitCode": 0 + }, + "chromeWasm": { + "passed": 21, + "runnerExitCode": 0 + }, + "flutterWeb": { + "js": 21, + "wasmNonIsolated": 21, + "wasmIsolated": 21, + "runnerExitCode": 0 + }, + "logs": { + "browser-profile-tests.log": { + "sha256": "7bbac204502db57154e0cefbd95a2dfd56a6bb8590fa0c0b1d37287d63ac5876" + }, + "browser-cleanup-js.log": { + "sha256": "dee57767d28ddaf07db9be34aba8104c62f533183d36871461da723ab74ff6ab" + }, + "browser-cleanup-wasm.log": { + "sha256": "3f650ea848b0964e753e496833574ae59e1d73c39254767645878140471c1235" + }, + "flutter-web-cleanup.log": { + "sha256": "cd6260d768cddd523c3eb80d6eace8f7399287f9d53986d75e3d893a2f410a44" + } + }, + "note": "Five real Chrome runs completed profile cleanup on macOS. Independent Linux browser CI remains required for the final head." + } +} diff --git a/research/validation/standalone-redesign.md b/research/validation/standalone-redesign.md new file mode 100644 index 00000000..7cae4ad0 --- /dev/null +++ b/research/validation/standalone-redesign.md @@ -0,0 +1,123 @@ +# Standalone ORM acceptance + +Captured on 2026-09-19. Initial runtime source: `7a54553933ae1d768576a54a4234b00b6bfcf97e`. +Final runtime and refreshed Web assets: `3aef4e7905f6997ff2fa9c181709bdddbdcc64ad`. +The refreshed browser reports pin the latter revision. Android reports retain the +initial revision. Later changes affect server drivers, operation capability +validation, test portability and Web asset fingerprints; Android was not rerun. +Validation commits add reports without changing runtime. + +## Actual environments + +Dart 3.13.3 and Flutter 3.47.4 stable on macOS arm64; SQLite 3.53.4; +PostgreSQL 18.4; MySQL 8.4.11 and MariaDB 11.8.9 in separate disposable containers. +MySQL/MariaDB tests require TLS with their fixture certificates. Flutter and +plain Dart browser tests use actual Chrome 153, not simulated DOM APIs. + +## Results and scope + +| Validation | Evidence | +| --- | --- | +| Native full regression, first invocation | 1119 passed, 5 failed in 11:50. Failures were two older CLI JSON expectations and three new temporary-metadata regressions. [Structured record](standalone-native.json). | +| Final session/transaction regression | 282 passed. Real SQLite/PostgreSQL session ownership, escaped connections, swallowed SQL failures, pending work, cursor cleanup, transaction/retry/acquisition/watch paths; MySQL/MariaDB transaction boundaries included. | +| Final CLI/import/migration/SQL regression | 71 passed. All four engines initialize and create Dart history; SQLite lifecycle and SQLite/PostgreSQL adoption use real consumers; MySQL/MariaDB test actual DDL/backfill recovery and metadata shadowing. | +| PostgreSQL review regression | 176 passed, including four new real TCP-proxy regressions for explicit/default timeout, cancellation before PID discovery and a shared PID/application deadline. All four new cases failed before the fix. | +| MySQL version boundary | 40 driver/migration-target checks passed. MySQL driver and migration support now both require 8.4+; older migration targets fail before locks, journals or DDL. | +| Static checks | Root Dart analysis and Flutter example analysis: no issues. Formatting and diff whitespace checks pass. | +| Plain Dart Web | JS and WASM each pass 21 scenarios. [Reports](standalone-browser.json). | +| Flutter Web release | JS, WASM without isolation and WASM with isolation each pass 21 scenarios. Assets are bundled automatically; nested routes, reload recovery, migrations and animation progress during SQL are checked. [Reports](standalone-flutter-web.json). | +| Flutter Android arm64 API 35 | Legacy debug installation: 4 checks; release AOT APK upgrade: 17; new-process reopen: 11. Host checks distinct process IDs, retained database path/data/history and APK version codes. [Report](standalone-flutter.json), [screenshot](standalone-flutter-android.png). | +| Native packaging | Both Android APKs contain arm64 `libsqlite3.so` and zero SQLite Web worker/WASM assets. | + +The initial full run and subsequent targeted runs are separate invocations. They +are not combined into a claimed all-green full run. The PR's GitHub CI check runs +formatting, analysis, resource fingerprints, the complete native suite with all +four engines enabled, and Chrome JS/WASM. Passing that check and Codex review of +the final head are required before merge; the PR records their results. + +## Correctness findings resolved before platform capture + +Independent review corrected MySQL/MariaDB registry parsing and non-atomic plan +reporting, temporary migration metadata shadowing, and borrowed-session ownership +and error tracking. A caught raw PostgreSQL error cannot make an aborted +transaction report success. Borrowed connections and cursors cannot escape their +session/savepoint; unawaited accepted work drains before the boundary completes. +MySQL/MariaDB migration failures and uncertain commits use durable checkpoints and +catalog verification rather than assuming that DDL rolls back. + +## Codex review correction + +The first external Codex review found an unbounded first PostgreSQL backend PID +lookup. The timer and cancellation listener now start before that lookup; an +interruption without a PID discards the connection before sending application SQL. +The cached PID path retains cancellation and reuse, and uncertain errors after a +statement starts are not reclassified as confirmed interruption. A real PostgreSQL +TCP proxy reproduces the original hang and verifies the correction. + +The second Codex review found that MySQL/MariaDB statement deadlines were +implemented by the driver but rejected by the runtime cancellation gate. +`Capabilities.statementTimeout` now distinguishes connection-discard deadlines +from token cancellation. The raw runtime and ORM accept per-operation timeouts; +transaction deadlines and retries still require actual interruption. All 140 +driver, typed database and transaction-boundary regressions pass against real +MySQL/MariaDB, including unknown-outcome messaging and caught-timeout failure. + +## Linux CI portability corrections + +The first Linux CI run revealed test assumptions hidden by macOS: borrowed +PostgreSQL fixtures omitted URL passwords, an import fixture used `compile exe` +without bundling native assets, and interruption tests assumed every SQLite build +exports `sqlite3_interrupt`. These are corrected without substituting another +SQLite library or changing the runtime capability contract. The PostgreSQL temporal +and acquisition regression run passes 48 checks after the fixture correction. +Linux arm64 passes 21 focused checks and two real AOT bundles, with 23 explicit +capability skips (8 interruption scenarios and 15 bounded retries). The same +focused suites on macOS/PostgreSQL pass 84 checks, with four inverse unsupported- +capability checks skipped. These platform runs use the unchanged `46265f83` runtime +plus the portability test fixtures. [Structured record](standalone-native.json). + +CLI workflows kept progressing in the original run; repeated independent Dart +starts made the full run longer than local acceptance. CI now permits 45 minutes +without removing checks. Canceled earlier runs are not passing acceptance evidence. + +The complete Linux run on `326f41e8` then reported 1129 passed, 17 failed and +25 skipped. All remaining failures were interruption assumptions in transaction, +acquisition and watch tests. Their correction preserves ordinary COMMIT failure, +unknown acknowledgement, acquisition and subscription checks; it only skips actual +interruption scenarios and explicitly verifies unsupported controls before SQL. +The corrected three suites pass 41 tests with 14 capability skips on Linux, and +108 tests with four inverse-capability skips on macOS/PostgreSQL. +Runtime and Web assets remain identical to `3aef4e79`. The final PR run, not this +failed attempt, is the complete merge gate. + +The subsequent complete native Linux run on `e933576a` succeeds: **1136 passed, +41 capability skips, zero failures**. Its Chrome JS report also passes all 21 +scenarios, but the runner exits unsuccessfully when profile deletion races with +exiting Chrome child processes. Runner cleanup is corrected with bounded retries, +and browser checks run as a separate CI job so their outcome is visible early. +The cleanup correction passes five deterministic regressions and five real Chrome +runs on macOS: Dart JS/WASM and Flutter Web JS/WASM with and without isolation +each pass 21 scenarios, including successful runner exit and profile deletion. +The final workflow must pass both jobs before merge. + +## Explicit boundaries + +MySQL/MariaDB provide exact values, storage, comparison and MIN/MAX within their +physical limits. Typed arithmetic, SUM, decimal set operations and explicit +precision narrowing that can silently lose digits are rejected; see the +[reproductions](../mysql-precision-boundaries.md) and [driver contract](../../docs/mysql.md). +Their initial adapters own a single queued connection and do not support streaming +or cancellation. Runtime leases add a cleanup ROLLBACK round trip. DDL remains +non-atomic and uses the [engine-specific recovery workflow](../../docs/mysql-migrations.md). + +The default Linux native asset in `sqlite3` 3.6.0 hides `sqlite3_interrupt`. +Statement cancellation, execution deadlines and bounded retries are rejected +before SQL; ordinary database operations and streaming remain supported. Tests +check that refusal explicitly and skip only scenarios requiring interruption. +Native executables must use `dart build cli` and ship the whole bundle. + +Chrome and an Android emulator do not certify Safari, Firefox, physical devices, +iOS or macOS Flutter. Timer/animation progress establishes worker separation, +not a frame-rate or throughput improvement. Historical benchmarks and validation +files retain their original source revisions; this change makes no new performance +claim. No package was published. diff --git a/research/validation/usage.json b/research/validation/usage.json new file mode 100644 index 00000000..e2f68eac --- /dev/null +++ b/research/validation/usage.json @@ -0,0 +1,157 @@ +{ + "capturedAt": "2026-09-15T10:03:19.386980+00:00", + "sourceBase": "20ffee7", + "commands": [ + { + "name": "dependencies", + "command": [ + "dart", + "pub", + "get", + "--offline" + ], + "environmentKeys": [], + "exitCode": 0, + "elapsedSeconds": 0.379, + "stdout": "Resolving dependencies...\nDownloading packages...\nGot dependencies!\n", + "stderr": "" + }, + { + "name": "generate", + "command": [ + "dart", + "run", + "orm", + "generate", + "example/schema.dart" + ], + "environmentKeys": [], + "exitCode": 0, + "elapsedSeconds": 3.588, + "stdout": "Generated example/schema.orm.dart\n", + "stderr": "Running build hooks...Running build hooks..." + }, + { + "name": "quickstart", + "command": [ + "dart", + "run", + "example/main.dart" + ], + "environmentKeys": [], + "exitCode": 0, + "elapsedSeconds": 1.503, + "stdout": "[(email: seven@example.com, titles: [Hello Dart])]\n", + "stderr": "Running build hooks...Running build hooks..." + }, + { + "name": "many_to_many", + "command": [ + "dart", + "run", + "example/teams/main.dart" + ], + "environmentKeys": [], + "exitCode": 0, + "elapsedSeconds": 1.139, + "stdout": "2 SQL templates; 32764 parent keys per batch\n[(name: Ada, teams: [(role: MembershipRole.member, team: Docs), (role: MembershipRole.owner, team: Core)])]\n2 SQL statements; [1, 2] rows\n1 acquisition; 2 decode batches\n", + "stderr": "Running build hooks...Running build hooks..." + }, + { + "name": "sqlite_queries", + "command": [ + "dart", + "run", + "example/queries.dart" + ], + "environmentKeys": [], + "exitCode": 0, + "elapsedSeconds": 1.442, + "stdout": "{\"backend\":\"sqlite\",\"checks\":[\"Dynamic typed filter and scalar selection\",\"Joined field orders root rows\",\"Correlated relation count orders root rows\",\"Grouping, HAVING and CTE exported columns\",\"Window ordering is independent of result ordering\",\"Typed IN subquery\",\"Correlated scalar aggregate\",\"Versioned cursor preserves NULL order and unique tie breaker\",\"Generated schema matches the live database\"],\"status\":\"passed\"}\n", + "stderr": "Running build hooks...Running build hooks...", + "report": { + "backend": "sqlite", + "checks": [ + "Dynamic typed filter and scalar selection", + "Joined field orders root rows", + "Correlated relation count orders root rows", + "Grouping, HAVING and CTE exported columns", + "Window ordering is independent of result ordering", + "Typed IN subquery", + "Correlated scalar aggregate", + "Versioned cursor preserves NULL order and unique tie breaker", + "Generated schema matches the live database" + ], + "status": "passed" + } + }, + { + "name": "postgres_queries", + "command": [ + "dart", + "run", + "example/queries.dart" + ], + "environmentKeys": [ + "ORM_EXAMPLE_POSTGRES" + ], + "exitCode": 0, + "elapsedSeconds": 1.26, + "stdout": "{\"backend\":\"postgres\",\"checks\":[\"Dynamic typed filter and scalar selection\",\"Joined field orders root rows\",\"Correlated relation count orders root rows\",\"Grouping, HAVING and CTE exported columns\",\"Window ordering is independent of result ordering\",\"Typed IN subquery\",\"Correlated scalar aggregate\",\"Versioned cursor preserves NULL order and unique tie breaker\",\"Generated schema matches the live database\"],\"status\":\"passed\"}\n", + "stderr": "Running build hooks...Running build hooks...", + "report": { + "backend": "postgres", + "checks": [ + "Dynamic typed filter and scalar selection", + "Joined field orders root rows", + "Correlated relation count orders root rows", + "Grouping, HAVING and CTE exported columns", + "Window ordering is independent of result ordering", + "Typed IN subquery", + "Correlated scalar aggregate", + "Versioned cursor preserves NULL order and unique tie breaker", + "Generated schema matches the live database" + ], + "status": "passed" + } + }, + { + "name": "migration_chain", + "command": [ + "dart", + "run", + "orm", + "migration", + "check", + "--dir", + "example/flutter/assets/migrations" + ], + "environmentKeys": [], + "exitCode": 0, + "elapsedSeconds": 0.476, + "stdout": "{\n \"valid\": true,\n \"dialect\": \"sqlite\",\n \"migrations\": [\n \"0001_initial\",\n \"0002_comments\"\n ]\n}\n", + "stderr": "Running build hooks...Running build hooks..." + }, + { + "name": "analysis", + "command": [ + "dart", + "analyze" + ], + "environmentKeys": [], + "exitCode": 0, + "elapsedSeconds": 1.341, + "stdout": "Analyzing dart-orm...\nNo issues found!\n", + "stderr": "" + } + ], + "sources": { + "example/main.dart": "82eb04e907bfcb02deabaead133cc4ddcf149d2fb5eba84ccbd91694a82ec83a", + "example/teams/main.dart": "655d58b82d10fdceaed987beb2dd877dc61afd951d9889c62880b352982a3d14", + "example/queries.dart": "c9395fdf6f9be194c8566fea8476e5fba724bba5906a42faa8b9d6eb653e56f1", + "example/schema.dart": "d164d87b21a62cd5d97f074dc1029f36725a622aa0ab88643215a9496bbb279e", + "example/schema.orm.dart": "ac37c40746b9c419378c71a6a25488fc658a2e6cc9e82ccd53b262ffe508b627", + "example/schema.orm.json": "97fd6dbd1c731b86e10dfe58b75dbecafe27c753f9795a396c8b419137f23683", + "lib/src/migrate/catalog.dart": "80c1a396727d57e9c49e37914c076e6fab64a3eb43437b522c82c749cf280359" + } +} diff --git a/test/acquisition_test.dart b/test/acquisition_test.dart new file mode 100644 index 00000000..3abd6846 --- /dev/null +++ b/test/acquisition_test.dart @@ -0,0 +1,579 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:postgres/postgres.dart' as pg; +import 'package:test/test.dart'; + +import 'support/tables.dart'; + +Matcher code(String code) => + isA().having((e) => e.code, 'code', code); + +void main() { + test( + 'an expired lease cannot enter before a delayed Timer gets its turn', + () async { + final driver = _GatedDriver( + await SqliteDriver.open(const SqliteOptions.memory()), + ); + final db = Database(driver); + var calls = 0; + try { + final result = db.session( + (s) async { + calls++; + }, + acquire: const AcquisitionOptions( + timeout: Duration(milliseconds: 10), + ), + ); + final failure = expectLater( + result, + throwsA(code('CONNECTION.TIMEOUT')), + ); + final busy = Stopwatch()..start(); + while (busy.elapsed < const Duration(milliseconds: 30)) { + // Deliberately starve the timer; the ready lease resumes in a microtask. + } + driver.ready.complete(); + await failure; + await db.close(); + expect(calls, 0); + } finally { + if (!driver.ready.isCompleted) driver.ready.complete(); + await db.close(); + } + }, + ); + runTests( + 'sqlite', + (observe) => sqlite(const SqliteOptions.memory(), onQuery: observe), + ); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + test('PostgreSQL native pool timeout is classified before entering the callback', () async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + maxConnections: 1, + poolTimeout: const Duration(milliseconds: 50), + ), + ); + final entered = Completer(), release = Completer(); + var calls = 0; + final owner = db.session((session) async { + entered.complete(); + await release.future; + }); + try { + await entered.future; + await expectLater( + db.session((s) async { + calls++; + }), + throwsA(code('CONNECTION.TIMEOUT')), + ); + release.complete(); + await owner; + await db.execute(SqlCommand('SELECT 1')); + expect(calls, 0); + await expectLater( + db.session((s) async => throw TimeoutException('user callback')), + throwsA(isA()), + ); + } finally { + if (!release.isCompleted) release.complete(); + await owner; + await db.close(); + } + }); + test( + 'PostgreSQL connection establishment timeout does not cap pool waiting', + () async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + maxConnections: 1, + connectTimeout: const Duration(milliseconds: 200), + poolTimeout: const Duration(seconds: 2), + ), + ); + final entered = Completer(), release = Completer(); + final owner = db.session((session) async { + await session.execute(SqlCommand('SELECT 1')); + entered.complete(); + await release.future; + }); + try { + await entered.future.timeout(const Duration(seconds: 3)); + final check = expectLater( + db.execute(SqlCommand('SELECT 2')), + completion( + isA().having((r) => r.rows, 'rows', [ + [2], + ]), + ), + ); + await Future.delayed(const Duration(milliseconds: 350)); + release.complete(); + await owner; + await check; + } finally { + if (!release.isCompleted) release.complete(); + await owner; + await db.close(); + } + }, + ); + runTests('postgres', (observe) async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_acquire_tests', + maxConnections: 1, + ), + onQuery: observe, + ); + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_acquire_tests'), + ); + return db; + }); + test('PostgreSQL timeout during real connection initialization releases the late lease', () async { + final uri = Uri.parse(url), + colon = uri.userInfo.indexOf(':'), + opening = Completer(), + ready = Completer(); + var opens = 0, calls = 0; + final pool = pg.Pool.withEndpoints( + [ + pg.Endpoint( + host: uri.host, + port: uri.hasPort ? uri.port : 5432, + database: uri.pathSegments.single, + username: uri.userInfo.isEmpty + ? null + : Uri.decodeComponent( + colon < 0 ? uri.userInfo : uri.userInfo.substring(0, colon), + ), + password: colon < 0 + ? null + : Uri.decodeComponent(uri.userInfo.substring(colon + 1)), + ), + ], + settings: pg.PoolSettings( + maxConnectionCount: 1, + sslMode: pg.SslMode.disable, + onOpen: (connection) async { + opens++; + opening.complete(); + await ready.future; + }, + ), + ); + final db = Database(PostgresDriver.borrow(pool)); + try { + final result = db.session( + (session) async { + calls++; + }, + acquire: const AcquisitionOptions( + timeout: Duration(milliseconds: 100), + ), + ); + final failure = expectLater( + result, + throwsA(code('CONNECTION.TIMEOUT')), + ); + await opening.future.timeout(const Duration(seconds: 3)); + await failure.timeout(const Duration(seconds: 3)); + expect(calls, 0); + ready.complete(); + expect((await db.execute(SqlCommand('SELECT 1'))).rows, [ + [1], + ]); + expect( + opens, + 1, + reason: 'An abandoned request must release, not discard, its late connection.', + ); + expect(calls, 0); + } finally { + if (!ready.isCompleted) ready.complete(); + await db.close(); + await pool.close(); + } + }); + } +} + +void runTests( + String name, + Future> Function(void Function(QueryEvent)) open, +) { + group('acquisition $name', () { + late Database db; + final events = []; + Future? held; + Completer? release; + Future hold() async { + final entered = Completer(); + release = Completer(); + held = db.session((session) async { + entered.complete(); + await release!.future; + }); + await entered.future; + events.clear(); + } + + Future free() async { + if (release != null && !release!.isCompleted) release!.complete(); + await held; + held = null; + } + + setUp(() async { + db = await open(events.add); + await db.execute(SqlCommand('DROP TABLE IF EXISTS users')); + await createTables(db); + events.clear(); + }); + tearDown(() async { + await free(); + await db.close(); + }); + + test( + 'expired raw writes never execute after a late lease becomes available', + () async { + await hold(); + await expectLater( + db.execute( + SqlCommand("INSERT INTO users(email) VALUES ('late')"), + options: const ExecutionOptions( + acquireTimeout: Duration(milliseconds: 40), + ), + ), + throwsA(code('CONNECTION.TIMEOUT')), + ).timeout(const Duration(seconds: 2)); + expect(events, isEmpty); + await free(); + expect(await db.table(users).count(), 0); + expect(events.any((e) => e.sql.contains("'late'")), false); + }, + ); + + test( + 'query cancellation returns while the pool is still occupied', + () async { + await hold(); + final token = CancellationToken(); + final result = db + .table(users) + .get(options: ExecutionOptions(cancellation: token)); + final failure = expectLater( + result, + throwsA(code('OPERATION.CANCELLED')), + ); + token.cancel(); + await failure.timeout(const Duration(seconds: 2)); + expect(release!.isCompleted, false); + expect(events, isEmpty); + await free(); + expect(await db.table(users).count(), 0); + expect(events.length, 1); + }, + ); + + test( + 'typed mutations and batch transactions propagate acquisition limits', + () async { + await hold(); + const options = ExecutionOptions( + acquireTimeout: Duration(milliseconds: 30), + ); + await expectLater( + db + .table(users) + .insert((u) => [u.email.set('mutation')]) + .execute(options: options), + throwsA(code('CONNECTION.TIMEOUT')), + ); + await expectLater( + db + .table(users) + .insertMany(['a', 'b'], (u, email) => [u.email.set(email)]) + .execute(options: options), + throwsA(code('CONNECTION.TIMEOUT')), + ); + final token = CancellationToken(); + final cancelled = db + .table(users) + .insertMany(['c'], (u, email) => [u.email.set(email)]) + .execute(options: ExecutionOptions(cancellation: token)); + final failure = expectLater( + cancelled, + throwsA(code('OPERATION.CANCELLED')), + ); + token.cancel(); + await failure.timeout(const Duration(seconds: 2)); + expect(events, isEmpty); + await free(); + expect(await db.table(users).count(), 0); + }, + ); + + test( + 'expired transactions and sessions never enter their callbacks', + () async { + await hold(); + var calls = 0; + const acquire = AcquisitionOptions(timeout: Duration(milliseconds: 30)); + await expectLater( + db.transaction((tx) async { + calls++; + }, acquire: acquire), + throwsA(code('CONNECTION.TIMEOUT')), + ); + final token = CancellationToken(); + final session = db.session((s) async { + calls++; + }, acquire: AcquisitionOptions(cancellation: token)); + final failure = expectLater( + session, + throwsA(code('OPERATION.CANCELLED')), + ); + token.cancel(); + await failure.timeout(const Duration(seconds: 2)); + await free(); + await db.execute(SqlCommand('SELECT 1')); + expect(calls, 0); + expect(events.length, 1); + }, + ); + + test('acquisition timeout stops on entry and statement timeout starts afterward', () async { + await hold(); + if (!db.capabilities.statementTimeout) { + expect( + () => db.execute( + SqlCommand('SELECT 99'), + options: const ExecutionOptions( + acquireTimeout: Duration(seconds: 2), + timeout: Duration(milliseconds: 100), + ), + ), + throwsA(code('CAPABILITY.CANCEL')), + ); + expect(events, isEmpty); + } + final result = db.execute( + SqlCommand('SELECT 1'), + options: ExecutionOptions( + acquireTimeout: const Duration(seconds: 2), + timeout: db.capabilities.statementTimeout + ? const Duration(milliseconds: 100) + : null, + ), + ); + await Future.delayed(const Duration(milliseconds: 180)); + await free(); + expect((await result).rows, [ + [1], + ]); + final token = CancellationToken(); + await db.session( + (s) async { + token.cancel(); + await Future.delayed(const Duration(milliseconds: 50)); + expect((await s.execute(SqlCommand('SELECT 2'))).rows, [ + [2], + ]); + }, + acquire: AcquisitionOptions( + timeout: const Duration(milliseconds: 30), + cancellation: token, + ), + ); + }); + + test( + 'invalid and already-cancelled acquisition options never queue work', + () async { + var called = false; + expect( + () => db.session((s) async { + called = true; + }, acquire: const AcquisitionOptions(timeout: Duration.zero)), + throwsArgumentError, + ); + final token = CancellationToken()..cancel(); + expect( + () => db.session((s) async { + called = true; + }, acquire: AcquisitionOptions(cancellation: token)), + throwsA(code('OPERATION.CANCELLED')), + ); + expect( + () => db + .table(users) + .get( + options: const ExecutionOptions(acquireTimeout: Duration.zero), + ), + throwsArgumentError, + ); + expect(called, false); + expect(events, isEmpty); + }, + ); + + test( + 'stream cancellation and timeout return without opening a cursor', + () async { + await hold(); + final stream = db + .table(users) + .stream( + options: const ExecutionOptions( + acquireTimeout: Duration(milliseconds: 40), + ), + ); + await expectLater( + stream.toList(), + throwsA(code('CONNECTION.TIMEOUT')), + ).timeout(const Duration(seconds: 2)); + final errors = []; + final subscription = db + .table(users) + .stream() + .listen((_) {}, onError: errors.add); + await subscription.cancel().timeout(const Duration(seconds: 2)); + expect(errors, isEmpty); + expect(events, isEmpty); + await free(); + expect(await db.table(users).count(), 0); + expect(events.length, 1); + }, + ); + + test( + 'query watch acquisition failure can recover on a later invalidation', + () async { + await hold(); + final failed = Completer(), rows = Completer>(); + final subscription = db + .table(users) + .select((u) => u.email) + .watch( + options: const ExecutionOptions( + acquireTimeout: Duration(milliseconds: 40), + ), + ) + .listen( + rows.complete, + onError: (Object error) { + expect(error, code('CONNECTION.TIMEOUT')); + failed.complete(); + }, + ); + try { + await failed.future.timeout(const Duration(seconds: 2)); + expect(events, isEmpty); + await free(); + db.invalidate([usersSchema]); + expect( + await rows.future.timeout(const Duration(seconds: 2)), + isEmpty, + ); + } finally { + await subscription.cancel(); + } + }, + ); + + test( + 'close drains abandoned reservations without running their callbacks', + () async { + await hold(); + await expectLater( + db.execute( + SqlCommand('SELECT 99'), + options: const ExecutionOptions( + acquireTimeout: Duration(milliseconds: 30), + ), + ), + throwsA(code('CONNECTION.TIMEOUT')), + ); + var closed = false; + final closing = db.close().then((_) => closed = true); + await Future.delayed(const Duration(milliseconds: 30)); + expect(closed, false); + await free(); + await closing.timeout(const Duration(seconds: 2)); + expect(events, isEmpty); + }, + ); + + test( + 'abandoned requests preserve pool reuse and subsequent action errors', + () async { + final pid = name == 'postgres' + ? (await db.execute(SqlCommand('SELECT pg_backend_pid()'))) + .rows + .single + .single + : null; + await hold(); + for (var i = 0; i < 3; i++) { + await expectLater( + db.execute( + SqlCommand('SELECT 99'), + options: const ExecutionOptions( + acquireTimeout: Duration(milliseconds: 20), + ), + ), + throwsA(code('CONNECTION.TIMEOUT')), + ); + } + await free(); + if (pid != null) { + expect( + (await db.execute(SqlCommand('SELECT pg_backend_pid()'))) + .rows + .single + .single, + pid, + ); + } + await expectLater( + db.session( + (s) async => throw StateError('action'), + acquire: const AcquisitionOptions(timeout: Duration(seconds: 1)), + ), + throwsStateError, + ); + expect((await db.execute(SqlCommand('SELECT 1'))).rows, [ + [1], + ]); + }, + ); + }); +} + +final class _GatedDriver(final Driver source) + implements Driver { + final ready = Completer(); + @override + Capabilities get capabilities => source.capabilities; + @override + Future run(Future Function(SqlConnection) action) => + ready.future.then((_) => source.run(action)); + @override + Future close() => source.close(); +} diff --git a/test/backfill_keys_test.dart b/test/backfill_keys_test.dart new file mode 100644 index 00000000..bc50147b --- /dev/null +++ b/test/backfill_keys_test.dart @@ -0,0 +1,274 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('backfill keys $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_backfill_key_tests', + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_backfill_key_tests'), + ); + } + for (final table in [ + 'keys', + '_orm_migrations', + '_orm_migration_steps', + ]) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + }); + tearDown(() => db.close()); + for (final sample in , List)>{ + 'integer': (Codecs.integer, [-9007199254740993, 0, 9007199254740993]), + 'bigint': ( + Codecs.bigint, + [ + '-900719925474099399999', + '22222222222222222222222', + '100000000000000000000000', + ], + ), + 'text': (Codecs.text, ['', r"a' $1 ?2", '数据 😀']), + 'real': (Codecs.real, [-1.125, 0.0, 1.23456789012345]), + 'boolean': (Codecs.boolean, [false, true]), + 'timestamp': ( + Codecs.dateTime, + [ + DateTime.utc(2026, 1, 1, 0, 0, 0, 0, 1), + DateTime.utc(2026, 1, 1, 0, 0, 0, 0, 2), + ], + ), + 'blob': ( + Codecs.bytes, + [ + Uint8List.fromList([0]), + Uint8List.fromList([0, 255]), + Uint8List.fromList([255, 0]), + ], + ), + }.entries) { + test('${sample.key} cursors preserve native values across resume', () async { + final table = TableSchema( + 'keys', + columns: [ + Column('key', sample.value.$1), + Column('touches', Codecs.integer, defaultSql: '0'), + ], + primaryKey: ['key'], + ); + final initial = Migration.create('0001_keys', [ + table, + ], dialect: db.dialect); + final runner = Migrator(db.sql); + await runner.apply([initial]); + for (final key in sample.value.$2) { + final stored = switch ((backend, key)) { + ('sqlite', DateTime date) => date.toUtc().toIso8601String(), + ('sqlite', bool value) => value ? 1 : 0, + _ => key, + }; + await db.execute( + SqlCommand( + 'INSERT INTO "keys" ("key") VALUES (${backend == 'sqlite' ? '?1' : r'$1'})', + [stored], + ), + ); + } + final migration = Migration.steps( + '0002_backfill', + [ + Backfill( + table, + set: {'touches': 'touches + 1'}, + doneWhen: 'SELECT NOT EXISTS(SELECT 1 FROM "keys" WHERE touches <> 1)', + batchSize: 1, + ), + ], + previous: initial.checksum, + dialect: db.dialect, + ); + await runner.apply([initial, migration], maxBackfillBatches: 1); + final cursor = (await runner.progress()).single.backfill!; + expect(cursor.lastKey, hasLength(1)); + expect(cursor.rows, 1); + await runner.apply([initial, migration]); + final rows = await db.execute( + SqlCommand('SELECT touches FROM "keys"'), + ); + expect(rows.rows.map((r) => r.single), everyElement(1)); + expect( + (await runner.progress()).single.backfill!.rows, + sample.value.$2.length, + ); + }); + } + test('composite key batches account for every bound parameter', () async { + final commands = []; + final limited = Database(_Limited(db.driver, commands)); + final table = TableSchema( + 'keys', + columns: [ + Column('tenant', Codecs.text), + Column('id', Codecs.integer), + Column('touches', Codecs.integer, defaultSql: '0'), + ], + primaryKey: ['tenant', 'id'], + ); + final initial = Migration.create('0001_keys', [ + table, + ], dialect: db.dialect); + final runner = Migrator(limited.sql); + await runner.apply([initial]); + for (var i = 0; i < 20; i++) { + await limited.execute( + SqlCommand( + 'INSERT INTO "keys" (tenant, id) VALUES (${backend == 'sqlite' ? '?1, ?2' : r'$1, $2'})', + ['tenant${i ~/ 10}', i], + ), + ); + } + final migration = Migration.steps( + '0002_backfill', + [ + Backfill( + table, + set: {'touches': 'touches + 1'}, + doneWhen: + 'SELECT NOT EXISTS(SELECT 1 FROM "keys" WHERE touches <> 1)', + batchSize: 1000, + ), + ], + previous: initial.checksum, + dialect: db.dialect, + ); + await runner.apply([initial, migration], maxBackfillBatches: 2); + expect((await runner.progress()).single.backfill!.rows, 12); + expect((await runner.progress()).single.backfill!.lastKey, [ + 'tenant1', + '11', + ]); + await runner.apply([initial, migration]); + expect((await runner.progress()).single.backfill!.batches, 4); + expect(commands.every((c) => c.parameters.length <= 12), true); + final data = await limited.execute( + SqlCommand('SELECT touches FROM "keys"'), + ); + expect(data.rows.map((r) => r.single), everyElement(1)); + }); + }); + } + test('invalid key, assignments and completion declarations fail before execution', () { + TableSchema schema( + Codec codec, { + bool nullable = false, + bool primary = true, + }) => TableSchema( + 'keys', + columns: [ + Column('key', codec, nullable: nullable), + Column('value', Codecs.text), + ], + primaryKey: primary ? ['key'] : [], + ); + for (final table in [ + schema(Codecs.integer, primary: false), + schema(Codecs.text.nullable(), nullable: true), + schema(Codecs.json), + ]) { + expect( + () => + Backfill(table, set: {'value': "'done'"}, doneWhen: 'SELECT TRUE'), + throwsA(isA()), + ); + } + final table = schema(Codecs.integer); + for (final set in [ + {}, + {'key': 'key + 1'}, + {'unknown': 'value'}, + {'value': ''}, + ]) { + expect( + () => Backfill(table, set: set, doneWhen: 'SELECT TRUE'), + throwsArgumentError, + ); + } + expect( + () => Backfill( + table, + set: {'value': "'done'"}, + doneWhen: 'DELETE FROM keys', + ), + throwsA(isA()), + ); + expect( + () => Backfill( + table, + set: {'value': "'done'"}, + doneWhen: 'SELECT TRUE', + batchSize: 0, + ), + throwsArgumentError, + ); + }); +} + +final class _Limited( + final Driver source, + final List commands, +) implements Driver { + @override + Capabilities get capabilities => Capabilities( + dialect: source.capabilities.dialect, + maxParameters: 12, + cancellation: source.capabilities.cancellation, + ); + @override + Future run(Future Function(SqlConnection) action) => + source.run((c) => action(_Counting(c, commands))); + @override + Future close() => source.close(); +} + +final class _Counting( + final SqlConnection source, + final List commands, +) implements SqlConnection { + @override + bool? get transactionActive => source.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) { + commands.add(command); + return source.execute(command, options: options); + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => source.openCursor(command, options: options); + @override + Future invalidate() => source.invalidate(); +} diff --git a/test/backfill_test.dart b/test/backfill_test.dart new file mode 100644 index 00000000..a86936fa --- /dev/null +++ b/test/backfill_test.dart @@ -0,0 +1,763 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/backfill.dart'; +import 'support/backfill_history/migrations.g.dart' as historical; +import 'support/migration_project.dart'; + +Matcher code(String value) => + isA().having((e) => e.code, 'code', value); +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('backfill $backend', () { + late Directory directory; + late Database db; + late Migration initial; + late Migrator runner; + late String path; + final commands = []; + Future> open() async { + if (backend == 'sqlite') return sqlite(SqliteOptions.file(path)); + final result = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_backfill_tests', + maxConnections: 1, + ), + ); + await result.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_backfill_tests'), + ); + return result; + } + + setUp(() async { + directory = await Directory.systemTemp.createTemp('orm-backfill-'); + path = '${directory.path}/db.sqlite'; + final base = await open(); + db = Database(_Driver(base.driver, commands)); + initial = initialFor(db.dialect); + runner = Migrator(db.sql); + for (final table in [ + 'children', + 'payload', + 'keys', + 'audit', + 'flags', + '_orm_migration_steps', + '_orm_migrations', + ]) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + commands.clear(); + }); + tearDown(() async { + await db.close(); + await directory.delete(recursive: true); + }); + Future>> data() async => (await db.execute( + SqlCommand('SELECT id, value, touches FROM payload ORDER BY id'), + )).rows; + Future progress() async => + (await runner.progress()).single.backfill!; + + test( + 'chunks pause and resume from a checksummed historical declaration', + () async { + await seed(db); + final migration = db.dialect == SqlDialect.sqlite + ? historical.migrationHistory.checked.last + : fill(dialect: db.dialect); + expect(migration.checksum, fill(dialect: db.dialect).checksum); + expect( + await runner.apply([initial, migration], maxBackfillBatches: 2), + isEmpty, + ); + final saved = await progress(); + expect( + [saved.rows, saved.batches, saved.lastKey, saved.upperKey], + [ + 6, + 2, + ['6'], + ['8'], + ], + ); + expect((await data()).where((r) => r[2] == 1).length, 6); + await expectLater( + runner.requireVersion([initial, migration], maximum: initial.id), + throwsA(code('MIGRATION.INCOMPLETE')), + ); + await expectLater( + runner.apply([initial, fill(dialect: db.dialect, batchSize: 2)]), + throwsA(code('MIGRATION.CHECKSUM')), + ); + expect(await runner.apply([initial, migration]), [migration.id]); + expect( + (await data()).every((r) => r[1] == 'V${r[0]}' && r[2] == 1), + true, + ); + expect((await progress()).rows, 8); + expect( + (await runner.requireVersion([initial, migration])).id, + migration.id, + ); + expect(await runner.apply([initial, migration]), isEmpty); + expect(commands.any((c) => c.sql.contains('OFFSET')), false); + }, + ); + + test('failed chunk rolls back both writes and cursor, then resumes after repair', () async { + await seed(db); + await db.execute( + SqlCommand('UPDATE payload SET source = NULL WHERE id = 7'), + ); + final migration = fill( + dialect: db.dialect, + set: { + 'value': 'upper(source)', + 'touches': + 'CASE WHEN source IS NULL THEN NULL ELSE touches + 1 END', + }, + ); + await expectLater( + runner.apply([initial, migration]), + throwsA(code('MIGRATION.STEP')), + ); + expect((await progress()).lastKey, ['6']); + expect((await data()).where((r) => r[2] == 1).length, 6); + expect((await runner.progress()).single.phase, 'update'); + await db.execute( + SqlCommand("UPDATE payload SET source = 'v7' WHERE id = 7"), + ); + await runner.apply([initial, migration]); + expect((await data()).every((r) => r[2] == 1), true); + }); + + test( + 'failed completion proof preserves finished chunks without replay', + () async { + await seed(db); + await db.execute( + SqlCommand('CREATE TABLE flags (ready INTEGER NOT NULL)'), + ); + await db.execute(SqlCommand('INSERT INTO flags VALUES (0)')); + final migration = fill( + dialect: db.dialect, + doneWhen: 'SELECT ready = 1 FROM flags', + ); + await expectLater( + runner.apply([initial, migration]), + throwsA(code('MIGRATION.STEP')), + ); + expect((await progress()).rows, 8); + expect((await runner.progress()).single.phase, 'verify'); + await db.execute(SqlCommand('UPDATE flags SET ready = 1')); + commands.clear(); + await runner.apply([initial, migration]); + expect( + commands.where((c) => c.sql.startsWith('UPDATE "payload"')), + isEmpty, + ); + expect((await data()).every((r) => r[2] == 1), true); + }, + ); + + test('frozen upper bound and completion proof detect incompatible new writes', () async { + await seed(db); + final migration = fill(dialect: db.dialect); + await runner.apply([initial, migration], maxBackfillBatches: 1); + await db.execute( + SqlCommand( + "INSERT INTO payload(id, source, value, touches) VALUES (99, 'new', 'NEW', 1), (0, 'late', NULL, 0)", + ), + ); + await expectLater( + runner.apply([initial, migration]), + throwsA(code('MIGRATION.STEP')), + ); + expect((await progress()).upperKey, ['8']); + expect((await progress()).rows, 8); + await db.execute( + SqlCommand( + "UPDATE payload SET value = 'LATE', touches = 1 WHERE id = 0", + ), + ); + await runner.apply([initial, migration]); + expect((await data()).every((r) => r[2] == 1), true); + }); + + test( + 'competing runners share committed chunks without duplicate updates', + () async { + await seed(db, count: 35); + final other = await open(); + final migration = fill(dialect: db.dialect, batchSize: 2); + try { + final results = await Future.wait([ + runner.apply([initial, migration]), + Migrator(other.sql).apply([initial, migration]), + ]); + expect(results.expand((r) => r).toList(), [migration.id]); + expect((await progress()).rows, 35); + expect((await data()).every((r) => r[2] == 1), true); + } finally { + await other.close(); + } + }, + ); + + test( + 'lost chunk commit acknowledgement preserves the committed frontier', + () async { + await seed(db); + var wrote = false, failed = false; + final driver = db.driver as _Driver; + driver.after = (command) { + if (command.sql.startsWith('UPDATE "payload" SET')) wrote = true; + if (wrote && command.sql == 'COMMIT' && !failed) { + failed = true; + throw StateError('Lost acknowledgement'); + } + }; + await expectLater( + runner.apply([initial, fill(dialect: db.dialect)]), + throwsA(code('MIGRATION.STEP')), + ); + expect((await progress()).rows, 3); + await runner.apply([initial, fill(dialect: db.dialect)]); + expect((await data()).every((r) => r[2] == 1), true); + }, + ); + + test('old checkpoint tables upgrade on apply; completed reads remain nonmutating', () async { + await seed(db); + await db.execute( + SqlCommand( + 'CREATE TABLE _orm_migration_steps (migration_id TEXT NOT NULL, checksum TEXT NOT NULL, step INTEGER NOT NULL, state TEXT NOT NULL, phase TEXT NOT NULL, failure TEXT, PRIMARY KEY(migration_id, step))', + ), + ); + expect(await runner.progress(), isEmpty); + expect((await runner.requireVersion([initial])).id, initial.id); + expect( + (await inspectColumns( + db.sql, + '_orm_migration_steps', + )).any((c) => c.name == 'backfill'), + false, + ); + await runner.apply([initial, fill(dialect: db.dialect)]); + expect((await progress()).rows, 8); + }); + + test( + 'completion conditions reject numeric results instead of truthiness', + () async { + await seed(db, count: 0); + await expectLater( + runner.apply([ + initial, + fill(dialect: db.dialect, doneWhen: 'SELECT 1.0'), + ]), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + code('MIGRATION.PROBE'), + ), + ), + ); + expect((await runner.history()).length, 1); + }, + ); + + test('empty input still requires the completion condition', () async { + await seed(db, count: 0); + await runner.apply([initial, fill(dialect: db.dialect)]); + expect((await progress()).toJson(), { + 'format': 1, + 'upper': null, + 'last': null, + 'rows': 0, + 'batches': 0, + }); + }); + + test('later ordinary migrations retain group atomicity after a completed backfill', () async { + await seed(db); + final migration = fill(dialect: db.dialect); + final add = Migration( + '0003_add', + ['CREATE TABLE audit (value INTEGER)'], + previous: migration.checksum, + dialect: db.dialect, + ); + final fail = Migration( + '0004_fail', + ['INSERT INTO flags(ready) VALUES(1)'], + previous: add.checksum, + dialect: db.dialect, + ); + await expectLater( + runner.apply([initial, migration, add, fail]), + throwsA(isA()), + ); + expect((await runner.history()).map((m) => m.id), [ + initial.id, + migration.id, + ]); + expect(await inspectColumns(db.sql, 'audit'), isEmpty); + expect((await data()).every((r) => r[2] == 1), true); + await db.execute(SqlCommand('CREATE TABLE flags (ready INTEGER)')); + expect(await runner.apply([initial, migration, add, fail]), [ + add.id, + fail.id, + ]); + }); + + test('AFTER triggers cannot silently move keys past a committed cursor', () async { + await seed(db); + if (backend == 'sqlite') { + await db.execute( + SqlCommand( + 'CREATE TRIGGER move_keys AFTER UPDATE OF value ON payload BEGIN UPDATE payload SET id = id + 100 WHERE id = NEW.id; END', + ), + ); + } else { + await db.execute( + SqlCommand( + r'CREATE OR REPLACE FUNCTION move_key() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN UPDATE payload SET id = id + 100 WHERE id = NEW.id; RETURN NEW; END $$', + ), + ); + await db.execute( + SqlCommand( + 'CREATE TRIGGER move_keys AFTER UPDATE OF value ON payload FOR EACH ROW EXECUTE FUNCTION move_key()', + ), + ); + } + await expectLater( + runner.apply([initial, fill(dialect: db.dialect)]), + throwsA(code('MIGRATION.STEP')), + ); + expect( + (await data()).map((r) => r.first), + List.generate(8, (i) => i + 1), + ); + expect((await data()).every((r) => r[2] == 0), true); + await db.execute( + SqlCommand( + 'DROP TRIGGER move_keys${backend == 'postgres' ? ' ON payload' : ''}', + ), + ); + await runner.apply([initial, fill(dialect: db.dialect)]); + expect((await data()).every((r) => r[2] == 1), true); + }); + + test('corrupt checkpoint dimensions and invalid run limits are rejected', () async { + await expectLater( + runner.apply([ + initial, + fill(dialect: db.dialect), + ], maxBackfillBatches: 0), + throwsArgumentError, + ); + expect(commands, isEmpty); + await seed(db); + await runner.apply([ + initial, + fill(dialect: db.dialect), + ], maxBackfillBatches: 1); + final bad = jsonEncode({ + 'format': 1, + 'upper': ['8'], + 'last': ['3', 'unexpected'], + 'rows': 3, + 'batches': 1, + }); + await db.execute( + SqlCommand( + 'UPDATE _orm_migration_steps SET backfill = ${backend == 'sqlite' ? '?1' : r'$1'}', + [bad], + ), + ); + await expectLater( + runner.apply([initial, fill(dialect: db.dialect)]), + throwsA(code('MIGRATION.HISTORY')), + ); + expect((await data()).where((r) => r[2] == 1).length, 3); + }); + + if (backend == 'sqlite') { + test('rebuild, chunked backfill and required-column rebuild preserve FK enforcement', () async { + await seed(db); + final expanded = TableSchema( + 'payload', + columns: [ + ...payload.columns, + Column('extra', Codecs.integer, defaultSql: '1'), + ], + primaryKey: ['id'], + ); + final required = TableSchema( + 'payload', + columns: [ + for (final c in expanded.columns) + c.name == 'value' ? Column('value', Codecs.text) : c, + ], + primaryKey: ['id'], + ); + final migration = Migration.steps( + '0002_rebuild', + [ + RebuildTable( + payload, + expanded, + copy: {for (final c in payload.columns) c.name: '"${c.name}"'}, + ), + Backfill( + expanded, + set: {'value': 'upper(source)', 'touches': 'touches + 1'}, + where: 'value IS NULL', + doneWhen: 'SELECT NOT EXISTS(SELECT 1 FROM payload WHERE value IS NULL)', + batchSize: 3, + ), + RebuildTable( + expanded, + required, + copy: {for (final c in expanded.columns) c.name: '"${c.name}"'}, + ), + ], + previous: initial.checksum, + snapshot: SchemaSnapshot([required]), + dialect: SqlDialect.sqlite, + ); + await runner.apply([initial, migration], maxBackfillBatches: 1); + expect( + (await db.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single, + 1, + ); + expect((await data()).where((r) => r[2] == 1).length, 3); + await runner.apply([initial, migration]); + expect( + (await verifySchema(db.sql, migration.snapshot!)).matches, + true, + ); + expect((await data()).every((r) => r[2] == 1), true); + expect( + (await db.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single, + 1, + ); + }); + } + + test( + 'CLI previews non-atomic work and reports a bounded run as incomplete', + () async { + await seed(db); + final project = await MigrationProject.create( + postgresSchema: backend == 'postgres' ? 'orm_backfill_tests' : null, + sqlitePath: path, + ); + addTearDown(project.dispose); + await project.target( + fill(dialect: db.dialect).snapshot ?? SchemaSnapshot([payload]), + ); + for (final m in [initial, fill(dialect: db.dialect)]) { + await project.append(m); + } + final plan = await project.run(['plan']); + expect(plan['atomic'], false); + final paused = await project.run([ + 'apply', + '--max-backfill-batches', + '1', + ]); + expect(paused, {'applied': [], 'complete': false}); + final status = await project.run(['status']); + expect( + (((status['progress'] as List).single as Map)['backfill'] + as Map)['rows'], + 3, + ); + expect(await project.run(['apply', '--max-backfill-batches', '10']), { + 'applied': ['0002_fill'], + 'complete': true, + }); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); + + test('temporary tables cannot redirect the historical backfill target', () async { + await seed(db); + await db.session((session) async { + await session.execute( + SqlCommand( + 'CREATE TEMP TABLE payload(id ${backend == 'postgres' ? 'BIGINT' : 'INTEGER'} PRIMARY KEY NOT NULL, source TEXT, value TEXT, touches ${backend == 'postgres' ? 'BIGINT' : 'INTEGER'} NOT NULL DEFAULT 0)', + ), + ); + try { + await expectLater( + Migrator(session.sql).apply([initial, fill(dialect: db.dialect)]), + throwsA(code('MIGRATION.STEP')), + ); + } finally { + await session.execute( + SqlCommand( + 'DROP TABLE ${backend == 'postgres' ? 'pg_temp' : 'temp'}.payload', + ), + ); + } + }); + expect((await data()).every((r) => r[2] == 0), true); + await runner.apply([initial, fill(dialect: db.dialect)]); + expect((await data()).every((r) => r[2] == 1), true); + }); + + test('temporary objects cannot impersonate durable migration metadata', () async { + await seed(db); + await db.session((session) async { + for (final table in ['_orm_migrations', '_orm_migration_steps']) { + final columns = table == '_orm_migrations' + ? 'id TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL' + : 'migration_id TEXT NOT NULL, checksum TEXT NOT NULL, step INTEGER NOT NULL, state TEXT NOT NULL, phase TEXT NOT NULL, failure TEXT, backfill TEXT, PRIMARY KEY(migration_id, step)'; + await session.execute( + SqlCommand('CREATE TEMP TABLE "$table" ($columns)'), + ); + try { + if (table == '_orm_migrations') { + await session.execute( + SqlCommand( + 'INSERT INTO "$table" SELECT * FROM ${backend == 'postgres' ? 'orm_backfill_tests' : 'main'}."$table"', + ), + ); + } + await expectLater( + Migrator(session.sql) + .apply([initial, fill(dialect: db.dialect)]), + throwsA(code('MIGRATION.SESSION')), + ); + } finally { + await session.execute( + SqlCommand( + 'DROP TABLE ${backend == 'postgres' ? 'pg_temp' : 'temp'}."$table"', + ), + ); + } + } + }); + expect((await data()).every((r) => r[2] == 0), true); + await runner.apply([initial, fill(dialect: db.dialect)]); + expect((await data()).every((r) => r[2] == 1), true); + }); + + if (backend == 'postgres') { + test('row security cannot hide unfinished rows from the completion proof', () async { + await seed(db); + final role = 'orm_backfill_${DateTime.now().microsecondsSinceEpoch}'; + await db.execute(SqlCommand('CREATE ROLE "$role"')); + try { + await db.execute( + SqlCommand( + 'GRANT USAGE, CREATE ON SCHEMA orm_backfill_tests TO "$role"', + ), + ); + await db.execute( + SqlCommand( + 'GRANT ALL ON ALL TABLES IN SCHEMA orm_backfill_tests TO "$role"', + ), + ); + await db.execute( + SqlCommand('ALTER TABLE payload ENABLE ROW LEVEL SECURITY'), + ); + await db.execute( + SqlCommand( + 'CREATE POLICY hide ON payload USING (false) WITH CHECK (false)', + ), + ); + await db.session((session) async { + await session.execute(SqlCommand('SET ROLE "$role"')); + try { + await expectLater( + Migrator(session.sql) + .apply([initial, fill(dialect: db.dialect)]), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + code('MIGRATION.BACKFILL_SCOPE'), + ), + ), + ); + } finally { + await session.execute(SqlCommand('RESET ROLE')); + } + }); + expect((await data()).every((r) => r[2] == 0), true); + } finally { + await db.execute(SqlCommand('DROP OWNED BY "$role"')); + await db.execute(SqlCommand('DROP ROLE "$role"')); + } + }); + + test( + 'inherited duplicate primary keys cannot advance a cursor', + () async { + await seed(db); + await db.execute( + SqlCommand('CREATE TABLE payload_child () INHERITS (payload)'), + ); + try { + await db.execute( + SqlCommand( + "INSERT INTO payload_child(id, source) VALUES (1, 'child')", + ), + ); + await expectLater( + runner.apply([initial, fill(dialect: db.dialect)]), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + code('MIGRATION.BACKFILL_KEY'), + ), + ), + ); + expect((await data()).every((r) => r[2] == 0), true); + } finally { + await db.execute(SqlCommand('DROP TABLE payload_child')); + } + }, + ); + } + + test( + 'committed chunks invalidate relevant typed query subscriptions', + () async { + await seed(db); + final table = Table( + payload, + _PayloadFields.new, + (p) => p.touches, + ); + final watch = StreamIterator(db.table(table).watch()); + try { + expect(await watch.moveNext(), true); + expect(watch.current, everyElement(0)); + final changed = watch.moveNext(); + await runner.apply([ + initial, + fill(dialect: db.dialect), + ], maxBackfillBatches: 1); + expect(await changed.timeout(const Duration(seconds: 2)), true); + expect(watch.current.where((v) => v == 1).length, 3); + } finally { + await watch.cancel(); + } + }, + ); + + for (final boundary in ['write', 'commit']) { + test( + 'real process exit after second chunk $boundary resumes correctly', + () async { + await seed(db); + final migrations = [initial, fill(dialect: db.dialect)]; + final project = await MigrationProject.create( + postgresSchema: backend == 'postgres' + ? 'orm_backfill_tests' + : null, + sqlitePath: path, + ); + addTearDown(project.dispose); + for (final m in migrations) { + await project.append(m); + } + await project.fixture.write('bin/crash.dart', ''' +import '${File('test/support/backfill_crash.dart').absolute.uri}'; +import '../lib/migrations/migrations.g.dart'; +Future main(List args) => crashBackfill(migrationHistory.checked, args); +'''); + final child = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/crash.dart', + backend, + backend == 'sqlite' ? path : 'orm_backfill_tests', + boundary, + ], workingDirectory: project.path); + expect( + child.exitCode, + 91, + reason: '${child.stdout}\n${child.stderr}', + ); + expect((await progress()).rows, boundary == 'write' ? 3 : 6); + expect( + (await data()).where((r) => r[2] == 1).length, + boundary == 'write' ? 3 : 6, + ); + await runner.apply(migrations); + expect((await data()).every((r) => r[2] == 1), true); + }, + ); + } + }); + } +} + +final class _PayloadFields extends Fields { + _PayloadFields(super.table); + late final Field touches = column(Column('touches', Codecs.integer)); +} + +final class _Driver( + final Driver source, + final List commands, +) implements Driver { + void Function(SqlCommand)? after; + @override + Capabilities get capabilities => source.capabilities; + @override + Future run(Future Function(SqlConnection) action) => + source.run((c) => action(_Connection(c, this))); + @override + Future close() => source.close(); +} + +final class _Connection(final SqlConnection source, final _Driver driver) + implements SqlConnection { + @override + bool? get transactionActive => source.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + driver.commands.add(command); + final result = await source.execute(command, options: options); + driver.after?.call(command); + return result; + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => source.openCursor(command, options: options); + @override + Future invalidate() => source.invalidate(); +} diff --git a/test/browser_profile_test.dart b/test/browser_profile_test.dart new file mode 100644 index 00000000..cd192eb6 --- /dev/null +++ b/test/browser_profile_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../tool/src/browser_profile.dart'; + +void main() { + final notEmpty = Platform.isWindows + ? 145 + : Platform.isMacOS + ? 66 + : 39; + FileSystemException failure(int code) => FileSystemException( + 'Deletion failed', + '/temporary/profile', + OSError('simulated filesystem race', code), + ); + + test( + 'removes a real nested profile and accepts an already removed root', + () async { + final profile = await Directory.systemTemp.createTemp( + 'orm-profile-test-', + ); + addTearDown(() async { + if (await profile.exists()) await profile.delete(recursive: true); + }); + final settings = File('${profile.path}/Default/Preferences'); + await settings.parent.create(); + await settings.writeAsString('{}'); + await deleteBrowserProfile(profile); + await deleteBrowserProfile(profile); + expect(await profile.exists(), isFalse); + }, + ); + + test('retries a directory that receives late browser writes', () async { + final profile = _Profile([failure(notEmpty), failure(notEmpty)]); + await deleteBrowserProfile(profile); + expect(profile.deletes, 3); + }); + + test( + 'retries a disappeared child while the profile root still exists', + () async { + final profile = _Profile([failure(2)]); + await deleteBrowserProfile(profile); + expect(profile.deletes, 2); + }, + ); + + test('exhausted cleanup preserves the final filesystem error', () async { + final last = failure(notEmpty); + final profile = _Profile(List.filled(20, last)); + await expectLater(deleteBrowserProfile(profile), throwsA(same(last))); + expect(profile.deletes, 20); + }); + + test('unrelated filesystem failures are not retried or swallowed', () async { + final denied = failure(13); + final profile = _Profile([denied]); + await expectLater(deleteBrowserProfile(profile), throwsA(same(denied))); + expect(profile.deletes, 1); + }); +} + +final class _Profile(this.failures) implements Directory { + final List failures; + int deletes = 0; + + @override + Future delete({bool recursive = false}) async { + expect(recursive, isTrue); + final index = deletes++; + if (index < failures.length) throw failures[index]; + return this; + } + + @override + Future exists() async => true; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/build_runner_test.dart b/test/build_runner_test.dart new file mode 100644 index 00000000..2174238c --- /dev/null +++ b/test/build_runner_test.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../tool/src/build_fixture.dart'; + +void main() { + test('real build_runner build/watch tracks imports, recovers errors and deletes stale outputs', () async { + final fixture = await BuildFixture.create(ormPath: Directory.current.path); + BuildWatch? watcher; + try { + await fixture.file('build.yaml').writeAsString(''' + orm:queries: + enabled: true + generate_for: + - lib/queries.dart +''', mode: FileMode.append); + await fixture.write('lib/queries.dart', ''' +import 'package:orm/schema.dart'; +import 'models.dart'; +typedef QueryRow = ({String title, Status status}); +final namedRows = sqlQuery(sqlite: 'rows.sql'); +'''); + const sql = 'SELECT title, status FROM rows_0 WHERE score >= :minimum'; + await fixture.write('lib/rows.sql', sql); + final client = fixture.file('lib/schema.orm.dart'); + final snapshot = fixture.file('lib/schema.snapshot.dart'); + final queries = fixture.file('lib/queries.queries.dart'); + await fixture.write('bin/read_schema.dart', r''' +import 'dart:convert'; +import '../lib/schema.snapshot.dart'; +void main() => print('@@schema ${jsonEncode(schema.toJson())}'); +'''); + Future> inspectSnapshot() async { + final result = await fixture.run(['run', 'bin/read_schema.dart']); + final line = result.output + .split('\n') + .firstWhere((l) => l.startsWith('@@schema ')); + return jsonDecode(line.substring('@@schema '.length)) + as Map; + } + + final first = await fixture.run(['run', 'build_runner', 'build']); + expect(first.output, contains('wrote 3 outputs')); + expect(await queries.readAsString(), contains('NamedRowsFields')); + expect(await client.exists(), true); + expect((await inspectSnapshot())['tables'], hasLength(1)); + final before = await client.stat(); + final unchanged = await fixture.run(['run', 'build_runner', 'build']); + expect(unchanged.output, contains('wrote 0 outputs')); + expect((await client.stat()).modified, before.modified); + + watcher = await fixture.watch(); + expect(await watcher.next(), contains('wrote 0 outputs')); + await fixture.write( + 'lib/schema.dart', + BuildFixture.schema(1, extra: true), + ); + await watcher.next(); + expect(await client.readAsString(), contains('required bool enabled')); + + await fixture.write( + 'lib/models.dart', + BuildFixture.domain(label: 'ready-now', defaultScore: 7), + ); + await watcher.next(); + expect(await client.readAsString(), contains('"ready-now"')); + expect(await queries.readAsString(), contains('"ready-now"')); + final json = await inspectSnapshot(); + final tables = json['tables'] as List; + final columns = + (tables.single as Map)['columns'] as List; + expect( + columns.cast>().singleWhere( + (c) => c['name'] == 'score', + )['default'], + '7', + ); + + final stable = await client.stat(); + await fixture.write('lib/unrelated.dart', 'const unrelated = 2;\n'); + await watcher.quiet(); + expect((await client.stat()).modified, stable.modified); + + await fixture.write('lib/rows.sql', '$sql ORDER BY title'); + await watcher.next(); + expect(await queries.readAsString(), contains('ORDER BY title')); + await fixture.write( + 'lib/rows.sql', + 'SELECT :unknown AS title, status FROM rows_0', + ); + await watcher.next(success: false); + await fixture.write('lib/rows.sql', sql); + await watcher.next(); + expect(await queries.readAsString(), isNot(contains('ORDER BY title'))); + + await fixture.write( + 'lib/schema.dart', + BuildFixture.schema(1, extra: true, invalid: true), + ); + await watcher.next(success: false); + await fixture.write( + 'lib/schema.dart', + BuildFixture.schema(1, extra: true), + ); + await watcher.next(); + await fixture.run(['analyze', 'lib']); + + await fixture.file('lib/schema.dart').delete(); + await watcher.next(); + expect(await client.exists(), false); + expect(await snapshot.exists(), false); + await fixture.write('lib/schema.dart', BuildFixture.schema(1)); + await watcher.next(); + expect(await client.exists(), true); + expect(await snapshot.exists(), true); + await fixture.file('lib/queries.dart').delete(); + await watcher.next(); + expect(await queries.exists(), false); + expect(await fixture.file('lib/queries.queries.json').exists(), false); + } finally { + await watcher?.close(); + await fixture.dispose(); + } + }, timeout: const Timeout(Duration(minutes: 4))); +} diff --git a/test/builder_test.dart b/test/builder_test.dart new file mode 100644 index 00000000..327cbb2a --- /dev/null +++ b/test/builder_test.dart @@ -0,0 +1,176 @@ +import 'dart:io'; + +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:orm/builder.dart'; +import 'package:orm/generate.dart' show generateSchema; +import 'package:test/test.dart'; + +void main() { + late TestReaderWriter files; + setUp(() async { + files = TestReaderWriter(rootPackage: 'orm', flattenOutput: true); + await files.testing.loadIsolateSources(); + }); + + Future run( + Map inputs, + Set roots, + ) => testBuilder( + _OnlyRoots(roots), + inputs, + rootPackage: 'orm', + readerWriter: files, + generateFor: roots, + flattenOutput: true, + ); + + test( + 'build assets and standalone CLI produce identical client and snapshot', + () async { + const root = 'test/support/codecs/schema.dart'; + final inputs = {}; + for (final name in ['schema.dart', 'types.dart', 'alternate.dart']) { + final path = 'test/support/codecs/$name'; + inputs['orm|$path'] = await File(path).readAsString(); + } + final result = await run(inputs, {'orm|$root'}); + expect(result.succeeded, true, reason: result.errors.toString()); + final standalone = await generateSchema(root); + expect( + files.testing.readString( + AssetId('orm', 'test/support/codecs/schema.orm.dart'), + ), + standalone.dart, + ); + expect( + files.testing.readString( + AssetId('orm', 'test/support/codecs/schema.snapshot.dart'), + ), + standalone.snapshotDart, + ); + expect( + files.testing.resolverEntrypointsTracked, + contains(AssetId('orm', root)), + ); + }, + ); + + test( + 'multiple explicit schema roots resolve shared public types under lib', + () async { + const types = ''' +import 'package:orm/schema.dart'; +enum Status { @EnumValue('waiting') pending, done } +'''; + final inputs = { + 'orm|lib/fixture/types.dart': types, + for (final name in ['one', 'two']) + 'orm|lib/fixture/$name.dart': + ''' +import 'package:orm/schema.dart'; +import 'types.dart'; +typedef Row = ({@Id() int id, Status status}); +final rows = entity(table: '$name'); +''', + 'orm|lib/fixture/unrelated.dart': 'const unrelated = 1;', + }; + final result = await run(inputs, { + 'orm|lib/fixture/one.dart', + 'orm|lib/fixture/two.dart', + }); + expect(result.succeeded, true, reason: result.errors.toString()); + expect(result.outputs.length, 4); + for (final name in ['one', 'two']) { + final output = files.testing.readString( + AssetId('orm', 'lib/fixture/$name.orm.dart'), + ); + expect(output, contains('package:orm/fixture/types.dart')); + expect(output, contains('"waiting"')); + } + expect( + files.testing.resolverEntrypointsTracked, + isNot(contains(AssetId('orm', 'lib/fixture/unrelated.dart'))), + ); + }, + ); + + for (final (name, body) in [ + ( + 'semantic_error', + 'typedef Row = ({@Id() int id}); final rows = entity(); int bad = "wrong";', + ), + ( + 'enum_labels', + "enum Status { @EnumValue('x') a, @EnumValue('x') b } typedef Row = ({Status status}); final rows = entity();", + ), + ('no_models', 'const empty = 1;'), + ('part_file', "part of 'root.dart';"), + ]) { + test('invalid $name never publishes partial outputs', () async { + final path = 'lib/fixture/$name.dart'; + final result = await run( + { + 'orm|$path': + "${name == 'part_file' ? '' : "import 'package:orm/schema.dart';"}\n$body", + }, + {'orm|$path'}, + ); + expect(result.succeeded, false); + expect(result.errors, isNotEmpty); + expect(result.outputs, isEmpty); + }); + } + + test('unknown options fail instead of silently changing build behavior', () { + expect( + () => ormBuilder(BuilderOptions({'unknown': true})), + throwsArgumentError, + ); + }); + + test('schema resolution can consume an earlier builder output', () async { + final result = await testBuilders( + [ + _DomainBuilder(), + _OnlyRoots({'orm|lib/fixture/schema.dart'}), + ], + { + 'orm|lib/fixture/models.domain': "import 'package:orm/schema.dart'; enum Status { @EnumValue('generated') ready }", + 'orm|lib/fixture/schema.dart': "import 'package:orm/schema.dart'; import 'models.dart'; typedef Row = ({Status status}); final rows = entity();", + }, + rootPackage: 'orm', + readerWriter: files, + flattenOutput: true, + ); + expect(result.succeeded, true, reason: result.errors.toString()); + expect( + files.testing.readString(AssetId('orm', 'lib/fixture/schema.orm.dart')), + contains('"generated"'), + ); + }); +} + +final class _DomainBuilder implements Builder { + @override + Map> get buildExtensions => const { + '.domain': ['.dart'], + }; + @override + Future build(BuildStep step) => step.writeAsString( + step.inputId.changeExtension('.dart'), + step.readAsString(step.inputId), + ); +} + +// build_test's default source set also includes every loaded root-package lib +// asset. Apply the explicit root filter here; the process test covers build.yaml. +final class _OnlyRoots(final Set roots) implements Builder { + final Builder delegate = ormBuilder(BuilderOptions.empty); + @override + Map> get buildExtensions => delegate.buildExtensions; + @override + Future build(BuildStep step) async { + if (roots.contains(step.inputId.toString())) await delegate.build(step); + } +} diff --git a/test/check_test.dart b/test/check_test.dart new file mode 100644 index 00000000..aa716564 --- /dev/null +++ b/test/check_test.dart @@ -0,0 +1,569 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/checks/schema.orm.dart'; + +void main() { + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + group( + 'CHECK ${dialect.name}', + () { + late Database db; + setUp(() async { + if (dialect == SqlDialect.sqlite) { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + schema: 'orm_check_tests', + tls: .disable, + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_check_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_check_tests')); + } + }); + tearDown(() => db.close()); + + test('generated named and unnamed constraints enforce values and permit NULL', () async { + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + final row = await db.products.create(price: 10, state: 'draft'); + expect(row.stock, 0); + expect(row.discount, isNull); + for (final action in Function()>[ + () => + db.products.create(stock: .set(-1), price: 10, state: 'draft'), + () => db.products.create(price: -1, state: 'draft'), + () => db.products.create(price: 10, discount: 11, state: 'draft'), + () => db.products.create(price: 10, state: 'unknown'), + () => db.products.create( + price: 10, + state: 'draft', + label: '; CHECK (0)', + ), + ]) { + await expectLater(action(), throwsA(isA())); + } + expect(await db.products.count(), 1); + await expectLater( + db.products.byId(row.id).patch(discount: .set(-1)), + throwsA(anything), + ); + final info = await inspectTable(db.sql, 'products'); + expect(info.checks.length, 6); + expect(info.unmanaged, isEmpty); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).differences, + isEmpty, + ); + }); + + test( + 'import preserves names and SQL then generates a matching snapshot', + () async { + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + await db.products.create(price: 10, state: 'draft'); + final directory = await Directory( + '.dart_tool/orm-check-import-${dialect.name}', + ).create(recursive: true); + try { + final imported = await importSchema(db.sql); + final source = File('${directory.path}/schema.dart'); + await source.writeAsString(imported.dart); + final generated = await generateSchema(source.path); + final result = await verifySchema(db.sql, generated.snapshot); + expect(result.differences, isEmpty); + expect(result.unmanaged, isEmpty); + expect(imported.dart, contains('.check(')); + expect( + imported.issues.map((i) => i.code), + contains('IMPORT.CHECK_SQL'), + ); + await db.execute(SqlCommand('DROP TABLE _orm_migrations')); + final snapshot = generated.snapshot; + await Migrator(db.sql).baseline([ + Migration.create( + '0001_imported', + snapshot.tables, + dialect: db.dialect, + ), + ], expected: snapshot); + expect(await db.products.count(), 1); + } finally { + await directory.delete(recursive: true); + } + }, + ); + + TableSchema scores( + List checks, { + String name = 'scores', + String value = 'value', + }) => TableSchema( + name, + columns: [Column('id', Codecs.integer), Column(value, Codecs.real)], + primaryKey: ['id'], + indexes: [ + IndexSchema('score_value', [value]), + ], + checks: checks, + ); + + test( + 'add, change and remove constraints validate old rows atomically', + () async { + final start = SchemaSnapshot([scores(const [])]); + final initial = Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + await db.execute(SqlCommand('INSERT INTO scores VALUES (1, -2)')); + final positive = SchemaSnapshot([ + scores(const [CheckSchema('positive', 'value >= 0')]), + ]); + final add = Migration.diff( + '0002_check', + from: start, + to: positive, + previous: initial.checksum, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([initial, add]), + throwsA(anything), + ); + expect( + (await db.execute(SqlCommand('SELECT value FROM scores'))) + .rows + .single + .single, + -2, + ); + expect( + (await db.execute( + SqlCommand('SELECT count(*) FROM _orm_migrations'), + )).rows.single.single, + 1, + ); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + await db.execute(SqlCommand('UPDATE scores SET value = 2')); + await Migrator(db.sql).apply([initial, add]); + expect((await verifySchema(db.sql, positive)).differences, isEmpty); + final stricter = SchemaSnapshot([ + scores(const [CheckSchema('positive', 'value >= 3')]), + ]); + final change = Migration.diff( + '0003_check', + from: positive, + to: stricter, + previous: add.checksum, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([initial, add, change]), + throwsA(anything), + ); + expect((await verifySchema(db.sql, positive)).differences, isEmpty); + await db.execute(SqlCommand('UPDATE scores SET value = 3')); + await Migrator(db.sql).apply([initial, add, change]); + final remove = Migration.diff( + '0004_check', + from: stricter, + to: start, + previous: change.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, add, change, remove]); + await db.execute(SqlCommand('UPDATE scores SET value = -4')); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + }, + ); + + test( + 'duplicate unnamed checks consume and drop exactly one catalog entry', + () async { + final start = SchemaSnapshot([ + scores(const [ + CheckSchema(null, 'value >= 0'), + CheckSchema(null, 'value >= 0'), + ]), + ]); + final initial = Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + final target = SchemaSnapshot([ + scores(const [CheckSchema(null, 'value >= 0')]), + ]); + final migration = Migration.diff( + '0002_remove_copy', + from: start, + to: target, + previous: initial.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, migration]); + expect((await inspectTable(db.sql, 'scores')).checks, hasLength(1)); + await expectLater( + db.execute(SqlCommand('INSERT INTO scores VALUES (1, -1)')), + throwsA(anything), + ); + }, + ); + + test('explicit checked renames preserve rows, incoming references, indexes and views', () async { + TableSchema notes(String target) => TableSchema( + 'notes', + columns: [Column('score_id', Codecs.integer)], + primaryKey: ['score_id'], + foreignKeys: [ + ForeignKey(['score_id'], target, ['id']), + ], + ); + final start = SchemaSnapshot([ + scores(const [CheckSchema('nonnegative', 'value >= 0')]), + notes('scores'), + ]); + final initial = Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + await db.execute(SqlCommand('INSERT INTO scores VALUES (1, 3)')); + await db.execute(SqlCommand('INSERT INTO notes VALUES (1)')); + await db.execute( + SqlCommand( + 'CREATE VIEW score_cards AS SELECT id, value AS original_value FROM scores', + ), + ); + if (dialect == SqlDialect.sqlite) { + await db.execute(SqlCommand('CREATE TABLE audit (value REAL)')); + await db.execute( + SqlCommand( + 'CREATE TRIGGER score_audit AFTER UPDATE ON scores BEGIN INSERT INTO audit VALUES (NEW.value); END', + ), + ); + } + final target = SchemaSnapshot([ + scores( + const [CheckSchema('nonnegative', 'points >= 0')], + name: 'rankings', + value: 'points', + ), + notes('rankings'), + ]); + final migration = Migration.diff( + '0002_rename', + from: start, + to: target, + previous: initial.checksum, + renames: const SchemaRenames( + tables: {'scores': 'rankings'}, + columns: { + 'rankings': {'value': 'points'}, + }, + ), + dialect: db.dialect, + ); + if (dialect == SqlDialect.sqlite) { + expect(migration.steps.whereType(), hasLength(2)); + } + await Migrator(db.sql).apply([initial, migration]); + expect((await verifySchema(db.sql, target)).differences, isEmpty); + expect( + (await db.execute( + SqlCommand('SELECT original_value FROM score_cards'), + )).rows.single.single, + 3, + ); + expect( + (await db.execute(SqlCommand('SELECT score_id FROM notes'))) + .rows + .single + .single, + 1, + ); + await db.execute(SqlCommand('UPDATE rankings SET points = 4')); + if (dialect == SqlDialect.sqlite) { + expect( + (await db.execute(SqlCommand('SELECT value FROM audit'))) + .rows + .single + .single, + 4, + ); + } + await expectLater( + db.execute(SqlCommand('UPDATE rankings SET points = -1')), + throwsA(anything), + ); + await expectLater( + db.execute(SqlCommand('DELETE FROM rankings')), + throwsA(anything), + ); + }); + + test('verification preserves casts, precedence, literals and trailing comments', () async { + final start = SchemaSnapshot([ + scores(const [ + CheckSchema('valid', 'value / 2 > 0 -- trailing comment'), + ]), + ]); + await Migrator(db.sql).apply([ + Migration.create('0001_initial', start.tables, dialect: db.dialect), + ]); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + for (final sql in [ + 'value / (2 + 1) > 0', + 'CAST(value AS INTEGER) / 2 > 0', + ]) { + final result = await verifySchema( + db.sql, + SchemaSnapshot([ + scores([CheckSchema('valid', sql)]), + ]), + ); + expect(result.differences, contains('scores checks differs')); + } + for (final name in ['value', 'renamed']) { + final wrongContext = TableSchema( + 'scores', + columns: [ + Column('id', Codecs.integer), + Column(name, Codecs.text), + ], + primaryKey: ['id'], + checks: [CheckSchema('valid', 'length($name) > 0')], + ); + expect( + (await verifySchema( + db.sql, + SchemaSnapshot([wrongContext]), + )).differences, + contains( + name == 'value' + ? 'scores.value type differs' + : 'scores.renamed is missing', + ), + ); + } + expect( + (await verifySchema( + db.sql, + SchemaSnapshot([scores(const [])]), + )).unmanaged.single.kind, + 'check', + ); + }); + + if (dialect == SqlDialect.sqlite) { + test( + 'quoted Unicode identifiers retain SQLite case distinctions', + () async { + TableSchema table(String expression) => TableSchema( + 'unicode_checks', + columns: [Column('Ä', Codecs.real), Column('ä', Codecs.real)], + checks: [CheckSchema('valid', expression)], + ); + await Migrator(db.sql).apply([ + Migration.create('0001_initial', [ + table('"Ä" > 0'), + ], dialect: db.dialect), + ]); + expect( + (await verifySchema( + db.sql, + SchemaSnapshot([table('"Ä" > 0')]), + )).matches, + true, + ); + expect( + (await verifySchema( + db.sql, + SchemaSnapshot([table('"ä" > 0')]), + )).matches, + false, + ); + }, + ); + test( + 'undeclared CHECK is refused before a rebuild and remains enforced', + () async { + await db.execute( + SqlCommand( + 'CREATE TABLE scores (id INTEGER NOT NULL PRIMARY KEY, value REAL NOT NULL CHECK (value >= 0))', + ), + ); + final before = scores(const []); + final after = TableSchema( + 'scores', + columns: [ + ...before.columns, + Column('label', Codecs.text.nullable(), nullable: true), + ], + primaryKey: ['id'], + ); + final step = RebuildTable( + before, + after, + copy: {'id': 'id', 'value': 'value', 'label': 'NULL'}, + ); + await expectLater( + Migrator(db.sql).apply([ + Migration.steps('0001_rebuild', [ + step, + ], dialect: SqlDialect.sqlite), + ]), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.UNMANAGED', + ), + ), + ); + expect( + (await inspectTable(db.sql, 'scores')).columns, + hasLength(2), + ); + await expectLater( + db.execute(SqlCommand('INSERT INTO scores VALUES (1, -1)')), + throwsA(anything), + ); + }, + ); + } else { + test( + 'catalog excludes unvalidated and NO INHERIT constraints', + () async { + await Migrator(db.sql).apply([ + Migration.create('0001_initial', [ + scores(const []), + ], dialect: db.dialect), + ]); + await db.execute( + SqlCommand( + 'ALTER TABLE scores ADD CONSTRAINT unvalidated CHECK (value > 0) NOT VALID, ADD CONSTRAINT local_only CHECK (value < 10) NO INHERIT', + ), + ); + final info = await inspectTable(db.sql, 'scores'); + expect(info.checks, isEmpty); + expect( + info.unmanaged.map((o) => o.name), + containsAll(['unvalidated', 'local_only']), + ); + final version = int.parse( + (await db.execute(SqlCommand('SHOW server_version_num'))) + .rows + .single + .single + as String, + ); + if (version >= 180000) { + await db.execute( + SqlCommand( + 'ALTER TABLE scores ADD CONSTRAINT unenforced CHECK (value <> 0) NOT ENFORCED', + ), + ); + final next = await inspectTable(db.sql, 'scores'); + expect(next.checks, isEmpty); + expect( + next.unmanaged.map((o) => o.name), + contains('unenforced'), + ); + } + }, + ); + test( + 'verification plans but does not execute volatile row expressions', + () async { + await db.execute(SqlCommand('CREATE SEQUENCE check_counter')); + final start = SchemaSnapshot([ + scores(const [ + CheckSchema('counter', "nextval('check_counter') > 0"), + ]), + ]); + await Migrator(db.sql).apply([ + Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ), + ]); + await db.execute(SqlCommand('INSERT INTO scores VALUES (1, 3)')); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM check_counter'), + )).rows.single.single, + 1, + ); + }, + ); + } + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } + test('dialect overrides only alter the affected backend and preserve old snapshot JSON', () { + TableSchema table(CheckSchema check) => TableSchema( + 'items', + columns: [Column('value', Codecs.text)], + checks: [check], + ); + final before = SchemaSnapshot([ + table(const CheckSchema('valid', 'length(value) > 0')), + ]); + final after = SchemaSnapshot([ + table( + const CheckSchema.forDialects( + 'valid', + sqlite: 'length(value) > 0', + postgres: 'char_length(value) > 1', + ), + ), + ]); + final diff = Migration.diff( + '0002_change', + from: before, + to: after, + dialect: SqlDialect.sqlite, + ); + expect(diff.steps, isEmpty); + expect( + Migration.diff( + '0002_change', + from: before, + to: after, + dialect: SqlDialect.postgres, + ).steps, + isNotEmpty, + ); + final legacy = SchemaSnapshot([ + TableSchema('legacy', columns: [Column('id', Codecs.integer)]), + ]).toJson(); + expect((legacy['tables'] as List).single, isNot(contains('checks'))); + }); +} diff --git a/test/cli_test.dart b/test/cli_test.dart new file mode 100644 index 00000000..1c176e12 --- /dev/null +++ b/test/cli_test.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import '../example/schema.orm.dart'; +import 'support/migration_project.dart'; + +void main() { + Future> inspect(String file) async { + final result = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/orm.dart', + '--json', + 'db', + 'inspect', + '--sqlite', + file, + '--table', + 'users', + ]); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + return jsonDecode(result.stdout as String) as Map; + } + + test('Dart CLI generates, reviews, upgrades and verifies constraints without overwriting history', () async { + final project = await MigrationProject.create(); + try { + final initial = SchemaSnapshot(appSchema); + await project.target(initial); + await project.run(['create', '0001_initial']); + expect((await project.run(['check']))['valid'], true); + await project.run(['plan'], code: 1); + expect(await File(project.databasePath).exists(), false); + expect((await project.run(['apply']))['applied'], ['0001_initial']); + final next = SchemaSnapshot([ + TableSchema( + 'users', + columns: [ + ...usersSchema.columns, + Column('status', Codecs.text, defaultSql: "'active'"), + Column( + 'email_size', + Codecs.integer, + computed: const ComputedColumn('length(email)'), + ), + ], + primaryKey: usersSchema.primaryKey, + uniqueKeys: usersSchema.uniqueKeys, + checks: const [ + CheckSchema('valid_status', "status IN ('active', 'disabled')"), + ], + ), + postsSchema, + ]); + await project.target(next); + await project.run(['create', '0002_status']); + expect((await project.run(['plan']))['pending'], hasLength(1)); + expect((await project.run(['status']))['applied'], hasLength(1)); + await project.run(['apply']); + final catalog = await inspect(project.databasePath); + expect((catalog['checks'] as List).single, { + 'name': 'valid_status', + 'expression': "status IN ('active', 'disabled')", + }); + expect( + (catalog['columns'] as List).cast>().singleWhere( + (c) => c['name'] == 'email_size', + )['computed'], + {'expression': 'length(email)', 'storage': 'stored'}, + ); + expect((await project.run(['verify']))['matches'], true); + await project.target(initial); + expect((await project.run(['verify'], code: 2))['matches'], false); + await project.target(next); + expect((await project.run(['create', '0003_same']))['created'], null); + expect( + await File('${project.migrationDirectory}/m0003_same.dart').exists(), + false, + ); + final file = File('${project.migrationDirectory}/m0001_initial.dart'); + final saved = await file.readAsString(); + await project.target(initial); + await project.run([ + 'create', + '0001_initial', + '--allow-destructive', + ], code: 1); + expect(await file.readAsString(), saved); + } finally { + await project.dispose(); + } + }, timeout: const Timeout(Duration(minutes: 3))); + + test('Dart CLI baselines existing rows; read-only connections and invalid options reject writes', () async { + final project = await MigrationProject.create(); + try { + await project.target(SchemaSnapshot(appSchema)); + await project.run(['create', '0001_baseline']); + final db = await sqlite(SqliteOptions.file(project.databasePath)); + try { + for (final sql in createSchema(appSchema, SqlDialect.sqlite)) { + await db.execute(sql); + } + await db.users.create(email: 'existing'); + } finally { + await db.close(); + } + expect((await project.run(['baseline']))['matches'], true); + final read = await sqlite(SqliteOptions.readOnly(project.databasePath)); + try { + expect((await read.users.single()).email, 'existing'); + expect(await Migrator(read.sql).history(), hasLength(1)); + await expectLater( + read.users.create(email: 'forbidden'), + throwsA(isA()), + ); + } finally { + await read.close(); + } + await project.run(['verify', '--tls', 'disable'], code: 64); + await project.run(['check', '--unknown', 'x'], code: 64); + final removed = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/orm.dart', + 'migration', + 'create', + '0001_legacy', + '--schema', + 'unused.json', + ]); + expect(removed.exitCode, 64); + } finally { + await project.dispose(); + } + }, timeout: const Timeout(Duration(minutes: 2))); + + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) { + test('Dart PostgreSQL configuration supports checked nontransactional recovery operations', () async { + final schema = 'orm_cli_${pid}_${DateTime.now().microsecondsSinceEpoch}'; + final project = await MigrationProject.create(postgresSchema: schema); + final admin = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + ), + ); + var created = false; + try { + await admin.execute(SqlCommand('CREATE SCHEMA "$schema"')); + created = true; + await project.target(SchemaSnapshot(appSchema)); + final first = await project.run(['create', '0001_initial']); + expect((await project.run(['check']))['valid'], true); + expect((await project.run(['plan']))['pending'], hasLength(1)); + expect((await project.run(['status']))['applied'], isEmpty); + expect((await project.run(['apply']))['applied'], ['0001_initial']); + expect((await project.run(['verify']))['matches'], true); + await project.append( + Migration.steps( + '0002_index', + [ + CheckedSql.createIndex( + 'users', + const IndexSchema('nickname_lookup', ['nickname']), + ), + ], + previous: first['checksum'] as String, + dialect: SqlDialect.postgres, + ), + ); + expect((await project.run(['plan']))['atomic'], false); + expect((await project.run(['apply']))['applied'], ['0002_index']); + final progress = (await project.run(['status']))['progress'] as List; + expect((progress.single as Map)['state'], 'complete'); + } finally { + if (created) { + await admin.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + } + await admin.close(); + await project.dispose(); + } + }, timeout: const Timeout(Duration(minutes: 3))); + } +} diff --git a/test/cli_workflow_test.dart b/test/cli_workflow_test.dart new file mode 100644 index 00000000..3e6d47f1 --- /dev/null +++ b/test/cli_workflow_test.dart @@ -0,0 +1,250 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../tool/src/build_fixture.dart'; + +void main() { + Future project() async { + final fixture = await BuildFixture.create(ormPath: Directory.current.path); + await fixture.file('lib/schema.dart').delete(); + return fixture; + } + + Future run( + BuildFixture fixture, + List args, { + int code = 0, + }) async { + final result = await Process.run( + Platform.resolvedExecutable, + ['run', 'orm', ...args], + workingDirectory: fixture.directory.path, + environment: {'DATABASE_URL': ''}, + ); + expect( + result.exitCode, + code, + reason: '${args.join(' ')}\n${result.stdout}\n${result.stderr}', + ); + return result; + } + + Future> report( + BuildFixture fixture, + List args, { + int code = 0, + }) async { + final result = await run(fixture, [...args, '--json'], code: code); + return jsonDecode(result.stdout as String) as Map; + } + + test( + 'init through migration apply is a real SQLite consumer workflow', + () async { + final fixture = await project(); + try { + final initialized = await report(fixture, [ + 'init', + '--database', + 'sqlite', + ]); + expect(initialized['database'], 'sqlite'); + expect(initialized['created'], hasLength(5)); + expect(await fixture.file('app.sqlite').exists(), false); + final declaration = await fixture + .file('lib/schema.dart') + .readAsString(); + expect(declaration, contains('final class Task')); + final config = await fixture.file('orm.config.dart').readAsString(); + expect(config, contains('package:orm/drivers/sqlite.dart')); + expect(config, isNot(contains('package:orm/orm.dart'))); + expect( + (await report(fixture, ['migrate', 'check']))['migrations'], + isEmpty, + ); + expect(await fixture.file('app.sqlite').exists(), false); + + await fixture.write( + 'lib/schema.dart', + declaration.replaceFirst( + 'required final String title,', + 'required final String title,\n required final String? detail,', + ), + ); + expect( + (await report(fixture, ['generate']))['generated'], + 'lib/schema.orm.dart', + ); + final first = await report(fixture, [ + 'migrate', + 'create', + '0001_initial', + ]); + expect(first['created'], contains('m0001_initial.dart')); + final initial = fixture.file('migrations/m0001_initial.dart'); + final initialSource = await initial.readAsString(); + expect(initialSource, contains('detail')); + expect(initialSource, isNot(contains('lib/schema.dart'))); + expect((await report(fixture, ['migrate', 'check']))['valid'], true); + await run(fixture, ['migrate', 'plan'], code: 1); + expect(await fixture.file('app.sqlite').exists(), false); + expect((await report(fixture, ['migrate', 'apply']))['applied'], [ + '0001_initial', + ]); + expect( + (await report(fixture, ['migrate', 'status']))['applied'], + hasLength(1), + ); + expect((await report(fixture, ['migrate', 'verify']))['matches'], true); + + final firstSnapshot = await fixture + .file('lib/schema.snapshot.dart') + .readAsString(); + await fixture.write( + 'lib/schema.dart', + declaration.replaceFirst( + 'required final String title,', + 'required final String title,\n required final String? detail,\n required final String? label,', + ), + ); + // No explicit generate: create must use the edited model, not the config's + // previously compiled snapshot. + await report(fixture, ['migrate', 'create', '0002_label']); + expect( + await fixture.file('lib/schema.snapshot.dart').readAsString(), + isNot(firstSnapshot), + ); + expect( + (await report(fixture, ['migrate', 'plan']))['pending'], + hasLength(1), + ); + expect( + (await report(fixture, ['migrate', 'verify'], code: 2))['matches'], + false, + ); + expect((await report(fixture, ['migrate', 'apply']))['applied'], [ + '0002_label', + ]); + expect((await report(fixture, ['migrate', 'verify']))['matches'], true); + expect( + (await report(fixture, [ + 'migrate', + 'create', + '0003_same', + ]))['created'], + isNull, + ); + expect(await initial.readAsString(), initialSource); + + await run(fixture, ['init', '--database', 'sqlite'], code: 64); + expect(await fixture.file('orm.config.dart').readAsString(), config); + expect(await initial.readAsString(), initialSource); + } finally { + await fixture.dispose(); + } + }, + timeout: const Timeout(Duration(minutes: 5)), + ); + + for (final engine in ['postgres', 'mysql', 'mariadb']) { + test( + '$engine init and history validation need no credentials or connection', + () async { + final fixture = await project(); + try { + await report(fixture, ['init', '--database', engine]); + final registry = await fixture + .file('migrations/migrations.g.dart') + .readAsString(); + expect(registry, contains('SqlDialect.$engine')); + final config = await fixture.file('orm.config.dart').readAsString(); + expect(config, contains('package:orm/drivers/$engine.dart')); + expect( + (await report(fixture, ['migrate', 'check']))['dialect'], + engine, + ); + await report(fixture, ['generate']); + final created = await report(fixture, [ + 'migrate', + 'create', + '0001_initial', + ]); + expect(created['created'], contains('m0001_initial.dart')); + expect( + await fixture.file('migrations/m0001_initial.dart').readAsString(), + contains('dialect: SqlDialect.$engine'), + ); + await report(fixture, ['migration', 'registry', 'migrations']); + expect((await report(fixture, ['migrate', 'check']))['migrations'], [ + '0001_initial', + ]); + final missing = await run(fixture, [ + 'migrate', + 'status', + '--json', + ], code: 64); + expect( + '${missing.stderr}', + contains('DATABASE_URL is empty or missing'), + ); + } finally { + await fixture.dispose(); + } + }, + timeout: const Timeout(Duration(minutes: 2)), + ); + } + + test( + 'help, invalid options and existing files fail without partial scaffolding', + () async { + final fixture = await project(); + try { + final help = await run(fixture, ['help', 'migrate']); + expect('${help.stdout}', contains('migrate ')); + expect('${help.stdout}', contains('apply')); + final invalid = await run(fixture, [ + 'init', + '--database', + 'unknown', + '--json', + ], code: 64); + expect( + // Dart's native-asset hook writes startup diagnostics to stderr. + jsonDecode( + (invalid.stderr as String).substring( + (invalid.stderr as String).indexOf('{"error"'), + ), + ), + containsPair('exitCode', 64), + ); + await run(fixture, [ + 'init', + '--database', + 'sqlite', + '--unexpected', + ], code: 64); + await run(fixture, ['migrate', 'check'], code: 64); + await fixture.write('lib/schema.dart', '// User-owned source.\n'); + await run(fixture, ['init', '--database', 'sqlite'], code: 64); + expect( + await fixture.file('lib/schema.dart').readAsString(), + '// User-owned source.\n', + ); + for (final path in [ + 'orm.config.dart', + 'lib/schema.orm.dart', + 'lib/schema.snapshot.dart', + 'migrations/migrations.g.dart', + ]) { + expect(await fixture.file(path).exists(), false, reason: path); + } + } finally { + await fixture.dispose(); + } + }, + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/test/client_default_test.dart b/test/client_default_test.dart new file mode 100644 index 00000000..eb1517b5 --- /dev/null +++ b/test/client_default_test.dart @@ -0,0 +1,268 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/defaults/schema.orm.dart'; +import 'support/defaults/schema.snapshot.dart' as physical; +import 'support/defaults/types.dart' as d; + +void main() { + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + group( + 'client defaults ${dialect.name}', + () { + late Database db; + setUp(() async { + d.reset(); + if (dialect == SqlDialect.sqlite) { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_client_default_tests', + ), + ); + await db.execute( + SqlCommand( + 'DROP SCHEMA IF EXISTS orm_client_default_tests CASCADE', + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA orm_client_default_tests'), + ); + } + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [0, 0, 0, 0], + ); + }); + tearDown(() => db.close()); + + test( + 'omitted values use typed Dart factories and return stored records', + () async { + final before = DateTime.now().toUtc(); + final row = await db.tickets.create(); + expect(row.id, d.TicketId(1)); + expect(row.name, 'ticket-1'); + expect(row.label, isNull); + expect(row.state, 'client-1'); + expect(row.createdAt.isBefore(before), false); + expect(row.createdAt.isAfter(DateTime.now().toUtc()), false); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [1, 1, 1, 1], + ); + expect(await db.tickets.single(), row); + }, + ); + + test( + 'explicit values, null and SQL DEFAULT bypass their client factory', + () async { + final instant = DateTime.utc(2026, 9, 15); + final row = await db.tickets.create( + id: .set(d.TicketId(20)), + name: .set('explicit'), + label: .set(null), + state: .defaultValue(), + createdAt: .set(instant), + ); + expect(row.state, 'server'); + expect(row.label, isNull); + expect(row.createdAt, instant); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [0, 0, 0, 0], + ); + await expectLater( + db.tickets.create(name: .defaultValue()), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MUTATION.DEFAULT', + ), + ), + ); + expect(await db.tickets.count(), 1); + }, + ); + + test('prepared mutations, repeated compilation and conflicts retain one generated value', () async { + final prepared = db.tickets.insert((row) => []); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [1, 1, 1, 1], + ); + final first = prepared.compile(); + expect(prepared.compile().parameters, first.parameters); + final conflict = prepared.onConflictDoNothing( + target: (row) => [row.id], + ); + expect(await conflict.execute(), 1); + expect(await conflict.execute(), 0); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [1, 1, 1, 1], + ); + expect((await db.tickets.single()).id, d.TicketId(1)); + }); + + test('batch insert prepares defaults once per omitted row and preserves explicit values', () async { + final batch = db.tickets.insertMany( + [null, 20, null], + (row, int? id) => [ + if (id != null) row.id.set(d.TicketId(id)), + if (id != null) row.state.defaultValue(), + ], + ); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [2, 3, 2, 3], + ); + final statements = batch.compile(); + expect( + batch.compile().map((s) => s.parameters).toList(), + statements.map((s) => s.parameters).toList(), + ); + final rows = await batch + .returning((t) => (t.id, t.state).map((id, state) => (id, state))) + .get(); + expect(rows, [ + (d.TicketId(1), 'client-1'), + (d.TicketId(20), 'server'), + (d.TicketId(2), 'client-2'), + ]); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [2, 3, 2, 3], + ); + }); + + test( + 'updates and conflict updates do not reset omitted client values', + () async { + final row = await db.tickets.create(); + await db.tickets.byId(row.id).patch(name: .set('changed')); + expect((await db.tickets.single()).state, 'client-1'); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [1, 1, 1, 1], + ); + await db.tickets + .insert((t) => [t.id.set(row.id), t.name.set('incoming')]) + .onConflictUpdate( + target: (t) => [t.id], + set: (old, incoming) => [ + old.name.setExpression(incoming.name), + ], + ) + .execute(); + expect((await db.tickets.single()).name, 'incoming'); + expect((await db.tickets.single()).state, 'client-1'); + expect(d.stateCalls, 2); // The attempted INSERT prepared a value; UPDATE kept the stored state. + }, + ); + + test('factory errors issue no writes, and rollback does not reverse Dart side effects', () async { + d.failName = true; + expect(() => db.tickets.insert((t) => []), throwsStateError); + expect(await db.tickets.count(), 0); + d.failName = false; + await expectLater( + db.transaction((tx) async { + await tx.tickets.create(); + throw StateError('rollback'); + }), + throwsStateError, + ); + expect(await db.tickets.count(), 0); + final row = await db.tickets.create(); + expect(row.id, d.TicketId(3)); + expect(d.nameCalls, 3); + }); + + test('snapshots, migrations, catalogs and imports never store or run a client factory', () async { + final original = SchemaSnapshot(appSchema); + final restored = physical.schema; + expect( + restored.tables + .expand((t) => t.columns) + .every((c) => c.clientDefault == null), + true, + ); + expect( + Migration.diff( + '0002_no_sql_change', + from: original, + to: restored, + dialect: db.dialect, + ).steps.isEmpty, + true, + ); + expect((await verifySchema(db.sql, original)).matches, true); + final info = await inspectTable(db.sql, 'tickets'); + expect( + info.columns.where((c) => c.defaultSql != null).map((c) => c.name), + ['state'], + ); + final imported = await importSchema(db.sql); + expect(imported.dart, isNot(contains('ClientDefault'))); + expect( + [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], + [0, 0, 0, 0], + ); + }); + test('explicit DEFAULT can choose database identity instead of the client value', () async { + final client = await db.sequences.create(); + expect(client.id, 1001); + final server = await db.sequences.create(id: .defaultValue()); + expect(server.id, dialect == SqlDialect.sqlite ? 1002 : 1); + expect(d.identityCalls, 1); + }); + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } + + test('generation reads factory references without executing them and supports moved output', () async { + d.reset(); + d.failName = true; + final directory = await Directory('.dart_tool/orm-default-generation') + .create(recursive: true); + try { + final path = '${directory.path}/generated.dart'; + await writeGeneratedSchema( + 'test/support/defaults/schema.dart', + output: path, + ); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + path, + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + expect([d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], [0, 0, 0, 0]); + } finally { + d.reset(); + await directory.delete(recursive: true); + } + }); +} diff --git a/test/codec_test.dart b/test/codec_test.dart new file mode 100644 index 00000000..b01d6e74 --- /dev/null +++ b/test/codec_test.dart @@ -0,0 +1,83 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'numeric aggregate text decodes without integer rounding or overflow', + () { + expect(Codecs.integer.decode('9223372036854775807'), 9223372036854775807); + expect( + Codecs.integer.decode('-9223372036854775808'), + -9223372036854775808, + ); + expect( + () => Codecs.integer.decode('9223372036854775808'), + throwsFormatException, + ); + expect(() => Codecs.integer.decode('1.5'), throwsFormatException); + expect(Codecs.real.decode('1.25'), 1.25); + }, + ); + test( + 'erased codecs keep checked argument types without function getter casts', + () { + final List> codecs = [ + Codecs.integer, + Codecs.text.nullable(), + Codecs.boolean, + ]; + expect(codecs[0].encode(codecs[0].decode(42)), 42); + expect(codecs[1].encode(codecs[1].decode(null)), null); + expect(codecs[2].encode(codecs[2].decode(1)), true); + expect(() => codecs[0].encode('42'), throwsA(isA())); + expect(codecs.map((c) => c.acceptsNull), [false, true, false]); + }, + ); + + test('mapped domain codecs preserve one decode and one encode boundary', () { + final codec = Codecs.text.map<_Email>(_Email.new, (email) => email.value); + final Codec erased = codec; + final email = erased.decode('seven@example.com') as _Email; + expect(email.value, 'seven@example.com'); + expect(erased.encode(email), 'seven@example.com'); + expect(codec.nullable().decode(null), null); + }); + + test( + 'parsed JSON scalars and document presence have unambiguous decoding', + () { + expect(Codecs.json.decode('"string"'), 'string'); + expect(Codecs.json.decode(const SqlJson('string')), 'string'); + expect(Codecs.jsonDocument.nullable().decode(null), isNull); + expect(() => Codecs.jsonDocument.decode(null), throwsFormatException); + expect(Codecs.jsonDocument.decode('null').value, isNull); + expect( + Codecs.jsonDocument.nullable().decode(const SqlJson(null))!.value, + isNull, + ); + expect(Codecs.jsonDocument.encode(const SqlJson(null)), 'null'); + }, + ); + + test( + 'enum mappings reject duplicates and do not follow mutable caller maps', + () { + final labels = {_State.a: 'one', _State.b: 'two'}; + final codec = Codecs.enumeration(labels); + labels[_State.a] = 'changed'; + expect(codec.encode(_State.a), 'one'); + expect(codec.decode('two'), _State.b); + expect( + () => Codecs.enumeration({_State.a: 'x', _State.b: 'x'}), + throwsArgumentError, + ); + expect(() => Codecs.enumeration<_State>({}), throwsArgumentError); + final partial = Codecs.enumeration({_State.a: 'one'}); + expect(() => partial.encode(_State.b), throwsA(isA())); + }, + ); +} + +final class _Email(final String value); + +enum _State { a, b } diff --git a/test/computed_test.dart b/test/computed_test.dart new file mode 100644 index 00000000..24b6e747 --- /dev/null +++ b/test/computed_test.dart @@ -0,0 +1,587 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/computed/schema.orm.dart'; + +TableSchema scores({ + String name = 'scores', + String value = 'value', + ComputedColumn? computed = const ComputedColumn('value * 2'), + bool includeTotal = true, + bool real = false, + bool checked = false, +}) => TableSchema( + name, + columns: [ + Column('id', Codecs.integer), + Column(value, Codecs.integer), + if (includeTotal) + Column( + 'total', + real ? Codecs.real : Codecs.integer, + computed: computed, + ), + ], + primaryKey: ['id'], + indexes: [ + if (includeTotal && computed?.storage != ComputedStorage.virtual) + const IndexSchema('score_total', ['total']), + ], + checks: [if (checked) CheckSchema('nonnegative', '$value >= 0')], +); + +void main() { + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + group( + 'computed ${dialect.name}', + () { + late Database db; + setUp(() async { + if (dialect == SqlDialect.sqlite) { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + schema: 'orm_computed_tests', + tls: .disable, + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_computed_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_computed_tests')); + } + }); + tearDown(() => db.close()); + + test('native expressions preserve quoted identifiers, comments and SQL-like string values', () async { + const expression = + '''coalesce("odd,value", ') AS (') || ',' /* ( STORED */'''; + final table = TableSchema( + 'quoted', + columns: [ + Column('id', Codecs.integer), + Column('odd,value', Codecs.text.nullable(), nullable: true), + Column( + 'result)AS', + Codecs.text, + computed: const ComputedColumn(expression), + ), + ], + primaryKey: ['id'], + ); + final snapshot = SchemaSnapshot([table]); + await Migrator(db.sql).apply([ + Migration.create( + '0001_initial', + snapshot.tables, + dialect: db.dialect, + ), + ]); + await db.execute(SqlCommand('INSERT INTO quoted (id) VALUES (1)')); + expect( + (await db.execute(SqlCommand('SELECT "result)AS" FROM quoted'))) + .rows, + [ + [') AS (,'], + ], + ); + final info = await inspectTable(db.sql, 'quoted'); + expect(info.unmanaged, isEmpty); + expect(info.columns.last.computed!.storage, ComputedStorage.stored); + expect((await verifySchema(db.sql, snapshot)).differences, isEmpty); + }); + + test('generated CRUD, projections, batch and conflict writes compute stored and virtual values', () async { + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + final row = await db.lines.create( + price: 4, + quantity: 3, + label: 'cat', + ); + expect((row.total, row.labelSize, row.normalizedNote), (12, 3, null)); + await db.lines + .byId(row.id) + .patch(quantity: .set(5), note: .set('Hello')); + expect( + await db.lines + .select((r) => (r.total, r.labelSize, r.normalizedNote).row) + .single(), + (20, 3, 'HELLO'), + ); + final inserted = await db.lines + .insertMany( + [2, 3], + (r, int q) => [ + r.price.set(10), + r.quantity.set(q), + r.label.set('batch'), + ], + ) + .returning((r) => r.total) + .get(); + expect(inserted, [20, 30]); + final updated = await db.lines + .insert( + (r) => [ + r.id.set(row.id), + r.price.set(7), + r.quantity.set(2), + r.label.set('conflict'), + ], + ) + .onConflictUpdate( + target: (r) => [r.id], + set: (old, incoming) => [ + old.price.setExpression(incoming.price), + ], + ) + .returning((r) => r.total) + .get(); + expect(updated, [35]); + expect(await db.lines.where((r) => r.total.gt(25)).count(), 2); + await db.bands.create(id: 35, name: 'large'); + expect( + await db.lines + .byId(row.id) + .select((r) => r.band.select((b) => b.name).one()) + .single(), + 'large', + ); + expect( + await db.bands + .byId(35) + .select((b) => b.lines.select((r) => r.total).many()) + .single(), + [35], + ); + expect( + () => db.lines.update((r) => [r.column(r.total.definition).set(1)]), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'COLUMN.READ_ONLY', + ), + ), + ); + await expectLater( + db.execute(SqlCommand('UPDATE lines SET total = 1')), + throwsA(isA()), + ); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).differences, + isEmpty, + ); + }); + + test( + 'catalog/import roundtrip preserves SQL and modes and detects drift', + () async { + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + final info = await inspectTable(db.sql, 'lines'); + expect(info.unmanaged, isEmpty); + expect(info.columns.where((c) => c.computed != null), hasLength(3)); + expect( + info.columns + .singleWhere((c) => c.name == 'label_size') + .computed! + .storage, + ComputedStorage.virtual, + ); + expect( + info.columns + .where((c) => c.computed != null) + .every((c) => c.defaultSql == null), + true, + ); + expect(await verifyColumns(db.sql, appSchema), isEmpty); + final dir = await Directory( + '.dart_tool/orm-computed-import-${dialect.name}', + ).create(recursive: true); + try { + final imported = await importSchema(db.sql); + expect( + imported.hasBlockingIssues, + false, + reason: '${imported.issues.map((i) => i.toJson())}', + ); + expect(imported.dart, contains('@Computed.sql(')); + final source = File('${dir.path}/schema.dart'); + await source.writeAsString(imported.dart); + final generated = await generateSchema(source.path); + expect( + (await verifySchema(db.sql, generated.snapshot)).differences, + isEmpty, + ); + } finally { + await dir.delete(recursive: true); + } + final wrong = SchemaSnapshot([scores()]); + await db.execute( + SqlCommand(createSchema(wrong.tables, dialect).first.sql), + ); + final changed = SchemaSnapshot([ + scores(computed: const ComputedColumn('value * 3')), + ]); + expect( + (await verifySchema(db.sql, changed)).differences, + contains('scores.total computed expression or storage differs'), + ); + expect( + await verifyColumns(db.sql, changed.tables), + contains('scores.total computed expression or storage differs'), + ); + }, + ); + + for (final storage in ComputedStorage.values) { + test( + '$storage add, expression/type change and drop preserve existing rows', + () async { + final start = SchemaSnapshot([scores(includeTotal: false)]); + final initial = Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + await db.execute(SqlCommand('INSERT INTO scores VALUES (1, 3)')); + final withTotal = SchemaSnapshot([ + scores(computed: ComputedColumn('value * 2', storage: storage)), + ]); + final add = Migration.diff( + '0002_add', + from: start, + to: withTotal, + previous: initial.checksum, + dialect: db.dialect, + ); + if (dialect == SqlDialect.sqlite) { + expect( + add.steps.whereType().length, + storage == ComputedStorage.stored ? 1 : 0, + ); + } + await Migrator(db.sql).apply([initial, add]); + expect( + (await db.execute(SqlCommand('SELECT total FROM scores'))).rows, + [ + [6], + ], + ); + expect( + (await verifySchema(db.sql, withTotal)).differences, + isEmpty, + ); + final changed = SchemaSnapshot([ + scores( + computed: ComputedColumn('value * 3', storage: storage), + real: true, + ), + ]); + final change = Migration.diff( + '0003_change', + from: withTotal, + to: changed, + previous: add.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, add, change]); + expect( + (await db.execute(SqlCommand('SELECT total FROM scores'))).rows, + [ + [9.0], + ], + ); + expect( + (await verifySchema(db.sql, changed)).differences, + isEmpty, + ); + final remove = Migration.diff( + '0004_drop', + from: changed, + to: start, + previous: change.checksum, + allowDestructive: true, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, add, change, remove]); + expect( + (await db.execute(SqlCommand('SELECT * FROM scores'))).rows, + [ + [1, 3], + ], + ); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + }, + ); + } + + test('stored to ordinary materialization retains values and permits later writes', () async { + final start = SchemaSnapshot([scores()]); + final initial = Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + await db.execute( + SqlCommand('INSERT INTO scores (id, value) VALUES (1, 3)'), + ); + final target = SchemaSnapshot([scores(computed: null)]); + final materialize = Migration.diff( + '0002_materialize', + from: start, + to: target, + previous: initial.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, materialize]); + expect( + (await db.execute(SqlCommand('SELECT total FROM scores'))).rows, + [ + [6], + ], + ); + await db.execute( + SqlCommand('UPDATE scores SET value = 4, total = 20'), + ); + expect( + (await db.execute(SqlCommand('SELECT total FROM scores'))).rows, + [ + [20], + ], + ); + expect((await verifySchema(db.sql, target)).differences, isEmpty); + }); + + test('explicit renamed expressions preserve indexes, checks, references and views', () async { + TableSchema notes(String target) => TableSchema( + 'notes', + columns: [Column('id', Codecs.integer)], + primaryKey: ['id'], + foreignKeys: [ + ForeignKey(['id'], target, ['id']), + ], + ); + final start = SchemaSnapshot([ + scores(checked: true), + notes('scores'), + ]); + final initial = Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + await db.execute( + SqlCommand('INSERT INTO scores (id, value) VALUES (1, 3)'), + ); + await db.execute(SqlCommand('INSERT INTO notes VALUES (1)')); + await db.execute( + SqlCommand('CREATE VIEW totals AS SELECT total FROM scores'), + ); + final target = SchemaSnapshot([ + scores( + name: 'marks', + value: 'amount', + checked: true, + computed: const ComputedColumn('amount * 3'), + ), + notes('marks'), + ]); + final rename = Migration.diff( + '0002_rename', + from: start, + to: target, + previous: initial.checksum, + renames: const SchemaRenames( + tables: {'scores': 'marks'}, + columns: { + 'marks': {'value': 'amount'}, + }, + ), + dialect: db.dialect, + ); + if (dialect == SqlDialect.sqlite) { + expect(rename.steps.whereType(), hasLength(2)); + } + await Migrator(db.sql).apply([initial, rename]); + expect((await db.execute(SqlCommand('SELECT * FROM totals'))).rows, [ + [9], + ]); + expect((await verifySchema(db.sql, target)).differences, isEmpty); + await expectLater( + db.execute(SqlCommand('DELETE FROM marks')), + throwsA(anything), + ); + await expectLater( + db.execute(SqlCommand('UPDATE marks SET amount = -1')), + throwsA(anything), + ); + }); + + test( + 'invalid recomputation rolls back the table and migration history', + () async { + final start = SchemaSnapshot([scores()]); + final initial = Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + await db.execute( + SqlCommand('INSERT INTO scores (id, value) VALUES (1, 3)'), + ); + final target = SchemaSnapshot([ + scores(computed: const ComputedColumn('NULL')), + ]); + final change = Migration.diff( + '0002_null', + from: start, + to: target, + previous: initial.checksum, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([initial, change]), + throwsA(anything), + ); + expect( + (await db.execute(SqlCommand('SELECT total FROM scores'))).rows, + [ + [6], + ], + ); + expect( + (await db.execute( + SqlCommand('SELECT count(*) FROM _orm_migrations'), + )).rows.single.single, + 1, + ); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + }, + ); + + test('decimal computed SQL keeps precision coercion and imports exactly once', () async { + final table = TableSchema( + 'amounts', + columns: [ + Column('id', Codecs.integer), + Column( + 'amount', + Codecs.decimal, + decimalPrecision: 5, + decimalScale: 2, + computed: const ComputedColumn("'12.345'"), + ), + ], + primaryKey: ['id'], + ); + final snapshot = SchemaSnapshot([table]); + await Migrator(db.sql).apply([ + Migration.create( + '0001_initial', + snapshot.tables, + dialect: db.dialect, + ), + ]); + await db.execute(SqlCommand('INSERT INTO amounts (id) VALUES (1)')); + expect( + (await db.execute(SqlCommand('SELECT amount FROM amounts'))) + .rows + .single + .single + .toString(), + '12.35', + ); + expect((await verifySchema(db.sql, snapshot)).differences, isEmpty); + final dir = await Directory( + '.dart_tool/orm-computed-decimal-${dialect.name}', + ).create(recursive: true); + try { + final imported = await importSchema(db.sql); + expect(imported.hasBlockingIssues, false); + final file = File('${dir.path}/schema.dart'); + await file.writeAsString(imported.dart); + final result = await generateSchema(file.path); + expect( + (await verifySchema(db.sql, result.snapshot)).differences, + isEmpty, + ); + } finally { + await dir.delete(recursive: true); + } + }); + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } + + test('mode conversions need explicit plans and historical copy/backfill cannot write computed columns', () { + final stored = SchemaSnapshot([scores()]); + final virtual = SchemaSnapshot([ + scores( + computed: const ComputedColumn( + 'value * 2', + storage: ComputedStorage.virtual, + ), + ), + ]); + final plain = SchemaSnapshot([scores(computed: null)]); + for (final (from, to) in [ + (stored, virtual), + (virtual, stored), + (plain, stored), + (virtual, plain), + ]) { + expect( + () => Migration.diff( + '0002_mode', + from: from, + to: to, + dialect: SqlDialect.postgres, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.COMPUTED', + ), + ), + ); + } + expect( + () => RebuildTable( + stored.tables.single, + stored.tables.single, + copy: {'total': 'value * 2'}, + ), + throwsA(isA()), + ); + expect( + () => Backfill( + stored.tables.single, + set: {'total': 'value * 2'}, + doneWhen: 'SELECT TRUE', + ), + throwsArgumentError, + ); + }); +} diff --git a/test/custom_codec_test.dart b/test/custom_codec_test.dart new file mode 100644 index 00000000..cc3bd3c8 --- /dev/null +++ b/test/custom_codec_test.dart @@ -0,0 +1,328 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/codecs/alternate.dart' as alt; +import 'support/codecs/schema.orm.dart'; +import 'support/codecs/schema.snapshot.dart' as physical; +import 'support/codecs/types.dart'; + +void main() { + runCodecTests('sqlite', () => sqlite(const SqliteOptions.memory())); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + const schema = 'orm_codec_tests'; + late Database admin; + setUpAll(() async { + admin = postgres(PostgresOptions(url: Uri.parse(url), tls: .disable)); + await admin.execute(SqlCommand('CREATE SCHEMA IF NOT EXISTS "$schema"')); + }); + tearDownAll(() async { + await admin.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + await admin.close(); + }); + runCodecTests( + 'postgres', + () async => postgres( + PostgresOptions(url: Uri.parse(url), tls: .disable, schema: schema), + ), + ); + } +} + +void runCodecTests(String name, Future> Function() open) { + group('custom codecs $name', () { + late Database db; + final initial = Migration.create( + '0001_initial', + appSchema, + dialect: SqlDialect.values.byName(name), + ); + setUp(() async { + db = await open(); + for (final table in ['notes', 'people', '_orm_migrations']) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + await Migrator(db.sql).apply([initial]); + }); + tearDown(() => db.close()); + + Future person({ + String email = 'seven@example.com', + SqlJson? details, + }) => db.people.create( + email: Email(email), + membership: .pending, + tags: ['dart', '数据库'], + location: (city: '成都', zone: 8), + alternate: const alt.Email('different domain'), + details: details, + ); + String parameter(int i) => db.dialect == .postgres ? '\$$i' : '?$i'; + + test( + 'generated custom values round trip through create, select and patch', + () async { + final value = await person(); + final PersonId id = value.id; + expect(id.value, 1); + expect(value.email.value, 'seven@example.com'); + expect(value.membership, Membership.pending); + expect(value.previousMembership, isNull); + expect(value.tags, ['dart', '数据库']); + expect(value.location, (city: '成都', zone: 8)); + expect(value.alternate.label, 'different domain'); + final Query<({Email email, Location? location}), PeopleFields> + projection = db.people + .byId(id) + .select( + (p) => ( + p.email, + p.location, + ).map((email, location) => (email: email, location: location)), + ); + expect((await projection.single()).email.value, value.email.value); + await db.people + .byId(id) + .patch( + email: .set(const Email('updated@example.com')), + membership: .set(.cancelled), + previousMembership: .set(.pending), + tags: .set([]), + location: .set(null), + ); + final updated = await db.people.byId(id).single(); + expect(updated.email.value, 'updated@example.com'); + expect(updated.membership, Membership.cancelled); + expect(updated.previousMembership, Membership.pending); + expect(updated.location, isNull); + expect(updated.tags, isEmpty); + }, + ); + + test('enum labels are stable text and unknown labels fail decoding', () async { + final value = await person(); + expect( + (await db.execute(SqlCommand('SELECT membership FROM people'))) + .rows + .single, + ['pending-payment'], + ); + await db.people.byId(value.id).patch(membership: .set(.cancelled)); + expect( + (await db.execute(SqlCommand('SELECT membership FROM people'))) + .rows + .single, + ['closed'], + ); + await db.execute( + SqlCommand("UPDATE people SET membership = 'future-value'"), + ); + await expectLater( + db.people.get(), + throwsA( + isA().having((e) => e.code, 'code', 'CODEC.ENUM'), + ), + ); + // Decoding is outside SQL execution; a bad value does not lose the lease. + expect(await db.people.count(), 1); + }); + + test( + 'domain validation and bound parameters apply to writes and predicates', + () async { + final value = await person(email: "'@x'); DROP TABLE people; --"); + final query = db.people.where((p) => p.email.eq(value.email)); + final command = query.compile(); + expect(command.sql, isNot(contains(value.email.value))); + expect(command.parameters, [value.email.value]); + expect((await query.single()).id, value.id); + final events = []; + final observed = Database(db.driver, onQuery: events.add); + await expectLater( + () => observed.people.create( + email: const Email('invalid'), + membership: .active, + tags: [], + alternate: const alt.Email('a'), + ), + throwsFormatException, + ); + expect(events, isEmpty); + await db.execute(SqlCommand("UPDATE people SET email = 'invalid'")); + await expectLater(db.people.single(), throwsFormatException); + expect(await db.people.count(), 1); + }, + ); + + test( + 'JSON documents distinguish SQL NULL, JSON null and string scalars', + () async { + final value = await person(); + expect(value.details, isNull); + expect(await db.people.where((p) => p.details.isNull()).count(), 1); + for (final document in [ + null, + 'plain string', + 'null', + '"quoted"', + 42, + true, + [null, 'x'], + { + 'nested': [null, '文本'], + }, + ]) { + await db.people + .byId(value.id) + .patch(details: .set(SqlJson(document))); + final result = await db.people + .byId(value.id) + .select((p) => p.details) + .single(); + expect(result, isA()); + expect(result!.value, document); + expect(await db.people.where((p) => p.details.isNull()).count(), 0); + final raw = (await db.execute( + SqlCommand('SELECT details FROM people'), + )).rows.single.single; + expect(Codecs.json.decode(raw), document); + } + await db.people.byId(value.id).patch(details: .set(null)); + expect((await db.people.single()).details, isNull); + }, + ); + + test('nullable structured codec rejects JSON null instead of masking invalid data', () async { + final value = await person(); + await db.execute( + SqlCommand('UPDATE people SET location = ${parameter(1)}', ['null']), + ); + await expectLater(db.people.single(), throwsA(isA())); + await db.people.byId(value.id).patch(location: .set(null)); + expect((await db.people.single()).location, isNull); + }); + + test('custom IDs and domain projections work across joined and batched relations', () async { + final owner = await person(); + for (var i = 0; i < 3; i++) { + await db.notes.create(ownerId: owner.id, body: 'note$i'); + } + final events = []; + final observed = Database(db.driver, onQuery: events.add); + final emails = await observed.notes + .select((n) => n.owner.select((p) => p.email).required()) + .get(); + expect(emails.map((e) => e.value), List.filled(3, owner.email.value)); + expect(events.length, 1); + final children = await db.people + .select( + (p) => p.notes + .orderBy((n) => [n.id.desc()]) + .take(2) + .select( + (n) => (n.ownerId, n.body).map((id, body) => (id, body)), + ) + .many(), + ) + .single(); + expect(children, [(owner.id, 'note2'), (owner.id, 'note1')]); + final batch = await db.notes + .select( + (n) => + n.owner.select((p) => p.membership).required(strategy: .batch), + ) + .get(); + expect(batch, List.filled(3, Membership.pending)); + }); + + test( + 'custom values participate in atomic batch writes and native upsert', + () async { + final ids = await db.people + .insertMany( + [1, 2, 3], + (p, i) => [ + p.email.set(Email('batch$i@example.com')), + p.membership.set(.active), + p.tags.set(['$i']), + p.alternate.set(const alt.Email('a')), + ], + ) + .returning((p) => p.id) + .get(); + expect(ids.map((id) => id.value), [1, 2, 3]); + await db.people + .insert( + (p) => [ + p.email.set(const Email('batch1@example.com')), + p.membership.set(.cancelled), + p.tags.set(['changed']), + p.alternate.set(const alt.Email('b')), + ], + ) + .onConflictUpdate( + target: (p) => [p.email], + set: (existing, incoming) => [ + existing.membership.setExpression(incoming.membership), + existing.tags.setExpression(incoming.tags), + ], + ) + .execute(); + final value = await db.people.byId(ids.first).single(); + expect(value.membership, Membership.cancelled); + expect(value.tags, ['changed']); + expect(await db.people.count(), 3); + }, + ); + + test( + 'custom IDs and enums survive cursor transport and streaming decoders', + () async { + final first = await person( + email: 'first@example.com', + details: const SqlJson('scalar'), + ); + final second = await person( + email: 'second@example.com', + details: const SqlJson(null), + ); + final token = db.people.cursorToken( + (p) => [p.membership.cursor(first.membership), p.id.cursor(first.id)], + ); + final rows = await db.people + .seekToken(token, orderBy: (p) => [p.membership.asc(), p.id.asc()]) + .stream(batchSize: 1) + .toList(); + expect(rows.single.id, second.id); + expect(rows.single.details, isA()); + expect(rows.single.details!.value, isNull); + final docs = await db.people + .orderBy((p) => [p.id.asc()]) + .select((p) => p.details) + .stream(batchSize: 1) + .toList(); + expect(docs.map((d) => d!.value), ['scalar', null]); + }, + ); + + test('migration snapshots retain physical storage without executing domain codecs', () async { + final snapshot = SchemaSnapshot(appSchema); + final restored = physical.schema; + expect(restored.toJson(), snapshot.toJson()); + final verification = await verifySchema(db.sql, restored); + expect(verification.differences, isEmpty); + expect(verification.unmanaged, isEmpty); + final columns = await inspectColumns(db.sql, 'people'); + expect(columns.firstWhere((c) => c.name == 'location').nullable, true); + expect(columns.firstWhere((c) => c.name == 'membership').nullable, false); + expect( + (await Migrator(db.sql).history()).single.checksum, + initial.checksum, + ); + }); + }); +} diff --git a/test/database_test.dart b/test/database_test.dart new file mode 100644 index 00000000..fa6efab8 --- /dev/null +++ b/test/database_test.dart @@ -0,0 +1,611 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/tables.dart'; +import 'support/advanced.dart'; + +void main() { + runDatabaseTests('sqlite', () => sqlite(const SqliteOptions.memory())); + final postgresUrl = Platform.environment['ORM_TEST_POSTGRES']; + if (postgresUrl != null) { + runDatabaseTests( + 'postgres', + () async => postgres( + PostgresOptions(url: Uri.parse(postgresUrl), tls: PostgresTls.disable), + ), + ); + } +} + +void runDatabaseTests(String name, Future> Function() open) { + group(name, () { + late Database db; + setUp(() async { + db = await open(); + await db.execute(SqlCommand('DROP TABLE IF EXISTS posts')); + await db.execute(SqlCommand('DROP TABLE IF EXISTS users')); + await createTables(db); + }); + tearDown(() => db.close()); + + Future create(String email, {String? nickname}) => db + .table(users) + .createRow((u) => [u.email.set(email), u.nickname.set(nickname)]); + + test('typed CRUD, exact projection and parameter binding', () async { + const input = "a' OR 1=1 --"; + final user = await create(input); + expect(user, (id: 1, email: input, nickname: null, score: 0)); + final query = db + .table(users) + .where((u) => u.email.eq(input)) + .select((u) => u.email); + expect(query.compile().sql, isNot(contains(input))); + expect(query.compile().sql, isNot(contains('nickname'))); + expect(query.compile().parameters, [input]); + final List emails = await query.get(); + expect(emails, [input]); + await db + .table(users) + .where((u) => u.id.eq(user.id)) + .update((u) => [u.score.increment(2), u.nickname.set('Seven')]) + .execute(); + expect((await db.table(users).single()).score, 2); + expect( + await db + .table(users) + .where((u) => u.email.eq(input)) + .delete() + .execute(), + 1, + ); + expect(await db.table(users).exists(), false); + }); + + test('record and DTO mapping run only when decoding', () async { + await create('one'); + var mapped = 0; + final query = db + .table(users) + .select( + (u) => (u.id, u.email).map((id, email) { + mapped++; + return (key: id, label: email); + }), + ); + query.compile(); + expect(mapped, 0); + expect(await query.single(), (key: 1, label: 'one')); + expect(mapped, 1); + }); + + test('immutable filters, empty IN, null and pagination', () async { + for (var i = 0; i < 5; i++) { + await create('user$i'); + } + final base = db.table(users).orderBy((u) => [u.id.desc()]); + expect(await base.count(), 5); + expect(await base.take(2).count(), 2); + expect(await base.skip(2).select((u) => u.id).get(), [3, 2, 1]); + expect(await base.where((u) => u.id.isIn([])).exists(), false); + expect(await base.where((u) => u.nickname.isNull()).count(), 5); + expect(await base.take(0).first(), null); + expect(base.single(), throwsA(isA())); + }); + + test( + 'cross-table identity and duplicate assignments rejected before IO', + () async { + late UserFields foreign; + db.table(users).select((u) { + foreign = u; + return u.id; + }); + expect( + () => db.table(users).where((u) => u.id.equals(foreign.id)).compile(), + throwsA(isA()), + ); + expect( + () => db + .table(users) + .update((u) => [u.score.set(1), u.score.set(2)]) + .compile(), + throwsA(isA()), + ); + expect( + () => db.table(users).update((u) => []).compile(), + throwsA(isA()), + ); + }, + ); + + test('commit, rollback, savepoint recovery and escaped session', () async { + late Database escaped; + await db.transaction((tx) async { + escaped = tx; + await tx.table(users).createRow((u) => [u.email.set('kept')]); + await expectLater( + tx.savepoint((sp) async { + await sp.table(users).createRow((u) => [u.email.set('discarded')]); + throw StateError('rollback inner'); + }), + throwsStateError, + ); + expect(await tx.table(users).count(), 1); + }); + expect(await db.table(users).count(), 1); + expect(() => escaped.table(users).get(), throwsA(isA())); + await expectLater( + db.transaction((tx) async { + await tx.table(users).delete().execute(); + throw StateError('rollback outer'); + }), + throwsStateError, + ); + expect(await db.table(users).count(), 1); + }); + + test('caught statement failures cannot masquerade as a commit', () async { + await expectLater( + db.transaction((tx) async { + await tx.table(users).createRow((u) => [u.email.set('same')]); + try { + await tx.table(users).createRow((u) => [u.email.set('same')]); + } catch (_) {} + }), + throwsA(isA()), + ); + expect(await db.table(users).count(), 0); + }); + + test('recoverable constraint errors stay within their savepoint', () async { + await db.transaction((tx) async { + await tx.table(users).createRow((u) => [u.email.set('same')]); + await expectLater( + tx.savepoint((sp) async { + await sp.table(users).createRow((u) => [u.email.set('same')]); + }), + throwsA(anything), + ); + await tx.table(users).createRow((u) => [u.email.set('after')]); + }); + expect(await db.table(users).count(), 2); + }); + + test('unawaited work is drained and rolled back', () async { + await expectLater( + db.transaction((tx) async { + unawaited( + tx + .table(users) + .insert((u) => [u.email.set('not-committed')]) + .execute(), + ); + }), + throwsA(isA()), + ); + expect(await db.table(users).count(), 0); + }); + + test('close waits for active operations and rejects further use', () async { + final work = db.transaction((tx) async { + await tx.table(users).createRow((u) => [u.email.set('one')]); + }); + final closed = db.close(); + await work; + await closed; + expect(() => db.table(users).get(), throwsA(isA())); + }); + + test( + 'leased sessions retain one connection across transactions and expire', + () async { + late Database escaped; + await db.session((session) async { + escaped = session; + expect(session.inTransaction, false); + await session.transaction((tx) async { + expect(tx.inTransaction, true); + expect( + () => session.table(users).get(), + throwsA(isA()), + ); + await tx.table(users).createRow((u) => [u.email.set('leased')]); + }); + expect(await session.table(users).count(), 1); + }); + expect(() => escaped.table(users).get(), throwsA(isA())); + }, + ); + + test( + 'native upsert updates atomically using existing and incoming values', + () async { + final initial = await create('same'); + final score = await db + .table(users) + .insert((u) => [u.email.set('same'), u.score.set(5)]) + .onConflictUpdate( + target: (u) => [u.email], + set: (existing, incoming) => [ + existing.score.setExpression(incoming.score.plus(1)), + ], + ) + .returning((u) => u.score) + .single(); + expect(score, 6); + expect((await db.table(users).single()).id, initial.id); + final skipped = await db + .table(users) + .insert((u) => [u.email.set('same')]) + .onConflictDoNothing(target: (u) => [u.email]) + .returning((u) => u.id) + .first(); + expect(skipped, null); + expect(await db.table(users).count(), 1); + expect( + () => db + .table(users) + .insert((u) => [u.email.set('x')]) + .onConflictUpdate( + target: (u) => [u.score], + set: (a, b) => [a.score.set(1)], + ), + throwsA(isA()), + ); + }, + ); + + test( + 'bulk inserts split at parameter limits and opt into returning', + () async { + final events = []; + final limited = Database( + _LimitedDriver(db.driver, 5), + onQuery: events.add, + ); + final batch = limited + .table(users) + .insertMany( + List.generate(7, (i) => i), + (u, i) => [u.email.set('bulk$i'), u.score.set(i)], + ); + expect(batch.compile().length, 4); + expect(batch.compile().every((c) => c.parameters.length <= 5), true); + final scores = await batch.returning((u) => u.score).get(); + expect(scores, unorderedEquals(List.generate(7, (i) => i))); + expect(events.where((e) => e.sql.startsWith('INSERT')).length, 4); + expect(events.where((e) => e.sql == 'BEGIN').length, 1); + expect(await db.table(users).count(), 7); + }, + ); + + test('a failed later bulk chunk rolls back all previous chunks', () async { + final limited = Database(_LimitedDriver(db.driver, 2)); + final batch = limited.table(users).insertMany([ + 'one', + 'two', + 'three', + 'one', + ], (u, email) => [u.email.set(email)]); + expect(batch.compile().length, 2); + await expectLater(batch.execute(), throwsA(anything)); + expect(await db.table(users).count(), 0); + await db.transaction((tx) async { + expect( + await tx.table(users).insertMany([ + 'a', + 'b', + ], (u, email) => [u.email.set(email)]).execute(), + 2, + ); + }); + expect(await db.table(users).count(), 2); + }); + + test( + 'bulk optional field shapes preserve defaults and explicit null', + () async { + final count = await db.table(users).insertMany( + [false, true], + (u, explicit) => [ + u.email.set(explicit ? 'set' : 'default'), + if (explicit) u.score.set(10), + u.nickname.set(null), + ], + ).execute(); + expect(count, 2); + expect( + await db + .table(users) + .orderBy((u) => [u.score.asc()]) + .select((u) => u.score) + .get(), + [0, 10], + ); + final empty = db + .table(users) + .insertMany([], (u, email) => [u.email.set(email)]); + expect(empty.compile(), isEmpty); + expect(await empty.execute(), 0); + }, + ); + + group('relations', () { + advancedScenarios(() => db); + setUp(() async { + await db.execute( + SqlCommand( + 'CREATE TABLE posts (id INTEGER PRIMARY KEY, ' + 'author_id ${name == 'postgres' ? 'BIGINT' : 'INTEGER'} NOT NULL REFERENCES users(id), ' + 'title TEXT NOT NULL, tag TEXT)', + ), + ); + for (var i = 0; i < 4; i++) { + await create('user$i'); + } + for (var i = 1; i <= 12; i++) { + await db + .table(postsTable) + .insert( + (p) => [ + p.id.set(i), + p.authorId.set((i - 1) ~/ 4 + 1), + p.title.set('post$i'), + p.tag.set(i.isEven ? 'even' : null), + ], + ) + .execute(); + } + }); + + test('root pagination and each parent limit use two queries', () async { + final events = []; + final observed = Database(db.driver, onQuery: events.add); + final rows = await observed + .table(users) + .orderBy((u) => [u.id.asc()]) + .skip(1) + .take(2) + .select( + (u) => ( + u.email, + u.posts + .orderBy((p) => [p.id.desc()]) + .take(2) + .select((p) => p.title) + .many(), + ).map((email, titles) => (email: email, titles: titles)), + ) + .get(); + expect(rows.map((r) => r.email).toList(), ['user1', 'user2']); + expect(rows.map((r) => r.titles).toList(), [ + ['post8', 'post7'], + ['post12', 'post11'], + ]); + expect(events.length, 2); + expect(events.last.sql, contains('PARTITION BY')); + expect(events.last.rowCount, 4); + }); + + test( + 'empty roots skip child queries; empty lists remain typed lists', + () async { + final events = []; + final observed = Database(db.driver, onQuery: events.add); + expect( + await observed + .table(users) + .where((u) => u.id.eq(100)) + .select((u) => u.posts.many()) + .get(), + isEmpty, + ); + expect(events.length, 1); + final List rows = await observed + .table(users) + .where((u) => u.id.eq(4)) + .select((u) => u.posts.many()) + .single(); + expect(rows, isEmpty); + }, + ); + + test( + 'nested relation projections and optional all-null projection', + () async { + final rows = await db + .table(users) + .where((u) => u.id.eq(1)) + .select( + (u) => u.posts + .orderBy((p) => [p.id.asc()]) + .take(1) + .select( + (p) => ( + p.title, + p.author.select((a) => a.email).required(), + ).map((title, email) => (title: title, author: email)), + ) + .many(), + ) + .single(); + expect(rows, [(title: 'post1', author: 'user0')]); + final missing = await db + .table(postsTable) + .where((p) => p.id.eq(1)) + .select((p) => p.author.where((u) => u.id.eq(99)).one()) + .single(); + expect(missing, null); + final present = await db + .table(postsTable) + .where((p) => p.id.eq(1)) + .select( + (p) => p.author + .select( + (u) => u.nickname.map((nickname) => (nickname: nickname)), + ) + .one(), + ) + .single(); + expect(present, (nickname: null)); + }, + ); + + test('correlated any, none, count and every with SQL nulls', () async { + expect(await db.table(users).where((u) => u.posts.any()).count(), 3); + expect( + await db + .table(users) + .where((u) => u.posts.none()) + .select((u) => u.id) + .get(), + [4], + ); + expect( + await db + .table(users) + .orderBy((u) => [u.id.asc()]) + .select((u) => u.posts.count()) + .get(), + [4, 4, 4, 0], + ); + expect( + await db + .table(users) + .where((u) => u.posts.every((p) => p.tag.eq('even'))) + .select((u) => u.id) + .get(), + [4], + ); + expect( + await db + .table(users) + .where( + (u) => u.posts.where((p) => p.tag.eq('even')).count().eq(2), + ) + .count(), + 3, + ); + }); + + test( + 'relation parameters bind correctly in projection and filter', + () async { + final rows = await db + .table(users) + .where((u) => u.id.eq(1)) + .select( + (u) => u.posts + .where((p) => p.id.gt(1)) + .orderBy((p) => [p.id.asc()]) + .skip(1) + .take(1) + .select((p) => p.id.plus(100)) + .many(), + ) + .single(); + expect(rows, [103]); + }, + ); + }); + + test( + 'keyset pagination uses a unique tie breaker in mixed directions', + () async { + await db + .table(users) + .insertMany( + [1, 1, 2, 2, 3].indexed, + (u, entry) => [ + u.email.set('score-${entry.$1}'), + u.score.set(entry.$2), + ], + ) + .execute(); + final rows = await db + .table(users) + .seekAfter( + (u) => [u.score.cursor(2, descending: true), u.id.cursor(3)], + ) + .select((u) => u.id) + .get(); + expect(rows, [4, 1, 2]); + expect( + () => db.table(users).seekAfter((u) => [u.score.cursor(1)]), + throwsA(isA()), + ); + }, + ); + + test( + 'keyset pagination handles null order and validates versioned tokens', + () async { + await create('one', nickname: 'a'); + await create('two', nickname: 'b'); + await create('three'); + await create('four'); + final q = db.table(users); + final token = q.cursorToken( + (u) => [u.nickname.cursor('b', nulls: .last), u.id.cursor(2)], + ); + expect( + await q + .seekToken( + token, + orderBy: (u) => [u.nickname.asc(nulls: .last), u.id.asc()], + ) + .select((u) => u.id) + .get(), + [3, 4], + ); + expect( + await q + .seekAfter( + (u) => [u.nickname.cursor(null, nulls: .first), u.id.cursor(3)], + ) + .select((u) => u.id) + .get(), + [4, 1, 2], + ); + expect( + await q + .seekAfter( + (u) => [u.nickname.cursor(null, nulls: .last), u.id.cursor(3)], + ) + .select((u) => u.id) + .get(), + [4], + ); + expect( + () => q.seekAfter((u) => [u.nickname.cursor(null), u.id.cursor(1)]), + throwsA(isA()), + ); + expect( + () => q.seekToken(token, orderBy: (u) => [u.id.asc()]), + throwsA(isA()), + ); + expect( + () => q.seekToken('bad-token', orderBy: (u) => [u.id.asc()]), + throwsA(isA()), + ); + }, + ); + }); +} + +final class _LimitedDriver(final Driver inner, final int limit) + implements Driver { + @override + Capabilities get capabilities => + Capabilities(dialect: inner.capabilities.dialect, maxParameters: limit); + @override + Future run(Future Function(SqlConnection) action) => + inner.run(action); + @override + Future close() async {} +} diff --git a/test/decimal_average_test.dart b/test/decimal_average_test.dart new file mode 100644 index 00000000..2d96a08d --- /dev/null +++ b/test/decimal_average_test.dart @@ -0,0 +1,614 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/decimals/schema.orm.dart'; + +Decimal d(String text) => Decimal.parse(text); + +void main() { + test('integer fractions round once and only bound their final result', () { + final huge = BigInt.from(10).pow(131072); + expect( + Decimal.fromFraction(huge, BigInt.from(10), scale: 0), + d('1e131071'), + ); + expect( + Decimal.fromFraction( + BigInt.from(469), + BigInt.from(200), + scale: 2, + rounding: .halfEven, + ), + d('2.34'), + ); + expect( + Decimal.fromFraction( + BigInt.from(-469), + BigInt.from(200), + scale: 2, + rounding: .floor, + ), + d('-2.35'), + ); + expect( + () => Decimal.fromFraction(BigInt.one, BigInt.zero, scale: 0), + throwsUnsupportedError, + ); + expect( + () => Decimal.fromFraction(BigInt.one, BigInt.from(3), scale: 2), + throwsFormatException, + ); + }); + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('decimal average $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_decimal_average_tests', + ), + ); + await db.execute( + SqlCommand( + 'DROP SCHEMA IF EXISTS orm_decimal_average_tests CASCADE', + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA orm_decimal_average_tests'), + ); + } + await Migrator(db.sql).apply([ + Migration.create('0001_average', appSchema, dialect: db.dialect), + ]); + }); + tearDown(() => db.close()); + Future rows(List values, {String bucket = 'a'}) async { + for (final v in values) { + await db.entries.create(amount: d(v), bucket: bucket); + } + } + + test( + 'empty null and constant inputs retain aggregate semantics', + () async { + expect( + await db.entries.select((e) => e.amount.average(scale: 2)).single(), + null, + ); + expect( + await db.entries + .select((e) => value(d('2'), Codecs.decimal).average(scale: 2)) + .single(), + null, + ); + await rows(['1', '2', '3']); + expect( + await db.entries.select((e) => e.amount.average(scale: 2)).single(), + d('2'), + ); + expect( + await db.entries.select((e) => e.fee.average(scale: 2)).single(), + null, + ); + expect( + await db.entries + .select((e) => value(d('2'), Codecs.decimal).average(scale: 2)) + .single(), + d('2'), + ); + await db.entries.byId(2).patch(fee: Change.set(d('4'))); + expect( + await db.entries.select((e) => e.fee.average(scale: 2)).single(), + d('4'), + ); + }, + ); + + test( + 'all rounding modes match exact signed sums across scales', + () async { + for (final values in [ + ['2.34', '2.35'], + ['-2.34', '-2.35'], + ['1e20', '-1'], + ['-1e20', '1'], + ['1', '0', '0'], + ['1250', '1450'], + ['-1250', '-1450'], + ]) { + await db.entries.delete().execute(); + await rows(values); + final sum = values.map(d).reduce((a, b) => a + b); + for (final mode in DecimalRounding.values) { + for (final scale in [-2, 0, 2, 30]) { + Decimal? expected; + try { + expected = sum.divide( + d('${values.length}'), + scale: scale, + rounding: mode, + ); + } on FormatException { + /* Exact mode intentionally rejects rounding. */ + } + final result = db.entries + .select( + (e) => e.amount.average(scale: scale, rounding: mode), + ) + .single(); + if (expected == null) { + await expectLater(result, throwsA(isA())); + } else { + expect( + await result, + expected, + reason: '$values $scale $mode', + ); + } + } + } + } + }, + ); + + test( + 'valid means survive sums exceeding the finite numeric range', + () async { + await rows(['9e131071', '9e131071']); + await expectLater( + db.entries.select((e) => e.amount.sum()).single(), + throwsA(isA()), + ); + expect( + await db.entries.select((e) => e.amount.average(scale: 0)).single(), + d('9e131071'), + ); + await db.entries.delete().execute(); + await rows(['9e131071', '-9e131071', '1']); + final actual = await db.entries + .select( + (e) => e.amount.average(scale: 16383, rounding: .halfEven), + ) + .single(); + expect( + actual == d('1').divide(d('3'), scale: 16383, rounding: .halfEven), + isTrue, + ); + await db.entries.delete().execute(); + await rows(['-9e131071', '-9e131071']); + expect( + await db.entries.select((e) => e.amount.average(scale: 0)).single(), + d('-9e131071'), + ); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); + + test( + 'minimum fractions and outermost rounding scales stay exact', + () async { + await rows(['1e-16383', '0']); + expect( + await db.entries + .select( + (e) => e.amount.average(scale: 16383, rounding: .halfEven), + ) + .single(), + d('0'), + ); + expect( + await db.entries + .select( + (e) => e.amount.average( + scale: 16383, + rounding: .halfAwayFromZero, + ), + ) + .single(), + d('1e-16383'), + ); + await db.entries.delete().execute(); + await rows(['5e131071', '5e131071']); + expect( + await db.entries + .select( + (e) => e.amount.average(scale: -131072, rounding: .halfEven), + ) + .single(), + d('0'), + ); + await expectLater( + db.entries + .select( + (e) => e.amount.average( + scale: -131072, + rounding: .halfAwayFromZero, + ), + ) + .single(), + throwsA(isA()), + ); + }, + ); + + test( + 'grouping HAVING ordering and CTE references preserve the mean', + () async { + await rows(['1', '2'], bucket: 'a'); + await rows(['2', '3'], bucket: 'b'); + final grouped = db.entries + .groupBy((e) => [e.bucket]) + .having((e) => e.amount.average(scale: 1).gt(d('2'))) + .orderBy((e) => [e.amount.average(scale: 1).desc()]) + .select((e) => (e.bucket, e.amount.average(scale: 1)).row); + expect(await grouped.get(), [('b', d('2.5'))]); + final cte = grouped.asCte('means'); + expect( + await cte.query + .where( + (e) => e.ref((o) => o.amount.average(scale: 1)).lt(d('3')), + ) + .get(), + [('b', d('2.5'))], + ); + expect(await grouped.union(grouped).get(), [('b', d('2.5'))]); + final unique = db.entries + .select((e) => e.amount) + .distinct() + .asCte('unique_amounts'); + expect( + await unique.query + .select((e) => e.ref((o) => o.amount).average(scale: 1)) + .single(), + d('2'), + ); + }, + ); + + test( + 'illegal window nesting and aggregate assignments fail before SQL', + () { + expect( + () => db.entries.select( + (e) => e.amount.sum().over().average(scale: 2), + ), + throwsA( + isA().having((e) => e.code, 'code', 'QUERY.WINDOW'), + ), + ); + expect( + () => db.entries.select( + (e) => e.amount + .average(scale: 2) + .over(orderBy: [e.amount.sum().over().asc()]), + ), + throwsA( + isA().having((e) => e.code, 'code', 'QUERY.WINDOW'), + ), + ); + expect( + () => db.entries + .update( + (e) => [e.fee.setExpression(e.amount.average(scale: 2))], + ) + .compile(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'QUERY.AGGREGATE', + ), + ), + ); + }, + ); + + test( + 'windows and rounded window means respect partitions and pagination', + () async { + await rows(['1', '2', '3']); + await rows(['10', '20'], bucket: 'b'); + Expr mean(EntriesFields e) => e.amount + .average(scale: 2) + .over( + partitionBy: [e.bucket], + orderBy: [e.id.asc()], + frame: .rowsToCurrent, + ); + expect( + await db.entries.orderBy((e) => [e.id.asc()]).select(mean).get(), + ['1', '1.5', '2', '10', '15'].map(d), + ); + expect( + await db.entries + .orderBy((e) => [mean(e).desc()]) + .select(mean) + .skip(1) + .take(2) + .get(), + [d('10'), d('2')], + ); + expect( + await db.entries + .where((e) => e.bucket.eq('a')) + .orderBy((e) => [e.id.asc()]) + .select((e) => mean(e).rounded(0, rounding: .halfEven)) + .get(), + [d('1'), d('2'), d('2')], + ); + final cte = db.entries.select(mean).asCte('running_means'); + expect(await cte.query.where((e) => e.ref(mean).gt(d('10'))).get(), [ + d('15'), + ]); + expect( + await db.entries + .orderBy((e) => [e.id.asc()]) + .select(mean) + .stream() + .toList(), + ['1', '1.5', '2', '10', '15'].map(d), + ); + final mixed = await db.entries + .where((e) => e.bucket.eq('a')) + .orderBy((e) => [e.id.asc()]) + .select( + (e) => ( + mean(e), + e.amount.sum().over( + orderBy: [e.id.asc()], + frame: .rowsToCurrent, + ), + ).row, + ) + .get(); + expect(mixed, [ + (d('1'), d('1')), + (d('1.5'), d('3')), + (d('2'), d('6')), + ]); + Expr complete(EntriesFields e) => + e.amount.average(scale: 1).over(frame: .rowsAll); + expect( + await db.entries + .where((e) => e.bucket.eq('a')) + .orderBy((e) => [complete(e).asc()]) + .select(complete) + .distinct() + .get(), + [d('2')], + ); + }, + ); + + test('correlated means can update values and failures roll back transactions', () async { + await rows(['1', '2'], bucket: 'a'); + await rows(['1', '0', '0'], bucket: 'b'); + final source = db.entries; + await db.entries + .where((e) => e.bucket.eq('a')) + .update( + (e) => [ + e.fee.setExpression( + source + .where((s) => s.bucket.equals(e.bucket)) + .select((s) => s.amount.average(scale: 1)) + .scalar(), + ), + ], + ) + .execute(); + expect( + await db.entries + .where((e) => e.bucket.eq('a')) + .select((e) => e.fee) + .get(), + [d('1.5'), d('1.5')], + ); + await expectLater( + db.transaction((tx) async { + await tx.entries + .where((e) => e.bucket.eq('a')) + .patch(fee: Change.set(d('99'))); + await tx.entries + .where((e) => e.bucket.eq('b')) + .select((e) => e.amount.average(scale: 1)) + .single(); + }), + throwsA(isA()), + ); + expect( + await db.entries + .where((e) => e.bucket.eq('a')) + .select((e) => e.fee) + .get(), + [d('1.5'), d('1.5')], + ); + }); + + test('batched relationship windows keep per-parent limits', () async { + for (final n in ['2', '4']) { + await db.rates.create(id: d(n), label: n); + for (var i = 0; i < 3; i++) { + await db.allocations.create(rateId: d(n)); + } + } + expect( + await db.rates + .orderBy((r) => [r.id.asc()]) + .select( + (r) => ( + r.label, + r.allocations + .orderBy((a) => [a.id.desc()]) + .take(1) + .select( + (a) => a.rateId + .average(scale: 1) + .over( + partitionBy: [a.rateId], + orderBy: [a.id.asc()], + frame: .rowsToCurrent, + ), + ) + .many(), + ).map((label, means) => (label, means.single)), + ) + .get(), + [('2', d('2')), ('4', d('4'))], + ); + }); + + if (backend == 'sqlite') { + test('sliding windows remove values, reset empty frames and reject varying policies', () async { + for (final fee in ['1', null, '3', null, null]) { + await db.entries.create( + amount: d('0'), + fee: fee == null ? null : d(fee), + bucket: 'a', + ); + } + final result = await db.execute( + SqlCommand( + 'SELECT orm_decimal_avg_v1(fee, 2, 5) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) FROM entries ORDER BY id', + ), + ); + expect( + result.rows.map( + (r) => r.single == null ? null : Codecs.decimal.decode(r.single), + ), + [d('1'), d('1'), d('3'), d('3'), null], + ); + await expectLater( + db.execute( + SqlCommand( + 'SELECT orm_decimal_avg_v1(amount, id, 5) FROM entries', + ), + ), + throwsA(isA()), + ); + }); + } + + test('window means of grouped sums run after HAVING', () async { + await rows(['1', '3'], bucket: 'a'); + await rows(['6'], bucket: 'b'); + await rows(['100'], bucket: 'excluded'); + expect( + await db.entries + .groupBy((e) => [e.bucket]) + .having((e) => e.amount.sum().lt(d('50'))) + .orderBy((e) => [e.bucket.asc()]) + .select( + (e) => ( + e.bucket, + e.amount + .sum() + .average(scale: 1) + .over(orderBy: [e.bucket.asc()], frame: .rowsToCurrent), + ).row, + ) + .get(), + [('a', d('4')), ('b', d('5'))], + ); + }); + + if (backend == 'postgres') { + test( + 'volatile inputs and window ordering are evaluated once per row', + () async { + await rows(['1', '2', '3']); + await db.execute(SqlCommand('CREATE SEQUENCE average_calls')); + final next = sql( + ["nextval('average_calls')::numeric"], + [], + Codecs.decimal, + ); + expect( + await db.entries.select((e) => next.average(scale: 1)).single(), + d('2'), + ); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM average_calls'), + )).rows.single.single, + 3, + ); + expect( + await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => next + .average(scale: 1) + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent), + ) + .get(), + [d('4'), d('4.5'), d('5')], + ); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM average_calls'), + )).rows.single.single, + 6, + ); + expect( + await db.entries + .select( + (e) => e.amount + .average(scale: 1) + .over(orderBy: [next.asc()], frame: .rowsToCurrent), + ) + .get(), + [d('1'), d('1.5'), d('2')], + ); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM average_calls'), + )).rows.single.single, + 9, + ); + final repeated = db.entries + .select((e) => e.bucket) + .asCte('repeated_anchors'); + expect( + await repeated.query + .select((e) => next.average(scale: 1)) + .single(), + d('11'), + ); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM average_calls'), + )).rows.single.single, + 12, + ); + expect( + await db.entries + .where((e) => e.id.lt(0)) + .select((e) => next.average(scale: 1)) + .single(), + null, + ); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM average_calls'), + )).rows.single.single, + 12, + ); + }, + ); + } + }); + } +} diff --git a/test/decimal_division_test.dart b/test/decimal_division_test.dart new file mode 100644 index 00000000..863ed055 --- /dev/null +++ b/test/decimal_division_test.dart @@ -0,0 +1,571 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/decimals/schema.orm.dart'; + +Decimal d(String s) => Decimal.parse(s); + +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('decimal division $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_decimal_division_tests', + ), + ); + await db.execute( + SqlCommand( + 'DROP SCHEMA IF EXISTS orm_decimal_division_tests CASCADE', + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA orm_decimal_division_tests'), + ); + } + await Migrator(db.sql).apply([ + Migration.create('0001_decimal', appSchema, dialect: db.dialect), + ]); + }); + tearDown(() => db.close()); + + Future ratio( + Decimal a, + Decimal b, + int scale, + DecimalRounding mode, + ) async { + return db.entries + .select( + (e) => value( + a, + Codecs.decimal, + ).divide(b, scale: scale, rounding: mode), + ) + .single(); + } + + test('signed modes and scales match exact integer arithmetic', () async { + for (final a in [ + '0', + '1', + '-1', + '5', + '-5', + '25', + '-25', + '1250', + '-1250', + '2.345', + '-2.345', + '2.355', + '-2.355', + '.4999999999999999999999999999', + ]) { + for (final b in ['2', '-2', '.03', '-.03']) { + await db.entries.create(amount: d(a), fee: d(b), bucket: 'a'); + } + } + final input = await db.entries.orderBy((e) => [e.id.asc()]).get(); + for (final mode in DecimalRounding.values.where((m) => m != .exact)) { + for (final scale in [-3, -1, 0, 1, 2, 8, 100]) { + final output = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => ( + e.amount.divideExpression( + e.fee, + scale: scale, + rounding: mode, + ), + e.amount.rounded(scale, rounding: mode), + ).row, + ) + .get(); + expect( + output, + input.map( + (r) => ( + r.amount.divide(r.fee!, scale: scale, rounding: mode), + r.amount.rounded(scale, rounding: mode), + ), + ), + reason: '$mode scale=$scale', + ); + } + } + }); + + test( + 'exact defaults reject lost digits and zero divisors, NULL propagates', + () async { + await db.entries.create(amount: d('1'), bucket: 'a'); + expect(await ratio(d('1'), d('8'), 3, .exact), d('.125')); + expect(await ratio(d('1200'), d('2'), -2, .exact), d('600')); + expect( + await db.entries.select((e) => e.amount.rounded(0)).single(), + d('1'), + ); + await expectLater( + ratio(d('1'), d('3'), 100, .exact), + throwsA(isA()), + ); + await expectLater( + ratio(d('1'), d('0'), 2, .halfEven), + throwsA(isA()), + ); + await expectLater( + db.entries.select((e) => e.amount.rounded(-1)).single(), + throwsA(isA()), + ); + expect( + await db.entries + .select((e) => e.fee.divide(d('0'), scale: 2)) + .single(), + null, + ); + expect( + await db.entries + .select((e) => e.amount.divideExpression(e.fee, scale: 2)) + .single(), + null, + ); + expect( + await db.entries.select((e) => e.fee.rounded(-2)).single(), + null, + ); + }, + ); + + test( + 'precision exceeds native division scale without double rounding', + () async { + await db.entries.create(amount: d('1'), bucket: 'a'); + for (final scale in [50, 1000, Decimal.maxFractionDigits]) { + final actual = await ratio(d('1'), d('3'), scale, .halfEven); + expect( + actual == + d('1').divide(d('3'), scale: scale, rounding: .halfEven), + isTrue, + reason: 'scale=$scale', + ); + } + expect( + await ratio( + d('.4999999999999999999999999999'), + d('1'), + 0, + .halfAwayFromZero, + ), + d('0'), + ); + expect( + await ratio( + d('2.5000000000000000000000000001'), + d('1'), + 0, + .halfEven, + ), + d('3'), + ); + final smallest = d('1e-16383'); + expect(await ratio(smallest, d('1'), 16383, .exact), smallest); + expect(await ratio(smallest, d('2'), 16383, .halfEven), d('0')); + expect( + await ratio(smallest, d('2'), 16383, .halfAwayFromZero), + smallest, + ); + }, + ); + + test( + 'huge remainders avoid overflowing intermediate scaled products', + () async { + await db.entries.create(amount: d('1'), bucket: 'a'); + final huge = d('9e131071'); + final pairs = [ + (d('8e131071'), huge), + (huge, huge + d('1')), + (huge, huge + d('1e-16383')), + ]; + for (final (a, b) in pairs) { + for (final scale in [1, 50, 16383]) { + for (final mode in [ + DecimalRounding.towardZero, + DecimalRounding.halfEven, + DecimalRounding.ceiling, + ]) { + final expected = a.divide(b, scale: scale, rounding: mode); + final actual = await ratio(a, b, scale, mode); + expect( + actual == expected, + isTrue, + reason: 'large operands scale=$scale mode=$mode', + ); + } + } + } + }, + timeout: const Timeout(Duration(minutes: 2)), + ); + + test( + 'outermost integer scale and result overflow are explicit', + () async { + await db.entries.create(amount: d('1'), bucket: 'a'); + expect( + await ratio(d('5e131071'), d('1'), -131072, .halfEven), + d('0'), + ); + expect( + await ratio(d('-5e131071'), d('1'), -131072, .ceiling), + d('0'), + ); + expect( + await ratio(d('9e131071'), d('1'), -131072, .towardZero), + d('0'), + ); + await expectLater( + ratio(d('5e131071'), d('1'), -131072, .halfAwayFromZero), + throwsA(isA()), + ); + await expectLater( + ratio(d('9e131071'), d('.1'), 0, .towardZero), + throwsA(isA()), + ); + expect(await ratio(d('0'), d('1'), -131072, .exact), d('0')); + }, + ); + + test('aggregates windows CTEs predicates and unions compose', () async { + await db.entries.create(amount: d('2.345'), bucket: 'a'); + await db.entries.create(amount: d('4.345'), bucket: 'a'); + expect( + await db.entries + .select( + (e) => e.amount.sum().divide( + d('2'), + scale: 2, + rounding: .halfEven, + ), + ) + .single(), + d('3.34'), + ); + expect( + await db.entries + .groupBy((e) => [e.bucket]) + .select( + (e) => ( + e.bucket, + e.amount.sum().rounded(1, rounding: .halfEven), + ).row, + ) + .single(), + ('a', d('6.7')), + ); + expect( + await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.amount + .sum() + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) + .divide(d('2'), scale: 2, rounding: .halfEven), + ) + .get(), + [d('1.17'), d('3.34')], + ); + final cte = db.entries + .select( + (e) => e.amount.divide(d('2'), scale: 2, rounding: .halfEven), + ) + .asCte('halves'); + expect( + await cte.query + .where( + (e) => e + .ref( + (o) => o.amount.divide( + d('2'), + scale: 2, + rounding: .halfEven, + ), + ) + .gt(d('2')), + ) + .get(), + [d('2.17')], + ); + expect( + await db.entries + .where((e) => e.amount.rounded(0, rounding: .halfEven).eq(d('2'))) + .count(), + 1, + ); + final set = db.entries + .select((e) => e.amount.rounded(0, rounding: .halfEven)) + .union( + db.entries.select( + (e) => e.amount.divide(d('1'), scale: 0, rounding: .halfEven), + ), + ); + expect(await set.get(), unorderedEquals([d('2'), d('4')])); + }); + + test( + 'expression assignments and transaction rollback preserve values', + () async { + final row = await db.entries.create( + amount: d('10'), + fee: d('3'), + bucket: 'a', + ); + await db.entries + .byId(row.id) + .update( + (e) => [ + e.amount.setExpression(e.amount.divide(d('4'), scale: 2)), + ], + ) + .execute(); + expect((await db.entries.byId(row.id).single()).amount, d('2.5')); + await expectLater( + db.transaction((tx) async { + await tx.entries + .byId(row.id) + .patch(bucket: const Change.set('changed')); + await tx.entries + .byId(row.id) + .update( + (e) => [ + e.amount.setExpression(e.amount.divide(d('3'), scale: 2)), + ], + ) + .execute(); + }), + throwsA(isA()), + ); + expect((await db.entries.byId(row.id).single()).bucket, 'a'); + expect((await db.entries.byId(row.id).single()).amount, d('2.5')); + }, + ); + + test('window filtering ordering DISTINCT and pagination keep their SQL order', () async { + for (final n in ['1', '3', '5']) { + await db.entries.create(amount: d(n), bucket: 'a'); + } + await db.entries.create(amount: d('100'), bucket: 'excluded'); + Expr running(EntriesFields e) => e.amount + .sum() + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) + .divide(d('2'), scale: 1); + final query = db.entries + .where((e) => e.bucket.eq('a')) + .orderBy((e) => [running(e).desc()]); + expect(await query.select(running).get(), [d('4.5'), d('2'), d('.5')]); + expect(await query.select(running).skip(1).take(1).get(), [d('2')]); + expect(await query.select((e) => e.id).get(), [3, 2, 1]); + Expr total(EntriesFields e) => + e.amount.sum().over(frame: .rowsAll).divide(d('3'), scale: 0); + final distinct = db.entries + .where((e) => e.bucket.eq('a')) + .orderBy((e) => [total(e).desc()]) + .select(total) + .distinct(); + expect(await distinct.get(), [d('3')]); + expect(await distinct.skip(1).get(), isEmpty); + }); + + test('windows over grouped aggregates run after HAVING', () async { + await db.entries.create(amount: d('1'), bucket: 'a'); + await db.entries.create(amount: d('3'), bucket: 'a'); + await db.entries.create(amount: d('6'), bucket: 'b'); + await db.entries.create(amount: d('100'), bucket: 'excluded'); + final result = await db.entries + .groupBy((e) => [e.bucket]) + .having((e) => e.amount.sum().lt(d('50'))) + .orderBy((e) => [e.bucket.asc()]) + .select( + (e) => ( + e.bucket, + e.amount + .sum() + .sum() + .over(orderBy: [e.bucket.asc()], frame: .rowsToCurrent) + .divide(d('2'), scale: 0), + ).row, + ) + .get(); + expect(result, [('a', d('2')), ('b', d('5'))]); + }); + + test( + 'window results compose through CTEs sets and correlated subqueries', + () async { + await db.entries.create(amount: d('1'), bucket: 'a'); + await db.entries.create(amount: d('3'), bucket: 'a'); + Expr running(EntriesFields e) => e.amount + .sum() + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) + .divide(d('2'), scale: 1); + final projected = db.entries + .orderBy((e) => [e.id.asc()]) + .select(running); + final cte = projected.asCte('running_halves'); + expect(await projected.stream().toList(), [d('.5'), d('2')]); + expect( + () => cte.query.where( + (e) => e + .ref( + (o) => o.amount + .sum() + .over(orderBy: [o.id.asc()], frame: .rowsAll) + .divide(d('2'), scale: 1), + ) + .gt(d('1')), + ), + throwsA(isA()), + ); + expect( + await cte.query.where((e) => e.ref(running).gt(d('1'))).get(), + [d('2')], + ); + expect( + await projected.union(projected).get(), + unorderedEquals([d('.5'), d('2')]), + ); + final outer = db.entries; + final inner = db.entries; + final result = await outer + .orderBy((e) => [e.id.asc()]) + .select( + (e) => inner + .where( + (i) => sql( + ['(', ' <= ', ')'], + [i.id, e.id], + Codecs.boolean.nullable(), + ), + ) + .orderBy((i) => [i.id.desc()]) + .select(running) + .take(1) + .scalar(), + ) + .get(); + expect(result, [d('.5'), d('2')]); + final alias = entriesTable.alias(); + final joined = await db.entries + .leftJoin(alias, on: (e, a) => e.id.equals(a.id).and(a.id.eq(1))) + .orderBy((e) => [e.id.asc()]) + .select( + (e) => ( + e.id, + running(e).rounded(0, rounding: .halfEven), + alias.optional(alias.fields.amount), + ).map((id, n, matched) => (id, n, matched)), + ) + .get(); + expect(joined, [(1, d('0'), d('1')), (2, d('2'), null)]); + }, + ); + + test('batched relations retain per-parent window pagination', () async { + for (final n in ['2', '4']) { + await db.rates.create(id: d(n), label: n); + for (var i = 0; i < 2; i++) { + await db.allocations.create(rateId: d(n)); + } + } + final result = await db.rates + .orderBy((r) => [r.id.asc()]) + .select( + (r) => ( + r.label, + r.allocations + .orderBy((a) => [a.id.desc()]) + .take(1) + .select( + (a) => a.rateId + .sum() + .over( + partitionBy: [a.rateId], + orderBy: [a.id.asc()], + frame: .rowsToCurrent, + ) + .divide(d('2'), scale: 0), + ) + .many(), + ).map((label, totals) => (label, totals.single)), + ) + .get(); + expect(result, [('2', d('2')), ('4', d('4'))]); + }); + + if (backend == 'postgres') { + test('volatile operands are evaluated exactly once each', () async { + await db.entries.create(amount: d('1'), bucket: 'a'); + await db.execute(SqlCommand('CREATE SEQUENCE division_calls')); + final next = sql( + ["nextval('division_calls')::numeric"], + [], + Codecs.decimal, + ); + expect( + await db.entries + .select((e) => next.divideExpression(next, scale: 2)) + .single(), + d('.5'), + ); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM division_calls'), + )).rows.single.single, + 2, + ); + expect( + await db.entries.select((e) => next.rounded(0)).single(), + d('3'), + ); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM division_calls'), + )).rows.single.single, + 3, + ); + await db.entries.create(amount: d('1'), bucket: 'a'); + final windowed = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.amount + .sum() + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) + .divideExpression(next, scale: 2, rounding: .halfEven), + ) + .get(); + expect(windowed, [d('.25'), d('.4')]); + expect( + (await db.execute( + SqlCommand('SELECT last_value FROM division_calls'), + )).rows.single.single, + 5, + ); + }); + } + }); + } +} diff --git a/test/decimal_precision_test.dart b/test/decimal_precision_test.dart new file mode 100644 index 00000000..617adb92 --- /dev/null +++ b/test/decimal_precision_test.dart @@ -0,0 +1,591 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/precision/schema.orm.dart'; +import 'support/precision/schema.snapshot.dart' as physical; + +Decimal d(String value) => Decimal.parse(value); + +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('decimal precision $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_precision_tests', + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_precision_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_precision_tests')); + } + }); + tearDown(() => db.close()); + Future create() => Migrator(db.sql).apply([ + Migration.create('0001_precision', appSchema, dialect: db.dialect), + ]); + + test('column coercion rounds signed ties and handles negative and excess scales', () async { + await create(); + final a = await db.wallets.create( + amount: d('123.455'), + hundreds: d('12350'), + fraction: d('.001235'), + ); + expect( + (a.amount, a.hundreds, a.fraction, a.defaulted, a.optional), + (d('123.46'), d('12400'), d('.00124'), d('1.24'), null), + ); + final b = await db.wallets.create( + amount: d('-123.455'), + hundreds: d('-12350'), + fraction: d('-.001235'), + optional: d('-1.235'), + ); + expect( + (b.amount, b.hundreds, b.fraction, b.optional), + (d('-123.46'), d('-12400'), d('-.00124'), d('-1.24')), + ); + final zero = await db.wallets.create( + amount: Decimal.zero, + hundreds: Decimal.zero, + fraction: Decimal.zero, + ); + expect(zero.fraction, Decimal.zero); + for (final values in [ + ('999.995', '0', '0'), + ('0', '99950', '0'), + ('0', '0', '.009995'), + ]) { + await expectLater( + db.wallets.create( + amount: d(values.$1), + hundreds: d(values.$2), + fraction: d(values.$3), + ), + throwsA(isA()), + ); + } + expect(await db.wallets.count(), 3); + }); + + test('writes constrain columns while arithmetic and wider aggregates retain their result precision', () async { + await create(); + final a = await db.wallets.create( + amount: d('999.994'), + hundreds: d('99949'), + fraction: d('.009994'), + ); + await db.wallets.create( + amount: d('999.99'), + hundreds: Decimal.zero, + fraction: Decimal.zero, + ); + expect( + await db.wallets.select((w) => w.amount.sum()).single(), + d('1999.98'), + ); + expect( + await db.wallets + .select((w) => w.amount.sum().constrained(6, 1)) + .single(), + d('2000'), + ); + expect( + await db.wallets + .where((w) => w.amount.constrained(6, 0).eq(d('1000'))) + .count(), + 2, + ); + final rounded = db.wallets + .select((w) => w.amount.constrained(5, 1)) + .asCte('rounded'); + expect( + await rounded.query + .select((c) => c.ref((w) => w.amount.constrained(5, 1))) + .get(), + [d('1000'), d('1000')], + ); + expect( + await db.wallets + .byId(a.id) + .select((w) => w.amount.times(d('1000'))) + .single(), + d('999990'), + ); + await expectLater( + db.wallets + .byId(a.id) + .update((w) => [w.amount.setExpression(w.amount.plus(d('.005')))]) + .execute(), + throwsA(isA()), + ); + expect((await db.wallets.byId(a.id).single()).amount, d('999.99')); + await db.wallets + .byId(a.id) + .patch( + amount: Change.set(d('1.235')), + optional: Change.set(d('2.225')), + ); + expect((await db.wallets.byId(a.id).single()).amount, d('1.24')); + await db.wallets.byId(a.id).patch(optional: const Change.set(null)); + expect((await db.wallets.byId(a.id).single()).optional, null); + }); + + test( + 'batch inserts and upserts use column coercion and roll back as a unit', + () async { + await create(); + final ids = await db.prices + .insertMany([ + '1.234', + '2.345', + ], (p, v) => [p.id.set(d(v)), p.label.set(v)]) + .returning((p) => p.id) + .get(); + expect(ids, [d('1.23'), d('2.35')]); + await db.prices + .insert((p) => [p.id.set(d('1.234')), p.label.set('updated')]) + .onConflictUpdate( + target: (p) => [p.id], + set: (old, incoming) => [ + old.label.setExpression(incoming.label), + ], + ) + .execute(); + expect((await db.prices.byId(d('1.23')).single()).label, 'updated'); + await expectLater( + db.prices.insertMany([ + '3.456', + '99.995', + ], (p, v) => [p.id.set(d(v)), p.label.set(v)]).execute(), + throwsA(isA()), + ); + expect(await db.prices.count(), 2); + final child = await db.receipts.create(priceId: d('1.234')); + expect(child.priceId, d('1.23')); + expect( + await db.receipts + .select((r) => r.price.select((p) => p.label).required()) + .single(), + 'updated', + ); + }, + ); + + test('catalogs snapshots and imported declarations retain precision scale and defaults', () async { + await create(); + final snapshot = SchemaSnapshot(appSchema); + expect(physical.schema.checksum, snapshot.checksum); + final verification = await verifySchema(db.sql, snapshot); + expect(verification.differences, isEmpty); + expect(verification.unmanaged, isEmpty); + expect(await verifyColumns(db.sql, appSchema), isEmpty); + final info = await inspectTable(db.sql, 'wallets'); + expect(info.columns.map((c) => (c.decimalPrecision, c.decimalScale)), [ + (null, null), + (5, 2), + (3, -2), + (3, 5), + (5, 2), + (5, 2), + ]); + final draft = await importSchema(db.sql); + expect(draft.issues, isEmpty); + expect(draft.dart, contains('@DecimalDigits(3, -2)')); + final dir = await Directory('.dart_tool/orm-precision-import-$backend') + .create(recursive: true); + try { + final file = File('${dir.path}/schema.dart'); + await file.writeAsString(draft.dart); + final generated = await generateSchema(file.path); + final check = await verifySchema(db.sql, generated.snapshot); + expect(check.differences, isEmpty); + } finally { + await dir.delete(recursive: true); + } + }); + + test('reviewed scale reduction detects rounded duplicate keys and rolls back schema history', () async { + TableSchema sized(int scale) => TableSchema( + 'sized', + columns: [ + Column('id', Codecs.integer), + Column( + 'amount', + Codecs.decimal, + decimalPrecision: 5, + decimalScale: scale, + ), + ], + primaryKey: ['id'], + uniqueKeys: [ + ['amount'], + ], + ); + final first = Migration.create('0001_sized', [ + sized(3), + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + await db.execute( + SqlCommand("INSERT INTO sized VALUES (1, '1.231'), (2, '1.234')"), + ); + final next = Migration.diff( + '0002_scale', + from: first.snapshot!, + to: SchemaSnapshot([sized(2)]), + previous: first.checksum, + using: { + 'sized': {'amount': 'amount'}, + }, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([first, next]), + throwsA(isA()), + ); + expect((await Migrator(db.sql).history()).length, 1); + expect( + (await verifySchema(db.sql, first.snapshot!)).differences, + isEmpty, + ); + await db.execute(SqlCommand('DELETE FROM sized WHERE id = 2')); + await Migrator(db.sql).apply([first, next]); + expect( + (await verifySchema(db.sql, next.snapshot!)).differences, + isEmpty, + ); + expect( + Codecs.decimal.decode( + (await db.execute(SqlCommand('SELECT amount FROM sized'))) + .rows + .single + .single, + ), + d('1.23'), + ); + }); + + test('physical renames and changing constrained defaults preserve column contracts', () async { + TableSchema schema( + String column, + int precision, + int scale, + String value, + ) => TableSchema( + 'renamed', + columns: [ + Column('id', Codecs.integer), + Column( + column, + Codecs.decimal, + decimalPrecision: precision, + decimalScale: scale, + defaultSql: value, + ), + ], + primaryKey: ['id'], + ); + final first = Migration.create('0001_before', [ + schema('amount', 5, 3, "'1.2345'"), + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + await db.execute(SqlCommand('INSERT INTO renamed (id) VALUES (1)')); + final next = Migration.diff( + '0002_after', + from: first.snapshot!, + to: SchemaSnapshot([schema('price', 6, 2, "'2.345'")]), + previous: first.checksum, + renames: const SchemaRenames( + columns: { + 'renamed': {'amount': 'price'}, + }, + ), + using: { + 'renamed': {'price': 'price'}, + }, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, next]); + await db.execute(SqlCommand('INSERT INTO renamed (id) VALUES (2)')); + final rows = await db.execute( + SqlCommand('SELECT price FROM renamed ORDER BY id'), + ); + expect(rows.rows.map((r) => Codecs.decimal.decode(r.single)), [ + d('1.24'), + d('2.35'), + ]); + final check = await verifySchema(db.sql, next.snapshot!); + expect(check.differences, isEmpty); + expect(check.unmanaged, isEmpty); + }); + + test( + 'maximum declared precision and scale bounds remain exact', + () async { + final columns = [ + Column('id', Codecs.integer), + Column( + 'large', + Codecs.decimal, + decimalPrecision: 1000, + decimalScale: -1000, + ), + Column( + 'tiny', + Codecs.decimal, + decimalPrecision: 1, + decimalScale: 1000, + ), + ]; + final schema = TableSchema( + 'bounds', + columns: columns, + primaryKey: ['id'], + ); + await Migrator(db.sql).apply([ + Migration.create('0001_bounds', [schema], dialect: db.dialect), + ]); + final parameter = backend == 'sqlite' ? '?1' : r'$1'; + final parameter2 = backend == 'sqlite' ? '?2' : r'$2'; + await db.execute( + SqlCommand( + 'INSERT INTO bounds VALUES (1, $parameter, $parameter2)', + [d('9e1999').toString(), d('9e-1000').toString()], + ), + ); + final row = (await db.execute( + SqlCommand('SELECT large, tiny FROM bounds'), + )).rows.single; + expect(row.map(Codecs.decimal.decode), [d('9e1999'), d('9e-1000')]); + expect( + (await verifySchema(db.sql, SchemaSnapshot([schema]))).differences, + isEmpty, + ); + }, + ); + + test( + 'historical backfills retain constrained decimal key declarations', + () async { + await create(); + for (final n in ['1.234', '2.345', '3.456']) { + await db.prices.create(id: d(n), label: 'pending'); + } + final first = Migration.create( + '0001_precision', + appSchema, + dialect: db.dialect, + ); + final next = Migration.steps( + '0002_backfill', + [ + Backfill( + pricesSchema, + set: {'label': "'done'"}, + doneWhen: "SELECT NOT EXISTS(SELECT 1 FROM prices WHERE label <> 'done')", + batchSize: 1, + ), + ], + previous: first.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, next], maxBackfillBatches: 1); + await Migrator(db.sql).apply([first, next]); + expect(await db.prices.where((p) => p.label.eq('done')).count(), 3); + }, + ); + }); + } + + test('SQLite enforces stored precision and checks default coercion and trusted-schema behavior', () async { + final db = await sqlite(const SqliteOptions.memory()); + try { + final expected = TableSchema( + 'quoted', + columns: [ + Column('id', Codecs.integer), + Column( + 'a"b', + Codecs.decimal, + decimalPrecision: 5, + decimalScale: 2, + defaultSql: "printf('%s.%s', '1', '235')", + ), + ], + primaryKey: ['id'], + ); + await Migrator(db.sql).apply([ + Migration.create('0001_quoted', [expected], dialect: db.dialect), + ]); + await db.execute(SqlCommand('INSERT INTO quoted (id) VALUES (1)')); + expect( + Codecs.decimal.decode( + (await db.execute(SqlCommand('SELECT "a""b" FROM quoted'))) + .rows + .single + .single, + ), + d('1.24'), + ); + expect( + (await verifySchema(db.sql, SchemaSnapshot([expected]))).differences, + isEmpty, + ); + final draft = await importSchema(db.sql, tables: ['quoted']); + expect(draft.dart, contains("printf('%s.%s', '1', '235')")); + for (final value in ['1.234', '1000', 'invalid', 'NaN']) { + await expectLater( + db.execute(SqlCommand('UPDATE quoted SET "a""b" = ?1', [value])), + throwsA(isA()), + ); + } + await db.execute(SqlCommand('PRAGMA trusted_schema = OFF')); + await expectLater( + db.execute(SqlCommand('INSERT INTO quoted (id) VALUES (2)')), + throwsA(isA()), + ); + expect( + (await db.execute(SqlCommand('PRAGMA trusted_schema'))) + .rows + .single + .single, + 0, + ); + await db.execute(SqlCommand('PRAGMA trusted_schema = ON')); + await db.execute(SqlCommand('INSERT INTO quoted (id) VALUES (2)')); + final ddl = createSchema([ + TableSchema( + 'missing', + columns: [ + Column('id', Codecs.integer), + Column( + 'amount', + Codecs.decimal, + decimalPrecision: 5, + decimalScale: 2, + defaultSql: "'1.235'", + ), + ], + primaryKey: ['id'], + ), + ], SqlDialect.sqlite).first.sql; + await db.execute( + SqlCommand( + ddl.replaceFirst("orm_decimal_cast_v1('1.235', 5, 2)", "'1.235'"), + ), + ); + final table = TableSchema( + 'missing', + columns: [ + Column('id', Codecs.integer), + Column( + 'amount', + Codecs.decimal, + decimalPrecision: 5, + decimalScale: 2, + defaultSql: "'1.235'", + ), + ], + primaryKey: ['id'], + ); + expect( + (await verifySchema(db.sql, SchemaSnapshot([table]))).differences, + ['missing.amount default differs'], + ); + } finally { + await db.close(); + } + }); + + test('generation validates DecimalDigits and retains custom decimal codec types', () async { + final dir = await Directory('.dart_tool/orm-precision-validation') + .create(recursive: true); + try { + final file = File('${dir.path}/schema.dart'); + for (final declaration in [ + '@DecimalDigits(0) Decimal n', + '@DecimalDigits(1001) Decimal n', + '@DecimalDigits(2, -1001) Decimal n', + '@DecimalDigits(2, 1001) Decimal n', + '@DecimalDigits(2) int n', + '@DecimalDigits(2) @DecimalDigits(3) Decimal n', + ]) { + await file.writeAsString( + "import 'package:orm/schema.dart'; typedef Row = ({$declaration}); final rows = entity();", + ); + await expectLater( + generateSchema(file.path), + throwsA(isA()), + ); + } + await file.writeAsString(""" +import 'package:orm/schema.dart'; +extension type Money(Decimal value) { + static const codec = Codec('decimal', decode, encode); + static Money decode(Object? value) => Money(Codecs.decimal.decode(value)); + static String encode(Money value) => value.value.toString(); +} +typedef Row = ({@DecimalDigits(8, 2) @UseCodec(Money.codec) Money n}); +final rows = entity(); +"""); + final generated = await generateSchema(file.path); + expect(generated.dart, contains('Column')); + expect(generated.dart, contains('decimalPrecision: 8')); + expect(generated.dart, contains('decimalScale: 2')); + } finally { + await dir.delete(recursive: true); + } + }); + + test( + 'metadata rejects invalid precisions scales and non-decimal columns', + () { + for (final column in [ + Column('n', Codecs.decimal, decimalScale: 2), + Column('n', Codecs.decimal, decimalPrecision: 0), + Column('n', Codecs.decimal, decimalPrecision: 1001), + Column('n', Codecs.decimal, decimalPrecision: 3, decimalScale: -1001), + Column('n', Codecs.decimal, decimalPrecision: 3, decimalScale: 1001), + Column('n', Codecs.integer, decimalPrecision: 3), + ]) { + expect( + () => createSchema([ + TableSchema('invalid', columns: [column]), + ], SqlDialect.sqlite), + throwsA(isA()), + ); + } + final a = SchemaSnapshot([ + TableSchema( + 'a', + columns: [Column('n', Codecs.decimal, decimalPrecision: 3)], + ), + ]); + final b = SchemaSnapshot([ + TableSchema( + 'a', + columns: [ + Column('n', Codecs.decimal, decimalPrecision: 3, decimalScale: 0), + ], + ), + ]); + expect(a.checksum, b.checksum); + }, + ); +} diff --git a/test/decimal_test.dart b/test/decimal_test.dart new file mode 100644 index 00000000..7b4b286d --- /dev/null +++ b/test/decimal_test.dart @@ -0,0 +1,523 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/decimals/schema.orm.dart'; +import 'support/decimals/schema.snapshot.dart' as physical; + +Decimal d(String s) => Decimal.parse(s); + +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('exact decimal $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_decimal_tests', + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_decimal_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_decimal_tests')); + } + }); + tearDown(() => db.close()); + Future create() => Migrator(db.sql).apply([ + Migration.create('0001_decimal', appSchema, dialect: db.dialect), + ]); + Future amounts(List values) async { + for (final v in values) { + await db.entries.create(amount: d(v), bucket: 'a'); + } + } + + test('generated writes, defaults, patches and projections preserve all digits', () async { + await create(); + final n = d('9007199254740993.12345678901234567890123456789'); + final first = await db.entries.create(amount: n, bucket: 'a'); + expect(first.amount, n); + expect(first.tax, d('.1')); + expect(first.fee, null); + await db.entries + .byId(first.id) + .patch(fee: Change.set(d('.00000000000000000000000000001'))); + final row = await db.entries.byId(first.id).single(); + expect(row.fee, d('.00000000000000000000000000001')); + final result = await db.entries + .select( + (e) => ( + e.amount.plus(d('.00000000000000000000000000001')), + e.amount.minus(d('.12345678901234567890123456789')), + e.tax.times(d('3')), + e.amount.plusExpression(e.fee), + ).row, + ) + .single(); + expect(result, ( + d('9007199254740993.1234567890123456789012345679'), + d('9007199254740993'), + d('.3'), + d('9007199254740993.1234567890123456789012345679'), + )); + await db.entries.byId(first.id).patch(fee: const Change.set(null)); + expect( + await db.entries + .select((e) => e.amount.plusExpression(e.fee)) + .single(), + null, + ); + expect( + await db.entries.select((e) => value(n, Codecs.decimal)).single(), + n, + ); + }); + + test( + 'numeric ordering, predicates, IN, distinct and stable cursors', + () async { + await create(); + await amounts(['10', '2', '-2', '-10', '.00000000000000000001', '2']); + expect( + await db.entries + .orderBy((e) => [e.amount.asc()]) + .select((e) => e.amount) + .get(), + ['-10', '-2', '.00000000000000000001', '2', '2', '10'].map(d), + ); + expect(await db.entries.where((e) => e.amount.gt(d('2'))).count(), 1); + expect( + await db.entries + .where((e) => e.amount.isIn([d('2.00'), d('-10')])) + .count(), + 3, + ); + expect( + await db.entries + .select((e) => e.amount.count(distinct: true)) + .single(), + 5, + ); + final ordered = db.entries.orderBy( + (e) => [e.amount.asc(), e.id.asc()], + ); + final first = (await ordered.take(2).get()).last; + final token = db.entries.cursorToken( + (e) => [e.amount.cursor(first.amount), e.id.cursor(first.id)], + ); + final rest = await db.entries + .seekToken(token, orderBy: (e) => [e.amount.asc(), e.id.asc()]) + .get(); + expect( + rest.map((r) => r.amount), + ['.00000000000000000001', '2', '2', '10'].map(d), + ); + }, + ); + + test( + 'sums, min/max, grouped aggregates and sliding windows stay exact', + () async { + await create(); + expect(await db.entries.select((e) => e.amount.sum()).single(), null); + await amounts(['.1', '.2', '.3', '.3']); + expect( + await db.entries + .select( + (e) => ( + e.amount.sum(), + e.amount.sum(distinct: true), + e.amount.min(), + e.amount.max(), + ).row, + ) + .single(), + (d('.9'), d('.6'), d('.1'), d('.3')), + ); + expect( + await db.entries + .groupBy((e) => [e.bucket]) + .select((e) => (e.bucket, e.amount.sum()).row) + .single(), + ('a', d('.9')), + ); + final windows = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.amount.sum().over( + orderBy: [e.id.asc()], + frame: .rowsToCurrent, + ), + ) + .get(); + expect(windows, ['.1', '.3', '.6', '.9'].map(d)); + await db.execute(SqlCommand('UPDATE entries SET fee = amount')); + expect(await db.entries.select((e) => e.fee.sum()).single(), d('.9')); + if (backend == 'sqlite') { + final rows = await db.execute( + SqlCommand( + 'SELECT orm_decimal_sum_v1(amount) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) FROM entries ORDER BY id', + ), + ); + expect( + rows.rows.map((r) => Codecs.decimal.decode(r.single)), + ['.1', '.3', '.5', '.6'].map(d), + ); + } + }, + ); + + test('computed CTEs, union results, subqueries and streaming keep decimal semantics', () async { + await create(); + await amounts(['2', '10', '-2']); + final source = db.entries.select((e) => e.amount.times(d('2'))); + final cte = source.asCte('doubled'); + expect( + await cte.query + .where((e) => e.ref((o) => o.amount.times(d('2'))).gt(d('5'))) + .get(), + [d('20')], + ); + final set = db.entries + .select((e) => e.amount) + .union(db.entries.select((e) => e.amount.plus(d('0')))); + expect(await set.get(), unorderedEquals(['2', '10', '-2'].map(d))); + expect( + await set.stream().toList(), + unorderedEquals(['2', '10', '-2'].map(d)), + ); + expect( + await db.entries + .where( + (e) => e.amount.isInQuery( + db.entries + .where((e) => e.amount.gt(d('2'))) + .select((e) => e.amount), + ), + ) + .count(), + 1, + ); + }); + + test( + 'numeric primary keys, foreign keys, joined and batched relations', + () async { + await create(); + await db.rates.create(id: d('2'), label: 'two'); + await db.execute( + SqlCommand( + "INSERT INTO allocations (\"rate_id\") VALUES ('2.000')", + ), + ); + expect( + await db.allocations + .select((a) => a.rate.select((r) => r.label).required()) + .single(), + 'two', + ); + final children = await db.rates + .select((r) => r.allocations.select((a) => a.rateId).many()) + .single(); + expect(children, [d('2')]); + await expectLater( + db.execute( + SqlCommand( + "INSERT INTO rates (id, label) VALUES ('2.00', 'duplicate')", + ), + ), + throwsA(isA()), + ); + await expectLater( + db.allocations.create(rateId: d('3')), + throwsA(isA()), + ); + expect((await db.rates.byId(d('2.000')).single()).label, 'two'); + if (backend == 'sqlite') { + final command = db.rates.byId(d('2')).compile(); + final plan = await db.execute( + SqlCommand( + 'EXPLAIN QUERY PLAN ${command.sql}', + command.parameters, + ), + ); + expect( + plan.rows.map((r) => r.last).join(' '), + contains('USING INDEX'), + ); + } + }, + ); + + test('snapshots, catalog verification and imported declarations retain decimal storage', () async { + await create(); + await amounts(['12345678901234567890.00000000001']); + final snapshot = SchemaSnapshot(appSchema); + expect(physical.schema.checksum, snapshot.checksum); + final verification = await verifySchema(db.sql, snapshot); + expect(verification.differences, isEmpty); + expect(verification.unmanaged, isEmpty); + expect(await verifyColumns(db.sql, appSchema), isEmpty); + final imported = await importSchema(db.sql); + expect(imported.issues, isEmpty); + expect(imported.dart, contains('Decimal')); + final directory = await Directory( + '.dart_tool/orm-decimal-import-$backend', + ).create(recursive: true); + try { + final source = File('${directory.path}/schema.dart'); + await source.writeAsString(imported.dart); + final generated = await generateSchema(source.path); + expect( + (await verifySchema(db.sql, generated.snapshot)).differences, + isEmpty, + ); + } finally { + await directory.delete(recursive: true); + } + expect( + (await db.entries.single()).amount, + d('12345678901234567890.00000000001'), + ); + }); + + test('aggregate intermediate overflow may cancel before a valid final result', () async { + await create(); + final huge = d('9e131071'); + await db.entries.create(amount: huge, bucket: 'a'); + await db.entries.create(amount: huge, bucket: 'a'); + await db.entries.create(amount: -huge, bucket: 'a'); + expect(await db.entries.select((e) => e.amount.sum()).single(), huge); + await db.entries + .where((e) => e.amount.lt(Decimal.zero)) + .delete() + .execute(); + await expectLater( + db.entries.select((e) => e.amount.sum()).single(), + throwsA(isA()), + ); + expect(await db.entries.count(), 2); + }); + + test('historical decimal keys resume backfills without lexical ordering or precision loss', () async { + await create(); + for (final n in ['10', '2', '-10', '.00000000000000000001']) { + await db.rates.create(id: d(n), label: 'pending'); + } + final first = Migration.create( + '0001_decimal', + appSchema, + dialect: db.dialect, + ); + final second = Migration.steps( + '0002_backfill', + [ + Backfill( + ratesSchema, + set: {'label': "'done'"}, + where: "label = 'pending'", + doneWhen: "SELECT NOT EXISTS (SELECT 1 FROM rates WHERE label = 'pending')", + batchSize: 1, + ), + ], + previous: first.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, second], maxBackfillBatches: 1); + expect(await db.rates.where((r) => r.label.eq('done')).count(), 1); + await Migrator(db.sql).apply([first, second]); + expect(await db.rates.where((r) => r.label.eq('done')).count(), 4); + }); + + test('reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back', () async { + TableSchema schema(Codec codec) => TableSchema( + 'converted', + columns: [Column('id', Codecs.integer), Column('amount', codec)], + primaryKey: ['id'], + uniqueKeys: [ + ['amount'], + ], + ); + final first = Migration.create('0001_text', [ + schema(Codecs.text), + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + await db.execute( + SqlCommand( + "INSERT INTO converted VALUES (1, '2'), (2, '2.00'), (3, '12345678901234567890.00000000001')", + ), + ); + final next = Migration.diff( + '0002_decimal', + from: first.snapshot!, + to: SchemaSnapshot([schema(Codecs.decimal)]), + previous: first.checksum, + using: (db.dialect == SqlDialect.sqlite + ? { + 'converted': {'amount': 'amount'}, + } + : { + 'converted': {'amount': 'CAST(amount AS NUMERIC)'}, + }), + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([first, next]), + throwsA(isA()), + ); + expect((await Migrator(db.sql).history()).length, 1); + expect( + (await db.execute( + SqlCommand('SELECT amount FROM converted WHERE id = 2'), + )).rows.single.single, + '2.00', + ); + await db.execute(SqlCommand('DELETE FROM converted WHERE id = 2')); + await Migrator(db.sql).apply([first, next]); + expect( + (await verifySchema(db.sql, next.snapshot!)).differences, + isEmpty, + ); + expect((await verifySchema(db.sql, next.snapshot!)).unmanaged, isEmpty); + final row = await db.execute( + SqlCommand('SELECT amount FROM converted WHERE id = 3'), + ); + expect( + Codecs.decimal.decode(row.rows.single.single), + d('12345678901234567890.00000000001'), + ); + }); + + test('invalid external values fail decoding; later valid queries still work', () async { + await create(); + await db.execute( + SqlCommand( + "INSERT INTO entries (amount, bucket) VALUES ('${backend == 'sqlite' ? 'invalid' : 'NaN'}', 'bad')", + ), + ); + await expectLater(db.entries.get(), throwsFormatException); + if (backend == 'sqlite') { + await amounts(['2', '-10', '10']); + final raw = await db.execute( + SqlCommand('SELECT amount FROM entries ORDER BY amount'), + ); + expect(raw.rows.map((r) => r.single), ['-10', '2', '10', 'invalid']); + await expectLater( + db.entries.select((e) => e.amount.sum()).single(), + throwsA(isA()), + ); + } + await db.entries.where((e) => e.bucket.eq('bad')).delete().execute(); + expect(await db.entries.where((e) => e.bucket.eq('bad')).count(), 0); + }); + }); + } + + test( + 'decimal SQL rejects drivers without exact operations before execution', + () async { + final db = Database(_NoDecimalDriver()); + expect( + () => db.entries.select((e) => e.amount).compile(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'CAPABILITY.DECIMAL', + ), + ), + ); + await db.close(); + }, + ); + + test('SQLite recognizes only column decimal collations, and detects drift', () async { + final db = await sqlite(const SqliteOptions.memory()); + try { + final table = TableSchema( + 'quoted', + columns: [ + Column('id', Codecs.integer), + Column('a"b', Codecs.decimal), + Column( + 'literal', + Codecs.text, + defaultSql: "'COLLATE orm_decimal_v1'", + ), + ], + primaryKey: ['id'], + uniqueKeys: [ + ['a"b'], + ], + ); + await Migrator(db.sql).apply([ + Migration.create('0001_quoted', [table], dialect: db.dialect), + ]); + expect( + (await verifySchema(db.sql, SchemaSnapshot([table]))).differences, + isEmpty, + ); + expect((await inspectTable(db.sql, 'quoted')).unmanaged, isEmpty); + await db.execute( + SqlCommand( + 'CREATE TABLE missing (id INTEGER NOT NULL, amount TEXT NOT NULL DEFAULT (\'COLLATE orm_decimal_v1\'), PRIMARY KEY (id))', + ), + ); + final expected = TableSchema( + 'missing', + columns: [ + Column('id', Codecs.integer), + Column('amount', Codecs.decimal), + ], + primaryKey: ['id'], + ); + expect(await verifyColumns(db.sql, [expected]), [ + 'missing.amount collation differs', + ]); + await db.execute( + SqlCommand('CREATE TABLE custom (amount TEXT COLLATE NOCASE)'), + ); + expect((await inspectTable(db.sql, 'custom')).unmanaged, isNotEmpty); + final imported = await importSchema(db.sql, tables: ['missing']); + expect(imported.dart, contains('String amount')); + await db.execute( + SqlCommand( + 'CREATE TABLE unicode_names (Ä TEXT COLLATE orm_decimal_v1, ä TEXT)', + ), + ); + final unicode = await inspectColumns(db.sql, 'unicode_names'); + expect(unicode.map((c) => (c.name, c.collation?.toLowerCase())), [ + ('Ä', 'orm_decimal_v1'), + ('ä', 'binary'), + ]); + } finally { + await db.close(); + } + }); +} + +final class _NoDecimalDriver implements Driver { + @override + final capabilities = const Capabilities( + dialect: SqlDialect.sqlite, + maxParameters: 999, + ); + @override + Future run(Future Function(SqlConnection) action) => + throw StateError('Compilation must reject first'); + @override + Future close() async {} +} diff --git a/test/decimal_value_test.dart b/test/decimal_value_test.dart new file mode 100644 index 00000000..c4852c42 --- /dev/null +++ b/test/decimal_value_test.dart @@ -0,0 +1,149 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +Decimal d(String s) => Decimal.parse(s); + +void main() { + test('normalization, scientific notation, equality and hash are exact', () { + for (final source in ['1', '+1.', '.1e1', '010.000e-1', '1.000']) { + expect(d(source), d('1')); + expect(d(source).toString(), '1'); + expect(d(source).hashCode, d('1').hashCode); + } + expect(d('-0.000'), Decimal.zero); + expect(d('1000').coefficient, BigInt.one); + expect(d('1000').scale, -3); + expect(d('-.00100').toString(), '-0.001'); + expect( + d('12345678901234567890.1234567890123456789').toString(), + '12345678901234567890.1234567890123456789', + ); + }); + test('invalid and approximate inputs fail instead of silently rounding', () { + for (final s in [ + '', + '.', + '+', + ' 1', + '1 ', + '1\n', + 'NaN', + 'Infinity', + '-Infinity', + '0x10', + '1_000', + '1e', + '1e9999999999999999999999', + '1e-9223372036854775808', + ]) { + expect(Decimal.tryParse(s), null, reason: s); + expect(() => d(s), throwsFormatException, reason: s); + } + for (final v in [1.0, double.nan, double.infinity, null, true]) { + expect(() => Codecs.decimal.decode(v), throwsFormatException); + } + expect( + Codecs.decimal.decode(BigInt.parse('9007199254740993')), + d('9007199254740993'), + ); + expect(Codecs.decimal.nullable().decode(null), null); + }); + test('range boundaries are checked without binary numeric conversions', () { + expect(d('1e131071').toString().length, 131072); + expect(d('1e-16383').scale, 16383); + expect(() => d('1e131072'), throwsRangeError); + expect( + () => Decimal.fromBigInt(BigInt.from(10), scale: -9223372036854775808), + throwsRangeError, + ); + expect( + () => Decimal.fromBigInt(BigInt.one, scale: 9223372036854775807), + throwsRangeError, + ); + expect(() => d('1e-16384'), throwsRangeError); + expect(() => d('1e131071') * d('10'), throwsRangeError); + expect(() => d('1e-16383') * d('.1'), throwsRangeError); + }); + test( + 'addition, subtraction, multiplication and ordering keep every digit', + () { + expect(d('0.1') + d('0.2'), d('0.3')); + expect( + d('9007199254740993.123456789') - d('.123456788'), + d('9007199254740993.000000001'), + ); + expect( + d('123456789.123456789') * d('1000000001'), + d('123456789246913578.123456789'), + ); + final sorted = [ + '10', + '2', + '-1000', + '-2.001', + '-2', + '0', + '0.00001', + ].map(d).toList()..sort(); + expect(sorted.map((v) => v.toString()), [ + '-1000', + '-2.001', + '-2', + '0', + '0.00001', + '2', + '10', + ]); + }, + ); + test('division requires an explicit scale and rejects implicit rounding', () { + expect(d('1').divide(d('8'), scale: 3), d('.125')); + expect(d('10').divide(d('0.2'), scale: 0), d('50')); + expect(d('.001').divide(d('10'), scale: 4), d('.0001')); + expect(() => d('1').divide(d('3'), scale: 8), throwsFormatException); + expect(() => d('1').divide(Decimal.zero, scale: 2), throwsUnsupportedError); + expect(() => d('1').rounded(16384), throwsRangeError); + }); + test('rounding modes handle negative values, ties and negative scales', () { + for (final negative in [false, true]) { + final v = d(negative ? '-2.5' : '2.5'); + expect(v.rounded(0, rounding: .towardZero), d(negative ? '-2' : '2')); + expect(v.rounded(0, rounding: .floor), d(negative ? '-3' : '2')); + expect(v.rounded(0, rounding: .ceiling), d(negative ? '-2' : '3')); + expect(v.rounded(0, rounding: .halfEven), d(negative ? '-2' : '2')); + expect( + v.rounded(0, rounding: .halfAwayFromZero), + d(negative ? '-3' : '3'), + ); + expect( + d(negative ? '-3.5' : '3.5').rounded(0, rounding: .halfEven), + d(negative ? '-4' : '4'), + ); + expect( + v.divide(d('-1'), scale: 0, rounding: .floor), + d(negative ? '2' : '-3'), + ); + } + expect(d('1250').rounded(-2, rounding: .halfEven), d('1200')); + expect(d('1350').rounded(-2, rounding: .halfEven), d('1400')); + expect(d('2.5001').rounded(0, rounding: .halfEven), d('3')); + expect(d('2.4999').rounded(0, rounding: .halfAwayFromZero), d('2')); + }); + test( + 'small fixed-scale arithmetic agrees with independent integer arithmetic', + () { + for (var a = -31; a <= 31; a++) { + for (var b = -7; b <= 7; b++) { + final da = Decimal.fromBigInt(BigInt.from(a), scale: 2), + db = Decimal.fromBigInt(BigInt.from(b), scale: 1); + expect( + da + db, + Decimal.fromBigInt(BigInt.from(a + b * 10), scale: 2), + ); + expect(da * db, Decimal.fromBigInt(BigInt.from(a * b), scale: 3)); + expect(da.compareTo(db).sign, a.compareTo(b * 10).sign); + } + } + }, + ); +} diff --git a/test/generated_database_test.dart b/test/generated_database_test.dart new file mode 100644 index 00000000..586e41de --- /dev/null +++ b/test/generated_database_test.dart @@ -0,0 +1,234 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import '../example/schema.orm.dart'; +import '../example/schema.snapshot.dart' as physical; + +void main() { + runGeneratedTests('sqlite', () => sqlite(const SqliteOptions.memory())); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + const schema = 'orm_generated_tests'; + late Database admin; + setUpAll(() async { + admin = postgres( + PostgresOptions(url: Uri.parse(url), tls: PostgresTls.disable), + ); + await admin.execute(SqlCommand('CREATE SCHEMA IF NOT EXISTS "$schema"')); + }); + tearDownAll(() async { + await admin.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + await admin.close(); + }); + runGeneratedTests( + 'postgres', + () async => postgres( + PostgresOptions( + url: Uri.parse(url), + tls: PostgresTls.disable, + schema: schema, + ), + ), + ); + } +} + +void runGeneratedTests(String name, Future> Function() open) { + group('generated $name', () { + late Database db; + final initial = Migration.create( + '0001_initial', + appSchema, + dialect: SqlDialect.values.byName(name), + ); + setUp(() async { + db = await open(); + for (final table in ['posts', 'users', '_orm_migrations']) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + }); + tearDown(() => db.close()); + + test( + 'generated client, DDL, relations, dates and patches work together', + () async { + expect(await Migrator(db.sql).apply([initial]), ['0001_initial']); + final time = DateTime.utc(2026, 9, 15, 1, 2, 3, 456, 789); + final user = await db.transaction((tx) async { + final user = await tx.users.create( + email: 'seven@example.com', + score: .set(10), + ); + await tx.posts.create( + authorId: user.id, + title: 'Hello', + createdAt: time, + ); + return user; + }); + expect(user.score, 10); + await db.users.byId(user.id).patch(nickname: .set('Seven')); + expect((await db.users.byId(user.id).single()).nickname, 'Seven'); + await db.users.byId(user.id).patch(nickname: .set(null)); + final titles = await db.users + .byId(user.id) + .select((u) => u.posts.select((p) => p.title).many()) + .single(); + expect(titles, ['Hello']); + expect((await db.posts.single()).createdAt, time); + expect(await verifyColumns(db.sql, appSchema), isEmpty); + await db.users.byId(user.id).delete().execute(); + expect(await db.posts.count(), 0); + }, + ); + + test('planning is read-only and repeat migration is a no-op', () async { + expect((await Migrator(db.sql).plan([initial])).map((m) => m.id), [ + '0001_initial', + ]); + expect(await inspectColumns(db.sql, '_orm_migrations'), isEmpty); + await Migrator(db.sql).apply([initial]); + expect(await Migrator(db.sql).apply([initial]), isEmpty); + expect( + (await Migrator(db.sql).history()).single.checksum, + initial.checksum, + ); + }); + + test( + 'compiled physical snapshot and full managed catalog comparison', + () async { + final snapshot = physical.schema; + await Migrator(db.sql).apply([initial]); + final verification = await verifySchema(db.sql, snapshot); + expect(verification.differences, isEmpty); + expect(verification.unmanaged, isEmpty); + await db.execute(SqlCommand('DROP INDEX author_timeline')); + expect( + (await verifySchema(db.sql, snapshot)).differences, + contains('posts indexes differs'), + ); + }, + ); + + test('baseline preserves existing rows and verifies keys before recording history', () async { + for (final statement in createSchema(appSchema, db.dialect)) { + await db.execute(statement); + } + final user = await db.users.create(email: 'already-exists'); + final verification = await Migrator(db.sql) + .baseline([initial], expected: SchemaSnapshot(appSchema)); + expect(verification.matches, true); + expect((await db.users.byId(user.id).single()).email, 'already-exists'); + expect(await Migrator(db.sql).plan([initial]), isEmpty); + await expectLater( + Migrator(db.sql) + .baseline([initial], expected: SchemaSnapshot(appSchema)), + throwsA(isA()), + ); + }); + + test( + 'full verification sees defaults and additional unique constraints', + () async { + await Migrator(db.sql).apply([initial]); + await db.execute( + SqlCommand( + 'CREATE UNIQUE INDEX unexpected_unique ON users(nickname)', + ), + ); + final changed = SchemaSnapshot([ + TableSchema( + 'users', + columns: [ + ...usersSchema.columns.where((c) => c.name != 'score'), + Column('score', Codecs.integer, defaultSql: '1'), + ], + primaryKey: usersSchema.primaryKey, + uniqueKeys: usersSchema.uniqueKeys, + ), + postsSchema, + ]); + final differences = (await verifySchema(db.sql, changed)).differences; + expect(differences, contains('users.score default differs')); + expect(differences, contains('users indexes differs')); + }, + ); + + test('history tampering and missing applied migrations fail', () async { + await Migrator(db.sql).apply([initial]); + final changed = Migration.steps(initial.id, [ + ...initial.steps, + ExecuteSql('SELECT 1'), + ], dialect: db.dialect); + await expectLater( + Migrator(db.sql).plan([changed]), + throwsA(isA()), + ); + await expectLater( + Migrator(db.sql).plan([]), + throwsA(isA()), + ); + expect(await db.users.count(), 0); + }); + + test('failed DDL rolls back schema and history atomically', () async { + final broken = Migration('0002_broken', [ + 'ALTER TABLE users ADD COLUMN migrated TEXT', + 'INSERT INTO table_does_not_exist VALUES (1)', + ], dialect: db.dialect); + await expectLater( + Migrator(db.sql).apply([initial, broken]), + throwsA(anything), + ); + expect(await inspectColumns(db.sql, 'users'), isEmpty); + expect(await Migrator(db.sql).history(), isEmpty); + await Migrator(db.sql).apply([initial]); + await expectLater( + Migrator(db.sql).apply([initial, broken]), + throwsA(anything), + ); + expect( + (await inspectColumns(db.sql, 'users')).map((c) => c.name), + isNot(contains('migrated')), + ); + expect((await Migrator(db.sql).history()).length, 1); + }); + + test('actual column drift is detected independently of history', () async { + await Migrator(db.sql).apply([initial]); + await db.execute(SqlCommand('ALTER TABLE users ADD COLUMN extra TEXT')); + expect(await verifyColumns(db.sql, appSchema), [ + 'users.extra is unmanaged', + ]); + expect(await Migrator(db.sql).plan([initial]), isEmpty); + }); + + test('transaction control cannot be hidden behind SQL comments', () async { + final bad = Migration('0001_bad', [ + '/* outer /* nested */ */ -- comment\n COMMIT', + ], dialect: db.dialect); + await expectLater( + Migrator(db.sql).apply([bad]), + throwsA(isA()), + ); + expect(await Migrator(db.sql).history(), isEmpty); + }); + + test( + 'concurrent migration callers serialize against shared history', + () async { + final result = await Future.wait([ + Migrator(db.sql).apply([initial]), + Migrator(db.sql).apply([initial]), + ]); + expect(result.expand((v) => v), ['0001_initial']); + expect((await Migrator(db.sql).history()).length, 1); + }, + ); + }); +} diff --git a/test/generator_dialects_test.dart b/test/generator_dialects_test.dart new file mode 100644 index 00000000..feaed8a3 --- /dev/null +++ b/test/generator_dialects_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory directory; + setUp(() async { + directory = await Directory('.dart_tool').createTemp('generator-dialects-'); + }); + tearDown(() => directory.delete(recursive: true)); + + test('computed and check overrides retain all engines and freeze only the selected expression', () async { + final source = File('${directory.path}/schema.dart'); + await source.writeAsString(''' +import 'package:orm/schema.dart'; +final class Entry( + @Id() final int id, + final int source, + @Computed.sql('source + 1', postgres: 'source + 2', mysql: 'source + 3', mariadb: 'source + 4') final int? computed, +); +final entries = entity(); +final valid = entries.check('source >= 1', postgres: 'source >= 2', mysql: 'source >= 3', mariadb: 'source >= 4'); +'''); + final result = await generateSchema(source.path); + for (final (index, dialect) in SqlDialect.values.indexed) { + final table = result.snapshot.forDialect(dialect).tables.single; + expect( + table.columns.last.computed!.expression(dialect), + 'source + ${index + 1}', + ); + expect(table.checks.single.expression(dialect), 'source >= ${index + 1}'); + } + expect(result.dart, contains('mysql: "source + 3"')); + expect(result.dart, contains('mariadb: "source + 4"')); + expect(result.dart, contains('mysql: "source >= 3"')); + expect(result.dart, contains('mariadb: "source >= 4"')); + }); + + test( + 'all named SQL backends emit QueryContext bindings and complete manifests', + () async { + final source = File('${directory.path}/queries.dart'); + await source.writeAsString(''' +import 'package:orm/schema.dart'; +typedef Result = ({int value}); +final result = sqlQuery( + sqlite: 'query.sql', + postgres: 'query.sql', + mysql: 'query.sql', + mariadb: 'query.sql', +); +final mysqlOnly = sqlQuery(mysql: 'query.sql'); +final mariaOnly = sqlQuery(mariadb: 'query.sql'); +'''); + await File('${directory.path}/query.sql') + .writeAsString('SELECT :minimum AS value'); + final generated = await generateQueries(source.path); + final queries = (generated.manifest['queries'] as List) + .cast>(); + expect((queries.first['sql'] as Map).keys, [ + 'sqlite', + 'postgres', + 'mysql', + 'mariadb', + ]); + expect((queries[1]['sql'] as Map).keys, ['mysql']); + expect((queries[2]['sql'] as Map).keys, ['mariadb']); + expect(generated.dart, contains("import 'package:orm/sql.dart'")); + expect(generated.dart, contains('extension ResultSql on QueryContext')); + expect(generated.dart, isNot(contains('Database<'))); + await writeGeneratedQueries(source.path); + final analyzed = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${directory.path}/queries.queries.dart', + ]); + expect( + analyzed.exitCode, + 0, + reason: '${analyzed.stdout}\n${analyzed.stderr}', + ); + final consumer = File('${directory.path}/compile.dart'); + await consumer.writeAsString(''' +import 'package:orm/sql.dart'; +import 'queries.queries.dart'; +void main() { + for (final dialect in SqlDialect.values) { + final sql = SqlBuilder(dialect).result(minimum: 4).compile(); + if (sql.parameters.length != 1 || sql.parameters.single != 4) { + throw StateError('Named parameter did not bind exactly once.'); + } + } + try { + SqlBuilder(SqlDialect.sqlite).mysqlOnly(minimum: 1); + throw StateError('Unsupported backend was accepted.'); + } on OrmException catch (error) { + if (error.code != 'QUERY.DIALECT') rethrow; + } +} +'''); + final compiled = await Process.run(Platform.resolvedExecutable, [ + 'run', + consumer.path, + ]); + expect( + compiled.exitCode, + 0, + reason: '${compiled.stdout}\n${compiled.stderr}', + ); + }, + ); +} diff --git a/test/generator_test.dart b/test/generator_test.dart new file mode 100644 index 00000000..87798440 --- /dev/null +++ b/test/generator_test.dart @@ -0,0 +1,623 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory fixtures; + setUpAll(() async { + fixtures = await Directory('.dart_tool/orm-generator-tests') + .create(recursive: true); + }); + tearDownAll(() => fixtures.delete(recursive: true)); + + Future generate(String name, String source) async { + final file = File('${fixtures.path}/$name.dart'); + await file.writeAsString("import 'package:orm/schema.dart';\n$source"); + return generateSchema(file.path); + } + + test( + 'target capabilities are checked when choosing a migration engine', + () async { + final sqlite = await generate('sqlite_virtual_index', ''' +typedef Item = ({int id, @Unique() @Computed.sql('id + 1', storage: ComputedStorage.virtual, postgres: '') int value}); +final items = entity(); +final positive = items.check('id > 0', postgres: ''); +'''); + expect(sqlite.snapshot.forDialect(.sqlite).tables, hasLength(1)); + expect( + () => sqlite.snapshot.forDialect(.postgres), + throwsA(isA()), + ); + final postgres = await generate('postgres_computed_pk', ''' +typedef Item = ({int source, @Id() @Computed.sql('source + 1') int id}); +final items = entity(); +final lower = items.check('source > 0', name: 'valid'); +final upper = items.check('source < 10', name: 'VALID'); +'''); + expect(postgres.snapshot.forDialect(.postgres).tables, hasLength(1)); + expect( + () => postgres.snapshot.forDialect(.sqlite), + throwsA(isA()), + ); + }, + ); + + test('unusable extension getters and generated symbol collisions fail at generation', () async { + for (final source in [ + 'typedef Item = ({int id}); final close = entity();', + 'typedef Item = ({int id}); final _items = entity();', + 'typedef Item = ({int id}); final app = entity();', + "typedef Item = ({int id}); final items = entity(); final Items = entity(table: 'others');", + 'typedef Item = ({int iD, int ID}); final items = entity();', + ]) { + await expectLater( + generate('collision', source), + throwsA(isA()), + ); + } + }); + + test('client and snapshot paths cannot overwrite the declaration', () async { + const declaration = + "import 'package:orm/schema.dart';\ntypedef Row = ({int id});\nfinal rows = entity();\n"; + for (final (name, outputName) in [ + ('same.dart', 'same.dart'), + ('overlap.snapshot.dart', 'overlap.dart'), + ]) { + final source = File('${fixtures.path}/$name'); + final output = File('${fixtures.path}/$outputName'); + await source.writeAsString(declaration); + await expectLater( + writeGeneratedSchema(source.path, output: output.path), + throwsA(isA()), + ); + expect(await source.readAsString(), declaration); + if (name != outputName) expect(await output.exists(), false); + } + }); + + test('record annotations, physical names, relations and snapshots', () async { + final result = await generateSchema('example/schema.dart'); + expect(result.dart, contains('Future create(')); + expect(result.dart, contains('Change score')); + expect( + result.dart, + contains('Relation get posts'), + ); + final tables = result.snapshot.toJson()['tables'] as List; + expect(tables.length, 2); + expect((tables[1] as Map)['foreignKeys'], [ + { + 'columns': ['author_id'], + 'target': 'users', + 'targetColumns': ['id'], + 'onDelete': 'CASCADE', + }, + ]); + }); + + test( + 'output is deterministic and matches the committed generated client', + () async { + for (final source in [ + 'example/schema.dart', + 'example/teams/schema.dart', + 'test/support/relations/schema.dart', + 'test/support/codecs/schema.dart', + 'test/support/integers/schema.dart', + 'test/support/decimals/schema.dart', + 'test/support/precision/schema.dart', + 'test/support/temporals/schema.dart', + 'test/support/instants/schema.dart', + 'test/support/unconstrained/schema.dart', + 'test/support/checks/schema.dart', + 'test/support/defaults/schema.dart', + 'test/support/computed/schema.dart', + ]) { + final result = await generateSchema(source); + final temporary = File('${fixtures.path}/deterministic.dart'); + await temporary.writeAsString(result.dart); + final format = await Process.run(Platform.resolvedExecutable, [ + 'format', + temporary.path, + ]); + expect(format.exitCode, 0); + final base = source.substring(0, source.length - 5); + expect( + await temporary.readAsString(), + await File('$base.orm.dart').readAsString(), + ); + expect( + result.snapshotDart, + await File('$base.snapshot.dart').readAsString(), + ); + } + }, + ); + + test( + 'composite keys and more than six fields generate analyzable code', + () async { + await generate('composite', ''' +typedef Account = ({int tenant, int id, String email, int a, int b, int c, int d, int e}); +typedef Event = ({int tenant, int owner, String title}); +final accounts = entity(); +final events = entity(); +final accountKey = accounts.primaryKey((a) => (a.tenant, a.id)); +final ownerAccount = events.key((e) => (e.tenant, e.owner)).references(accounts.key((a) => (a.tenant, a.id)), inverse: 'events'); +'''); + final source = '${fixtures.path}/composite.dart'; + await writeGeneratedSchema(source); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${fixtures.path}/composite.orm.dart', + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + }, + ); + + test('computed keys are rejected instead of executing a fake row', () async { + await expectLater( + generate('computed', ''' +typedef User = ({int id}); +final users = entity(); +final key = users.primaryKey((u) => u.id + 1); +'''), + throwsA(isA()), + ); + }); + + test('computed declarations reject conflicting defaults, empty SQL and invalid row identities', () async { + final cases = [ + "typedef Item = ({int id, @Computed.sql('') int value});", + "typedef Item = ({int id, @Computed.sql('1') @Default.sql('0') int value});", + "int factory() => 1; typedef Item = ({int id, @Computed.sql('1') @ClientDefault(factory) int value});", + "typedef Item = ({@Id.generated() @Computed.sql('1') int id, int value});", + "typedef Item = ({@Computed.sql('1') int value});", + "typedef Item = ({int id, @Computed.sql('1') @Computed.sql('2') int value});", + "typedef Item = ({int id, int readColumn});", + ]; + for (var i = 0; i < cases.length; i++) { + await expectLater( + generate( + 'computed_invalid_$i', + '${cases[i]}\nfinal items = entity();', + ), + throwsA(isA()), + reason: cases[i], + ); + } + }); + + test('client defaults reject incompatible, asynchronous, private and ambiguous factories', () async { + for (final (name, declaration) in [ + ( + 'erased_domain', + ''' +extension type const UserId(int value) {} +const idCodec = Codec('integer', decodeId, encodeId); +UserId decodeId(Object? raw) => UserId(raw as int); +Object? encodeId(UserId id) => id.value; +int factory() => 1; +typedef Item = ({@UseCodec(idCodec) @ClientDefault(factory) UserId id}); +''', + ), + ( + 'return_type', + 'int factory() => 1; typedef Item = ({@ClientDefault(factory) String value});', + ), + ( + 'async', + "Future factory() async => 'x'; typedef Item = ({@ClientDefault(factory) String value});", + ), + ( + 'nullable', + 'String? factory() => null; typedef Item = ({@ClientDefault(factory) String value});', + ), + ( + 'dynamic', + "dynamic factory() => 'x'; typedef Item = ({@ClientDefault(factory) String value});", + ), + ( + 'arguments', + 'String factory(String value) => value; typedef Item = ({@ClientDefault(factory) String value});', + ), + ( + 'private', + "String _factory() => 'x'; typedef Item = ({@ClientDefault(_factory) String value});", + ), + ( + 'private_owner', + "class _Defaults { static String make() => 'x'; } typedef Item = ({@ClientDefault(_Defaults.make) String value});", + ), + ( + 'duplicate', + "String factory() => 'x'; typedef Item = ({@ClientDefault(factory) @ClientDefault(factory) String value});", + ), + ( + 'variable', + "String make() => 'x'; final factory = make; typedef Item = ({@ClientDefault(factory) String value});", + ), + ]) { + await expectLater( + generate( + 'client_default_$name', + '$declaration\nfinal items = entity();', + ), + throwsA(isA()), + ); + } + }); + + test( + 'CHECK declarations reject dynamic SQL, empty SQL and duplicate names', + () async { + for (final (name, declaration) in [ + ( + 'dynamic_check', + "final sql = 'id > 0'; final valid = items.check(sql);", + ), + ('empty_check', "final valid = items.check(' ');"), + ( + 'duplicate_check', + "final one = items.check('id > 0', name: 'valid'); final two = items.check('id < 10', name: 'valid');", + ), + ('empty_check_name', "final valid = items.check('id > 0', name: '');"), + ]) { + await expectLater( + generate(name, ''' +typedef Item = ({@Id() int id}); +final items = entity(); +$declaration +'''), + throwsA(isA()), + ); + } + }, + ); + + test('custom types resolve defining libraries when output moves to another directory', () async { + final output = '${fixtures.path}/nested/domain_client.dart'; + await writeGeneratedSchema( + 'test/support/codecs/schema.dart', + output: output, + ); + final result = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + output, + ]); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + final source = await File(output).readAsString(); + expect(source, contains('Codec') /* static const codec references */); + expect(source, contains('types0.Email')); + expect(source, contains('types1.Email')); + }); + + test('nested generic and record types preserve nullable aliases without running codecs', () async { + await generate('structured', ''' +typedef Value = ({String label, List numbers}); +typedef Optional = Value?; +const valueCodec = Codec('json', decode, encode); +Value decode(Object? raw) => throw StateError('must not run'); +String encode(Value value) => throw StateError('must not run'); +const nestedCodec = Codec>('json', decodeNested, encodeNested); +List<({String name, int count})> decodeNested(Object? raw) => throw StateError('must not run'); +String encodeNested(List<({String name, int count})> value) => throw StateError('must not run'); +typedef Row = ({@Id() int id, @UseCodec(valueCodec) Optional value, @UseCodec(nestedCodec) List<({String name, int count})> items}); +final rows = entity(); +'''); + final source = '${fixtures.path}/structured.dart'; + await writeGeneratedSchema(source); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${fixtures.path}/structured.orm.dart', + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + final result = await generateSchema(source); + expect(result.dart, contains('models.Optional')); + expect(result.dart, contains('models.valueCodec.nullable()')); + final table = (result.snapshot.toJson()['tables'] as List).single as Map; + final columns = table['columns'] as List; + expect((columns[1] as Map)['nullable'], true); + }); + + for (final (name, source, message) in <(String, String, String)>[ + ( + 'wrong_codec', + ''' +const valueCodec = Codec('text', decode, encode); +String decode(Object? value) => value as String; +String encode(String value) => value; +typedef Row = ({@UseCodec(valueCodec) int value}); +final rows = entity(); +''', + 'does not match', + ), + ( + 'nullable_codec', + ''' +const valueCodec = Codec('integer', decode, encode); +int? decode(Object? value) => value as int?; +int? encode(int? value) => value; +typedef Row = ({@UseCodec(valueCodec) int value}); +final rows = entity(); +''', + 'does not match', + ), + ( + 'erased_extension', + ''' +extension type const UserId(int value) {} +const valueCodec = Codec('integer', decode, encode); +int decode(Object? value) => value as int; +int encode(int value) => value; +typedef Row = ({@UseCodec(valueCodec) UserId value}); +final rows = entity(); +''', + 'does not match', + ), + ( + 'unknown_storage', + ''' +const valueCodec = Codec('magic', decode, encode); +int decode(Object? value) => value as int; +int encode(int value) => value; +typedef Row = ({@UseCodec(valueCodec) int value}); +final rows = entity(); +''', + 'Unknown codec storage', + ), + ( + 'private_codec', + ''' +const _valueCodec = Codec('integer', decode, encode); +int decode(Object? value) => value as int; +int encode(int value) => value; +typedef Row = ({@UseCodec(_valueCodec) int value}); +final rows = entity(); +''', + 'public const codec', + ), + ( + 'private_type', + ''' +class _Value { const _Value(); } +const valueCodec = Codec<_Value>('text', decode, encode); +_Value decode(Object? value) => const _Value(); +String encode(_Value value) => ''; +typedef Row = ({@UseCodec(valueCodec) _Value value}); +final rows = entity(); +''', + 'public symbols', + ), + ( + 'inline_codec', + ''' +int decode(Object? value) => value as int; +int encode(int value) => value; +typedef Row = ({@UseCodec(Codec('integer', decode, encode)) int value}); +final rows = entity(); +''', + 'public const codec', + ), + ( + 'duplicate_codec', + ''' +const valueCodec = Codec('integer', decode, encode); +int decode(Object? value) => value as int; +int encode(int value) => value; +typedef Row = ({@UseCodec(valueCodec) @UseCodec(valueCodec) int value}); +final rows = entity(); +''', + 'only appear once', + ), + ( + 'missing_codec', + ''' +class Value {} +typedef Row = ({Value value}); +final rows = entity(); +''', + 'explicit @UseCodec', + ), + ( + 'duplicate_labels', + ''' +enum Value { @EnumValue('same') a, @EnumValue('same') b } +typedef Row = ({Value value}); +final rows = entity(); +''', + 'Duplicate enum storage label', + ), + ( + 'duplicate_enum_annotation', + ''' +enum Value { @EnumValue('one') @EnumValue('two') a } +typedef Row = ({Value value}); +final rows = entity(); +''', + 'EnumValue appears twice', + ), + ]) { + test('invalid custom declaration: $name', () async { + await expectLater( + generate(name, source), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains(message), + ), + ), + ); + }); + } + + test('foreign keys require unique targets', () async { + await expectLater( + generate('not_unique', ''' +typedef User = ({int id}); +typedef Post = ({int authorId}); +final users = entity(); +final posts = entity(); +final author = posts.key((p) => p.authorId).references(users.key((u) => u.id)); +'''), + throwsA(isA()), + ); + }); + + test('query-only edges do not alter physical snapshots or require unique targets', () async { + const source = ''' +typedef User = ({@Id() int id, @ColumnName('lookup_name') String label}); +typedef Event = ({@Id() int id, String label}); +final users = entity(); +final events = entity(); +'''; + final before = await generate('without_navigation', source); + final after = await generate('with_navigation', ''' +$source +final matchingUsers = events.key((e) => e.label).relatesTo(users.key((u) => u.label), inverse: 'events'); +'''); + expect(after.snapshot.toJson(), before.snapshot.toJson()); + expect(after.dart, contains('get matchingUsers')); + expect(after.dart, contains('get events')); + expect( + after.dart, + contains('Read-only navigation; no database foreign key'), + ); + expect(after.dart, isNot(contains('ForeignKey('))); + }); + + test( + 'constrained and unconstrained edges retain separate migration meaning', + () async { + final result = await generate('mixed_navigation', ''' +typedef User = ({@Id() int id}); +typedef Event = ({@Id() int id, int owner, int lookup}); +final users = entity(); +final events = entity(); +final ownerAccount = events.key((e) => e.owner).references(users.key((u) => u.id)); +final lookupAccount = events.key((e) => e.lookup).relatesTo(users.key((u) => u.id)); +'''); + final table = (result.snapshot.toJson()['tables'] as List).last as Map; + expect(table['foreignKeys'], [ + { + 'columns': ['owner'], + 'target': 'users', + 'targetColumns': ['id'], + 'onDelete': 'RESTRICT', + }, + ]); + expect(result.dart, contains('get ownerAccount')); + expect(result.dart, contains('get lookupAccount')); + }, + ); + + for (final (name, source, expected) in [ + ( + 'lookup_computed', + ''' +typedef Row = ({@Id() int id}); +final rows = entity(); +final peers = rows.key((r) => r.id + 1).relatesTo(rows.key((r) => r.id)); +''', + 'direct fields', + ), + ( + 'lookup_codec', + ''' +int decode(Object? raw) => int.parse(raw as String); +String encode(int value) => value.toString(); +const textInt = Codec('text', decode, encode); +typedef Row = ({@Id() int id, @UseCodec(textInt) int label}); +final rows = entity(); +final peers = rows.key((r) => r.id).relatesTo(rows.key((r) => r.label)); +''', + 'Relationship storage types differ', + ), + ( + 'lookup_inverse', + ''' +typedef Row = ({@Id() int id}); +final rows = entity(); +final peers = rows.key((r) => r.id).relatesTo(rows.key((r) => r.id), inverse: 'id'); +''', + 'relationship name', + ), + ( + 'lookup_type', + ''' +typedef Row = ({@Id() int id, String label}); +final rows = entity(); +final peers = rows.key((r) => r.id).relatesTo(rows.key((r) => r.label)); +''', + "isn't returnable", + ), + ( + 'lookup_delete', + ''' +typedef Row = ({@Id() int id}); +final rows = entity(); +final peers = rows.key((r) => r.id).relatesTo(rows.key((r) => r.id), onDelete: ReferentialAction.cascade); +''', + 'onDelete', + ), + ]) { + test('invalid query-only relation: $name', () async { + await expectLater( + generate(name, source), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains(expected), + ), + ), + ); + }); + } + + test('Dart type errors stop generation', () async { + await expectLater( + generate('type_error', ''' +typedef User = ({@Id() int id}); +typedef Post = ({String authorId}); +final users = entity(); +final posts = entity(); +final author = posts.key((p) => p.authorId).references(users.key((u) => u.id)); +'''), + throwsA(isA()), + ); + }); + + test( + 'duplicate physical names and nullable primary keys are rejected', + () async { + await expectLater( + generate('duplicate', ''' +typedef User = ({@ColumnName('x') int id, @ColumnName('x') String name}); +final users = entity(); +'''), + throwsA(isA()), + ); + await expectLater( + generate('nullable_id', ''' +typedef User = ({@Id() int? id}); +final users = entity(); +'''), + throwsA(isA()), + ); + }, + ); +} diff --git a/test/import_cli_test.dart b/test/import_cli_test.dart new file mode 100644 index 00000000..cc739a1c --- /dev/null +++ b/test/import_cli_test.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/migration_project.dart'; + +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + test( + 'CLI $backend imports reviews generates and baselines existing data without replacing files', + () async { + final schema = + 'orm_import_${pid}_${DateTime.now().microsecondsSinceEpoch}'; + final project = await MigrationProject.create( + postgresSchema: backend == 'postgres' ? schema : null, + ); + final path = project.databasePath; + final source = '${project.path}/lib/imported.dart'; + final options = backend == 'sqlite' + ? ['--sqlite', path] + : [ + '--postgres-env', + 'ORM_TEST_POSTGRES', + '--tls', + 'disable', + '--database-schema', + schema, + ]; + late Database db; + Future cli(List args, {int code = 0}) async { + final result = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/orm.dart', + ...args, + '--json', + ]); + expect( + result.exitCode, + code, + reason: + '${args.take(2).join(' ')}: ${result.stdout}\n${result.stderr}', + ); + return result; + } + + try { + if (backend == 'sqlite') { + await cli([ + 'db', + 'import', + ...options, + '--output', + source, + ], code: 1); + expect(await File(path).exists(), false); + expect(await File(source).exists(), false); + db = await sqlite(SqliteOptions.file(path)); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: schema, + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS $schema CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA $schema')); + } + try { + await db.execute( + SqlCommand( + 'CREATE TABLE existing (id ${backend == 'sqlite' ? 'INTEGER' : 'BIGINT'} NOT NULL PRIMARY KEY, value TEXT NOT NULL)', + ), + ); + await db.execute( + SqlCommand( + "INSERT INTO existing(id, value) VALUES (7, 'retained')", + ), + ); + final imported = await cli([ + 'db', + 'import', + ...options, + '--output', + source, + '--table', + 'existing', + ]); + final report = + jsonDecode(imported.stdout as String) as Map; + expect(report['issues'], isEmpty); + expect( + await File('${project.path}/lib/imported.import.json').exists(), + true, + ); + final original = await File(source).readAsString(); + await cli([ + 'db', + 'import', + ...options, + '--output', + source, + ], code: 64); + expect(await File(source).readAsString(), original); + await cli(['generate', source]); + await project.fixture.write( + 'lib/target.dart', + await File('${project.path}/lib/imported.snapshot.dart') + .readAsString(), + ); + await project.run(['create', '0001_imported']); + final baseline = await project.run(['baseline']); + expect(baseline['matches'], true); + await project.run(['verify']); + expect( + (await db.execute(SqlCommand('SELECT id, value FROM existing'))) + .rows, + [ + [7, 'retained'], + ], + ); + + final blockedSource = '${project.path}/lib/blocked.dart'; + final blocked = await cli([ + 'db', + 'import', + ...options, + '--output', + blockedSource, + '--table', + 'missing', + ], code: 2); + expect( + (jsonDecode(blocked.stdout as String) as Map)['issues'], + isNotEmpty, + ); + expect(await File(blockedSource).exists(), true); + await cli([ + 'db', + 'import', + ...options, + '--output', + '${project.path}/lib/invalid.dart', + '--table', + 'existing', + '--table', + 'existing', + ], code: 64); + expect( + await File('${project.path}/lib/invalid.dart').exists(), + false, + ); + } finally { + if (backend == 'postgres') { + await db.execute(SqlCommand('DROP SCHEMA $schema CASCADE')); + } + await db.close(); + } + } finally { + await project.dispose(); + } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); + } +} diff --git a/test/import_test.dart b/test/import_test.dart new file mode 100644 index 00000000..ab87acdc --- /dev/null +++ b/test/import_test.dart @@ -0,0 +1,610 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('catalog import $backend', () { + late Database db; + late Directory directory; + setUp(() async { + directory = await Directory('.dart_tool/orm-import-tests/$backend') + .create(recursive: true); + if (backend == 'sqlite') { + db = await sqlite( + SqliteOptions.file('${directory.path}/data.sqlite'), + ); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_import_tests', + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_import_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_import_tests')); + } + }); + tearDown(() async { + await db.close(); + await directory.delete(recursive: true); + }); + String getInt() => backend == 'sqlite' ? 'INTEGER' : 'BIGINT'; + Future generate(ImportedSchema draft) async { + final source = '${directory.path}/schema.dart'; + await File(source).writeAsString(draft.dart); + final result = await generateSchema(source); + await File('${directory.path}/schema.orm.dart') + .writeAsString(result.dart); + return result; + } + + test('imports records, identities, defaults and constraints then baselines unchanged rows', () async { + final integer = getInt(); + await db.execute( + SqlCommand( + 'CREATE TABLE accounts (id $integer ${backend == 'postgres' ? 'GENERATED BY DEFAULT AS IDENTITY' : ''} PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, label TEXT, score $integer NOT NULL DEFAULT 0)', + ), + ); + await db.execute( + SqlCommand( + 'CREATE TABLE notes (id $integer PRIMARY KEY NOT NULL, account_id $integer NOT NULL DEFAULT 1, body TEXT NOT NULL, FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET DEFAULT)', + ), + ); + await db.execute(SqlCommand('CREATE INDEX notes_body ON notes(body)')); + await db.execute( + SqlCommand("INSERT INTO accounts(email) VALUES ('existing')"), + ); + await db.execute( + SqlCommand( + "INSERT INTO notes(id, account_id, body) VALUES (7, 1, 'kept')", + ), + ); + final draft = await importSchema(db.sql); + expect(draft.issues, isEmpty); + expect(draft.entities, {'accounts': 'accounts', 'notes': 'notes'}); + expect(draft.dart, contains('@Id.generated()')); + final client = await generate(draft); + final snapshot = client.snapshot; + expect((await verifySchema(db.sql, snapshot)).differences, isEmpty); + final history = [ + Migration.create( + '0001_baseline', + snapshot.tables, + dialect: db.dialect, + ), + ]; + await Migrator(db.sql).baseline(history, expected: snapshot); + expect( + (await db.execute(SqlCommand('SELECT email FROM accounts'))).rows, + [ + ['existing'], + ], + ); + expect((await db.execute(SqlCommand('SELECT body FROM notes'))).rows, [ + ['kept'], + ]); + expect((await importSchema(db.sql)).entities, draft.entities); + final connection = backend == 'sqlite' + ? 'await sqlite(SqliteOptions.file(${jsonEncode('${directory.absolute.path}/data.sqlite')}))' + : "postgres(PostgresOptions(url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), tls: .disable, schema: 'orm_import_tests'))"; + await File('${directory.path}/use.dart').writeAsString( + ''' +import 'dart:io'; +import 'package:orm/$backend.dart'; +import 'schema.orm.dart'; +Future main() async { + final db = $connection; + try { + final account = await db.accounts.create(email: 'created'); + final note = await db.notes.create(id: const Change.set(8), accountId: Change.set(account.id), body: 'new'); + final joined = await db.notes.byId(note.id).select((n) => (n.body, n.notesAccounts.select((a) => a.email).required()).map((body, email) => (body: body, email: email))).single(); + if (joined != (body: 'new', email: 'created')) throw StateError('Typed relation lost values'); + await db.accounts.byId(account.id).delete().execute(); + final remaining = await db.notes.byId(note.id).single(); + if (remaining.accountId != 1) throw StateError('SET DEFAULT was not preserved'); + print('Imported typed client preserves rows, defaults and relations.'); + } finally { await db.close(); } +} +''' + .replaceAll( + "import 'dart:io';", + backend == 'sqlite' ? '' : "import 'dart:io';", + ) + .replaceAll( + 'id: const Change.set(8)', + backend == 'sqlite' ? 'id: const Change.set(8)' : 'id: 8', + ), + ); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${directory.path}/use.dart', + '${directory.path}/schema.orm.dart', + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + if (backend == 'sqlite') { + final compiled = await Process.run(Platform.resolvedExecutable, [ + 'build', + 'cli', + '--target', + '${directory.path}/use.dart', + '--output', + '${directory.path}/aot', + ]); + expect( + compiled.exitCode, + 0, + reason: '${compiled.stdout}\n${compiled.stderr}', + ); + } + final run = backend == 'sqlite' + ? await Process.run( + '${directory.path}/aot/bundle/bin/use', + const [], + ) + : await Process.run(Platform.resolvedExecutable, [ + 'run', + '${directory.path}/use.dart', + ]); + expect(run.exitCode, 0, reason: '${run.stdout}\n${run.stderr}'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('normalizes reserved, colliding and hostile identifiers without changing physical names', () async { + final integer = getInt(); + String quote(String s) => '"${s.replaceAll('"', '""')}"'; + final names = [ + r'user-$name', + 'user name', + 'class', + '123', + '中文', + 'execute', + 'users_schema', + ]; + final columns = [ + 'id', + 'table', + 'column', + 'class', + 'hashCode', + 'runtimeType', + 'a b', + 'a-b', + '中文', + r'value$1', + 'line\nbreak', + 'quote"name', + 'row', + ]; + for (final name in names) { + await db.execute( + SqlCommand( + 'CREATE TABLE ${quote(name)} (${[for (final c in columns) '${quote(c)} $integer NOT NULL'].join(', ')}, PRIMARY KEY (id))', + ), + ); + } + await db.execute( + SqlCommand('CREATE TABLE foo (bar_baz $integer NOT NULL)'), + ); + await db.execute( + SqlCommand('CREATE TABLE foo_bar (baz $integer NOT NULL)'), + ); + final draft = await importSchema(db.sql); + expect(draft.issues, isEmpty); + expect(draft.entities.values.toSet().length, names.length + 2); + for (final name in names) { + expect(draft.fields[name]!.values.toSet().length, columns.length); + } + final client = await generate(draft); + expect( + (await verifySchema(db.sql, client.snapshot)).differences, + isEmpty, + ); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${directory.path}/schema.dart', + '${directory.path}/schema.orm.dart', + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + expect((await importSchema(db.sql)).dart, draft.dart); + }); + + test('composite and self relations preserve key order and unique indexes', () async { + final integer = getInt(); + await db.execute( + SqlCommand( + 'CREATE TABLE owners (tenant $integer NOT NULL, id $integer NOT NULL, parent_tenant $integer, parent_id $integer, PRIMARY KEY(tenant, id), FOREIGN KEY(parent_tenant, parent_id) REFERENCES owners(tenant, id) ON DELETE SET NULL)', + ), + ); + await db.execute( + SqlCommand( + 'CREATE TABLE labels (tenant $integer NOT NULL, name TEXT NOT NULL)', + ), + ); + await db.execute( + SqlCommand('CREATE UNIQUE INDEX label_key ON labels(tenant, name)'), + ); + await db.execute( + SqlCommand( + 'CREATE TABLE links (tenant $integer NOT NULL, label TEXT NOT NULL, FOREIGN KEY(tenant, label) REFERENCES labels(tenant, name))', + ), + ); + final draft = await importSchema(db.sql); + expect(draft.issues, isEmpty); + final result = await generate(draft); + expect( + (await verifySchema(db.sql, result.snapshot)).differences, + isEmpty, + ); + final subset = await importSchema(db.sql, tables: ['links']); + expect(subset.hasBlockingIssues, true); + expect(subset.issues.single.code, 'IMPORT.RELATION'); + await generate(subset); + }); + + test('unsupported storage excludes its table while computed SQL imports with a review note', () async { + final integer = getInt(); + await db.execute( + SqlCommand( + 'CREATE TABLE supported (id $integer NOT NULL PRIMARY KEY)', + ), + ); + await db.execute( + SqlCommand('CREATE TABLE unsupported (amount VARCHAR(12) NOT NULL)'), + ); + await db.execute( + SqlCommand( + 'CREATE TABLE computed (id $integer NOT NULL PRIMARY KEY, doubled $integer GENERATED ALWAYS AS (id * 2) STORED)', + ), + ); + final draft = await importSchema(db.sql); + expect(draft.entities.keys, ['computed', 'supported']); + expect( + draft.issues.map((i) => i.code), + containsAll(['IMPORT.TYPE', 'IMPORT.COMPUTED_SQL']), + ); + expect(draft.hasBlockingIssues, true); + await generate(draft); + expect( + (await importSchema(db.sql, tables: ['missing'])).issues.single.code, + 'IMPORT.MISSING', + ); + }); + + test('read-only snapshot and selected scope reject temporary shadowing and active transactions', () async { + final integer = getInt(); + await db.execute( + SqlCommand('CREATE TABLE stored (id $integer NOT NULL PRIMARY KEY)'), + ); + await db.session((session) async { + await session.execute( + SqlCommand('CREATE TEMP TABLE stored (wrong TEXT)'), + ); + try { + final draft = await importSchema(session.sql, tables: ['stored']); + expect(draft.issues.single.code, 'IMPORT.SCOPE'); + expect(draft.entities, isEmpty); + } finally { + await session.execute( + SqlCommand( + 'DROP TABLE ${backend == 'sqlite' ? 'temp' : 'pg_temp'}.stored', + ), + ); + } + await session.transaction((tx) async { + expect(() => importSchema(tx.sql), throwsA(isA())); + }); + }); + expect(() => importSchema(db.sql, tables: []), throwsArgumentError); + expect( + () => importSchema(db.sql, tables: ['stored', 'stored']), + throwsArgumentError, + ); + if (backend == 'sqlite') { + final readOnly = await sqlite( + SqliteOptions.readOnly('${directory.path}/data.sqlite'), + ); + try { + expect((await importSchema(readOnly.sql)).entities.keys, [ + 'stored', + ]); + } finally { + await readOnly.close(); + } + } + }); + + test( + 'physical storage mapping does not infer domain types from data', + () async { + if (backend == 'sqlite') { + await db.execute( + SqlCommand( + 'CREATE TABLE values_table (id INTEGER NOT NULL PRIMARY KEY, document TEXT, timestamp TEXT, flag INTEGER NOT NULL, data BLOB, score REAL)', + ), + ); + final draft = await importSchema(db.sql); + expect(draft.dart, contains('String? document')); + expect(draft.dart, contains('String? timestamp')); + expect(draft.dart, contains('int flag')); + await generate(draft); + } else { + await db.execute( + SqlCommand( + 'CREATE TABLE values_table (id BIGINT NOT NULL PRIMARY KEY, document JSONB, timestamp TIMESTAMPTZ, flag BOOLEAN NOT NULL, data BYTEA, score DOUBLE PRECISION)', + ), + ); + final draft = await importSchema(db.sql); + expect(draft.dart, contains('@UseCodec(Codecs.jsonDocument)')); + expect(draft.dart, contains('SqlJson? document')); + final result = await generate(draft); + expect( + (await verifySchema(db.sql, result.snapshot)).differences, + isEmpty, + ); + } + }, + ); + + test('views triggers and specialized indexes remain in the review report', () async { + final integer = getInt(); + await db.execute( + SqlCommand( + 'CREATE TABLE records (id $integer NOT NULL PRIMARY KEY, label TEXT)', + ), + ); + await db.execute( + SqlCommand( + 'CREATE INDEX label_partial ON records(label) WHERE label IS NOT NULL', + ), + ); + await db.execute( + SqlCommand('CREATE VIEW labels AS SELECT label FROM records'), + ); + if (backend == 'sqlite') { + await db.execute( + SqlCommand(''' +CREATE TRIGGER label_normalize AFTER INSERT ON records BEGIN + UPDATE records SET label = upper(NEW.label) WHERE id = NEW.id; +END'''), + ); + } else { + await db.execute( + SqlCommand(r''' +CREATE FUNCTION normalize_label() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN NEW.label := upper(NEW.label); RETURN NEW; END $$'''), + ); + await db.execute( + SqlCommand(''' +CREATE TRIGGER label_normalize BEFORE INSERT ON records +FOR EACH ROW EXECUTE FUNCTION normalize_label()'''), + ); + } + if (backend == 'postgres') { + await db.execute( + SqlCommand( + 'CREATE INDEX label_pattern ON records(label text_pattern_ops)', + ), + ); + await db.execute( + SqlCommand( + 'CREATE INDEX label_collation ON records(label COLLATE "C")', + ), + ); + await db.execute( + SqlCommand( + 'CREATE UNIQUE INDEX label_nulls ON records(label) NULLS NOT DISTINCT', + ), + ); + await db.execute( + SqlCommand( + 'ALTER TABLE records ADD CONSTRAINT label_unique UNIQUE NULLS NOT DISTINCT(label)', + ), + ); + } + final draft = await importSchema(db.sql); + expect(draft.entities.keys, ['records']); + expect( + draft.issues.any((i) => i.object.contains('label_partial')), + true, + ); + expect(draft.issues.any((i) => i.object.contains('labels')), true); + expect( + draft.issues.any((i) => i.object.contains('label_normalize')), + true, + ); + expect(draft.hasBlockingIssues, false); + if (backend == 'postgres') { + for (final name in [ + 'label_pattern', + 'label_collation', + 'label_nulls', + 'label_unique', + ]) { + expect( + draft.issues.any((i) => i.object.contains(name)), + true, + reason: name, + ); + } + } + final result = await generate(draft); + expect( + (await verifySchema(db.sql, result.snapshot)).differences, + isEmpty, + ); + // Read-only import and verification must leave the trigger operational. + await db.execute( + SqlCommand( + backend == 'sqlite' + ? 'INSERT INTO records(id, label) VALUES (?1, ?2)' + : r'INSERT INTO records(id, label) VALUES ($1, $2)', + [1, 'kept'], + ), + ); + expect( + (await db.execute( + SqlCommand('SELECT label FROM records WHERE id = 1'), + )).rows.single.single, + 'KEPT', + ); + }); + + if (backend == 'sqlite') { + test('nullable primary keys, virtual tables and their storage tables need explicit handling', () async { + await db.execute( + SqlCommand('CREATE TABLE nullable_key (id TEXT PRIMARY KEY)'), + ); + await db.execute( + SqlCommand('CREATE VIRTUAL TABLE search USING fts5(body)'), + ); + final draft = await importSchema(db.sql); + expect(draft.entities, isEmpty); + expect( + draft.issues.any((i) => i.code == 'IMPORT.NULLABLE_KEY'), + true, + ); + expect( + draft.issues.any((i) => i.object == 'search' && i.blocking), + true, + ); + expect(draft.issues.any((i) => i.object == 'search_data'), true); + }); + test( + 'foreign keys with coercible but different Dart types are reported', + () async { + await db.execute( + SqlCommand('CREATE TABLE parent (id TEXT NOT NULL PRIMARY KEY)'), + ); + await db.execute( + SqlCommand( + 'CREATE TABLE child (parent_id INTEGER REFERENCES parent(id))', + ), + ); + final draft = await importSchema(db.sql); + expect(draft.issues.single.code, 'IMPORT.RELATION'); + await generate(draft); + }, + ); + } else { + test( + 'policy reports retain roles, commands, mode and row-security state', + () async { + await db.execute( + SqlCommand( + 'CREATE TABLE protected (id BIGINT NOT NULL PRIMARY KEY)', + ), + ); + await db.execute( + SqlCommand(''' +CREATE POLICY positive ON protected AS RESTRICTIVE FOR ALL TO PUBLIC +USING (id > 0) WITH CHECK (id < 100)'''), + ); + Future> security() async { + final table = await inspectTable(db.sql, 'protected'); + final policy = jsonDecode( + table.unmanaged + .singleWhere((o) => o.kind == 'policy') + .definition, + ) as Map; + expect(policy, { + 'permissive': 'RESTRICTIVE', + 'roles': ['public'], + 'command': 'ALL', + 'using': '(id > 0)', + 'withCheck': '(id < 100)', + }); + return jsonDecode( + table.unmanaged + .singleWhere((o) => o.kind == 'row_security') + .definition, + ) as Map; + } + + expect(await security(), {'enabled': false, 'forced': false}); + await db.execute( + SqlCommand('ALTER TABLE protected ENABLE ROW LEVEL SECURITY'), + ); + await db.execute( + SqlCommand('ALTER TABLE protected FORCE ROW LEVEL SECURITY'), + ); + expect(await security(), {'enabled': true, 'forced': true}); + final draft = await importSchema(db.sql); + expect(draft.hasBlockingIssues, false); + expect( + draft.issues.where((i) => i.object.contains('protected')), + hasLength(2), + ); + final generated = await generate(draft); + final expected = generated.snapshot; + final baseline = await Migrator(db.sql).baseline([ + Migration.create( + '0001_protected', + expected.tables, + dialect: db.dialect, + ), + ], expected: expected); + expect(baseline.matches, true); + expect( + baseline.unmanaged.map((o) => o.kind), + containsAll(['policy', 'row_security']), + ); + expect(await security(), {'enabled': true, 'forced': true}); + await db.execute( + SqlCommand('ALTER TABLE protected DISABLE ROW LEVEL SECURITY'), + ); + expect(await security(), {'enabled': false, 'forced': true}); + await db.execute( + SqlCommand('ALTER TABLE protected NO FORCE ROW LEVEL SECURITY'), + ); + await db.execute(SqlCommand('DROP POLICY positive ON protected')); + expect( + (await inspectTable(db.sql, 'protected')).unmanaged, + isEmpty, + ); + }, + ); + + test('always identity and inherited tables are not flattened into ordinary writable models', () async { + await db.execute( + SqlCommand( + 'CREATE TABLE always_id (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY)', + ), + ); + await db.execute( + SqlCommand('CREATE TABLE ancestor (id BIGINT PRIMARY KEY)'), + ); + await db.execute( + SqlCommand('CREATE TABLE descendant () INHERITS (ancestor)'), + ); + final draft = await importSchema(db.sql); + expect(draft.entities, isEmpty); + expect(draft.issues.any((i) => i.code == 'IMPORT.GENERATED'), true); + expect( + draft.issues.where((i) => i.code == 'IMPORT.SCOPE'), + hasLength(2), + ); + }); + } + }); + } +} diff --git a/test/instant_test.dart b/test/instant_test.dart new file mode 100644 index 00000000..62f1af04 --- /dev/null +++ b/test/instant_test.dart @@ -0,0 +1,500 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/instants/schema.orm.dart'; +import 'support/instants/m0001_legacy.dart' as historical; +import 'support/instants/m0001_legacy_postgres.dart' as historical_pg; + +DateTime instant(String value) => Codecs.dateTime.decode(value); + +void main() { + test('strict UTC parsing handles eras, offsets, endpoints and invalid components', () { + expect(instant('2024-01-01 08:00+08'), DateTime.utc(2024)); + expect(instant('2024-01-01 00:00'), DateTime.utc(2024)); + expect( + instant('2024-01-01 00:00:01.000001+00:00:01'), + DateTime.utc(2024, 1, 1, 0, 0, 0, 0, 1), + ); + expect(instant('0001-01-01 00:00Z BC'), DateTime.utc(0)); + expect( + instant('275760-09-13 15:59:59+15:59:59').microsecondsSinceEpoch, + 8640000000000000000, + ); + expect( + instant('4714-11-23 23:00-01 BC').microsecondsSinceEpoch, + -210866803200000000, + ); + for (final ticks in [-210866803200000000, -1, 0, 1, 8640000000000000000]) { + final value = DateTime.fromMicrosecondsSinceEpoch(ticks, isUtc: true); + expect(instant(Codecs.dateTime.encode(value) as String), value); + } + for (final text in [ + '2024-02-30 00:00Z', + '2024-01-01 24:00:00.000001Z', + '2024-01-01 00:00:60Z', + '2024-01-01 00:00+16', + '2024-01-01 00:00+01:99', + '2024-01-01 00:00:00.0000001Z', + '275760-09-13 00:00:00.000001Z', + '4714-11-24 00:00+00:00:01 BC', + 'infinity', + '2024-01-01 00:00Z\n', + ]) { + expect(() => instant(text), throwsFormatException, reason: text); + } + expect( + () => Codecs.dateTime.encode(DateTime.utc(-4713, 11, 23)), + throwsFormatException, + ); + expect( + () => Codecs.dateTime.decode(LocalDateTime.parse('2024-01-01 00:00')), + throwsFormatException, + ); + }); + + test('UTC defaults and local DateTime inputs do not depend on the process timezone', () async { + final result = await Process.run( + Platform.resolvedExecutable, + ['run', 'test/support/instants/native.dart'], + environment: {'TZ': 'Asia/Shanghai'}, + ); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + expect( + result.stdout, + contains( + 'Instant UTC, precision, ordering, relations and defaults verified.', + ), + ); + }); + + Future legacy([SqlDialect dialect = SqlDialect.sqlite]) async => + dialect == SqlDialect.sqlite + ? historical.migration + : historical_pg.migration; + test( + 'historical migration checksum and timestamp tag remain immutable', + () async { + final first = await legacy(); + // Captured as a SQLite-only historical timestamp definition. + expect( + first.checksum, + 'f7c0a7ff8af0bf106e3deabaa36c4816337ae6937350dc14a44edcaab34456ab', + ); + expect( + first.snapshot!.tables.single.columns.first.codec.sqlType, + 'timestamp', + ); + expect(first.checksum, historical.migrationChecksum); + expect(Codecs.dateTime.sqlType, 'instant'); + }, + ); + + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('UTC instant $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_instant_tests', + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_instant_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_instant_tests')); + } + }); + tearDown(() => db.close()); + Future create() => Migrator(db.sql).apply([ + Migration.create('0001_instant', appSchema, dialect: db.dialect), + ]); + Migration upgrade(Migration first) => Migration.diff( + '0002_instant', + from: first.snapshot!, + to: SchemaSnapshot([momentsSchema]), + previous: first.checksum, + using: (db.dialect == SqlDialect.sqlite + ? { + 'moments': {'at': 'orm_instant_v1(at)'}, + } + : { + 'moments': {'at': 'at'}, + }), + dialect: db.dialect, + ); + + test('native round trips and streams preserve exact microseconds and common endpoints', () async { + await create(); + final dates = [ + DateTime.utc(-4713, 11, 24), + DateTime.utc(0), + DateTime.utc(10000), + DateTime.fromMicrosecondsSinceEpoch(-1, isUtc: true), + DateTime.fromMicrosecondsSinceEpoch(0, isUtc: true), + DateTime.fromMicrosecondsSinceEpoch(1, isUtc: true), + DateTime.fromMicrosecondsSinceEpoch(8640000000000000000, isUtc: true), + ]; + for (final date in dates) { + final row = await db.events.create(at: date); + expect(row.at, date); + expect(row.created.isUtc, true); + expect( + await db.events + .byId(row.id) + .select((e) => value(date, Codecs.dateTime)) + .single(), + date, + ); + } + expect( + (await db.events + .orderBy((e) => [e.id.asc()]) + .stream(batchSize: 1) + .toList()) + .map((e) => e.at), + dates, + ); + if (backend == 'postgres') { + await db.session((s) async { + await s.execute(SqlCommand("SET DateStyle TO 'SQL, DMY'")); + await s.execute(SqlCommand("SET TIME ZONE 'Europe/Paris'")); + expect( + (await s.events.orderBy((e) => [e.id.asc()]).get()).map( + (e) => e.at, + ), + dates, + ); + }); + } + }); + + test('chronological comparisons, grouping, windows, union and CTEs use instants', () async { + await create(); + final epoch = DateTime.utc(2024), + next = DateTime.utc(2024, 1, 1, 0, 0, 0, 0, 1); + await db.events.create(at: epoch); + await db.events.create(at: next); + await db.execute( + SqlCommand("INSERT INTO events (at) VALUES ('2024-01-01 08:00+08')"), + ); + expect( + await db.events + .orderBy((e) => [e.at.asc(), e.id.asc()]) + .select((e) => e.at) + .get(), + [epoch, epoch, next], + ); + expect(await db.events.where((e) => e.at.gt(epoch)).count(), 1); + expect(await db.events.where((e) => e.at.isIn([epoch])).count(), 2); + expect( + await db.events.select((e) => (e.at.min(), e.at.max()).row).single(), + (epoch, next), + ); + expect( + await db.events + .groupBy((e) => [e.at]) + .orderBy((e) => [e.at.asc()]) + .select((e) => (e.at, e.id.count()).row) + .get(), + [(epoch, 2), (next, 1)], + ); + expect( + await db.events + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.at.max().over( + orderBy: [e.id.asc()], + frame: .rowsToCurrent, + ), + ) + .get(), + [epoch, next, next], + ); + final source = db.events.select((e) => e.at), + cte = source.asCte('times'); + expect( + await cte.query.where((c) => c.ref((e) => e.at).gt(epoch)).get(), + [next], + ); + expect( + await source.union(source).get(), + unorderedEquals([epoch, next]), + ); + final token = db.events.cursorToken( + (e) => [e.at.cursor(epoch), e.id.cursor(3)], + ); + expect( + (await db.events + .seekToken(token, orderBy: (e) => [e.at.asc(), e.id.asc()]) + .get()) + .map((e) => e.at), + [next], + ); + }); + + test( + 'offset-equivalent keys join and batch through the same unique index', + () async { + await create(); + final date = DateTime.utc(2024); + await db.moments.create(at: date); + await db.execute( + SqlCommand( + "INSERT INTO links (at) VALUES ('2024-01-01 08:00:00+0800')", + ), + ); + expect( + await db.moments + .select((m) => m.links.select((l) => l.at).many()) + .single(), + [date], + ); + expect( + await db.links + .select((l) => l.moment.select((m) => m.at).one()) + .single(), + date, + ); + await expectLater( + db.execute( + SqlCommand( + "INSERT INTO moments (at) VALUES ('2023-12-31 23:00-01')", + ), + ), + throwsA(isA()), + ); + if (backend == 'sqlite') { + final command = db.moments.byId(date).compile(); + final plan = await db.execute( + SqlCommand( + 'EXPLAIN QUERY PLAN ${command.sql}', + command.parameters, + ), + ); + expect( + plan.rows.map((r) => r.last).join(' '), + contains('USING INDEX'), + ); + } + }, + ); + + test('current snapshots, physical catalog and imported DateTime declarations agree', () async { + await create(); + final snapshot = SchemaSnapshot(appSchema); + expect((await verifySchema(db.sql, snapshot)).differences, isEmpty); + expect((await verifySchema(db.sql, snapshot)).unmanaged, isEmpty); + expect(await verifyColumns(db.sql, appSchema), isEmpty); + final imported = await importSchema(db.sql); + expect(imported.issues, isEmpty); + expect(imported.dart, contains('DateTime')); + final directory = await Directory( + '.dart_tool/orm-instant-import-$backend', + ).create(recursive: true); + try { + final file = File('${directory.path}/schema.dart'); + await file.writeAsString(imported.dart); + final generated = await generateSchema(file.path); + expect( + (await verifySchema(db.sql, generated.snapshot)).differences, + isEmpty, + ); + } finally { + await directory.delete(recursive: true); + } + }); + + test('offset literals compare by value while expression casts remain visible', () async { + final table = TableSchema( + 'defaults', + columns: [ + Column('at', Codecs.dateTime, defaultSql: "'2024-01-01 08:00+08'"), + ], + ); + final migration = Migration.create('0001_defaults', [ + table, + ], dialect: db.dialect); + await Migrator(db.sql).apply([migration]); + expect( + (await verifySchema(db.sql, migration.snapshot!)).differences, + isEmpty, + ); + final row = await db.execute( + SqlCommand('INSERT INTO defaults DEFAULT VALUES RETURNING at'), + ); + expect( + Codecs.dateTime.decode(row.rows.single.single), + DateTime.utc(2024), + ); + if (backend == 'postgres') { + await db.execute( + SqlCommand( + 'ALTER TABLE defaults ALTER COLUMN at SET DEFAULT (CURRENT_TIMESTAMP::date)::timestamp with time zone', + ), + ); + final expected = SchemaSnapshot([ + TableSchema( + 'defaults', + columns: [ + Column( + 'at', + Codecs.dateTime, + defaultSql: 'CURRENT_TIMESTAMP::date', + ), + ], + ), + ]); + expect( + (await verifySchema(db.sql, expected)).differences, + isNotEmpty, + ); + } + }); + + test('reviewed migration preserves history and repairs legacy timestamp ordering', () async { + final first = await legacy(db.dialect); + await Migrator(db.sql).apply([first]); + await db.execute( + SqlCommand( + "INSERT INTO moments (at) VALUES ('2024-01-01T00:00:00.000Z'), ('2024-01-01T00:00:00.000001Z')", + ), + ); + final second = upgrade(first); + expect(second.snapshot!.checksum, isNot(first.snapshot!.checksum)); + await Migrator(db.sql).apply([first, second]); + expect( + (await Migrator(db.sql).history()).first.checksum, + first.checksum, + ); + expect( + (await verifySchema(db.sql, second.snapshot!)).differences, + isEmpty, + ); + expect( + await db.moments + .orderBy((m) => [m.at.asc()]) + .select((m) => m.at) + .get(), + [DateTime.utc(2024), DateTime.utc(2024, 1, 1, 0, 0, 0, 0, 1)], + ); + }); + + if (backend == 'sqlite') { + test('legacy key collisions and invalid calendar values roll back conversion', () async { + final first = await legacy(db.dialect); + await Migrator(db.sql).apply([first]); + await db.execute( + SqlCommand( + "INSERT INTO moments (at) VALUES ('2024-01-01T00:00:00Z'), ('2024-01-01 08:00+08')", + ), + ); + final second = upgrade(first); + await expectLater( + Migrator(db.sql).apply([first, second]), + throwsA(isA()), + ); + expect((await Migrator(db.sql).history()).length, 1); + expect( + (await verifySchema(db.sql, first.snapshot!)).differences, + isEmpty, + ); + await db.execute( + SqlCommand("DELETE FROM moments WHERE at = '2024-01-01 08:00+08'"), + ); + await db.execute( + SqlCommand("INSERT INTO moments (at) VALUES ('2024-02-31 00:00')"), + ); + await expectLater( + Migrator(db.sql).apply([first, second]), + throwsA(isA()), + ); + expect((await Migrator(db.sql).history()).length, 1); + await db.execute( + SqlCommand("DELETE FROM moments WHERE at = '2024-02-31 00:00'"), + ); + await Migrator(db.sql).apply([first, second]); + expect((await db.moments.single()).at, DateTime.utc(2024)); + }); + } + + test('instant primary-key backfills resume across sub-millisecond boundaries', () async { + await create(); + final times = [ + DateTime.utc(2024), + DateTime.utc(2024, 1, 1, 0, 0, 0, 0, 1), + DateTime.utc(0), + ]; + for (final time in times) { + await db.moments.create(at: time); + } + final first = Migration.create( + '0001_instant', + appSchema, + dialect: db.dialect, + ); + final second = Migration.steps( + '0002_backfill', + [ + Backfill( + momentsSchema, + set: {'label': "'done'"}, + where: "label = 'pending'", + doneWhen: "SELECT NOT EXISTS (SELECT 1 FROM moments WHERE label = 'pending')", + batchSize: 1, + ), + ], + previous: first.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, second], maxBackfillBatches: 1); + expect( + (await db.moments.where((m) => m.label.eq('done')).single()).at, + DateTime.utc(0), + ); + await Migrator(db.sql).apply([first, second]); + expect(await db.moments.where((m) => m.label.eq('done')).count(), 3); + }); + + test('external infinity and out-of-DateTime values fail decoding without wrapping', () async { + await create(); + for (final text in [ + 'infinity', + '-infinity', + '294276-12-31 23:59:59.999999+00', + ]) { + await db.execute( + SqlCommand("INSERT INTO events (at) VALUES ('$text')"), + ); + } + final raw = await db.execute( + SqlCommand('SELECT at FROM events ORDER BY id'), + ); + expect(raw.rows.every((r) => r.single is String), true); + for (final row in raw.rows) { + expect( + () => Codecs.dateTime.decode(row.single), + throwsFormatException, + ); + } + await expectLater(db.events.get(), throwsFormatException); + expect( + (await db.events.create(at: DateTime.utc(2024))).at, + DateTime.utc(2024), + ); + }); + }); + } +} diff --git a/test/integer_test.dart b/test/integer_test.dart new file mode 100644 index 00000000..e3803fdd --- /dev/null +++ b/test/integer_test.dart @@ -0,0 +1,528 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/integers/schema.orm.dart'; +import 'support/integers/schema.snapshot.dart' as physical; + +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('integer widths $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_integer_tests', + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_integer_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_integer_tests')); + } + }); + tearDown(() => db.close()); + Future create() => Migrator(db.sql).apply([ + Migration.create('0001_samples', appSchema, dialect: db.dialect), + ]); + + test('storage bounds, nulls and wider sums keep the normal integer value codec', () async { + await create(); + final a = await db.samples.create( + small: 32767, + medium: 2147483647, + large: 9007199254740993, + ); + final b = await db.samples.create( + small: 32767, + medium: 2147483647, + large: -9007199254740993, + optional: -32768, + ); + expect(a.optional, null); + expect(b.optional, -32768); + final sums = await db.samples + .select((s) => (s.small.sum(), s.medium.sum(), s.large.sum()).row) + .single(); + expect(sums, (65534, 4294967294, 0)); + expect((await db.samples.byId(a.id).single()).large, 9007199254740993); + for (final values in [ + (32768, 0), + (-32769, 0), + (0, 2147483648), + (0, -2147483649), + ]) { + await expectLater( + db.samples.create(small: values.$1, medium: values.$2, large: 0), + throwsA(isA()), + ); + } + expect(await db.samples.count(), 2); + await expectLater( + db.execute(SqlCommand('UPDATE samples SET small = 32768')), + throwsA(isA()), + ); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).differences, + isEmpty, + ); + final info = await inspectTable(db.sql, 'samples'); + expect(info.columns.map((c) => c.integerBits).toList(), [ + 32, + 16, + 32, + 64, + 16, + ]); + expect(info.unmanaged, isEmpty); + }); + + test('relations, union and cursors share int expressions across physical widths', () async { + await create(); + final a = await db.samples.create(small: 10, medium: 20, large: 30); + await db.owners.create(id: 1, sampleId: a.id); + expect( + await db.owners + .select((o) => o.sample.select((s) => s.small).required()) + .single(), + 10, + ); + final set = db.samples + .select((s) => s.small) + .unionAll(db.samples.select((s) => s.medium)); + expect(await set.get(), unorderedEquals([10, 20])); + final values = await db.samples.orderBy((s) => [s.id.asc()]).get(); + expect(values.single.id, a.id); + final stream = await set.stream().toList(); + expect(stream, unorderedEquals([10, 20])); + final b = await db.samples.create(small: 11, medium: 21, large: 31); + final token = db.samples.cursorToken((s) => [s.id.cursor(a.id)]); + expect( + (await db.samples + .seekToken(token, orderBy: (s) => [s.id.asc()]) + .single()) + .id, + b.id, + ); + }); + + test( + 'defaulted and quoted columns retain enforced nullable bounds', + () async { + final table = TableSchema( + 'quoted', + columns: [ + Column('id', Codecs.integer, generated: true, integerBits: 16), + Column( + 'a"b CHECK(1)', + Codecs.integer, + integerBits: 16, + defaultSql: '7', + ), + Column( + 'optional', + Codecs.integer.nullable(), + nullable: true, + integerBits: 32, + ), + ], + primaryKey: ['id'], + ); + await Migrator(db.sql).apply([ + Migration.create('0001_quoted', [table], dialect: db.dialect), + ]); + await db.execute(SqlCommand('INSERT INTO quoted DEFAULT VALUES')); + expect( + (await db.execute( + SqlCommand('SELECT "a""b CHECK(1)", optional FROM quoted'), + )).rows, + [ + [7, null], + ], + ); + expect( + (await verifySchema(db.sql, SchemaSnapshot([table]))).differences, + isEmpty, + ); + expect((await inspectTable(db.sql, 'quoted')).unmanaged, isEmpty); + await expectLater( + db.execute(SqlCommand('UPDATE quoted SET "a""b CHECK(1)" = 32768')), + throwsA(isA()), + ); + }, + ); + + test('Dart snapshots and imports retain widths without confusing value codecs', () async { + await create(); + final snapshot = SchemaSnapshot(appSchema); + final restored = physical.schema; + expect(restored.checksum, snapshot.checksum); + final imported = await importSchema(db.sql); + expect(imported.issues, isEmpty); + expect(imported.dart, contains('@IntegerBits(16)')); + expect(imported.dart, contains('@IntegerBits(32)')); + final directory = await Directory( + '.dart_tool/orm-integer-import-$backend', + ).create(recursive: true); + try { + final file = File('${directory.path}/schema.dart'); + await file.writeAsString(imported.dart); + final result = await generateSchema(file.path); + expect( + (await verifySchema(db.sql, result.snapshot)).differences, + isEmpty, + ); + } finally { + await directory.delete(recursive: true); + } + }); + + TableSchema sized(int? bits, {String column = 'value'}) => TableSchema( + 'sized', + columns: [ + Column('id', Codecs.integer), + Column(column, Codecs.integer, integerBits: bits), + ], + primaryKey: ['id'], + ); + test('widening and narrowing use reviewed conversions with atomic failure recovery', () async { + final first = Migration.create('0001_sized', [ + sized(16), + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + await db.execute( + SqlCommand('INSERT INTO sized(id, value) VALUES (1, 32767)'), + ); + final wider = Migration.diff( + '0002_wider', + from: first.snapshot!, + to: SchemaSnapshot([sized(32)]), + previous: first.checksum, + using: { + 'sized': {'value': 'value'}, + }, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, wider]); + await db.execute( + SqlCommand('INSERT INTO sized(id, value) VALUES (2, 32768)'), + ); + final narrow = Migration.diff( + '0003_narrow', + from: wider.snapshot!, + to: SchemaSnapshot([sized(16)]), + previous: wider.checksum, + using: { + 'sized': {'value': 'value'}, + }, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([first, wider, narrow]), + throwsA(isA()), + ); + expect((await Migrator(db.sql).history()).length, 2); + expect( + (await verifySchema(db.sql, wider.snapshot!)).differences, + isEmpty, + ); + expect( + (await db.execute(SqlCommand('SELECT count(*) FROM sized'))) + .rows + .single + .single, + 2, + ); + await db.execute(SqlCommand('DELETE FROM sized WHERE id = 2')); + await Migrator(db.sql).apply([first, wider, narrow]); + expect( + (await verifySchema(db.sql, narrow.snapshot!)).differences, + isEmpty, + ); + if (backend == 'sqlite') { + expect( + (await db.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single, + 1, + ); + } + }); + + test( + 'column renames preserve width metadata and range constraints', + () async { + final first = Migration.create('0001_sized', [ + sized(16), + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + final renamed = Migration.diff( + '0002_renamed', + from: first.snapshot!, + to: SchemaSnapshot([sized(16, column: 'new_value')]), + previous: first.checksum, + renames: const SchemaRenames( + columns: { + 'sized': {'value': 'new_value'}, + }, + ), + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, renamed]); + expect( + (await verifySchema(db.sql, renamed.snapshot!)).differences, + isEmpty, + ); + await expectLater( + db.execute( + SqlCommand('INSERT INTO sized(id, new_value) VALUES (1, 32768)'), + ), + throwsA(isA()), + ); + }, + ); + + test( + 'historical bounded backfills verify widths and resume normally', + () async { + final table = TableSchema( + 'sized', + columns: [ + Column('id', Codecs.integer, integerBits: 16), + Column('value', Codecs.integer, integerBits: 32), + ], + primaryKey: ['id'], + ); + final first = Migration.create('0001_sized', [ + table, + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + for (var i = 1; i <= 4; i++) { + await db.execute( + SqlCommand('INSERT INTO sized(id, value) VALUES ($i, 0)'), + ); + } + final second = Migration.steps( + '0002_backfill', + [ + Backfill( + table, + set: {'value': 'value + 1'}, + doneWhen: + 'SELECT NOT EXISTS(SELECT 1 FROM sized WHERE value <> 1)', + batchSize: 2, + ), + ], + previous: first.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, second], maxBackfillBatches: 1); + expect((await Migrator(db.sql).progress()).single.backfill!.rows, 2); + await Migrator(db.sql).apply([first, second]); + expect((await Migrator(db.sql).progress()).single.backfill!.rows, 4); + }, + ); + + test('changing an identity width preserves generation and rejects out-of-range explicit IDs', () async { + TableSchema identity(int bits) => TableSchema( + 'identities', + columns: [ + Column('id', Codecs.integer, generated: true, integerBits: bits), + ], + primaryKey: ['id'], + ); + final first = Migration.create('0001_identity', [ + identity(32), + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + await db.execute(SqlCommand('INSERT INTO identities DEFAULT VALUES')); + final narrow = Migration.diff( + '0002_identity', + from: first.snapshot!, + to: SchemaSnapshot([identity(16)]), + previous: first.checksum, + using: { + 'identities': {'id': 'id'}, + }, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, narrow]); + await db.execute(SqlCommand('INSERT INTO identities DEFAULT VALUES')); + expect( + (await db.execute( + SqlCommand('SELECT id FROM identities ORDER BY id'), + )).rows, + [ + [1], + [2], + ], + ); + await expectLater( + db.execute(SqlCommand('INSERT INTO identities(id) VALUES (32768)')), + throwsA(isA()), + ); + expect( + (await verifySchema(db.sql, narrow.snapshot!)).differences, + isEmpty, + ); + if (backend == 'postgres') { + final sequence = await db.execute( + SqlCommand( + "SELECT seqtypid::regtype::text FROM pg_sequence WHERE seqrelid = pg_get_serial_sequence('identities', 'id')::regclass", + ), + ); + expect(sequence.rows.single.single, 'smallint'); + } + }); + + if (backend == 'sqlite') { + test('quoted defaults and comments cannot masquerade as managed range checks', () async { + final check = + '"value" IS NULL OR (typeof("value") = \'integer\' AND "value" BETWEEN -32768 AND 32767)'; + await db.execute( + SqlCommand( + "CREATE TABLE sized(id INTEGER PRIMARY KEY NOT NULL, value INTEGER NOT NULL, note TEXT DEFAULT ('CHECK (${check.replaceAll("'", "''")})')) /* CHECK ($check) */", + ), + ); + final info = await inspectTable(db.sql, 'sized'); + expect( + info.columns.singleWhere((c) => c.name == 'value').integerBits, + 64, + ); + expect( + (await verifySchema( + db.sql, + SchemaSnapshot([sized(16)]), + )).differences, + contains('sized.value integer width differs'), + ); + await db.execute( + SqlCommand('INSERT INTO sized(id, value) VALUES (1, 32768)'), + ); + }); + test('extra weaker expressions remain unmanaged and cannot claim a narrower width', () async { + await db.execute( + SqlCommand( + 'CREATE TABLE sized (id INTEGER PRIMARY KEY NOT NULL, value INTEGER CHECK ("value" IS NULL OR (typeof("value") = \'integer\' AND "value" BETWEEN -32768 AND 32767) OR 1))', + ), + ); + final info = await inspectTable(db.sql, 'sized'); + expect(info.columns.last.integerBits, 64); + expect(info.checks.single.expression, endsWith('OR 1')); + final verification = await verifySchema( + db.sql, + SchemaSnapshot([sized(16)]), + ); + expect( + verification.differences, + contains('sized.value integer width differs'), + ); + expect(verification.unmanaged.map((o) => o.kind), contains('check')); + }); + } + }); + } + + test('analyzer validates width annotations and integer-backed domain codecs', () async { + final directory = await Directory('.dart_tool/orm-integer-source') + .create(recursive: true); + try { + final source = File('${directory.path}/schema.dart'); + for (final declaration in [ + '@IntegerBits(8) int id', + '@IntegerBits(32) String id', + '@IntegerBits(16) @IntegerBits(32) int id', + ]) { + await source.writeAsString( + "import 'package:orm/schema.dart';\ntypedef Row = ({$declaration});\nfinal rows = entity();", + ); + await expectLater( + generateSchema(source.path), + throwsA(isA()), + ); + } + await source.writeAsString('''import 'package:orm/schema.dart'; +extension type Identifier(int value) {} +Identifier decode(Object? value) => Identifier(value as int); +int encode(Identifier value) => value.value; +const idCodec = Codec.integer(decode, encode); +typedef Row = ({@IntegerBits(16) @UseCodec(idCodec) Identifier id}); +final rows = entity(); +'''); + final generated = await generateSchema(source.path); + final column = generated.snapshot.tables.single.columns.single; + expect(column.integerBits, 16); + expect(column.codec.sqlType, 'integer'); + expect(generated.dart, contains('integerBits: 16')); + } finally { + await directory.delete(recursive: true); + } + }); + + test('width declarations reject wrong kinds and ranges without changing default snapshots', () { + for (final bits in [0, 8, 128]) { + expect( + () => SchemaSnapshot([ + TableSchema( + 't', + columns: [Column('id', Codecs.integer, integerBits: bits)], + ), + ]), + throwsA(isA()), + ); + } + expect( + () => SchemaSnapshot([ + TableSchema( + 't', + columns: [Column('name', Codecs.text, integerBits: 16)], + ), + ]), + throwsA(isA()), + ); + final implicit = SchemaSnapshot([ + TableSchema('t', columns: [Column('id', Codecs.integer)]), + ]); + final explicit = SchemaSnapshot([ + TableSchema( + 't', + columns: [Column('id', Codecs.integer, integerBits: 64)], + ), + ]); + expect(implicit.checksum, explicit.checksum); + final change = Migration.diff( + '0001_same', + from: implicit, + to: explicit, + dialect: SqlDialect.sqlite, + ); + expect(change.steps.isEmpty, true); + expect( + () => Migration.diff( + '0001_wider', + from: implicit, + to: SchemaSnapshot([ + TableSchema( + 't', + columns: [Column('id', Codecs.integer, integerBits: 16)], + ), + ]), + dialect: SqlDialect.sqlite, + ), + throwsA(isA()), + ); + }); +} diff --git a/test/layer_boundaries_test.dart b/test/layer_boundaries_test.dart new file mode 100644 index 00000000..75d73488 --- /dev/null +++ b/test/layer_boundaries_test.dart @@ -0,0 +1,279 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:orm/driver.dart' as raw; +import 'package:orm/migrate.dart' as migrations; +import 'package:orm/schema_model.dart' as schema; +import 'package:orm/sql.dart' as sql; +import 'package:orm/values.dart' as values; +import 'package:test/test.dart'; + +void main() { + test('values and metadata can be authored without a query or session', () { + final decimal = values.Decimal.parse('9007199254740993.01'); + expect( + values.Codecs.decimal.decode(values.Codecs.decimal.encode(decimal)), + decimal, + ); + final nullable = values.Codecs.text.nullable(); + expect(nullable.sameStorageAs(values.Codecs.text.nullable()), isTrue); + expect(nullable.sameStorageAs(values.Codecs.text), isFalse); + final table = schema.TableSchema( + 'independent', + columns: [schema.Column('amount', values.Codecs.decimal)], + ); + final ddl = migrations.createSchema([table], raw.SqlDialect.sqlite); + expect(ddl.first.sql, contains('CREATE TABLE "independent"')); + }); + + test( + 'a raw driver executes commands without constructing an ORM runtime', + () async { + final driver = _RawDriver(); + final result = await driver.run( + (connection) => + connection.execute(raw.SqlCommand('SELECT value', [42])), + ); + expect(result.rows, [ + [42], + ]); + expect(result.columns, ['value']); + await driver.close(); + }, + ); + + test('typed queries compile without a driver or a runtime session', () { + final builder = sql.SqlBuilder(raw.SqlDialect.postgres); + final query = builder.table(_table).where((row) => row.id.gt(12)).take(5); + final command = query.compile(); + expect(command.sql, contains(r'$1')); + expect(command.parameters, contains(12)); + expect(query.inspect().reads, ['independent']); + expect( + () => query.get(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'QUERY.UNBOUND', + ), + ), + ); + }); + + test( + 'physical library dependencies follow the public layer boundaries', + () async { + final root = Directory.current.absolute.path; + final contexts = AnalysisContextCollection(includedPaths: [root]); + try { + Future resolve(String entry) async { + final path = '$root/lib/$entry'; + final result = await contexts + .contextFor(path) + .currentSession + .getResolvedLibrary(path); + return (result as ResolvedLibraryResult).element; + } + + String? localName(Uri uri) { + if (uri.scheme == 'package' && uri.path.startsWith('orm/')) { + return uri.path.substring(4); + } + if (uri.scheme == 'file' && + uri.toFilePath().startsWith('$root/lib/')) { + return uri.toFilePath().substring('$root/lib/'.length); + } + return null; + } + + Set dependencies(LibraryElement entry) { + final visited = {}; + void visit(LibraryElement library) { + if (!visited.add(library.uri) || library.uri.scheme == 'dart') { + return; + } + for (final dependency in [ + ...library.exportedLibraries, + for (final fragment in library.fragments) + ...fragment.importedLibraries, + ]) { + visit(dependency); + } + } + + visit(entry); + return visited; + } + + const forbidden = >{ + 'values.dart': { + 'driver.dart', + 'schema_model.dart', + 'schema.dart', + 'sql.dart', + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'driver.dart': { + 'schema_model.dart', + 'schema.dart', + 'sql.dart', + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'schema_model.dart': { + 'schema.dart', + 'sql.dart', + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'schema.dart': { + 'sql.dart', + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'sql.dart': { + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'runtime.dart': { + 'schema_model.dart', + 'schema.dart', + 'sql.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'migrate.dart': { + 'schema.dart', + 'sql.dart', + 'orm.dart', + 'generate.dart', + }, + 'drivers/sqlite.dart': { + 'schema_model.dart', + 'schema.dart', + 'sql.dart', + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'drivers/postgres.dart': { + 'schema_model.dart', + 'schema.dart', + 'sql.dart', + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'drivers/mysql.dart': { + 'schema_model.dart', + 'schema.dart', + 'sql.dart', + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + 'drivers/mariadb.dart': { + 'schema_model.dart', + 'schema.dart', + 'sql.dart', + 'runtime.dart', + 'orm.dart', + 'migrate.dart', + 'generate.dart', + }, + }; + for (final entry in forbidden.entries) { + final closure = dependencies(await resolve(entry.key)); + final local = closure.map(localName).whereType().toSet(); + expect( + local.intersection(entry.value), + isEmpty, + reason: '${entry.key} imports a higher layer', + ); + for (final dependency in closure) { + expect( + dependency.toString(), + isNot(startsWith('package:analyzer/')), + reason: '${entry.key} imports the generator', + ); + expect( + dependency.toString(), + isNot(startsWith('package:build/')), + reason: '${entry.key} imports build tooling', + ); + } + if (!entry.key.startsWith('drivers/')) { + expect( + closure.map((uri) => uri.toString()), + isNot(contains('dart:io')), + reason: '${entry.key} should be platform independent', + ); + } + } + } finally { + await contexts.dispose(); + } + }, + ); +} + +final _id = schema.Column('id', values.Codecs.integer); +final _schema = schema.TableSchema( + 'independent', + columns: [_id], + primaryKey: ['id'], +); +final _table = sql.Table(_schema, _Fields.new, (row) => row.id); + +final class _Fields extends sql.Fields { + _Fields(super.table); + late final id = column(_id); +} + +final class _RawDriver implements raw.Driver { + @override + raw.Capabilities get capabilities => const raw.Capabilities( + dialect: raw.SqlDialect.sqlite, + maxParameters: 999, + ); + @override + Future run(Future Function(raw.SqlConnection) action) => + action(_RawConnection()); + @override + Future close() async {} +} + +final class _RawConnection implements raw.SqlConnection { + @override + bool get transactionActive => false; + @override + Future execute( + raw.SqlCommand command, { + raw.ExecutionOptions options = const raw.ExecutionOptions(), + }) async => raw.SqlResult([command.parameters], columns: ['value']); + @override + Future openCursor( + raw.SqlCommand command, { + raw.ExecutionOptions options = const raw.ExecutionOptions(), + }) => throw UnsupportedError('No cursor needed for this protocol test.'); + @override + Future invalidate() async {} +} diff --git a/test/many_to_many_test.dart b/test/many_to_many_test.dart new file mode 100644 index 00000000..21e9fe18 --- /dev/null +++ b/test/many_to_many_test.dart @@ -0,0 +1,471 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import '../example/teams/schema.dart'; +import '../example/teams/schema.orm.dart'; + +final day1 = DateTime.utc(2026, 1, 1), day2 = DateTime.utc(2026, 1, 2); + +void main() { + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + group( + 'many-to-many ${dialect.name}', + () { + late Database db; + final events = []; + setUp(() async { + if (dialect == SqlDialect.sqlite) { + db = await sqlite( + const SqliteOptions.memory(), + onQuery: events.add, + ); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_many_to_many_tests', + ), + onQuery: events.add, + ); + await db.execute( + SqlCommand( + 'DROP SCHEMA IF EXISTS orm_many_to_many_tests CASCADE', + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA orm_many_to_many_tests'), + ); + } + await Migrator(db.sql).apply([ + Migration.create('0001_teams', appSchema, dialect: db.dialect), + ]); + await db.transaction((tx) async { + await tx.users.insertMany( + [(1, 'Ada'), (2, 'Ben'), (3, 'Cy'), (4, 'Dee')], + (u, value) => [u.id.set(value.$1), u.name.set(value.$2)], + ).execute(); + await tx.teams.insertMany( + [(10, 'Core'), (20, 'Docs'), (30, 'Tools'), (40, 'Empty')], + (t, value) => [t.id.set(value.$1), t.name.set(value.$2)], + ).execute(); + await tx.memberships.insertMany( + [ + (10, 1, MembershipRole.owner, day1), + (10, 2, MembershipRole.member, day2), + (10, 3, MembershipRole.member, day2), + (20, 1, MembershipRole.member, day2), + (20, 2, MembershipRole.owner, day1), + (30, 1, MembershipRole.member, day2), + ], + (m, value) => [ + m.teamId.set(value.$1), + m.userId.set(value.$2), + m.role.set(value.$3), + m.joinedAt.set(value.$4), + ], + ).execute(); + }); + events.clear(); + }); + tearDown(() => db.close()); + + test('two declared FKs and a composite primary key enforce association identity', () async { + final info = await inspectTable(db.sql, 'memberships'); + expect(info.primaryKey, ['team_id', 'user_id']); + expect(info.foreignKeys.map((k) => k.target).toSet(), { + 'teams', + 'users', + }); + expect(info.foreignKeys.every((k) => k.onDelete == 'CASCADE'), true); + expect(info.indexes.map((i) => i.name), ['user_memberships']); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).differences, + isEmpty, + ); + await expectLater( + db.memberships.create(teamId: 10, userId: 1, joinedAt: day2), + throwsA(isA()), + ); + await expectLater( + db.memberships.create(teamId: 999, userId: 4, joinedAt: day2), + throwsA(isA()), + ); + await expectLater( + db.memberships.create(teamId: 40, userId: 999, joinedAt: day2), + throwsA(isA()), + ); + expect(await db.memberships.count(), 6); + }); + + test('forward projection keeps payload, shared targets and per-user limits in two statements', () async { + final query = db.users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => + ( + u.name, + u.memberships + .orderBy((m) => [m.joinedAt.desc(), m.teamId.desc()]) + .take(2) + .select( + (m) => + ( + m.team.select((t) => t.name).required(), + m.role, + m.joinedAt, + ).map( + (team, role, joinedAt) => ( + team: team, + role: role, + joinedAt: joinedAt, + ), + ), + ) + .many(), + ).map( + (name, memberships) => + (name: name, memberships: memberships), + ), + ); + query.compile(); + expect(events, isEmpty); + final List< + ({ + String name, + List<({String team, MembershipRole role, DateTime joinedAt})> + memberships, + }) + > + rows = await query.get(); + expect(rows.map((r) => r.name), ['Ada', 'Ben', 'Cy', 'Dee']); + expect( + rows.map( + (r) => r.memberships.map((m) => (m.team, m.role)).toList(), + ), + [ + [ + ('Tools', MembershipRole.member), + ('Docs', MembershipRole.member), + ], + [('Core', MembershipRole.member), ('Docs', MembershipRole.owner)], + [('Core', MembershipRole.member)], + <(String, MembershipRole)>[], + ], + ); + expect(rows[0].memberships.every((m) => m.joinedAt == day2), true); + expect(events, hasLength(2)); + expect(events.map((e) => e.rowCount), [4, 5]); + expect(events.last.sql, contains('PARTITION BY')); + expect(events.last.sql, contains('LEFT JOIN')); + }); + + test('reverse projection applies tie-breaking and offset to each team without changing root pagination', () async { + final rows = await db.teams + .orderBy((t) => [t.id.asc()]) + .take(3) + .select( + (t) => ( + t.name, + t.memberships + .orderBy((m) => [m.joinedAt.desc(), m.userId.desc()]) + .skip(1) + .take(1) + .select((m) => m.user.select((u) => u.name).required()) + .many(), + ).map((team, users) => (team, users)), + ) + .get(); + expect(rows.map((r) => r.$1), ['Core', 'Docs', 'Tools']); + expect(rows.map((r) => r.$2), [ + ['Ben'], + ['Ben'], + [], + ]); + expect(events, hasLength(2)); + expect(events.map((e) => e.rowCount), [3, 2]); + events.clear(); + expect( + await db.users + .where((u) => u.id.eq(999)) + .select((u) => u.memberships.many()) + .get(), + isEmpty, + ); + expect(events, hasLength(1)); + }); + + test( + 'nested team rosters deduplicate shared team keys and batch by level', + () async { + final rows = await db.users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => ( + u.name, + u.memberships + .orderBy((m) => [m.teamId.asc()]) + .select( + (m) => m.team + .select( + (t) => + ( + t.name, + t.memberships + .orderBy( + (member) => [member.userId.asc()], + ) + .select( + (member) => member.user + .select((person) => person.name) + .required(), + ) + .many(), + ).map( + (name, roster) => + (name: name, roster: roster), + ), + ) + .required(), + ) + .many(), + ).map((name, teams) => (name: name, teams: teams)), + ) + .get(); + expect(rows.map((r) => r.teams.map((t) => t.name).toList()), [ + ['Core', 'Docs', 'Tools'], + ['Core', 'Docs'], + ['Core'], + [], + ]); + expect(rows.map((r) => r.teams.map((t) => t.roster).toList()), [ + [ + ['Ada', 'Ben', 'Cy'], + ['Ada', 'Ben'], + ['Ada'], + ], + [ + ['Ada', 'Ben', 'Cy'], + ['Ada', 'Ben'], + ], + [ + ['Ada', 'Ben', 'Cy'], + ], + >[], + ]); + expect(rows[3].teams, isEmpty); + expect(events, hasLength(3)); + expect(events.map((e) => e.rowCount), [4, 6, 6]); + }, + ); + + test( + 'association predicates and counts stay in one SQL statement', + () async { + final rows = await db.users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => ( + u.name, + u.memberships.count(), + u.memberships.every( + (m) => m.role.eq(MembershipRole.member), + ), + u.memberships + .where( + (m) => m.team.where((t) => t.name.eq('Core')).any(), + ) + .any(), + ).row, + ) + .get(); + expect(rows, [ + ('Ada', 3, false, true), + ('Ben', 2, false, true), + ('Cy', 1, true, true), + ('Dee', 0, true, false), + ]); + expect(events, hasLength(1)); + expect(events.single.rowCount, 4); + }, + ); + + test('limited parameter capacity chunks associations without changing per-parent limits', () async { + final limited = Database( + _LimitedDriver(db.driver), + onQuery: events.add, + ); + final rows = await limited.users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => u.memberships + .where((m) => m.role.eq(MembershipRole.member)) + .orderBy((m) => [m.joinedAt.desc(), m.teamId.desc()]) + .take(2) + .select((m) => m.team.select((t) => t.name).required()) + .many(), + ) + .get(); + expect(rows, [ + ['Tools', 'Docs'], + ['Core'], + ['Core'], + [], + ]); + expect(events, hasLength(3)); + expect(events.map((e) => e.rowCount), [4, 3, 1]); + expect(events.skip(1).every((e) => e.parameterCount <= 5), true); + }); + + test('transaction rollback restores payload changes and new endpoints after an invalid association', () async { + await expectLater( + db.transaction((tx) async { + await tx.memberships + .byId(teamId: 10, userId: 1) + .patch(role: .set(MembershipRole.member)); + await tx.users.create(id: 5, name: 'Eve'); + await tx.teams.create(id: 50, name: 'New'); + await tx.memberships.create( + teamId: 50, + userId: 5, + joinedAt: day2, + ); + await tx.memberships.create( + teamId: 999, + userId: 5, + joinedAt: day2, + ); + }), + throwsA(isA()), + ); + expect( + (await db.memberships.byId(teamId: 10, userId: 1).single()).role, + MembershipRole.owner, + ); + expect(await db.users.byId(5).exists(), false); + expect(await db.teams.byId(50).exists(), false); + expect(await db.memberships.count(), 6); + }); + + test('composite-key conflict updates preserve membership dates and unlinking keeps endpoints', () async { + await db.transaction((tx) async { + final rows = await tx.memberships + .insert( + (m) => [ + m.teamId.set(10), + m.userId.set(2), + m.joinedAt.set(day1), + m.role.set(MembershipRole.owner), + ], + ) + .onConflictUpdate( + target: (m) => [m.teamId, m.userId], + set: (old, incoming) => [ + old.role.setExpression(incoming.role), + ], + ) + .returning((m) => (m.role, m.joinedAt).row) + .get(); + expect(rows, [(MembershipRole.owner, day2)]); + await tx.memberships.byId(teamId: 10, userId: 1).delete().execute(); + }); + expect(await db.users.count(), 4); + expect(await db.teams.count(), 4); + expect(await db.memberships.count(), 5); + await db.teams.byId(20).delete().execute(); + await db.users.byId(1).delete().execute(); + expect(await db.memberships.count(), 2); + expect(await db.users.count(), 3); + expect(await db.teams.count(), 3); + expect( + (await db.memberships.orderBy((m) => [m.userId.asc()]).get()).map( + (m) => (m.teamId, m.userId), + ), + [(10, 2), (10, 3)], + ); + }); + + test('watch tracks junction payload and opposite endpoint changes after commit', () async { + final iterator = StreamIterator( + db.users + .byId(1) + .select( + (u) => u.memberships + .orderBy((m) => [m.teamId.asc()]) + .select( + (m) => ( + m.team.select((t) => t.name).required(), + m.role, + ).map((team, role) => (team, role)), + ) + .many(), + ) + .watch(), + ); + try { + expect( + await iterator.moveNext().timeout(const Duration(seconds: 5)), + true, + ); + expect(iterator.current.single.first, ( + 'Core', + MembershipRole.owner, + )); + await db.transaction((tx) async { + await tx.teams.byId(10).patch(name: .set('Kernel')); + await tx.memberships + .byId(teamId: 10, userId: 1) + .patch(role: .set(MembershipRole.member)); + }); + expect( + await iterator.moveNext().timeout(const Duration(seconds: 5)), + true, + ); + expect(iterator.current.single.first, ( + 'Kernel', + MembershipRole.member, + )); + await db.teams.byId(10).delete().execute(); + expect( + await iterator.moveNext().timeout(const Duration(seconds: 5)), + true, + ); + expect(iterator.current.single.map((m) => m.$1), ['Docs', 'Tools']); + } finally { + await iterator.cancel(); + } + }); + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } +} + +final class _LimitedDriver(final Driver inner) + implements Driver { + @override + Capabilities get capabilities => Capabilities( + dialect: inner.capabilities.dialect, + maxParameters: 5, + returning: inner.capabilities.returning, + windowFunctions: inner.capabilities.windowFunctions, + streaming: inner.capabilities.streaming, + cancellation: inner.capabilities.cancellation, + exactDecimal: inner.capabilities.exactDecimal, + temporal: inner.capabilities.temporal, + ); + @override + Future run(Future Function(SqlConnection) action) => + inner.run(action); + @override + Future close() async {} +} diff --git a/test/migration_plan_cli_test.dart b/test/migration_plan_cli_test.dart new file mode 100644 index 00000000..d34cf390 --- /dev/null +++ b/test/migration_plan_cli_test.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/migrate_cli.dart'; +import 'package:orm/runtime.dart'; +import 'package:test/test.dart'; + +void main() { + for (final dialect in SqlDialect.values) { + test('${dialect.name} CLI reports the generated DDL atomicity', () async { + final migration = Migration.create('0001_initial', [ + TableSchema( + 'items', + columns: [Column('id', Codecs.integer)], + primaryKey: ['id'], + ), + ], dialect: dialect); + final driver = _CatalogDriver(dialect); + final output = _Output(); + final previousExitCode = exitCode; + try { + exitCode = 0; + await IOOverrides.runZoned( + () => runMigrationCli( + ['plan'], + history: MigrationHistory([ + (migration, migration.checksum), + ], dialect: dialect), + directory: 'unused', + connect: ({required readOnly}) { + expect(readOnly, isTrue); + return SqlDatabase(driver); + }, + ), + stdout: () => output, + ); + expect(exitCode, 0); + final report = + jsonDecode(output.buffer.toString()) as Map; + expect(report['pending'], hasLength(1)); + expect( + report['atomic'], + dialect == SqlDialect.sqlite || dialect == SqlDialect.postgres, + ); + expect(driver.closed, isTrue); + } finally { + exitCode = previousExitCode; + } + }); + } +} + +final class _Output implements Stdout { + final buffer = StringBuffer(); + @override + void writeln([Object? object = '']) => buffer.writeln(object); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Only protocol metadata is needed: the simulated server has no migration +/// history tables. Any attempted DDL fails instead of being silently accepted. +final class _CatalogDriver(final SqlDialect dialect) + implements Driver { + bool closed = false; + @override + late final capabilities = Capabilities(dialect: dialect, maxParameters: 999); + @override + Future run(Future Function(SqlConnection) action) => + action(_CatalogConnection(dialect)); + @override + Future close() async => closed = true; +} + +final class _CatalogConnection(final SqlDialect dialect) + implements SqlConnection { + @override + bool get transactionActive => false; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + if (command.sql == 'SELECT VERSION()') { + return SqlResult([ + [dialect == SqlDialect.mysql ? '8.4.0' : '11.8.0-MariaDB'], + ]); + } + if (command.sql == 'SHOW server_version_num') { + return const SqlResult([ + ['180000'], + ]); + } + if (!command.sql.startsWith('SELECT')) { + throw StateError('Migration plan must not execute DDL: ${command.sql}'); + } + return dialect == SqlDialect.postgres + ? const SqlResult([ + [false, true], + ]) + : const SqlResult([]); + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => throw UnsupportedError('Migration plan does not open cursors.'); + @override + Future invalidate() async {} +} diff --git a/test/migration_recovery_test.dart b/test/migration_recovery_test.dart new file mode 100644 index 00000000..bda004de --- /dev/null +++ b/test/migration_recovery_test.dart @@ -0,0 +1,455 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/migration_project.dart'; + +void main() { + test( + 'SQLite and transaction controls reject recoverable autocommit steps', + () async { + final db = await sqlite(const SqliteOptions.memory()); + try { + final migration = Migration.steps('0001_invalid', [ + const CheckedSql( + 'VACUUM', + readyWhen: 'SELECT true', + doneWhen: 'SELECT true', + ), + ], dialect: SqlDialect.sqlite); + await expectLater( + Migrator(db.sql).apply([migration]), + throwsA(isA()), + ); + expect(await Migrator(db.sql).history(), isEmpty); + expect( + () => validateMigrations([ + Migration.steps('0001_control', [ + const CheckedSql( + 'COMMIT', + readyWhen: 'SELECT true', + doneWhen: 'SELECT true', + ), + ], dialect: SqlDialect.postgres), + ], dialect: SqlDialect.postgres), + throwsA(isA()), + ); + } finally { + await db.close(); + } + }, + ); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url == null) return; + const schema = 'orm_recovery_tests'; + late Database admin, db; + final initial = Migration('0001_initial', [ + 'CREATE TABLE payload (id BIGINT PRIMARY KEY, value TEXT NOT NULL, touches BIGINT NOT NULL DEFAULT 0)', + "INSERT INTO payload (id, value) VALUES (1, 'one'), (2, 'two')", + ], dialect: SqlDialect.postgres); + final index = CheckedSql.createIndex( + 'payload', + const IndexSchema('value_lookup', ['value'], unique: true), + ); + Migration build(List steps) => Migration.steps( + '0002_index', + steps, + previous: initial.checksum, + dialect: SqlDialect.postgres, + ); + setUpAll(() async { + admin = postgres(PostgresOptions(url: Uri.parse(url), tls: .disable)); + await admin.execute(SqlCommand('CREATE SCHEMA IF NOT EXISTS $schema')); + }); + tearDownAll(() async { + await admin.execute(SqlCommand('DROP SCHEMA $schema CASCADE')); + await admin.close(); + }); + setUp(() async { + db = postgres( + PostgresOptions(url: Uri.parse(url), tls: .disable, schema: schema), + ); + for (final table in [ + 'payload', + 'repair_target', + '_orm_migration_steps', + '_orm_migrations', + ]) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + await Migrator(db.sql).apply([initial]); + }); + tearDown(() => db.close()); + + test( + 'mixed migrations checkpoint SQL and verify concurrent indexes', + () async { + expect( + (await db.execute(SqlCommand(index.doneWhen))).rows.single.single, + false, + ); + final migration = build([ + ExecuteSql("UPDATE payload SET value = 'updated' WHERE id = 1"), + index, + ExecuteSql('UPDATE payload SET touches = touches + 1'), + ]); + expect(await Migrator(db.sql).apply([initial, migration]), [ + migration.id, + ]); + expect( + (await db.execute(SqlCommand(index.doneWhen))).rows.single.single, + true, + ); + expect( + (await Migrator(db.sql).progress()).map((p) => p.state), + everyElement(MigrationStepState.complete), + ); + expect(await Migrator(db.sql).apply([initial, migration]), isEmpty); + expect( + (await db.execute(SqlCommand('SELECT touches FROM payload'))).rows + .map((r) => r.single), + [1, 1], + ); + }, + ); + + test( + 'same-name wrong-definition indexes are never accepted as completion', + () async { + await db.execute(SqlCommand('CREATE INDEX value_lookup ON payload(id)')); + final migration = build([index]); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA( + isA().having((e) => e.code, 'code', 'MIGRATION.STEP'), + ), + ); + final progress = (await Migrator(db.sql).progress()).single; + expect(progress.state, MigrationStepState.failed); + expect(progress.phase, 'inspect'); + expect(progress.failure, 'MIGRATION.RECOVERY'); + expect((await Migrator(db.sql).history()).length, 1); + }, + ); + + test( + 'failed unique builds leave INVALID indexes and require explicit repair', + () async { + await db.execute(SqlCommand("UPDATE payload SET value = 'duplicate'")); + final migration = build([index]); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA(isA()), + ); + final invalid = await db.execute( + SqlCommand( + "SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema() AND c.relname = 'value_lookup'", + ), + ); + expect(invalid.rows.single.single, false); + expect((await Migrator(db.sql).progress()).single.phase, 'execute'); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA(isA()), + ); + expect( + (await Migrator(db.sql).progress()).single.failure, + 'MIGRATION.RECOVERY', + ); + await db.execute(SqlCommand('DROP INDEX CONCURRENTLY value_lookup')); + await db.execute( + SqlCommand("UPDATE payload SET value = 'repaired' WHERE id = 2"), + ); + await Migrator(db.sql).apply([initial, migration]); + expect( + (await db.execute(SqlCommand(index.doneWhen))).rows.single.single, + true, + ); + }, + ); + + test( + 'failed attempts freeze migration checksums and block baseline', + () async { + final migration = build([ + const CheckedSql( + 'SELECT 1', + readyWhen: 'SELECT false', + doneWhen: 'SELECT false', + ), + ]); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA(isA()), + ); + await expectLater( + Migrator(db.sql).plan([ + initial, + build([index]), + ]), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.CHECKSUM', + ), + ), + ); + await expectLater( + Migrator(db.sql).plan([initial]), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.CHECKSUM', + ), + ), + ); + // Even an empty applied history cannot hide an attempted migration. + await db.execute(SqlCommand('DELETE FROM "_orm_migrations"')); + final snapshot = SchemaSnapshot([]); + final baseline = Migration.create( + '0001_baseline', + snapshot.tables, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).baseline([baseline], expected: snapshot), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.BASELINE', + ), + ), + ); + }, + ); + + test('failed postconditions report their actual failing stage', () async { + final migration = build([ + const CheckedSql( + 'SELECT 1', + readyWhen: 'SELECT true', + doneWhen: 'SELECT false', + ), + ]); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA(isA()), + ); + expect( + (await Migrator(db.sql).progress()).single.failure, + 'MIGRATION.POSTCONDITION', + ); + expect((await Migrator(db.sql).progress()).single.phase, 'verify'); + }); + + test('recovery probes must produce one boolean value', () async { + final migration = build([ + const CheckedSql( + 'SELECT 1', + readyWhen: 'SELECT true', + doneWhen: 'SELECT 1', + ), + ]); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA(isA()), + ); + expect( + (await Migrator(db.sql).progress()).single.failure, + 'MIGRATION.PROBE', + ); + expect((await Migrator(db.sql).progress()).single.phase, 'inspect'); + }); + + test( + 'concurrent runners serialize normal and autocommit steps under one lock', + () async { + final migration = build([ + ExecuteSql('UPDATE payload SET touches = touches + 1'), + index, + ]); + final results = await Future.wait([ + Migrator(db.sql).apply([initial, migration]), + Migrator(db.sql).apply([initial, migration]), + ]); + expect(results.expand((r) => r), [migration.id]); + expect( + (await db.execute(SqlCommand('SELECT touches FROM payload'))).rows + .map((r) => r.single), + [1, 1], + ); + }, + ); + + test( + 'runner lock timeout leaves history untouched and the connection reusable', + () async { + final blocker = postgres( + PostgresOptions(url: Uri.parse(url), tls: .disable, schema: schema), + ); + try { + await blocker.session((held) async { + await held.execute( + SqlCommand( + 'SELECT pg_advisory_lock(182983479, hashtext(current_schema()))', + ), + ); + try { + final migrator = Migrator( + db.sql, + lockTimeout: const Duration(milliseconds: 25), + ); + await expectLater( + migrator.apply([ + initial, + build([index]), + ]), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.LOCK_TIMEOUT', + ), + ), + ); + expect((await migrator.history()).length, 1); + expect(await migrator.progress(), isEmpty); + expect( + (await db.execute(SqlCommand('SELECT 1'))).rows.single.single, + 1, + ); + } finally { + await held.execute( + SqlCommand( + 'SELECT pg_advisory_unlock(182983479, hashtext(current_schema()))', + ), + ); + } + }); + await Migrator(db.sql).apply([ + initial, + build([index]), + ]); + } finally { + await blocker.close(); + } + }, + ); + + test('ordinary SQL inside a mixed migration rolls back only its failed step and resumes', () async { + final migration = build([ + ExecuteSql('UPDATE payload SET touches = touches + 1'), + ExecuteSql('INSERT INTO repair_target VALUES (1)'), + index, + ]); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA(isA()), + ); + expect((await Migrator(db.sql).progress()).map((p) => p.state), [ + MigrationStepState.complete, + MigrationStepState.failed, + ]); + await db.execute( + SqlCommand('CREATE TABLE repair_target (id BIGINT PRIMARY KEY)'), + ); + await Migrator(db.sql).apply([initial, migration]); + expect( + (await db.execute(SqlCommand('SELECT touches FROM payload'))).rows + .map((r) => r.single), + [1, 1], + ); + expect( + (await db.execute(SqlCommand('SELECT COUNT(*) FROM repair_target'))) + .rows + .single + .single, + 1, + ); + }); + + test( + 'completion checks reject partial and NULLS NOT DISTINCT definitions', + () async { + for (final suffix in ['WHERE id > 0', 'NULLS NOT DISTINCT']) { + await db.execute( + SqlCommand( + 'CREATE UNIQUE INDEX value_lookup ON payload(value) $suffix', + ), + ); + expect( + (await db.execute(SqlCommand(index.doneWhen))).rows.single.single, + false, + ); + await db.execute(SqlCommand('DROP INDEX value_lookup')); + } + }, + ); + + for (final crashAtCommit in [false, true]) { + test( + 'real process exit after ${crashAtCommit ? 'transaction commit' : 'concurrent DDL'} resumes without replay', + () async { + const update = 'UPDATE payload SET touches = touches + 1'; + final migration = build( + crashAtCommit + ? [ExecuteSql(update), index] + : [index, ExecuteSql(update)], + ); + final project = await MigrationProject.create(postgresSchema: schema); + try { + for (final m in [initial, migration]) { + await project.append(m); + } + await project.fixture.write('bin/crash.dart', ''' +import '${File('test/support/migration_crash.dart').absolute.uri}'; +import '../lib/migrations/migrations.g.dart'; +Future main(List args) => crashMigration(migrationHistory.checked, args); +'''); + final process = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/crash.dart', + schema, + crashAtCommit ? update : index.sql, + crashAtCommit ? 'commit' : 'statement', + ], workingDirectory: project.path); + expect( + process.exitCode, + 91, + reason: '${process.stdout}\n${process.stderr}', + ); + expect((await Migrator(db.sql).history()).length, 1); + final progress = await Migrator(db.sql).progress(); + expect( + progress.single.state, + crashAtCommit + ? MigrationStepState.complete + : MigrationStepState.running, + ); + if (!crashAtCommit) { + expect( + (await db.execute(SqlCommand(index.doneWhen))).rows.single.single, + true, + ); + } + await Migrator(db.sql).apply([initial, migration]); + expect( + (await db.execute(SqlCommand('SELECT touches FROM payload'))).rows + .map((r) => r.single), + [1, 1], + ); + expect((await Migrator(db.sql).history()).length, 2); + } finally { + await project.dispose(); + } + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + } +} diff --git a/test/migration_source_test.dart b/test/migration_source_test.dart new file mode 100644 index 00000000..e7e161a5 --- /dev/null +++ b/test/migration_source_test.dart @@ -0,0 +1,335 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import '../tool/src/build_fixture.dart'; + +void main() { + late BuildFixture fixture; + setUp(() async { + fixture = await BuildFixture.create(ormPath: Directory.current.path); + }); + tearDown(() => fixture.dispose()); + + test('Dart emission preserves storage metadata and every operation without application imports', () async { + final table = TableSchema( + 'quoted"table', + columns: [ + Column('id', Codecs.integer, integerBits: 32), + Column('amount', Codecs.decimal, decimalPrecision: 20, decimalScale: 4), + Column('moment', Codecs.dateTime, temporalPrecision: 3), + Column('local', Codecs.localDateTime, temporalPrecision: 2), + Column('day', Codecs.date), + Column('time', Codecs.time), + Column('data', Codecs.bytes), + Column('huge', Codecs.bigint), + Column('document', Codecs.json), + Column('active', Codecs.boolean, defaultSql: 'TRUE'), + Column('ratio', Codecs.real), + Column( + 'label', + Codecs.text.nullable(), + nullable: true, + defaultSql: r"'cost $5'", + ), + Column( + 'derived', + Codecs.integer, + computed: const ComputedColumn('id + 1'), + ), + ], + primaryKey: ['id'], + uniqueKeys: const [ + ['label', 'id'], + ], + indexes: const [ + IndexSchema('label_index', ['label']), + ], + checks: const [ + CheckSchema.forDialects( + 'positive', + sqlite: 'id > 0', + postgres: 'id > 0', + ), + ], + foreignKeys: const [ + ForeignKey(['id'], 'other', ['id'], onDelete: 'CASCADE'), + ], + ); + final other = TableSchema( + 'other', + columns: [ + Column('id', Codecs.integer, integerBits: 32), + Column('label', Codecs.text), + ], + primaryKey: ['id'], + ); + final snapshot = SchemaSnapshot([other, table]); + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + final migration = Migration.steps( + '0001_literals', + dialect == SqlDialect.sqlite + ? [ + ExecuteSql( + "SELECT 'quote\" and dollar\$ and \\ slash'\n-- retained", + ), + RebuildTable(table, table, copy: {'id': 'id'}), + DropTable('gone'), + ] + : [ + ExecuteSql('SELECT 1'), + DropConstraint('other', { + 'kind': 'u', + 'columns': ['id'], + }), + CheckedSql( + 'CREATE INDEX CONCURRENTLY foo ON other(id)', + readyWhen: 'SELECT true', + doneWhen: 'SELECT false', + ), + Backfill( + other, + set: {'label': "'done'"}, + doneWhen: 'SELECT true', + ), + ], + snapshot: snapshot, + dialect: dialect, + ); + await fixture.write('lib/frozen.dart', schemaSource(snapshot)); + await fixture.write( + 'lib/m0001_literals.dart', + migrationSource(migration), + ); + await fixture.write('bin/check.dart', ''' +import '../lib/frozen.dart' as s; +import '../lib/m0001_literals.dart' as m; +void main() { + print(s.schema.checksum); + print(m.migration.checksum); + if (m.migration.checksum != m.migrationChecksum) throw StateError('fingerprint'); +} +'''); + final source = await fixture + .file('lib/m0001_literals.dart') + .readAsString(); + expect(source, isNot(contains('fromJson'))); + expect(source, isNot(contains('schema.orm.dart'))); + final result = await fixture.run(['run', 'bin/check.dart']); + expect(result.output, contains(snapshot.checksum)); + expect(result.output, contains(migration.checksum)); + await fixture.run([ + 'analyze', + 'lib/frozen.dart', + 'lib/m0001_literals.dart', + 'bin/check.dart', + ]); + } + }); + + test('project CLI creates Dart history, upgrades a database and preserves recorded fingerprints', () async { + final initial = TableSchema( + 'people', + columns: [Column('id', Codecs.integer), Column('name', Codecs.text)], + primaryKey: ['id'], + ); + await fixture.write( + 'lib/target.dart', + schemaSource(SchemaSnapshot([initial])), + ); + await writeMigrationRegistry( + '${fixture.directory.path}/lib/migrations', + dialect: SqlDialect.sqlite, + ); + await fixture.write('bin/migrate.dart', r''' +import 'package:orm/migrate_cli.dart'; +import 'package:orm/sqlite.dart'; +import '../lib/target.dart'; +import '../lib/migrations/migrations.g.dart'; +Future main(List args) => runMigrationCli(args, + history: migrationHistory, + directory: 'lib/migrations', + schema: schema, + connect: ({required readOnly}) async => (await sqlite(readOnly ? SqliteOptions.readOnly('database.sqlite') : SqliteOptions.file('database.sqlite'))).sql, +); +'''); + Future> command( + List args, { + int code = 0, + }) async { + final result = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/migrate.dart', + ...args, + ], workingDirectory: fixture.directory.path); + expect( + result.exitCode, + code, + reason: '${result.stdout}\n${result.stderr}', + ); + return code == 0 + ? jsonDecode(result.stdout as String) as Map + : {}; + } + + expect((await command(['check']))['valid'], true); + expect(await fixture.file('database.sqlite').exists(), false); + final created = await command(['create', '0001_people']); + expect(created['created'], 'lib/migrations/m0001_people.dart'); + expect( + await fixture.file('lib/migrations/0001_people.json').exists(), + false, + ); + await command(['plan'], code: 1); + expect(await fixture.file('database.sqlite').exists(), false); + expect((await command(['apply']))['applied'], ['0001_people']); + final db = await sqlite( + SqliteOptions.file(fixture.file('database.sqlite').path), + ); + try { + await db.execute(SqlCommand("INSERT INTO people VALUES(1, 'Ada')")); + } finally { + await db.close(); + } + final after = TableSchema( + 'people', + columns: [ + ...initial.columns, + Column('nickname', Codecs.text.nullable(), nullable: true), + ], + primaryKey: ['id'], + ); + await fixture.write( + 'lib/target.dart', + schemaSource(SchemaSnapshot([after])), + ); + await command(['create', '0002_nickname']); + expect((await command(['plan']))['pending'], hasLength(1)); + expect((await command(['apply']))['applied'], ['0002_nickname']); + expect((await command(['verify']))['matches'], true); + expect((await command(['apply']))['applied'], isEmpty); + final read = await sqlite( + SqliteOptions.readOnly(fixture.file('database.sqlite').path), + ); + try { + expect( + (await read.execute(SqlCommand('SELECT name, nickname FROM people'))) + .rows + .single, + ['Ada', null], + ); + } finally { + await read.close(); + } + + final latest = fixture.file('lib/migrations/m0002_nickname.dart'); + final source = await latest.readAsString(); + await latest.writeAsString(source.replaceAll('ADD COLUMN', 'ADD COLUMN')); + await command(['check'], code: 1); + await writeMigrationRegistry( + '${fixture.directory.path}/lib/migrations', + dialect: SqlDialect.sqlite, + ); + await command(['check'], code: 1); + await command(['record', '0001_people'], code: 1); + await command(['record', '0002_nickname']); + expect((await command(['check']))['valid'], true); + // Re-recording source cannot change an independently applied DB fingerprint. + await command(['plan'], code: 1); + await command(['apply', '--max-backfill-batches', '0'], code: 64); + }, timeout: const Timeout(Duration(minutes: 3))); + test( + 'AOT migration bundle works without Dart sources or migration assets', + () async { + final schema = SchemaSnapshot([ + TableSchema( + 'people', + columns: [Column('id', Codecs.integer), Column('name', Codecs.text)], + primaryKey: ['id'], + ), + ]); + final initial = Migration( + '0001_initial', + [ + ...createSchema(schema.tables, SqlDialect.sqlite).map((s) => s.sql), + "INSERT INTO people(id, name) VALUES (7, 'retained')", + ], + snapshot: schema, + dialect: SqlDialect.sqlite, + ); + await fixture.write('lib/target.dart', schemaSource(schema)); + await writeMigration( + initial, + directory: '${fixture.directory.path}/lib/migrations', + history: MigrationHistory([], dialect: SqlDialect.sqlite), + ); + await fixture.write('bin/migrate.dart', """ +import 'package:orm/migrate_cli.dart'; +import 'package:orm/sqlite.dart'; +import '../lib/target.dart'; +import '../lib/migrations/migrations.g.dart'; +Future main(List args) => runMigrationCli(args, + directory: 'lib/migrations', history: migrationHistory, schema: schema, + connect: ({required readOnly}) async => (await sqlite(readOnly ? const SqliteOptions.readOnly('database.sqlite') : const SqliteOptions.file('database.sqlite'))).sql, +); +"""); + await fixture.run([ + 'build', + 'cli', + '--target', + 'bin/migrate.dart', + '--output', + 'deployment', + ]); + await Directory('${fixture.directory.path}/lib').delete(recursive: true); + await Directory('${fixture.directory.path}/bin').delete(recursive: true); + await Directory('${fixture.directory.path}/.dart_tool') + .delete(recursive: true); + await fixture.file('pubspec.yaml').delete(); + final working = await Directory('${fixture.directory.path}/run').create(); + final executable = + '${fixture.directory.path}/deployment/bundle/bin/migrate'; + Future> run(String command, {int code = 0}) async { + final result = await Process.run(executable, [ + command, + ], workingDirectory: working.path); + expect( + result.exitCode, + code, + reason: '$command: ${result.stdout}\n${result.stderr}', + ); + return code == 0 + ? jsonDecode(result.stdout as String) as Map + : {}; + } + + expect((await run('check'))['valid'], true); + await run('plan', code: 1); + final path = '${working.path}/database.sqlite'; + expect(await File(path).exists(), false); + expect((await run('apply'))['applied'], ['0001_initial']); + expect((await run('apply'))['applied'], isEmpty); + expect((await run('verify'))['matches'], true); + final db = await sqlite(SqliteOptions.readOnly(path)); + try { + expect( + (await db.execute(SqlCommand('SELECT id, name FROM people'))).rows, + [ + [7, 'retained'], + ], + ); + expect( + (await Migrator(db.sql).history()).single.checksum, + initial.checksum, + ); + } finally { + await db.close(); + } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/test/migration_target_test.dart b/test/migration_target_test.dart new file mode 100644 index 00000000..c8bb92e4 --- /dev/null +++ b/test/migration_target_test.dart @@ -0,0 +1,463 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/migrate_cli.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +Matcher code(String value) => + isA().having((e) => e.code, 'code', value); + +TableSchema scores({ + String other = 'id * 3', + bool indexed = false, + ComputedStorage storage = ComputedStorage.virtual, + bool computed = true, +}) => TableSchema( + 'scores', + columns: [ + Column('id', Codecs.integer), + Column( + 'value', + Codecs.integer, + computed: computed + ? ComputedColumn.forDialects( + sqlite: 'id * 2', + postgres: other, + storage: storage, + ) + : null, + ), + ], + primaryKey: ['id'], + indexes: indexed + ? [ + const IndexSchema('value_index', ['value']), + ] + : [], + checks: [ + CheckSchema.forDialects('positive', sqlite: 'id > 0', postgres: other), + ], +); + +void main() { + test( + 'history requires a single engine even when empty or SQL is portable', + () { + final sqlite = Migration('0001_start', ['SELECT 1'], dialect: .sqlite); + final postgres = Migration('0001_start', [ + 'SELECT 1', + ], dialect: .postgres); + expect(sqlite.checksum, isNot(postgres.checksum)); + expect( + MigrationHistory([], dialect: .postgres).dialect, + SqlDialect.postgres, + ); + expect( + () => MigrationHistory([ + (sqlite, sqlite.checksum), + ], dialect: .postgres).checked, + throwsA(code('MIGRATION.TARGET')), + ); + final next = Migration( + '0002_next', + ['SELECT 2'], + dialect: .postgres, + previous: sqlite.checksum, + ); + expect( + () => validateMigrations([sqlite, next], dialect: .sqlite), + throwsA(code('MIGRATION.TARGET')), + ); + expect( + () => validateMigrations([ + Migration.steps('0001_rebuild', [ + RebuildTable(scores(), scores(), copy: {'id': 'id'}), + ], dialect: .postgres), + ], dialect: .postgres), + throwsA(code('MIGRATION.TARGET')), + ); + expect( + () => validateMigrations([ + Migration.steps('0001_index', [ + CheckedSql( + 'CREATE INDEX CONCURRENTLY i ON scores(id)', + readyWhen: 'SELECT true', + doneWhen: 'SELECT false', + ), + ], dialect: .sqlite), + ], dialect: .sqlite), + throwsA(code('MIGRATION.TARGET')), + ); + }, + ); + + test('unselected expressions cannot change a saved plan, hash or source', () { + final before = SchemaSnapshot([scores(other: 'postgres_only_a')]); + final after = SchemaSnapshot([scores(other: 'postgres_only_b')]); + final initial = Migration.create( + '0001_start', + before.tables, + dialect: .sqlite, + ); + final same = Migration.create('0001_start', after.tables, dialect: .sqlite); + expect(initial.checksum, same.checksum); + expect( + Migration.create('0001_start', [ + scores(other: ''), + ], dialect: .sqlite).checksum, + initial.checksum, + ); + final change = Migration.diff( + '0002_change', + from: initial.snapshot!, + to: after, + previous: initial.checksum, + dialect: .sqlite, + ); + expect(change.steps, isEmpty); + final source = migrationSource(initial); + expect(source, contains('dialect: SqlDialect.sqlite')); + expect(source, isNot(contains('postgres'))); + expect(source, isNot(contains('forDialects'))); + Migration fill(TableSchema table) => Migration.steps('0002_fill', [ + Backfill(table, set: {'value': 'id'}, doneWhen: 'SELECT true'), + ], dialect: .sqlite); + final a = scores(other: 'unused_a', computed: false); + final b = scores(other: 'unused_b', computed: false); + expect(fill(a).checksum, fill(b).checksum); + expect(migrationSource(fill(a)), isNot(contains('unused_a'))); + }); + + test( + 'SQLite virtual indexes and computed mode changes ignore PostgreSQL limits', + () async { + final db = await sqlite(const SqliteOptions.memory()); + try { + final start = Migration.create('0001_start', [ + scores(indexed: true), + ], dialect: .sqlite); + expect( + () => Migration.create('0001_start', [ + scores(indexed: true), + ], dialect: .postgres), + throwsA(code('SCHEMA.COMPUTED')), + ); + await Migrator(db.sql).apply([start]); + await db.execute(SqlCommand('INSERT INTO scores(id) VALUES (7)')); + final stored = SchemaSnapshot([ + scores(storage: .stored, indexed: true), + ]); + final change = Migration.diff( + '0002_stored', + from: start.snapshot!, + to: stored, + previous: start.checksum, + dialect: .sqlite, + ); + expect(change.steps.single, isA()); + await Migrator(db.sql).apply([start, change]); + final plain = SchemaSnapshot([scores(computed: false, indexed: true)]); + final materialize = Migration.diff( + '0003_plain', + from: change.snapshot!, + to: plain, + previous: change.checksum, + dialect: .sqlite, + ); + await Migrator(db.sql).apply([start, change, materialize]); + expect( + (await db.execute(SqlCommand('SELECT id, value FROM scores'))) + .rows + .single, + [7, 14], + ); + expect((await verifySchema(db.sql, plain)).matches, true); + } finally { + await db.close(); + } + }, + ); + + test( + 'replacing stored values with computed values requires explicit review', + () { + final from = SchemaSnapshot([scores(computed: false)]); + final to = SchemaSnapshot([scores(storage: .stored)]); + expect( + () => Migration.diff( + '0002_compute', + from: from, + to: to, + dialect: .sqlite, + ), + throwsA(code('MIGRATION.DESTRUCTIVE')), + ); + expect( + Migration.diff( + '0002_compute', + from: from, + to: to, + dialect: .sqlite, + allowDestructive: true, + ).steps.single, + isA(), + ); + }, + ); + + test( + 'older PostgreSQL is rejected before locks, journals or partial execution', + () async { + final driver = _VersionDriver(); + final db = Database(driver); + final history = [ + Migration('0001_start', [ + 'CREATE TABLE proof(id int)', + ], dialect: .postgres), + ]; + for (final operation in [ + () => Migrator(db.sql).apply(history), + () => Migrator(db.sql).plan(history), + ]) { + driver.statements.clear(); + await expectLater(operation(), throwsA(code('CAPABILITY.VERSION'))); + expect(driver.statements, ['SHOW server_version_num']); + } + }, + ); + + for (final version in ['5.7.44', '8.0.15', '8.0.46', '8.3.0']) { + test('MySQL $version is rejected before locks, journals or DDL', () async { + final driver = _VersionDriver(dialect: .mysql, version: version); + final db = Database(driver); + final migration = Migration.create('0001_start', [ + TableSchema( + 'proof', + columns: [Column('id', Codecs.integer)], + primaryKey: ['id'], + ), + ], dialect: .mysql); + try { + for (final operation in Function()>[ + () => Migrator(db.sql).apply([migration]), + () => Migrator(db.sql).plan([migration]), + () => + Migrator(db.sql) + .baseline([migration], expected: migration.snapshot!), + ]) { + driver.statements.clear(); + await expectLater(operation(), throwsA(code('CAPABILITY.VERSION'))); + expect(driver.statements, ['SELECT VERSION()']); + } + } finally { + await db.close(); + } + }); + } + + test( + 'registry fixes its engine at initialization and refuses retargeting', + () async { + final directory = await Directory.systemTemp.createTemp('orm-target-'); + try { + await expectLater( + writeMigrationRegistry(directory.path), + throwsA(isA()), + ); + final path = await writeMigrationRegistry( + directory.path, + dialect: .sqlite, + ); + final source = await File(path).readAsString(); + expect(source, contains('const migrationDialect = SqlDialect.sqlite')); + await writeMigrationRegistry(directory.path); + await expectLater( + writeMigrationRegistry(directory.path, dialect: .postgres), + throwsA(isA()), + ); + expect(await File(path).readAsString(), source); + await expectLater( + writeMigration( + Migration('0001_wrong', ['SELECT 1'], dialect: .postgres), + directory: directory.path, + history: MigrationHistory([], dialect: .sqlite), + ), + throwsA(code('MIGRATION.TARGET')), + ); + expect( + await File('${directory.path}/m0001_wrong.dart').exists(), + false, + ); + } finally { + await directory.delete(recursive: true); + } + }, + ); + + test('wrong engine fails before any migration SQL for runner and every connected CLI command', () async { + final migration = Migration.create('0001_start', [], dialect: .postgres); + final history = MigrationHistory([ + (migration, migration.checksum), + ], dialect: .postgres); + final base = await sqlite(const SqliteOptions.memory()); + final statements = []; + final db = Database(base.driver, onQuery: (e) => statements.add(e.sql)); + try { + await expectLater( + Migrator(db.sql).apply(history.checked), + throwsA(code('MIGRATION.TARGET')), + ); + await expectLater( + Migrator(db.sql).plan(history.checked), + throwsA(code('MIGRATION.TARGET')), + ); + await expectLater( + Migrator(db.sql) + .baseline(history.checked, expected: SchemaSnapshot([])), + throwsA(code('MIGRATION.TARGET')), + ); + expect(statements, isEmpty); + } finally { + await db.close(); + } + for (final args in [ + ['plan'], + ['apply'], + ['status'], + ['verify'], + ['baseline'], + ['inspect', 'scores'], + ]) { + var connected = false; + try { + await runMigrationCli( + args, + history: args.first == 'apply' + ? MigrationHistory([], dialect: .postgres) + : history, + directory: 'unused', + schema: SchemaSnapshot([]), + connect: ({required readOnly}) async { + connected = true; + final base = await sqlite(const SqliteOptions.memory()); + return SqlDatabase( + base.driver, + onQuery: (e) => statements.add(e.sql), + ); + }, + ); + expect(exitCode, 1, reason: args.first); + expect(connected, true); + expect(statements, isEmpty); + } finally { + exitCode = 0; + } + } + }); + + if (Platform.environment['ORM_TEST_POSTGRES'] case final url?) { + test('PostgreSQL applies only its own generated conversion and rejects a SQLite history before SQL', () async { + final base = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_target_tests', + maxConnections: 1, + ), + ); + final statements = []; + final db = Database(base.driver, onQuery: (e) => statements.add(e.sql)); + try { + final wrong = Migration.create('0001_wrong', [], dialect: .sqlite); + await expectLater( + Migrator(db.sql).apply([wrong]), + throwsA(code('MIGRATION.TARGET')), + ); + expect(statements, isEmpty); + await db.execute(SqlCommand('CREATE SCHEMA orm_target_tests')); + TableSchema table(Codec codec) => TableSchema( + 'values', + columns: [Column('id', codec)], + primaryKey: ['id'], + ); + final start = Migration.create('0001_start', [ + table(Codecs.text), + ], dialect: .postgres); + await Migrator(db.sql).apply([start]); + await db.execute(SqlCommand('INSERT INTO "values" VALUES (\'42\')')); + final change = Migration.diff( + '0002_integer', + dialect: .postgres, + from: start.snapshot!, + to: SchemaSnapshot([table(Codecs.integer)]), + previous: start.checksum, + using: { + 'values': {'id': 'id::bigint'}, + }, + ); + expect(migrationSource(change), isNot(contains('sqlite'))); + await Migrator(db.sql).apply([start, change]); + expect( + (await db.execute(SqlCommand('SELECT id FROM "values"'))) + .rows + .single + .single, + 42, + ); + expect(await Migrator(db.sql).apply([start, change]), isEmpty); + } finally { + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_target_tests CASCADE'), + ); + await db.close(); + } + }); + } +} + +/// Unit-level version response; rejects every statement after the version probe. +final class _VersionDriver implements Driver, SqlConnection { + final SqlDialect dialect; + final String version; + final statements = []; + _VersionDriver({this.dialect = .postgres, this.version = '170000'}); + @override + Capabilities get capabilities => + Capabilities(dialect: dialect, maxParameters: 65535); + @override + bool? get transactionActive => false; + @override + Future run(Future Function(SqlConnection) action) => action(this); + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + statements.add(command.sql); + final probe = dialect == SqlDialect.postgres + ? 'SHOW server_version_num' + : 'SELECT VERSION()'; + if (command.sql != probe) { + throw StateError('Unexpected SQL: ${command.sql}'); + } + return SqlResult( + [ + [version], + ], + columns: ['version'], + ); + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => throw UnimplementedError(); + @override + Future invalidate() async {} + @override + Future close() async {} +} diff --git a/test/migration_test.dart b/test/migration_test.dart new file mode 100644 index 00000000..ff619dbe --- /dev/null +++ b/test/migration_test.dart @@ -0,0 +1,579 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +TableSchema accounts({ + String name = 'accounts', + String label = 'nickname', + bool requiredLabel = false, + String scoreDefault = '0', + bool textScore = false, + bool extra = false, + bool compoundUnique = false, +}) => TableSchema( + name, + columns: [ + Column('id', Codecs.integer, generated: true), + Column('email', Codecs.text), + Column( + label, + requiredLabel ? Codecs.text : Codecs.text.nullable(), + nullable: !requiredLabel, + ), + Column( + 'score', + textScore ? Codecs.text : Codecs.integer, + defaultSql: scoreDefault, + ), + if (extra) Column('status', Codecs.text, defaultSql: "'active'"), + ], + primaryKey: ['id'], + uniqueKeys: [ + compoundUnique ? ['email', label] : ['email'], + ], + indexes: [ + IndexSchema('account_label', [label]), + ], +); +TableSchema notes({String target = 'accounts', String onDelete = 'CASCADE'}) => + TableSchema( + 'notes', + columns: [ + Column('id', Codecs.integer), + Column('account_id', Codecs.integer), + Column('body', Codecs.text), + ], + primaryKey: ['id'], + foreignKeys: [ + ForeignKey(['account_id'], target, ['id'], onDelete: onDelete), + ], + ); + +void main() { + runMigrations('sqlite', () => sqlite(const SqliteOptions.memory())); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + late Database admin; + setUpAll(() async { + admin = postgres(PostgresOptions(url: Uri.parse(url), tls: .disable)); + await admin.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_migration_tests'), + ); + }); + tearDownAll(() async { + await admin.execute( + SqlCommand('DROP SCHEMA orm_migration_tests CASCADE'), + ); + await admin.close(); + }); + runMigrations( + 'postgres', + () async => postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_migration_tests', + ), + ), + ); + } + test('diff requires explicit renames, casts and backfills', () { + final from = SchemaSnapshot([accounts()]); + final renamed = SchemaSnapshot([accounts(name: 'members')]); + expect( + () => Migration.diff( + '0002_change', + from: from, + to: renamed, + dialect: SqlDialect.sqlite, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.DESTRUCTIVE', + ), + ), + ); + expect( + () => Migration.diff( + '0002_change', + from: from, + to: SchemaSnapshot([accounts(textScore: true)]), + dialect: SqlDialect.sqlite, + ), + throwsA( + isA().having((e) => e.code, 'code', 'MIGRATION.CAST'), + ), + ); + final required = TableSchema( + 'accounts', + columns: [...accounts().columns, Column('required', Codecs.text)], + primaryKey: ['id'], + uniqueKeys: [ + ['email'], + ], + ); + expect( + () => Migration.diff( + '0002_change', + from: from, + to: SchemaSnapshot([required]), + dialect: SqlDialect.sqlite, + ), + throwsA( + isA().having((e) => e.code, 'code', 'MIGRATION.BACKFILL'), + ), + ); + expect( + Migration.diff( + '0002_same', + from: from, + to: from, + dialect: SqlDialect.sqlite, + ).steps.isEmpty, + true, + ); + }); +} + +void runMigrations(String backend, Future> Function() open) { + group('migrations $backend', () { + late Database db; + late SchemaSnapshot start; + late Migration initial; + setUp(() async { + db = await open(); + await db.execute(SqlCommand('DROP VIEW IF EXISTS account_cards')); + for (final table in [ + 'notes', + 'accounts', + 'members', + 'audit', + '_orm_migrations', + ]) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + start = SchemaSnapshot([accounts(), notes()]); + initial = Migration.create( + '0001_initial', + start.tables, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + await db.execute( + SqlCommand( + "INSERT INTO accounts(id, email, nickname, score) VALUES (1, 'seven', NULL, 17)", + ), + ); + await db.execute( + SqlCommand("INSERT INTO notes VALUES (2, 1, 'preserve')"), + ); + }); + tearDown(() => db.close()); + + test( + 'explicit table and column renames preserve incoming references and data', + () async { + final target = SchemaSnapshot([ + accounts( + name: 'members', + label: 'label', + scoreDefault: '5', + extra: true, + ), + notes(target: 'members'), + ]); + final migration = Migration.diff( + '0002_rename', + from: start, + to: target, + previous: initial.checksum, + renames: const SchemaRenames( + tables: {'accounts': 'members'}, + columns: { + 'members': {'nickname': 'label'}, + }, + ), + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, migration]); + expect( + (await db.execute( + SqlCommand('SELECT id, email, label, score, status FROM members'), + )).rows.single, + [1, 'seven', null, 17, 'active'], + ); + expect( + (await db.execute(SqlCommand('SELECT body FROM notes'))).rows.single, + ['preserve'], + ); + expect((await verifySchema(db.sql, target)).differences, isEmpty); + expect(await inspectColumns(db.sql, 'accounts'), isEmpty); + }, + ); + + test('constraint changes resolve catalog names and retain rows', () async { + final target = SchemaSnapshot([ + accounts(compoundUnique: true), + notes(onDelete: 'RESTRICT'), + ]); + final migration = Migration.diff( + '0002_constraints', + from: start, + to: target, + previous: initial.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, migration]); + expect((await verifySchema(db.sql, target)).differences, isEmpty); + expect( + (await db.execute(SqlCommand('SELECT COUNT(*) FROM notes'))) + .rows + .single + .single, + 1, + ); + await expectLater( + db.execute(SqlCommand('DELETE FROM accounts WHERE id = 1')), + throwsA(anything), + ); + }); + + test( + 'failed NOT NULL rebuild restores data history and foreign key settings', + () async { + final target = SchemaSnapshot([accounts(requiredLabel: true), notes()]); + final migration = Migration.diff( + '0002_required', + from: start, + to: target, + previous: initial.checksum, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA(anything), + ); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + expect((await Migrator(db.sql).history()).length, 1); + expect( + (await db.execute(SqlCommand('SELECT nickname FROM accounts'))) + .rows + .single + .single, + null, + ); + if (db.dialect == SqlDialect.sqlite) { + expect( + (await db.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single, + 1, + ); + } + final fill = Migration( + '0002_backfill', + ["UPDATE accounts SET nickname = 'Seven' WHERE nickname IS NULL"], + previous: initial.checksum, + dialect: db.dialect, + ); + final required = Migration.diff( + '0003_required', + from: start, + to: target, + previous: fill.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, fill, required]); + expect((await verifySchema(db.sql, target)).differences, isEmpty); + expect( + (await db.execute(SqlCommand('SELECT nickname FROM accounts'))) + .rows + .single + .single, + 'Seven', + ); + }, + ); + + test('reviewed conversion expressions preserve values when storage types change', () async { + final target = SchemaSnapshot([ + accounts(textScore: true, scoreDefault: "'0'"), + notes(), + ]); + final migration = Migration.diff( + '0002_convert', + from: start, + to: target, + previous: initial.checksum, + using: { + 'accounts': {'score': 'CAST(score AS TEXT)'}, + }, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, migration]); + expect( + (await db.execute(SqlCommand('SELECT score FROM accounts'))) + .rows + .single + .single, + '17', + ); + expect((await verifySchema(db.sql, target)).differences, isEmpty); + }); + + test('additive changes use ALTER without a table rebuild', () async { + final target = SchemaSnapshot([accounts(extra: true), notes()]); + final migration = Migration.diff( + '0002_add', + from: start, + to: target, + previous: initial.checksum, + dialect: db.dialect, + ); + expect(migration.steps.whereType(), isEmpty); + await Migrator(db.sql).apply([initial, migration]); + expect((await verifySchema(db.sql, target)).differences, isEmpty); + expect( + (await db.execute(SqlCommand('SELECT status FROM accounts'))) + .rows + .single + .single, + 'active', + ); + }); + + test( + 'reviewed table removal handles restrictive foreign keys atomically', + () async { + final restricted = SchemaSnapshot([ + accounts(), + notes(onDelete: 'RESTRICT'), + ]); + final change = Migration.diff( + '0002_restrict', + from: start, + to: restricted, + previous: initial.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, change]); + final remove = Migration.diff( + '0003_remove', + from: restricted, + to: SchemaSnapshot([]), + previous: change.checksum, + allowDestructive: true, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial, change, remove]); + expect(await inspectColumns(db.sql, 'accounts'), isEmpty); + expect(await inspectColumns(db.sql, 'notes'), isEmpty); + expect((await Migrator(db.sql).history()).length, 3); + if (db.dialect == SqlDialect.sqlite) { + expect( + (await db.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single, + 1, + ); + } + }, + ); + + test('broken migration chain is refused before DDL', () async { + final migration = Migration.diff( + '0002_add', + from: start, + to: SchemaSnapshot([accounts(extra: true), notes()]), + previous: 'wrong', + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA( + isA().having((e) => e.code, 'code', 'MIGRATION.CHAIN'), + ), + ); + expect((await verifySchema(db.sql, start)).differences, isEmpty); + }); + + if (backend == 'sqlite') { + test('catalog distinguishes nullable non-rowid primary keys', () async { + await db.execute( + SqlCommand('CREATE TABLE audit (id TEXT PRIMARY KEY)'), + ); + expect((await inspectColumns(db.sql, 'audit')).single.nullable, true); + await db.execute(SqlCommand('DROP TABLE audit')); + await db.execute( + SqlCommand('CREATE TABLE audit (id INTEGER PRIMARY KEY)'), + ); + expect((await inspectColumns(db.sql, 'audit')).single.nullable, false); + }); + + test( + 'rebuild preserves unmanaged index trigger and view definitions', + () async { + await db.execute( + SqlCommand('CREATE TABLE audit (account_id INTEGER, email TEXT)'), + ); + await db.execute( + SqlCommand('CREATE INDEX email_search ON accounts(lower(email))'), + ); + await db.execute( + SqlCommand( + 'CREATE TRIGGER account_audit AFTER UPDATE OF email ON accounts BEGIN INSERT INTO audit VALUES (new.id, new.email); END', + ), + ); + await db.execute( + SqlCommand( + 'CREATE VIEW account_cards AS SELECT id, email FROM accounts', + ), + ); + final target = SchemaSnapshot([accounts(scoreDefault: '9'), notes()]); + await Migrator(db.sql).apply([ + initial, + Migration.diff( + '0002_default', + from: start, + to: target, + previous: initial.checksum, + dialect: db.dialect, + ), + ]); + await db.execute( + SqlCommand("UPDATE accounts SET email = 'updated' WHERE id = 1"), + ); + expect( + (await db.execute(SqlCommand('SELECT email FROM account_cards'))) + .rows + .single + .single, + 'updated', + ); + expect( + (await db.execute(SqlCommand('SELECT * FROM audit'))).rows.single, + [1, 'updated'], + ); + expect( + (await inspectTable( + db.sql, + 'accounts', + )).unmanaged.map((o) => o.name), + containsAll(['email_search', 'account_audit', 'account_cards']), + ); + }, + ); + + test('foreign key check rolls back changed parent keys without cascading data away', () async { + final rebuilt = RebuildTable( + accounts(), + accounts(), + copy: { + 'id': 'id + 100', + 'email': 'email', + 'nickname': 'nickname', + 'score': 'score', + }, + ); + final bad = Migration.steps( + '0002_invalid', + [rebuilt], + previous: initial.checksum, + dialect: SqlDialect.sqlite, + ); + await expectLater( + Migrator(db.sql).apply([initial, bad]), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.FOREIGN_KEY', + ), + ), + ); + expect( + (await db.execute(SqlCommand('SELECT id FROM accounts'))) + .rows + .single + .single, + 1, + ); + expect( + (await db.execute(SqlCommand('SELECT body FROM notes'))) + .rows + .single + .single, + 'preserve', + ); + expect( + (await db.execute(SqlCommand('PRAGMA foreign_keys'))) + .rows + .single + .single, + 1, + ); + }); + + test('unmodeled CHECK constraints are refused without losing their definition', () async { + await db.execute( + SqlCommand( + 'ALTER TABLE accounts ADD COLUMN checked INTEGER CHECK (checked > 0)', + ), + ); + final old = TableSchema( + 'accounts', + columns: [ + ...accounts().columns, + Column('checked', Codecs.integer.nullable(), nullable: true), + ], + primaryKey: ['id'], + uniqueKeys: [ + ['email'], + ], + indexes: accounts().indexes, + ); + final next = TableSchema( + 'accounts', + columns: [ + ...accounts(scoreDefault: '8').columns, + Column('checked', Codecs.integer.nullable(), nullable: true), + ], + primaryKey: ['id'], + uniqueKeys: [ + ['email'], + ], + indexes: accounts().indexes, + ); + final migration = Migration.diff( + '0002_default', + from: SchemaSnapshot([old, notes()]), + to: SchemaSnapshot([next, notes()]), + previous: initial.checksum, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([initial, migration]), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.UNMANAGED', + ), + ), + ); + await expectLater( + db.execute(SqlCommand('UPDATE accounts SET checked = -1')), + throwsA(anything), + ); + expect((await Migrator(db.sql).history()).length, 1); + }); + } + }); +} diff --git a/test/mysql_database_test.dart b/test/mysql_database_test.dart new file mode 100644 index 00000000..1d090efe --- /dev/null +++ b/test/mysql_database_test.dart @@ -0,0 +1,525 @@ +import 'dart:io'; + +import 'package:orm/mysql.dart'; +import 'package:orm/mariadb.dart'; +import 'package:test/test.dart'; + +final _id = Column('id', Codecs.integer, generated: true); +final _name = Column('name', Codecs.text); +final _json = Column('data', Codecs.jsonDocument.nullable(), nullable: true); +final _amount = Column( + 'amount', + Codecs.decimal, + decimalPrecision: 30, + decimalScale: 6, +); +final _created = Column('created_at', Codecs.dateTime, temporalPrecision: 6); +final _big = Column('large_integer', Codecs.bigint, defaultSql: '0'); +final _schema = TableSchema( + 'orm_typed_rows', + columns: [_id, _name, _json, _amount, _created, _big], + primaryKey: ['id'], + uniqueKeys: [ + ['name'], + ], +); + +final class _Fields(super.table) extends Fields { + late final id = column(_id); + late final name = column(_name); + late final data = column(_json); + late final amount = column(_amount); + late final created = column(_created); + late final big = column(_big); +} + +typedef _Row = (int, String, SqlJson?, Decimal, DateTime); +final _table = Table<_Row, _Fields>( + _schema, + _Fields.new, + (f) => (f.id, f.name, f.data, f.amount, f.created).row, +); + +TypeMatcher _code(String code) => + isA().having((error) => error.code, 'code', code); + +void main() { + test('statement timeout capability defaults to cancellation support', () { + for (final cancellation in [false, true]) { + expect( + Capabilities( + dialect: SqlDialect.sqlite, + maxParameters: 999, + cancellation: cancellation, + ).statementTimeout, + cancellation, + ); + } + const deadlinesOnly = Capabilities( + dialect: SqlDialect.mysql, + maxParameters: 65535, + statementTimeout: true, + ); + expect(deadlinesOnly.cancellation, isFalse); + expect(deadlinesOnly.statementTimeout, isTrue); + }); + + for (final engine in ['mysql', 'mariadb']) { + final variable = 'ORM_TEST_${engine.toUpperCase()}'; + final address = Platform.environment[variable]; + final tls = MysqlTls.values.byName( + Platform.environment['${variable}_TLS'] ?? 'verifyFull', + ); + group( + engine, + () { + late Database db; + final events = []; + final instant = DateTime.utc(2026, 9, 19, 1, 2, 3, 0, 1); + Future> open({ + Duration queryTimeout = const Duration(seconds: 30), + void Function(QueryEvent)? onQuery, + }) async => engine == 'mysql' + ? await mysql( + MysqlOptions( + url: Uri.parse(address!), + tls: tls, + queryTimeout: queryTimeout, + ), + onQuery: onQuery, + ) + : await mariadb( + MariadbOptions( + url: Uri.parse(address!), + tls: tls, + queryTimeout: queryTimeout, + ), + onQuery: onQuery, + ); + Future<_Row> create(String name, {SqlJson? data}) => db + .table(_table) + .createRow( + (r) => [ + r.name.set(name), + r.data.set(data), + r.amount.set(Decimal.parse('9007199254740993.000001')), + r.created.set(instant), + ], + ); + setUp(() async { + db = await open(onQuery: events.add); + await db.execute(SqlCommand('DROP TABLE IF EXISTS orm_typed_rows')); + await db.execute( + SqlCommand( + 'CREATE TABLE orm_typed_rows (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL UNIQUE, data JSON NULL, amount DECIMAL(30,6) NOT NULL, created_at DATETIME(6) NOT NULL, large_integer DECIMAL(65,0) NOT NULL DEFAULT 0)', + ), + ); + events.clear(); + }); + tearDown(() async { + await db.execute(SqlCommand('DROP TABLE IF EXISTS orm_typed_rows')); + await db.close(); + }); + for (final typedMutation in [false, true]) { + test( + '${typedMutation ? 'ORM mutation' : 'SQL runtime'} statement timeout discards the driver without replay', + () async { + await create('timeout-target'); + final observed = []; + final subject = await open(onQuery: observed.add); + const deadline = ExecutionOptions( + timeout: Duration(milliseconds: 30), + ); + try { + await expectLater( + typedMutation + ? subject + .table(_table) + .update( + (r) => [ + r.name.setExpression( + sql( + ["IF(SLEEP(0.5)=0, 'changed', 'changed')"], + [], + Codecs.text, + ), + ), + ], + ) + .execute(options: deadline) + : subject.sql.execute( + SqlCommand('SELECT SLEEP(0.5)'), + options: deadline, + ), + throwsA( + _code('OPERATION.TIMEOUT').having( + (error) => error.toString(), + 'message', + contains('outcome may be unknown'), + ), + ), + ); + expect(observed, hasLength(1)); + expect(observed.single.error, isA()); + await expectLater( + subject.sql.execute(SqlCommand('SELECT 1')), + throwsA(_code('DRIVER.CLOSED')), + ); + final replacement = await open(); + try { + expect( + (await replacement.execute(SqlCommand('SELECT 2'))).rows, + [ + [2], + ], + ); + } finally { + await replacement.close(); + } + // The submitted UPDATE may have completed before disconnect; + // a deadline is not proof that an autocommit write rolled back. + } finally { + await subject.close(); + } + }, + ); + } + test( + 'execution timeout overrides the configured queryTimeout default', + () async { + final subject = await open( + queryTimeout: const Duration(milliseconds: 80), + ); + try { + expect( + (await subject.execute( + SqlCommand('SELECT SLEEP(0.15)'), + options: const ExecutionOptions( + timeout: Duration(seconds: 2), + ), + )).rows, + [ + [0], + ], + ); + await expectLater( + subject.execute(SqlCommand('SELECT SLEEP(0.5)')), + throwsA(_code('OPERATION.TIMEOUT')), + ); + await expectLater( + subject.execute(SqlCommand('SELECT 1')), + throwsA(_code('DRIVER.CLOSED')), + ); + } finally { + await subject.close(); + } + }, + ); + test( + 'tokens and transaction deadlines still require real cancellation', + () async { + final token = CancellationToken(); + var callbacks = 0; + for (final operation in Function()>[ + () => db.sql.execute( + SqlCommand('SELECT SLEEP(0.5)'), + options: ExecutionOptions(cancellation: token), + ), + () => db.execute( + SqlCommand('SELECT SLEEP(0.5)'), + options: ExecutionOptions(cancellation: token), + ), + () => db.transaction( + (_) async => callbacks++, + timeout: const Duration(milliseconds: 30), + ), + () => + db.transaction((_) async => callbacks++, cancellation: token), + () => db.transaction( + (_) async => callbacks++, + retry: const TransactionRetry(), + ), + ]) { + await expectLater(operation, throwsA(_code('CAPABILITY.CANCEL'))); + } + expect(callbacks, 0); + expect(events, isEmpty); + expect((await db.execute(SqlCommand('SELECT 1'))).rows, [ + [1], + ]); + }, + ); + test( + 'caught statement timeout cannot commit preceding transaction writes', + () async { + final observed = []; + final subject = await open(onQuery: observed.add); + var callbacks = 0; + try { + await expectLater( + subject.transaction((tx) async { + callbacks++; + await tx + .table(_table) + .insert( + (r) => [ + r.name.set('before-timeout'), + r.amount.set(Decimal.parse('1')), + r.created.set(instant), + ], + ) + .execute(); + await expectLater( + tx.sql.execute( + SqlCommand('SELECT SLEEP(0.5)'), + options: const ExecutionOptions( + timeout: Duration(milliseconds: 30), + ), + ), + throwsA(_code('OPERATION.TIMEOUT')), + ); + }), + throwsA(_code('TRANSACTION.ROLLBACK')), + ); + expect(callbacks, 1); + expect( + observed.map((event) => event.sql), + isNot(contains('COMMIT')), + ); + final replacement = await open(); + try { + expect(await replacement.table(_table).count(), 0); + expect( + (await replacement.execute(SqlCommand('SELECT 3'))).rows, + [ + [3], + ], + ); + } finally { + await replacement.close(); + } + } finally { + await subject.close(); + } + }, + ); + test( + 'create returns exact values on its transaction connection', + () async { + final row = await create('one', data: const SqlJson({'n': 1})); + expect(row.$1, 1); + expect(row.$2, 'one'); + expect(row.$3!.value, {'n': 1}); + expect(row.$4.toString(), '9007199254740993.000001'); + expect(row.$5, instant); + expect(events.where((e) => e.sql.startsWith('INSERT')).length, 1); + expect(events.where((e) => e.sql.startsWith('SELECT')).length, 1); + }, + ); + test( + 'JSON null, SQL NULL and parameter comparison stay distinct', + () async { + await create('sql-null'); + await create('json-null', data: const SqlJson(null)); + await create('object', data: const SqlJson({'n': 1})); + final rows = await db + .table(_table) + .orderBy((r) => [r.id.asc()]) + .get(); + expect(rows[0].$3, null); + expect(rows[1].$3, isA()); + expect(rows[1].$3!.value, null); + final matching = await db + .table(_table) + .where((r) => r.data.eq(const SqlJson({'n': 1}))) + .select((r) => r.name) + .get(); + expect(matching, ['object']); + final cte = db + .table(_table) + .select((r) => (r.name, r.data).row) + .asCte('docs'); + final throughCte = await cte.query + .where((r) => r.ref((f) => f.data).eq(const SqlJson({'n': 1}))) + .get(); + expect(throughCte.single.$1, 'object'); + }, + ); + test( + 'rollback and savepoint preserve explicit transaction boundaries', + () async { + await expectLater( + db.transaction((tx) async { + await tx + .table(_table) + .insert( + (r) => [ + r.name.set('rollback'), + r.amount.set(Decimal.parse('1')), + r.created.set(instant), + ], + ) + .execute(); + throw StateError('rollback'); + }), + throwsStateError, + ); + expect(await db.table(_table).count(), 0); + await db.transaction((tx) async { + await tx + .table(_table) + .insert( + (r) => [ + r.name.set('keep'), + r.amount.set(Decimal.parse('1')), + r.created.set(instant), + ], + ) + .execute(); + await expectLater( + tx.savepoint((nested) async { + await nested + .table(_table) + .insert( + (r) => [ + r.name.set('discard'), + r.amount.set(Decimal.parse('2')), + r.created.set(instant), + ], + ) + .execute(); + throw StateError('savepoint'); + }), + throwsStateError, + ); + }); + expect(await db.table(_table).select((r) => r.name).get(), [ + 'keep', + ]); + }, + ); + test( + 'exact composite relation keys and BigInt avoid float coercion', + () async { + for (final key in ['9007199254740992', '9007199254740993']) { + await db + .table(_table) + .insert( + (r) => [ + r.name.set(key), + r.amount.set(Decimal.parse(key)), + r.created.set(instant), + r.big.set(BigInt.parse(key)), + ], + ) + .execute(); + } + final names = await db + .table(_table) + .where((r) => r.big.eq(BigInt.parse('9007199254740993'))) + .select((r) => r.name) + .get(); + expect(names, ['9007199254740993']); + events.clear(); + final matches = await db + .table(_table) + .orderBy((r) => [r.id.asc()]) + .select( + (r) => ( + r.name, + Relation( + _table, + parent: [r.amount, r.created], + child: (c) => [c.amount, c.created], + ).select((c) => c.name).many(), + ).map((name, related) => (name, related)), + ) + .get(); + expect(matches.map((r) => r.$2), [ + ['9007199254740992'], + ['9007199254740993'], + ]); + expect(events.where((e) => e.sql.startsWith('SELECT')).length, 2); + }, + ); + test('JSON DISTINCT through a CTE retains native comparison', () async { + await create('first', data: const SqlJson({'n': 1})); + await create('second', data: const SqlJson({'n': 1})); + final result = await db + .table(_table) + .select((r) => r.data) + .distinct() + .asCte('distinct_docs') + .query + .get(); + expect(result.single!.value, {'n': 1}); + final union = await db + .table(_table) + .where((r) => r.name.eq('first')) + .select((r) => r.data) + .union( + db + .table(_table) + .where((r) => r.name.eq('second')) + .select((r) => r.data), + ) + .get(); + expect(union.single!.value, {'n': 1}); + }); + test( + 'decimal column overflow fails instead of storing a clamped value', + () async { + await expectLater( + db + .table(_table) + .insert( + (r) => [ + r.name.set('overflow'), + r.amount.set(Decimal.parse('9999999999999999999999999')), + r.created.set(instant), + ], + ) + .execute(), + throwsA(isA()), + ); + expect(await db.table(_table).count(), 0); + }, + ); + test('duplicate update uses native unique key semantics', () async { + await create('one'); + await db + .table(_table) + .insert( + (r) => [ + r.name.set('one'), + r.amount.set(Decimal.parse('2')), + r.created.set(instant), + ], + ) + .onDuplicateKeyUpdate( + set: (existing, incoming) => [ + existing.amount.setExpression(incoming.amount), + ], + ) + .execute(); + expect((await db.table(_table).single()).$4.toString(), '2'); + }); + test('named SQL handles MySQL comments, quoted identifiers and repeated values', () async { + final template = SqlTemplate( + "SELECT :n + :n AS `n:quoted`, ':literal' AS s # :ignored\n", + dialect: db.dialect, + ); + final command = template.compile(db.capabilities, { + 'n': value(3, Codecs.integer), + }); + expect(command.parameters, [3, 3]); + final result = await db.sql.execute(command); + expect(result.rows.single, [6, ':literal']); + }); + }, + skip: address == null + ? 'Set $variable for live database validation.' + : false, + ); + } +} diff --git a/test/mysql_driver_test.dart b/test/mysql_driver_test.dart new file mode 100644 index 00000000..723fa429 --- /dev/null +++ b/test/mysql_driver_test.dart @@ -0,0 +1,517 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:orm/drivers/mysql.dart'; +import 'package:orm/drivers/mariadb.dart' as maria; +import 'package:test/test.dart'; + +Matcher _code(String code) => + isA().having((error) => error.code, 'code', code); + +void main() { + test('connection options fail before opening a socket', () async { + for (final url in [ + 'postgres://user:pass@localhost/db', + 'mysql://localhost/db', + 'mysql://user:pass@localhost/', + 'mysql://user:pass@localhost/db?tls=false', + 'mysql://user:pass@localhost/db#fragment', + ]) { + await expectLater( + MysqlDriver.open(MysqlOptions(url: Uri.parse(url))), + throwsArgumentError, + ); + } + await expectLater( + MysqlDriver.open( + MysqlOptions( + url: Uri.parse('mysql://user:pass@localhost/db'), + queryTimeout: Duration.zero, + ), + ), + throwsArgumentError, + ); + }); + + for (final engine in ['mysql', 'mariadb']) { + final variable = 'ORM_TEST_${engine.toUpperCase()}'; + final address = Platform.environment[variable]; + final tls = MysqlTls.values.byName( + Platform.environment['${variable}_TLS'] ?? 'verifyFull', + ); + Future> open() async { + final url = Uri.parse(address!); + return engine == 'mysql' + ? await MysqlDriver.open(MysqlOptions(url: url, tls: tls)) + : await maria.MariadbDriver.open( + maria.MariadbOptions(url: url, tls: tls), + ); + } + + group( + engine, + () { + late Driver driver; + setUp(() async => driver = await open()); + tearDown(() => driver.close()); + + test( + 'trailing delimiters preserve result rows and literal semicolons', + () async { + await driver.run((connection) async { + for (final tail in [ + ';', + '; -- done\n', + '; # done\n', + '; /* done ; */', + '; /* first */ -- second\n', + ]) { + final plain = await connection.execute( + SqlCommand( + "/* harmless */ SELECT '; COMMIT', 'it''s safe'$tail", + ), + ); + expect(plain.rows, [ + ['; COMMIT', "it's safe"], + ]); + final prepared = await connection.execute( + SqlCommand('SELECT ? AS `value;name`$tail', [ + 'literal; -- text', + ]), + ); + expect(prepared.rows, [ + ['literal; -- text'], + ]); + expect(prepared.columns, ['value;name']); + } + }); + }, + ); + + test('declares actual engine and unsupported operations', () async { + expect(driver.capabilities.dialect.name, engine); + expect(driver.capabilities.returning, isFalse); + expect(driver.capabilities.streaming, isFalse); + expect(driver.capabilities.cancellation, isFalse); + expect(driver.capabilities.statementTimeout, isTrue); + expect(driver.capabilities.exactDecimal, isTrue); + expect(driver.capabilities.temporal, isTrue); + await driver.run((connection) async { + expect(connection.transactionActive, isNull); + await expectLater( + connection.openCursor(SqlCommand('SELECT 1')), + throwsA(_code('CAPABILITY.STREAMING')), + ); + await expectLater( + connection.execute( + SqlCommand('SELECT 1'), + options: ExecutionOptions(cancellation: CancellationToken()), + ), + throwsA(_code('CAPABILITY.CANCELLATION')), + ); + final result = await connection.execute( + SqlCommand('SELECT @@session.sql_mode, @@session.time_zone'), + ); + expect(result.rows.single[1], '+00:00'); + final modes = (result.rows.single[0] as String).split(','); + expect( + modes, + containsAll([ + 'ANSI_QUOTES', + 'STRICT_ALL_TABLES', + 'NO_BACKSLASH_ESCAPES', + ]), + ); + }); + }); + + test( + 'binary-collated text stays UTF-8 in both wire protocols', + () async { + await driver.run((connection) async { + await connection.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE binary_text (name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, bytes BLOB)', + ), + ); + final bytes = Uint8List.fromList([0, 128, 255]); + await connection.execute( + SqlCommand('INSERT INTO binary_text VALUES (?, ?)', [ + '雪', + bytes, + ]), + ); + for (final command in [ + SqlCommand('SELECT name, bytes FROM binary_text'), + SqlCommand( + 'SELECT name, bytes FROM binary_text WHERE name = ?', + ['雪'], + ), + ]) { + final row = (await connection.execute(command)).rows.single; + expect(row[0], '雪'); + expect(row[1], bytes); + } + }); + }, + ); + + test( + 'prepared values preserve text, signed integers and binary bytes', + () async { + await driver.run((connection) async { + await connection.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE driver_values (' + 'id BIGINT PRIMARY KEY AUTO_INCREMENT, ' + 'body TEXT NOT NULL, enabled BOOLEAN NOT NULL, ' + 'large BIGINT NOT NULL, small TINYINT NOT NULL, ' + 'bytes BLOB NOT NULL, optional TEXT)', + ), + ); + const text = + "quote' ? :named \\ newline\n雪; DROP TABLE driver_values"; + final bytes = Uint8List.fromList([0, 1, 127, 128, 255, 0]); + final inserted = await connection.execute( + SqlCommand( + 'INSERT INTO driver_values (body, enabled, large, small, bytes, optional) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + [text, true, 9223372036854775807, -128, bytes, null], + ), + ); + expect(inserted.affectedRows, 1); + expect(inserted.lastInsertId, 1); + final selected = await connection.execute( + SqlCommand( + 'SELECT body, enabled, large, small, bytes, optional ' + 'FROM driver_values WHERE id = ?', + [inserted.lastInsertId], + ), + ); + expect(selected.columns, [ + 'body', + 'enabled', + 'large', + 'small', + 'bytes', + 'optional', + ]); + expect(selected.rows.single, [ + text, + 1, + 9223372036854775807, + -128, + bytes, + null, + ]); + expect(Codecs.boolean.decode(selected.rows.single[1]), isTrue); + final updated = await connection.execute( + SqlCommand( + 'UPDATE driver_values SET enabled = ? WHERE id = ?', + [false, 1], + ), + ); + expect(updated.affectedRows, 1); + }); + }, + ); + + test( + 'decimals and temporal microseconds round trip without doubles', + () async { + await driver.run((connection) async { + await connection.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE driver_precision (' + 'amount DECIMAL(65,30), day DATE, clock TIME(6), ' + 'local_time DATETIME(6), instant DATETIME(6))', + ), + ); + final amount = Decimal.parse( + '123456789012345678901234567890.123456789012345678901234567891', + ); + final day = LocalDate(2026, 9, 19); + final time = LocalTime(12, 34, 56, 1); + final local = LocalDateTime(day, time); + final instant = DateTime.utc(2026, 9, 19, 12, 34, 56, 0, 1); + await connection.execute( + SqlCommand( + 'INSERT INTO driver_precision VALUES (?, ?, ?, ?, ?)', + [amount, day, time, local, instant], + ), + ); + final result = await connection.execute( + SqlCommand('SELECT * FROM driver_precision WHERE amount = ?', [ + amount, + ]), + ); + final row = result.rows.single; + expect(Codecs.decimal.decode(row[0]), amount); + expect(Codecs.date.decode(row[1]), day); + expect(Codecs.time.decode(row[2]), time); + expect(Codecs.localDateTime.decode(row[3]), local); + expect(Codecs.dateTime.decode(row[4]), instant); + expect(row[2], '12:34:56.000001'); + }); + }, + ); + + test( + 'JSON text projections distinguish SQL null and all JSON values', + () async { + await driver.run((connection) async { + await connection.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE driver_json (id INTEGER PRIMARY KEY, document JSON)', + ), + ); + final values = [ + const SqlJson({ + 'number': 1, + 'nested': [true, null], + }), + const SqlJson('a JSON string'), + const SqlJson(null), + ]; + for (var i = 0; i < values.length; i++) { + await connection.execute( + SqlCommand('INSERT INTO driver_json VALUES (?, ?)', [ + i, + values[i], + ]), + ); + } + await connection.execute( + SqlCommand('INSERT INTO driver_json VALUES (?, ?)', [3, null]), + ); + final result = await connection.execute( + SqlCommand( + 'SELECT CAST(document AS CHAR CHARACTER SET utf8mb4) ' + 'FROM driver_json ORDER BY id', + ), + ); + for (var i = 0; i < values.length; i++) { + expect( + Codecs.jsonDocument.decode(result.rows[i][0]).value, + values[i].value, + ); + } + expect(result.rows[3][0], isNull); + }); + }, + ); + + test('commits persist, rollbacks and abandoned transactions do not', () async { + await driver.run((connection) async { + await connection.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE driver_transactions (id INTEGER PRIMARY KEY) ENGINE=InnoDB', + ), + ); + await connection.execute(SqlCommand('START TRANSACTION')); + await connection.execute( + SqlCommand('INSERT INTO driver_transactions VALUES (1)'), + ); + await connection.execute(SqlCommand('COMMIT')); + await connection.execute(SqlCommand('START TRANSACTION')); + await connection.execute( + SqlCommand('INSERT INTO driver_transactions VALUES (2)'), + ); + await connection.execute(SqlCommand('ROLLBACK')); + await connection.execute(SqlCommand('START TRANSACTION')); + await connection.execute( + SqlCommand('INSERT INTO driver_transactions VALUES (3)'), + ); + // A raw caller forgot its rollback. Lease release must perform it. + }); + final result = await driver.run( + (connection) => connection.execute( + SqlCommand('SELECT id FROM driver_transactions ORDER BY id'), + ), + ); + expect(result.rows, [ + [1], + ]); + }); + + test('savepoints roll back only their own writes', () async { + await driver.run((connection) async { + await connection.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE driver_savepoint (id INTEGER) ENGINE=InnoDB', + ), + ); + await connection.execute(SqlCommand('START TRANSACTION')); + await connection.execute( + SqlCommand('INSERT INTO driver_savepoint VALUES (1)'), + ); + await connection.execute(SqlCommand('SAVEPOINT nested')); + await connection.execute( + SqlCommand('INSERT INTO driver_savepoint VALUES (2)'), + ); + await connection.execute( + SqlCommand('ROLLBACK TO SAVEPOINT nested'), + ); + await connection.execute(SqlCommand('RELEASE SAVEPOINT nested')); + await connection.execute(SqlCommand('COMMIT')); + expect( + (await connection.execute( + SqlCommand('SELECT id FROM driver_savepoint'), + )).rows, + [ + [1], + ], + ); + }); + }); + + test('a server rejection preserves its numeric code and recovers', () async { + await driver.run((connection) async { + await connection.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE driver_unique (id INTEGER PRIMARY KEY)', + ), + ); + await connection.execute( + SqlCommand('INSERT INTO driver_unique VALUES (?)', [1]), + ); + await expectLater( + connection.execute( + SqlCommand('INSERT INTO driver_unique VALUES (?)', [1]), + ), + throwsA( + isA().having((error) => error.code, 'code', 1062), + ), + ); + expect( + (await connection.execute( + SqlCommand('SELECT COUNT(*) FROM driver_unique'), + )).rows, + [ + [1], + ], + ); + }); + }); + + test( + 'whole leases serialize, all close callers wait for accepted work', + () async { + final entered = Completer(), release = Completer(); + final events = []; + final first = driver.run((connection) async { + events.add('first'); + entered.complete(); + await release.future; + await connection.execute(SqlCommand('SELECT 1')); + events.add('first done'); + }); + await entered.future; + final second = driver.run((connection) async { + events.add('second'); + await connection.execute(SqlCommand('SELECT 2')); + }); + final close = driver.close(); + expect(identical(close, driver.close()), isTrue); + var closed = false; + close.then((_) => closed = true); + await Future.delayed(Duration.zero); + expect(events, ['first']); + expect(closed, isFalse); + await expectLater( + driver.run((_) async {}), + throwsA(_code('DRIVER.CLOSED')), + ); + release.complete(); + await Future.wait([first, second, close]); + expect(events, ['first', 'first done', 'second']); + }, + ); + + test( + 'escaped leases cannot execute or discard a later lease', + () async { + late SqlConnection escaped; + await driver.run((connection) async => escaped = connection); + await expectLater( + escaped.execute(SqlCommand('SELECT 1')), + throwsA(_code('SESSION.CLOSED')), + ); + await escaped.invalidate(); + expect( + (await driver.run( + (connection) => connection.execute(SqlCommand('SELECT 2')), + )).rows, + [ + [2], + ], + ); + }, + ); + + test('connection invalidation prevents reuse', () async { + await driver.run((connection) => connection.invalidate()); + await expectLater( + driver.run((_) async {}), + throwsA(_code('DRIVER.CLOSED')), + ); + }); + + test( + 'timed-out statements discard the connection instead of reusing it', + () async { + await expectLater( + driver.run( + (connection) => connection.execute( + SqlCommand('SELECT SLEEP(0.25)'), + options: const ExecutionOptions( + timeout: Duration(milliseconds: 20), + ), + ), + ), + throwsA(_code('OPERATION.TIMEOUT')), + ); + await expectLater( + driver.run((_) async {}), + throwsA(_code('DRIVER.CLOSED')), + ); + final fresh = await open(); + try { + expect( + (await fresh.run( + (connection) => connection.execute(SqlCommand('SELECT 1')), + )).rows, + [ + [1], + ], + ); + } finally { + await fresh.close(); + } + }, + ); + + test('the wrong engine is rejected before use', () async { + final url = Uri.parse(address!); + await expectLater( + engine == 'mysql' + ? maria.MariadbDriver.open( + maria.MariadbOptions(url: url, tls: tls), + ) + : MysqlDriver.open( + MysqlOptions( + url: url.replace(scheme: 'mysql'), + tls: tls, + ), + ), + throwsA(_code('DRIVER.ENGINE')), + ); + }); + }, + skip: address == null + ? 'Set $variable to run against a real $engine server.' + : false, + ); + } +} diff --git a/test/mysql_import_test.dart b/test/mysql_import_test.dart new file mode 100644 index 00000000..cf650c87 --- /dev/null +++ b/test/mysql_import_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:orm/drivers/mysql.dart'; +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/runtime.dart'; +import 'package:test/test.dart'; + +void main() { + for (final engine in ['mysql', 'mariadb']) { + final variable = 'ORM_TEST_${engine.toUpperCase()}'; + final address = Platform.environment[variable]; + final tls = MysqlTls.values.byName( + Platform.environment['${variable}_TLS'] ?? 'verifyFull', + ); + group( + 'catalog import $engine', + () { + late SqlDatabase db; + late Directory directory; + final table = 'orm_import_${pid}_$engine'; + setUp(() async { + db = SqlDatabase( + engine == 'mysql' + ? await MysqlDriver.open( + MysqlOptions(url: Uri.parse(address!), tls: tls), + ) + : await MariadbDriver.open( + MariadbOptions(url: Uri.parse(address!), tls: tls), + ), + ); + directory = await Directory('.dart_tool').createTemp('mysql-import-'); + }); + tearDown(() async { + try { + await db.execute(SqlCommand('DROP TABLE IF EXISTS `$table`')); + } finally { + await db.close(); + await directory.delete(recursive: true); + } + }); + + test('supported storage imports nominal classes and reproduces physical metadata', () async { + final schema = TableSchema( + table, + columns: [ + Column('id', Codecs.integer, generated: true), + Column('title', Codecs.text), + Column('active', Codecs.boolean, defaultSql: 'false'), + Column( + 'amount', + Codecs.decimal, + decimalPrecision: 12, + decimalScale: 2, + ), + Column('created', Codecs.localDateTime, temporalPrecision: 3), + Column( + 'document', + Codecs.jsonDocument.nullable(), + nullable: true, + ), + ], + primaryKey: ['id'], + ); + for (final command in createSchema([schema], db.dialect)) { + await db.execute(command); + } + final imported = await importSchema(db, tables: [table]); + expect( + imported.hasBlockingIssues, + false, + reason: imported.toJson().toString(), + ); + expect(imported.dart, contains('final class')); + expect(imported.dart, contains('@Id.generated()')); + expect(imported.dart, contains('LocalDateTime')); + expect( + imported.issues.map((i) => i.code), + contains('IMPORT.TEMPORAL_SEMANTICS'), + ); + expect( + imported.issues.map((i) => i.code), + contains('IMPORT.BOOLEAN_SEMANTICS'), + ); + final source = File('${directory.path}/schema.dart'); + await source.writeAsString(imported.dart); + final generated = await generateSchema(source.path); + final verification = await verifySchema(db, generated.snapshot); + expect(verification.differences, isEmpty); + }); + + test( + 'unsigned and binary text types cannot become guessed codecs', + () async { + await db.execute( + SqlCommand('''CREATE TABLE `$table` ( + id BIGINT UNSIGNED NOT NULL, + token VARBINARY(16) NOT NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin'''), + ); + final imported = await importSchema(db, tables: [table]); + expect(imported.hasBlockingIssues, true); + expect(imported.entities, isEmpty); + expect( + imported.issues.where((i) => i.code == 'IMPORT.TYPE'), + hasLength(2), + ); + }, + ); + + test('named SQL checks structure without pretending to infer storage types', () async { + final source = File('${directory.path}/queries.dart'); + await source.writeAsString(''' +import 'package:orm/schema.dart'; +typedef Result = ({int value}); +final probe = sqlQuery($engine: 'query.sql'); +'''); + await File('${directory.path}/query.sql') + .writeAsString('SELECT :minimum AS value'); + final generated = await generateQueries(source.path); + final checked = await checkSqlQueries(db, generated); + expect(checked.single['structureChecked'], true); + expect(checked.single['storageTypesChecked'], false); + expect(checked.single['nullabilityChecked'], false); + }); + + test('named SQL preparation never executes stored functions', () async { + final function = '${table}_effect'; + await db.execute( + SqlCommand('CREATE TABLE `$table` (n INT NOT NULL)'), + ); + await db.execute( + SqlCommand('''CREATE FUNCTION `$function`() RETURNS INT +DETERMINISTIC MODIFIES SQL DATA +BEGIN INSERT INTO `$table` VALUES (1); RETURN 1; END; /* final delimiter */'''), + ); + try { + final source = File('${directory.path}/queries.dart'); + await source.writeAsString(''' +import 'package:orm/schema.dart'; +typedef Result = ({int value}); +final probe = sqlQuery($engine: 'query.sql'); +'''); + final sql = File('${directory.path}/query.sql'); + await sql.writeAsString('SELECT `$function`() + :minimum AS value'); + final generated = await generateQueries(source.path); + expect( + (await checkSqlQueries(db, generated)).single['structureChecked'], + true, + ); + expect( + (await db.execute(SqlCommand('SELECT COUNT(*) FROM `$table`'))) + .rows + .single + .single, + 0, + ); + + await sql.writeAsString( + 'SELECT `$function`() + :minimum AS wrong_alias', + ); + final invalid = await generateQueries(source.path); + await expectLater( + checkSqlQueries(db, invalid), + throwsA(isA()), + ); + // A rejected PREPARE also cleans up its session state and keeps the + // connection usable without running the function during checking. + expect( + (await db.execute(SqlCommand('SELECT COUNT(*) FROM `$table`'))) + .rows + .single + .single, + 0, + ); + } finally { + await db.execute(SqlCommand('DROP FUNCTION IF EXISTS `$function`')); + } + }); + }, + skip: address == null ? 'Set $variable to a disposable database.' : false, + ); + } +} diff --git a/test/mysql_migration_test.dart b/test/mysql_migration_test.dart new file mode 100644 index 00000000..ab89f338 --- /dev/null +++ b/test/mysql_migration_test.dart @@ -0,0 +1,751 @@ +import 'dart:io'; + +import 'package:orm/drivers/mysql.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/runtime.dart'; +import 'package:test/test.dart'; + +import '../tool/src/build_fixture.dart'; + +TableSchema _items({ + String table = 'items', + String label = 'label', + bool note = false, + bool requiredNote = false, +}) => TableSchema( + table, + columns: [ + Column('id', Codecs.integer, generated: true), + Column(label, Codecs.text), + Column('enabled', Codecs.boolean, defaultSql: 'false'), + Column('escaped', Codecs.text, defaultSql: r"'a''b\c'"), + Column( + 'created', + Codecs.localDateTime, + temporalPrecision: 3, + defaultSql: 'CURRENT_TIMESTAMP(3)', + ), + Column( + 'amount', + Codecs.decimal, + decimalPrecision: 12, + decimalScale: 2, + defaultSql: '0.00', + ), + if (note) + Column( + 'note', + requiredNote ? Codecs.text : Codecs.text.nullable(), + nullable: !requiredNote, + ), + ], + primaryKey: ['id'], + uniqueKeys: [ + [label], + ], + checks: [ + const CheckSchema( + 'items_amount_positive', + 'amount >= 0 AND amount <= 1000000', + ), + ], +); + +void main() { + for (final dialect in [SqlDialect.mysql, SqlDialect.mariadb]) { + test( + '${dialect.name} freezes recoverable DDL and standalone Dart source', + () { + final migration = Migration.create('0001_initial', [ + _items(), + ], dialect: dialect); + expect(migration.steps, everyElement(isA())); + expect(migrationSource(migration), contains('CheckedTableSql(')); + expect( + migrationSource(migration), + contains('SqlDialect.${dialect.name}'), + ); + expect( + migration.checksum, + Migration.create('0001_initial', [ + _items(), + ], dialect: dialect).checksum, + ); + validateMigrations([migration], dialect: dialect); + }, + ); + test('${dialect.name} rejects uncheckpointed DDL and unsafe narrowing', () { + expect( + () => validateMigrations([ + Migration('0001_unsafe', [ + 'CREATE TABLE unsafe (id INT)', + ], dialect: dialect), + ], dialect: dialect), + throwsA(isA()), + ); + for (final sql in [ + 'UPDATE n SET id = 1; COMMIT', + 'UPDATE n SET id = 1 /*!; COMMIT */', + ]) { + expect( + () => validateMigrations([ + Migration('0001_hidden', [sql], dialect: dialect), + ], dialect: dialect), + throwsA(isA()), + ); + } + final a = TableSchema( + 'n', + columns: [Column('id', Codecs.integer)], + primaryKey: ['id'], + ); + final b = TableSchema( + 'n', + columns: [Column('id', Codecs.integer, integerBits: 16)], + primaryKey: ['id'], + ); + expect( + () => Migration.diff( + '0002_narrow', + dialect: dialect, + from: SchemaSnapshot([a]), + to: SchemaSnapshot([b]), + ), + throwsA(isA()), + ); + expect( + () => Migration.diff( + '0002_drop', + dialect: dialect, + from: SchemaSnapshot([a]), + to: SchemaSnapshot([]), + ), + throwsA(isA()), + ); + }); + } + test('MySQL and MariaDB frozen Dart migrations compile with unchanged fingerprints', () async { + final fixture = await BuildFixture.create(ormPath: Directory.current.path); + try { + final imports = []; + final assertions = []; + for (final dialect in [SqlDialect.mysql, SqlDialect.mariadb]) { + final first = Migration.create('0001_initial', [ + _items(), + ], dialect: dialect); + final after = _items(note: true); + final change = Migration.diff( + '0002_backfill', + dialect: dialect, + from: first.snapshot!, + to: SchemaSnapshot([after]), + ); + final second = Migration.steps( + '0002_backfill', + [ + ...change.steps, + Backfill(after, set: {'note': 'label'}, doneWhen: 'SELECT TRUE'), + ], + dialect: dialect, + previous: first.checksum, + snapshot: SchemaSnapshot([after]), + ); + for (final migration in [first, second]) { + final alias = '${dialect.name}_${migration.id}'; + await fixture.write('lib/$alias.dart', migrationSource(migration)); + imports.add("import '../lib/$alias.dart' as $alias;"); + assertions.add( + "if ($alias.migration.checksum != $alias.migrationChecksum || $alias.migration.checksum != '${migration.checksum}') throw StateError('$alias fingerprint');", + ); + } + } + for (final dialect in [SqlDialect.mysql, SqlDialect.mariadb]) { + assertions.add( + 'validateMigrations([${dialect.name}_0001_initial.migration, ${dialect.name}_0002_backfill.migration], dialect: SqlDialect.${dialect.name});', + ); + } + await fixture.write( + 'bin/check.dart', + "import 'package:orm/migrate.dart';\n${imports.join('\n')}\nvoid main() { ${assertions.join('\n')} print('frozen-mysql-history-ok'); }", + ); + final result = await fixture.run(['run', 'bin/check.dart']); + expect(result.output, contains('frozen-mysql-history-ok')); + } finally { + await fixture.dispose(); + } + }); + + for (final engine in ['mysql', 'mariadb']) { + final variable = 'ORM_TEST_${engine.toUpperCase()}'; + final address = Platform.environment[variable]; + final tls = MysqlTls.values.byName( + Platform.environment['${variable}_TLS'] ?? 'verifyFull', + ); + group( + '$engine migrations', + () { + late SqlDatabase admin, db; + late _FaultDriver driver; + final namespace = 'orm_migrations_${pid}_$engine'; + Future> open(Uri url) async => engine == 'mysql' + ? await MysqlDriver.open(MysqlOptions(url: url, tls: tls)) + : await MariadbDriver.open(MariadbOptions(url: url, tls: tls)); + setUpAll(() async { + admin = SqlDatabase(await open(Uri.parse(address!))); + }); + tearDownAll(() => admin.close()); + setUp(() async { + await admin.execute( + SqlCommand('DROP DATABASE IF EXISTS "$namespace"'), + ); + await admin.execute( + SqlCommand( + 'CREATE DATABASE "$namespace" CHARACTER SET utf8mb4 COLLATE utf8mb4_bin', + ), + ); + driver = _FaultDriver( + await open(Uri.parse(address!).replace(path: '/$namespace')), + ); + db = SqlDatabase(driver); + }); + tearDown(() async { + await db.close(); + await admin.execute(SqlCommand('DROP DATABASE "$namespace"')); + }); + Migration initial() => + Migration.create('0001_initial', [_items()], dialect: db.dialect); + + test('create, catalog, immutable history and DML agree', () async { + final migration = initial(); + expect(await Migrator(db).apply([migration]), ['0001_initial']); + final check = await verifySchema(db, migration.snapshot!); + expect(check.differences, isEmpty); + expect(check.unmanaged, isEmpty); + expect(await Migrator(db).apply([migration]), isEmpty); + expect( + (await Migrator(db).requireVersion([migration])).id, + migration.id, + ); + expect( + (await Migrator(db).progress()).map((p) => p.state), + everyElement(MigrationStepState.complete), + ); + await expectLater( + Migrator(db).plan([ + Migration.create('0001_initial', [ + _items(note: true), + ], dialect: db.dialect), + ]), + throwsA(isA()), + ); + }); + + for (final metadata in ['_orm_migrations', '_orm_migration_steps']) { + test('temporary-only $metadata cannot receive migration state', () async { + final first = initial(); + await db.session((session) async { + final columns = metadata == '_orm_migrations' + ? 'id VARCHAR(191) NOT NULL PRIMARY KEY, checksum CHAR(64) NOT NULL, applied_at VARCHAR(40) NOT NULL' + : 'migration_id VARCHAR(191) NOT NULL, checksum CHAR(64) NOT NULL, step INT NOT NULL, state VARCHAR(16) NOT NULL, phase VARCHAR(32) NOT NULL, failure TEXT, backfill LONGTEXT, PRIMARY KEY(migration_id, step)'; + expect( + (await session.execute( + SqlCommand( + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?', + [metadata], + ), + )).rows, + isEmpty, + ); + await session.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE "$metadata" ($columns) ENGINE=InnoDB', + ), + ); + try { + await expectLater( + Migrator(session).apply([first]), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'MIGRATION.SESSION', + ), + ), + ); + expect( + (await session.execute( + SqlCommand('SELECT * FROM "$metadata"'), + )).rows, + isEmpty, + ); + expect(await inspectColumns(session, 'items'), isEmpty); + } finally { + await session.execute( + SqlCommand('DROP TEMPORARY TABLE "$metadata"'), + ); + } + }); + expect(await Migrator(db).history(), isEmpty); + expect(await Migrator(db).progress(), isEmpty); + expect(await Migrator(db).apply([first]), [first.id]); + }); + } + + test( + 'DDL acknowledgement failure resumes from exact catalog state', + () async { + final first = initial(); + await Migrator(db).apply([first]); + final second = Migration.diff( + '0002_note', + dialect: db.dialect, + from: first.snapshot!, + to: SchemaSnapshot([_items(note: true)]), + previous: first.checksum, + ); + driver.failAfterDdl = true; + await expectLater( + Migrator(db).apply([first, second]), + throwsA(isA()), + ); + expect( + (await inspectColumns(db, 'items')).map((c) => c.name), + contains('note'), + ); + expect((await Migrator(db).history()), hasLength(1)); + final executed = driver.alterCount; + expect(await Migrator(db).apply([first, second]), [second.id]); + expect( + driver.alterCount, + executed, + reason: 'the acknowledged durable DDL must not replay', + ); + }, + ); + + test( + 'same table name with wrong shape is never accepted as completion', + () async { + await db.execute( + SqlCommand( + 'CREATE TABLE items (id BIGINT NOT NULL PRIMARY KEY) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin', + ), + ); + await expectLater( + Migrator(db).apply([initial()]), + throwsA(isA()), + ); + expect(await Migrator(db).history(), isEmpty); + expect( + (await Migrator(db).progress()).single.failure, + 'MIGRATION.RECOVERY', + ); + expect((await inspectColumns(db, 'items')), hasLength(1)); + final other = SqlDatabase( + await open(Uri.parse(address!).replace(path: '/$namespace')), + ); + try { + await expectLater( + Migrator(other, lockTimeout: Duration.zero).apply([initial()]), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'MIGRATION.STEP', + ), + ), + ); + } finally { + await other.close(); + } + }, + ); + + test( + 'bounded backfill checkpoints rows with DML in one transaction', + () async { + final first = initial(); + await Migrator(db).apply([first]); + await db.execute( + SqlCommand("INSERT INTO items(label) VALUES ('a'), ('b'), ('c')"), + ); + final expanded = _items(note: true); + final diff = Migration.diff( + '0002_fill', + dialect: db.dialect, + from: first.snapshot!, + to: SchemaSnapshot([expanded]), + ); + final second = Migration.steps( + '0002_fill', + [ + ...diff.steps, + Backfill( + expanded, + set: {'note': 'label'}, + where: 'note IS NULL', + doneWhen: 'SELECT NOT EXISTS (SELECT 1 FROM items WHERE note IS NULL)', + batchSize: 1, + ), + ], + dialect: db.dialect, + snapshot: SchemaSnapshot([expanded]), + previous: first.checksum, + ); + expect( + await Migrator(db).apply([first, second], maxBackfillBatches: 1), + isEmpty, + ); + expect((await Migrator(db).progress()).last.backfill!.rows, 1); + expect(await Migrator(db).apply([first, second]), [second.id]); + expect( + (await db.execute( + SqlCommand('SELECT label,note FROM items ORDER BY id'), + )).rows, + [ + ['a', 'a'], + ['b', 'b'], + ['c', 'c'], + ], + ); + final third = Migration.diff( + '0003_required', + dialect: db.dialect, + from: second.snapshot!, + to: SchemaSnapshot([_items(note: true, requiredNote: true)]), + previous: second.checksum, + ); + expect(await Migrator(db).apply([first, second, third]), [ + third.id, + ]); + }, + ); + + test( + 'exact BigInt and instant cursors survive backfill resume', + () async { + final table = TableSchema( + 'cursor_keys', + columns: [ + Column('huge', Codecs.bigint), + Column('moment', Codecs.dateTime), + Column('touches', Codecs.integer, defaultSql: '0'), + ], + primaryKey: ['huge', 'moment'], + ); + final first = Migration.create('0001_keys', [ + table, + ], dialect: db.dialect); + await Migrator(db).apply([first]); + final moments = [ + DateTime.utc(2026, 1, 1, 0, 0, 0, 0, 1), + DateTime.utc(2026, 1, 1, 0, 0, 0, 0, 2), + ]; + for (var i = 0; i < 2; i++) { + await db.execute( + SqlCommand( + 'INSERT INTO cursor_keys(huge,moment) VALUES (?,?)', + [ + BigInt.parse('90071992547409930000') + BigInt.from(i), + moments[i], + ], + ), + ); + } + final second = Migration.steps( + '0002_fill', + [ + Backfill( + table, + set: {'touches': 'touches + 1'}, + where: 'touches = 0', + doneWhen: 'SELECT NOT EXISTS (SELECT 1 FROM cursor_keys WHERE touches <> 1)', + batchSize: 1, + ), + ], + dialect: db.dialect, + previous: first.checksum, + snapshot: SchemaSnapshot([table]), + ); + expect( + await Migrator(db).apply([first, second], maxBackfillBatches: 1), + isEmpty, + ); + expect((await Migrator(db).progress()).last.backfill!.rows, 1); + expect(await Migrator(db).apply([first, second]), [second.id]); + final rows = (await db.execute( + SqlCommand( + 'SELECT huge,moment,touches FROM cursor_keys ORDER BY huge,moment', + ), + )).rows; + expect(rows.map((r) => r[0].toString()), [ + '90071992547409930000', + '90071992547409930001', + ]); + expect(rows.map((r) => Codecs.dateTime.decode(r[1])), moments); + expect(rows.map((r) => r[2]), [1, 1]); + }, + ); + + test('DML rolls back when its completion checkpoint fails', () async { + final first = initial(); + await Migrator(db).apply([first]); + await db.execute( + SqlCommand("INSERT INTO items(label) VALUES ('old')"), + ); + final second = Migration( + '0002_data', + ["UPDATE items SET label = 'new'"], + dialect: db.dialect, + previous: first.checksum, + ); + driver.failDmlCheckpoint = true; + await expectLater( + Migrator(db).apply([first, second]), + throwsA(isA()), + ); + expect( + (await db.execute(SqlCommand('SELECT label FROM items'))) + .rows + .single + .single, + 'old', + ); + expect(await Migrator(db).apply([first, second]), [second.id]); + expect( + (await db.execute(SqlCommand('SELECT label FROM items'))) + .rows + .single + .single, + 'new', + ); + }); + + test( + 'uncertain DML commit preserves completion and never repeats writes', + () async { + final first = initial(); + await Migrator(db).apply([first]); + await db.execute( + SqlCommand("INSERT INTO items(label) VALUES ('old')"), + ); + final second = Migration( + '0002_data', + ["UPDATE items SET label = CONCAT(label, '!')"], + dialect: db.dialect, + previous: first.checksum, + ); + driver.failAfterDmlCommit = true; + await expectLater( + Migrator(db).apply([first, second]), + throwsA(isA()), + ); + expect( + (await Migrator(db).progress()).last.state, + MigrationStepState.complete, + ); + expect( + (await db.execute(SqlCommand('SELECT label FROM items'))) + .rows + .single + .single, + 'old!', + ); + expect(await Migrator(db).apply([first, second]), [second.id]); + expect( + (await db.execute(SqlCommand('SELECT label FROM items'))) + .rows + .single + .single, + 'old!', + ); + }, + ); + + test( + 'explicit table and column renames retain data and canonical keys', + () async { + final first = initial(); + await Migrator(db).apply([first]); + await db.execute( + SqlCommand("INSERT INTO items(label) VALUES ('kept')"), + ); + final next = _items(table: 'products', label: 'title'); + final second = Migration.diff( + '0002_rename', + dialect: db.dialect, + from: first.snapshot!, + to: SchemaSnapshot([next]), + renames: const SchemaRenames( + tables: {'items': 'products'}, + columns: { + 'products': {'label': 'title'}, + }, + ), + previous: first.checksum, + ); + expect(await Migrator(db).apply([first, second]), [second.id]); + expect( + (await db.execute(SqlCommand('SELECT title FROM products'))) + .rows + .single + .single, + 'kept', + ); + expect( + (await verifySchema(db, second.snapshot!)).differences, + isEmpty, + ); + }, + ); + + test('foreign keys, supporting indexes, JSON and computed values round trip', () async { + final parent = TableSchema( + 'parents', + columns: [Column('id', Codecs.integer)], + primaryKey: ['id'], + ); + final child = TableSchema( + 'children', + columns: [ + Column('id', Codecs.integer), + Column('parent_id', Codecs.integer), + Column( + 'document', + Codecs.jsonDocument.nullable(), + nullable: true, + ), + Column( + 'doubled', + Codecs.integer.nullable(), + nullable: true, + computed: const ComputedColumn('id * (id + 1)'), + ), + ], + primaryKey: ['id'], + foreignKeys: [ + const ForeignKey( + ['parent_id'], + 'parents', + ['id'], + onDelete: 'NO ACTION', + ), + ], + ); + final first = Migration.create('0001_relations', [ + parent, + child, + ], dialect: db.dialect); + expect(await Migrator(db).apply([first]), [first.id]); + final verified = await verifySchema(db, first.snapshot!); + expect(verified.differences, isEmpty); + expect(verified.unmanaged, isEmpty); + await db.execute(SqlCommand('INSERT INTO parents VALUES (1)')); + await db.execute( + SqlCommand('INSERT INTO children(id,parent_id) VALUES(2,1)'), + ); + expect( + (await db.execute(SqlCommand('SELECT doubled FROM children'))) + .rows + .single + .single, + 6, + ); + final second = Migration.diff( + '0002_drop_relations', + dialect: db.dialect, + from: first.snapshot!, + to: SchemaSnapshot([]), + previous: first.checksum, + allowDestructive: true, + ); + expect(await Migrator(db).apply([first, second]), [second.id]); + }); + + test( + 'baseline verifies existing schema and registers the fixed engine', + () async { + final first = initial(); + for (final command in createSchema([_items()], db.dialect)) { + await db.execute(command); + } + expect( + (await Migrator( + db, + ).baseline([first], expected: first.snapshot!)).matches, + true, + ); + expect( + (await Migrator(db).history()).single.checksum, + first.checksum, + ); + }, + ); + }, + skip: address == null + ? 'Set $variable to a disposable database with CREATE DATABASE privileges.' + : false, + ); + } +} + +final class _FaultDriver implements Driver { + final Driver inner; + bool failAfterDdl = false, failDmlCheckpoint = false; + bool failAfterDmlCommit = false, commitFailureArmed = false; + int alterCount = 0; + _FaultDriver(this.inner); + @override + Capabilities get capabilities => inner.capabilities; + @override + Future run(Future Function(SqlConnection) action) => + inner.run((connection) => action(_FaultConnection(this, connection))); + @override + Future close() => inner.close(); +} + +final class _FaultConnection implements SqlConnection { + final _FaultDriver driver; + final SqlConnection inner; + _FaultConnection(this.driver, this.inner); + @override + bool? get transactionActive => inner.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + if (driver.failDmlCheckpoint && + command.sql.contains('INSERT INTO "_orm_migration_steps"') && + command.parameters.length == 7 && + command.parameters[3] == 'complete') { + driver.failDmlCheckpoint = false; + throw StateError('injected checkpoint failure before submission'); + } + final result = await inner.execute(command, options: options); + if (driver.failAfterDmlCommit && command.sql.startsWith('UPDATE items')) { + driver.commitFailureArmed = true; + } + if (command.sql == 'COMMIT' && driver.commitFailureArmed) { + driver.failAfterDmlCommit = false; + driver.commitFailureArmed = false; + throw StateError('injected lost commit acknowledgement'); + } + if (command.sql.startsWith('ALTER TABLE')) { + driver.alterCount++; + if (driver.failAfterDdl) { + driver.failAfterDdl = false; + throw StateError('injected lost DDL acknowledgement'); + } + } + return result; + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => inner.openCursor(command, options: options); + @override + Future invalidate() => inner.invalidate(); +} diff --git a/test/mysql_transaction_boundary_test.dart b/test/mysql_transaction_boundary_test.dart new file mode 100644 index 00000000..7d73c547 --- /dev/null +++ b/test/mysql_transaction_boundary_test.dart @@ -0,0 +1,356 @@ +import 'dart:io'; + +import 'package:orm/drivers/mysql.dart'; +import 'package:orm/runtime.dart'; +import 'package:test/test.dart'; + +Matcher _code(String code) => + isA().having((error) => error.code, 'code', code); + +void main() { + for (final dialect in [SqlDialect.mysql, SqlDialect.mariadb]) { + group('${dialect.name} boundary without server status', () { + for (final sql in [ + 'CREATE TABLE unsafe (id INT)', + 'CREATE TEMPORARY TABLE unsafe (id INT)', + 'ALTER TABLE data ADD COLUMN extra INT', + 'DROP TABLE data', + 'TRUNCATE TABLE data', + 'COMMIT', + 'ROLLBACK', + 'BEGIN', + 'START TRANSACTION', + 'SAVEPOINT user_savepoint', + 'RELEASE SAVEPOINT user_savepoint', + 'SET AUTOCOMMIT = 1', + 'SET SESSION sql_mode = ""', + 'CALL might_commit()', + 'PREPARE stmt FROM "COMMIT"', + 'EXECUTE stmt', + 'EXECUTE IMMEDIATE "COMMIT"', + 'LOCK TABLES data WRITE', + 'UNLOCK TABLES', + 'ANALYZE TABLE data', + 'SELECT 1; COMMIT', + 'SELECT 1; /* comment */ SET AUTOCOMMIT=1', + 'SELECT 1;;', + '/*!80000 COMMIT */', + '/*M!100100 COMMIT */', + 'SELECT 1 /*! INTO OUTFILE "/tmp/unsafe" */', + 'SELECT 1; /*! COMMIT */', + 'SELECT 1; /*M! COMMIT */', + "SELECT 'unterminated", + 'SELECT 1 /* unterminated', + '/* nested /* comment */ SELECT 1 */', + r"SELECT 'ambiguous\'; COMMIT; -- '", + '# comment only', + ]) { + test('rejects $sql before execution', () async { + final driver = _Driver(dialect); + final db = SqlDatabase(driver); + await expectLater( + db.transaction((tx) => tx.execute(SqlCommand(sql))), + throwsA(_code('TRANSACTION.STATEMENT')), + ); + expect(driver.connection.commands, ['START TRANSACTION', 'ROLLBACK']); + await db.close(); + }); + } + + test('permits data statements, comments and quoted semicolons', () async { + final driver = _Driver(dialect); + final db = SqlDatabase(driver); + final statements = [ + '/* ordinary */ -- comment\n SELECT 1; # trailing comment', + "SELECT '; COMMIT', 'it''s fine', `a``;b`, \"a\"\";b\"", + "SELECT '/*! COMMIT */', '/*M! COMMIT */'", + 'SELECT 1--1', + 'SELECT /*+ MAX_EXECUTION_TIME(1000) */ 1', + 'WITH result AS (SELECT 1 AS n) SELECT n FROM result', + 'INSERT INTO data VALUES (?)', + 'UPDATE data SET id = ?', + 'DELETE FROM data WHERE id = ?', + 'REPLACE INTO data VALUES (?)', + 'SHOW TABLES', + 'DESCRIBE data', + 'DESC data', + 'EXPLAIN SELECT 1', + ]; + await db.transaction((tx) async { + for (final sql in statements) { + await tx.execute(SqlCommand(sql)); + } + }); + expect(driver.connection.commands, [ + 'START TRANSACTION', + ...statements, + 'COMMIT', + ]); + await db.close(); + }); + + test('run connections cannot bypass checks or swallow failure', () async { + final driver = _Driver(dialect); + final db = SqlDatabase(driver); + await expectLater( + db.transaction((tx) async { + await tx.run((connection) async { + await expectLater( + connection.execute(SqlCommand('COMMIT')), + throwsA(_code('TRANSACTION.STATEMENT')), + ); + }); + }), + throwsA(_code('TRANSACTION.FAILED')), + ); + expect(driver.connection.commands, ['START TRANSACTION', 'ROLLBACK']); + await db.close(); + }); + + test('executeOn cannot bypass checks with a raw connection', () async { + final driver = _Driver(dialect); + final db = SqlDatabase(driver); + await expectLater( + db.transaction( + (tx) => tx.executeOn(driver.connection, SqlCommand('COMMIT')), + ), + throwsA(_code('TRANSACTION.STATEMENT')), + ); + expect(driver.connection.commands, ['START TRANSACTION', 'ROLLBACK']); + await db.close(); + }); + + test( + 'cursor entry rejects control SQL before reaching the driver', + () async { + final driver = _Driver(dialect); + final db = SqlDatabase(driver); + await expectLater( + db.transaction( + (tx) => tx.run( + (connection) => connection.openCursor(SqlCommand('COMMIT')), + ), + ), + throwsA(_code('TRANSACTION.STATEMENT')), + ); + expect(driver.connection.commands, ['START TRANSACTION', 'ROLLBACK']); + await db.close(); + }, + ); + + test('borrowed transaction connection expires with its owner', () async { + final driver = _Driver(dialect); + final db = SqlDatabase(driver); + late SqlConnection saved; + await db.transaction( + (tx) => tx.run((connection) async { + saved = connection; + await connection.execute(SqlCommand('SELECT 1')); + }), + ); + await expectLater( + saved.execute(SqlCommand('SELECT 2')), + throwsA(_code('SESSION.CLOSED')), + ); + expect(driver.connection.commands, [ + 'START TRANSACTION', + 'SELECT 1', + 'COMMIT', + ]); + await db.close(); + }); + }); + } + + for (final engine in ['mysql', 'mariadb']) { + final variable = 'ORM_TEST_${engine.toUpperCase()}'; + final address = Platform.environment[variable]; + final tls = MysqlTls.values.byName( + Platform.environment['${variable}_TLS'] ?? 'verifyFull', + ); + group( + '$engine real transaction boundary', + () { + late SqlDatabase db; + final events = []; + Future insert(SqlDatabase session, int id) async { + await session.execute( + SqlCommand('INSERT INTO orm_transaction_boundary (id) VALUES (?)', [ + id, + ]), + ); + } + + Future> ids() async => (await db.execute( + SqlCommand('SELECT id FROM orm_transaction_boundary ORDER BY id'), + )).rows.map((row) => row.single).toList(); + setUp(() async { + final Driver driver = engine == 'mysql' + ? await MysqlDriver.open( + MysqlOptions(url: Uri.parse(address!), tls: tls), + ) + : await MariadbDriver.open( + MariadbOptions(url: Uri.parse(address!), tls: tls), + ); + db = SqlDatabase(driver, onQuery: events.add); + await db.execute( + SqlCommand('DROP TABLE IF EXISTS orm_transaction_boundary'), + ); + await db.execute( + SqlCommand( + 'CREATE TABLE orm_transaction_boundary (id BIGINT PRIMARY KEY) ENGINE=InnoDB', + ), + ); + events.clear(); + }); + tearDown(() async { + await db.execute( + SqlCommand('DROP TABLE IF EXISTS orm_transaction_boundary'), + ); + await db.close(); + }); + + test('DDL cannot commit previously inserted rows', () async { + await expectLater( + db.transaction((tx) async { + await insert(tx, 1); + await tx.execute( + SqlCommand( + 'ALTER TABLE orm_transaction_boundary ADD COLUMN unsafe INT', + ), + ); + }), + throwsA(_code('TRANSACTION.STATEMENT')), + ); + expect(await ids(), isEmpty); + final columns = await db.execute( + SqlCommand('SHOW COLUMNS FROM orm_transaction_boundary'), + ); + expect(columns.rows.map((row) => row.first), ['id']); + expect(events.any((event) => event.error is OrmException), isTrue); + }); + + test('caught raw COMMIT still rolls back the transaction', () async { + await expectLater( + db.transaction((tx) async { + await insert(tx, 1); + await tx.run((connection) async { + await expectLater( + connection.execute(SqlCommand('COMMIT')), + throwsA(_code('TRANSACTION.STATEMENT')), + ); + }); + }), + throwsA(_code('TRANSACTION.FAILED')), + ); + expect(await ids(), isEmpty); + }); + + test( + 'caught driver error through run cannot commit a partial batch', + () async { + await expectLater( + db.transaction((tx) async { + await insert(tx, 1); + await tx.run((connection) async { + await expectLater( + connection.execute( + SqlCommand( + 'INSERT INTO orm_transaction_boundary (id) VALUES (1)', + ), + ), + throwsA(isA()), + ); + }); + }), + throwsA(_code('TRANSACTION.FAILED')), + ); + expect(await ids(), isEmpty); + }, + ); + + test( + 'savepoint rejection rolls back only the child and preserves events', + () async { + await db.transaction((tx) async { + await insert(tx, 1); + await expectLater( + tx.savepoint((child) async { + await insert(child, 2); + await child.run( + (connection) => + connection.execute(SqlCommand('SET AUTOCOMMIT = 1')), + ); + }), + throwsA(_code('TRANSACTION.STATEMENT')), + ); + await tx.savepoint((child) async { + await insert(child, 3); + final result = await child.execute( + SqlCommand( + "/* harmless */ SELECT '; COMMIT', 'it''s safe'; -- done\n", + ), + ); + expect(result.rows.single, ['; COMMIT', "it's safe"]); + }); + }); + expect(await ids(), [1, 3]); + expect( + events.map((event) => event.sql), + containsAllInOrder([ + 'START TRANSACTION', + 'SAVEPOINT orm_sp_0', + 'ROLLBACK TO SAVEPOINT orm_sp_0', + 'RELEASE SAVEPOINT orm_sp_0', + 'SAVEPOINT orm_sp_1', + 'RELEASE SAVEPOINT orm_sp_1', + 'COMMIT', + ]), + ); + }, + ); + }, + skip: address == null + ? 'Set $variable for live database validation.' + : false, + ); + } +} + +final class _Driver(final SqlDialect dialect) implements Driver { + final connection = _Connection(); + @override + late final capabilities = Capabilities( + dialect: dialect, + maxParameters: 65535, + ); + @override + Future run(Future Function(SqlConnection) action) => + action(connection); + @override + Future close() async {} +} + +final class _Connection implements SqlConnection { + final commands = []; + @override + bool? get transactionActive => null; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + commands.add(command.sql); + return const SqlResult([]); + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => throw StateError( + 'Cursor SQL must be rejected before reaching the driver.', + ); + @override + Future invalidate() async {} +} diff --git a/test/named_sql_generation_test.dart b/test/named_sql_generation_test.dart new file mode 100644 index 00000000..b754d602 --- /dev/null +++ b/test/named_sql_generation_test.dart @@ -0,0 +1,260 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:orm/generate.dart'; +import 'package:orm/builder.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory directory; + setUp(() async { + directory = await Directory('.dart_tool').createTemp('named-generation-'); + }); + tearDown(() => directory.delete(recursive: true)); + File file(String name) => File('${directory.path}/$name'); + + Future declaration(String record, String call, String sql) async { + await file('query.dart').writeAsString( + "import 'package:orm/schema.dart';\n$record\nfinal query = $call;\n", + ); + await file('query.sql').writeAsString(sql); + } + + test('source contracts reject missing/extra parameters and row metadata', () async { + for (final (record, call, sql) in [ + ( + 'typedef Row = ({int n});', + "sqlQuery(sqlite: 'query.sql')", + 'SELECT :n AS n', + ), + ( + 'typedef Row = ({@Id() int n});', + "sqlQuery(sqlite: 'query.sql')", + 'SELECT 1 AS n', + ), + ( + "typedef Row = ({@Computed.sql('1') int n});", + "sqlQuery(sqlite: 'query.sql')", + 'SELECT 1 AS n', + ), + ( + "int factory() => 1; typedef Row = ({@ClientDefault(factory) int n});", + "sqlQuery(sqlite: 'query.sql')", + 'SELECT 1 AS n', + ), + ( + 'typedef Row = ({@TemporalPrecision(2) DateTime n});', + "sqlQuery(sqlite: 'query.sql')", + 'SELECT 1 AS n', + ), + ( + 'typedef Row = ({int n, @ColumnName("n") String duplicate});', + "sqlQuery(sqlite: 'query.sql')", + 'SELECT 1 AS n', + ), + ( + 'typedef Row = (int,);', + "sqlQuery(sqlite: 'query.sql')", + 'SELECT 1 AS n', + ), + ('typedef Row = ({int n});', 'sqlQuery()', 'SELECT 1 AS n'), + ( + 'typedef Row = ({int n});', + "sqlQuery(sqlite: 'query.sql')", + 'SELECT 1; DELETE FROM posts', + ), + ]) { + await declaration(record, call, sql); + await expectLater( + generateQueries(file('query.dart').path), + throwsA(anyOf(isA(), isA())), + ); + } + }); + + test('SQL edits and modified generated code fail freshness checks', () async { + await declaration( + 'typedef Row = ({int n});', + "sqlQuery(sqlite: 'query.sql')", + 'SELECT 1 AS n', + ); + final source = file('query.dart').path; + await writeGeneratedQueries(source); + await checkGeneratedQueries(source); + expect(await file('query.queries.json').exists(), false); + await file('query.sql').writeAsString('SELECT 2 AS n'); + await expectLater( + checkGeneratedQueries(source), + throwsA(isA()), + ); + await writeGeneratedQueries(source); + await file('query.queries.dart') + .writeAsString('// modified\n', mode: FileMode.append); + await expectLater( + checkGeneratedQueries(source), + throwsA(isA()), + ); + await expectLater( + writeGeneratedQueries(source, output: source), + throwsA(isA()), + ); + expect(await file('query.dart').readAsString(), contains('sqlQuery')); + final output = file('custom.dart').path; + await writeGeneratedQueries(source, output: output); + await checkGeneratedQueries(source, output: output); + await file('query.dart').writeAsString( + (await file('query.dart').readAsString()).replaceAll('int n', 'double n'), + ); + await expectLater( + checkGeneratedQueries(source, output: output), + throwsA(isA()), + ); + }); + + test( + 'asset builder and standalone generator emit identical SQL contracts', + () async { + final files = TestReaderWriter(rootPackage: 'orm', flattenOutput: true); + await files.testing.loadIsolateSources(); + const root = 'test/support/named_sql/queries.dart'; + final inputs = {}; + for (final name in [ + 'queries.dart', + 'stats.sqlite.sql', + 'stats.postgres.sql', + 'echo.sql', + 'constant.sql', + ]) { + final path = 'test/support/named_sql/$name'; + inputs['orm|$path'] = await File(path).readAsString(); + } + final result = await testBuilder( + _QueryRoot(), + inputs, + rootPackage: 'orm', + readerWriter: files, + generateFor: {'orm|$root'}, + flattenOutput: true, + ); + expect(result.succeeded, true, reason: result.errors.toString()); + final standalone = await generateQueries(root); + expect( + files.testing.readString( + AssetId('orm', 'test/support/named_sql/queries.queries.dart'), + ), + standalone.dart, + ); + }, + ); + + test('generated APIs reject missing/wrong parameters and result types', () async { + final invalid = [ + 'db.authorStats();', + 'db.authorStats(minimum: "wrong");', + 'db.authorStats(minimum: 0, author: 1);', + 'db.authorStats(minimum: 0).select((s) => s.missing);', + 'db.authorStats(minimum: 0).where((s) => s.points.eq("wrong"));', + 'final Future> wrong = db.authorStats(minimum: 0).get();', + ]; + final negative = file('negative.dart').absolute; + await negative.writeAsString( + "import 'package:orm/orm.dart';\nimport '../../test/support/named_sql/queries.queries.dart';\nvoid wrong(Database db) {\n${invalid.join('\n')}\n}\n", + ); + final contexts = AnalysisContextCollection(includedPaths: [negative.path]); + try { + final result = + await contexts + .contextFor(negative.path) + .currentSession + .getResolvedUnit(negative.path) + as ResolvedUnitResult; + final errors = result.diagnostics.where( + (e) => e.severity.name.toLowerCase() == 'error', + ); + expect( + errors.any( + (e) => e.diagnosticCode.lowerCaseName.contains('uri_does_not_exist'), + ), + false, + ); + for (var i = 0; i < invalid.length; i++) { + expect( + errors.any( + (e) => result.lineInfo.getLocation(e.offset).lineNumber == i + 4, + ), + true, + reason: invalid[i], + ); + } + } finally { + await contexts.dispose(); + } + }); + + test( + 'CLI checks a persistent SQLite database without creating missing files', + () async { + await declaration( + 'typedef Row = ({int n});', + "sqlQuery(sqlite: 'query.sql')", + 'SELECT COUNT(*) AS n FROM entries', + ); + Future run(List arguments) => Process.run( + Platform.resolvedExecutable, + ['run', 'bin/orm.dart', ...arguments], + ); + final generate = await run([ + 'queries', + 'generate', + file('query.dart').path, + ]); + expect( + generate.exitCode, + 0, + reason: '${generate.stdout}\n${generate.stderr}', + ); + final database = await sqlite( + SqliteOptions.file(file('database.db').path), + ); + await database.execute(SqlCommand('CREATE TABLE entries (n INTEGER)')); + await database.close(); + final args = [ + '--json', + 'queries', + 'check', + '--source', + file('query.dart').path, + '--sqlite', + ]; + final checked = await run([...args, file('database.db').path]); + expect( + checked.exitCode, + 0, + reason: '${checked.stdout}\n${checked.stderr}', + ); + expect(checked.stdout, contains('"structureChecked": true')); + final missing = file('missing.db'); + final rejected = await run([...args, missing.path]); + expect(rejected.exitCode, isNot(0)); + expect(await missing.exists(), false); + }, + timeout: const Timeout(Duration(minutes: 1)), + ); +} + +// build_test loads root-package lib assets in addition to the requested input. +final class _QueryRoot implements Builder { + final Builder delegate = ormQueryBuilder(BuilderOptions.empty); + @override + Map> get buildExtensions => delegate.buildExtensions; + @override + Future build(BuildStep step) async { + if (step.inputId.path == 'test/support/named_sql/queries.dart') { + await delegate.build(step); + } + } +} diff --git a/test/named_sql_test.dart b/test/named_sql_test.dart new file mode 100644 index 00000000..71e6fbb8 --- /dev/null +++ b/test/named_sql_test.dart @@ -0,0 +1,380 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/named_sql/queries.queries.dart'; + +const source = 'test/support/named_sql/queries.dart'; +final posts = TableSchema( + 'posts', + columns: [Column('author', Codecs.text), Column('points', Codecs.integer)], +); + +GeneratedQueries fixed( + SqlDialect dialect, + String sql, { + String type = 'integer', + String column = 'n', +}) => GeneratedQueries('', { + 'queries': [ + { + 'name': 'fixed', + 'parameters': >[], + 'columns': [ + {'name': column, 'type': type, 'nullable': false}, + ], + 'sql': {dialect.name: sql}, + }, + ], +}); + +void main() { + test( + 'template preserves quotes/comments and binds each protocol correctly', + () { + for (final dialect in SqlDialect.values) { + final template = SqlTemplate( + "SELECT :input AS n, ':ignored;''text' AS s, \"quoted:field\" -- :line\n" + '/* :comment */ FROM data WHERE n = :input; -- trailing', + dialect: dialect, + ); + final command = template.compile( + Capabilities(dialect: dialect, maxParameters: 2), + {'input': value(3, Codecs.integer)}, + ); + expect(template.parameters, {'input'}); + expect( + command.parameters, + dialect == SqlDialect.mysql || dialect == SqlDialect.mariadb + ? [3, 3] + : [3], + ); + expect(command.sql, contains("':ignored;''text'")); + expect(command.sql, endsWith(' -- trailing')); + } + final pg = SqlTemplate( + r'''SELECT :n::bigint, $tag$:ignored; '$tag$, E'\:ignored', payload ? 'key' /* nested /* :ignored */ end */''', + dialect: SqlDialect.postgres, + ); + expect(pg.parameters, {'n'}); + final sqlite = SqlTemplate( + 'SELECT :n, [name:ignored], `name:ignored`', + dialect: SqlDialect.sqlite, + ); + expect(sqlite.parameters, {'n'}); + }, + ); + + test( + 'templates reject ambiguous bindings and incomplete or multiple statements', + () { + for (final dialect in SqlDialect.values) { + for (final sql in [ + "SELECT 'unfinished", + 'SELECT 1 /* incomplete', + 'SELECT 1; SELECT 2', + 'DELETE FROM data', + r'SELECT $1', + '-- only a comment', + ]) { + expect( + () => SqlTemplate(sql, dialect: dialect), + throwsA(isA()), + reason: sql, + ); + } + final template = SqlTemplate('SELECT :n', dialect: dialect); + final caps = Capabilities(dialect: dialect, maxParameters: 0); + expect(() => template.compile(caps, {}), throwsA(isA())); + expect( + () => template.compile(caps, {'n': value(1, Codecs.integer)}), + throwsA(isA()), + ); + expect( + () => template.compile(caps, {'n': value(1, Codecs.integer).plus(2)}), + throwsA(isA()), + ); + } + for (final sql in ['SELECT ?1', 'SELECT @name']) { + expect( + () => SqlTemplate(sql, dialect: SqlDialect.sqlite), + throwsA(isA()), + ); + } + expect( + () => SqlTemplate(r"SELECT '\'", dialect: SqlDialect.postgres), + throwsA(isA()), + ); + expect( + () => SqlTemplate(r'SELECT $$unfinished', dialect: SqlDialect.postgres), + throwsA(isA()), + ); + }, + ); + + test('generated client is deterministic and checked against its Dart declaration', () async { + final generated = await generateQueries(source); + expect( + generated.dart, + await File('test/support/named_sql/queries.queries.dart').readAsString(), + ); + expect((await checkGeneratedQueries(source)).dart, generated.dart); + }); + + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + group( + dialect.name, + () { + late Database db; + setUp(() async { + if (dialect == SqlDialect.sqlite) { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: PostgresTls.disable, + schema: 'orm_named_sql_tests', + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_named_sql_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_named_sql_tests')); + } + await db.execute( + SqlCommand( + 'CREATE TABLE posts (author TEXT NOT NULL, points BIGINT NOT NULL)', + ), + ); + await db.execute( + SqlCommand("INSERT INTO posts VALUES ('a', 1), ('a', 3), ('b', 7)"), + ); + }); + tearDown(() => db.close()); + + test( + 'typed parameters, projection and repeated nullable bindings compose', + () async { + final query = db + .authorStats(minimum: 0) + .orderBy((s) => [s.author.asc()]); + expect(await query.get(), [ + (author: 'a', postCount: 2, points: 4), + (author: 'b', postCount: 1, points: 7), + ]); + expect(await db.authorStats(minimum: 2, author: 'a').single(), ( + author: 'a', + postCount: 1, + points: 3, + )); + expect( + await query + .where((s) => s.points.gt(4)) + .select( + (s) => ( + s.author, + s.points, + ).map((author, points) => (author, points)), + ) + .get(), + [('b', 7)], + ); + expect( + await db.authorStats(minimum: 0, author: "a' OR 1=1 --").get(), + isEmpty, + ); + expect(await db.constant().single(), (n: 42)); + }, + ); + + test( + 'native numeric and temporal codecs survive binding and streaming', + () async { + final instant = DateTime.utc(2024, 1, 2, 3, 4, 5, 6, 7); + final amount = Decimal.parse('12345678901234567890.00000000001'); + final day = LocalDate.parse('0001-01-01 BC'); + final query = db.echo( + value: ':ignored; " --', + at: instant, + amount: amount, + day: day, + ); + expect(await query.single(), ( + value: ':ignored; " --', + at: instant, + amount: amount, + day: day, + )); + expect(await query.select((q) => q.amount).stream().toList(), [ + amount, + ]); + }, + ); + + test( + 'CTEs and unions retain distinct query arguments and field identity', + () async { + final a = db.authorStats(minimum: 0, author: 'a'); + final b = db.authorStats(minimum: 0, author: 'b'); + final cte = a.asCte('author_subset'); + expect( + await cte.query.select((s) => s.ref((r) => r.points)).single(), + 4, + ); + expect( + await a + .select((s) => s.author) + .unionAll(b.select((s) => s.author)) + .get(), + ['a', 'b'], + ); + expect( + await db + .constant() + .select((c) => a.select((s) => s.points).take(1).scalar()) + .single(), + 4, + ); + }, + ); + + test('transactions see pending writes and rollback normally', () async { + await expectLater( + db.transaction((tx) async { + await tx.execute( + SqlCommand("INSERT INTO posts VALUES ('a', 10)"), + ); + expect( + (await tx.authorStats(minimum: 0, author: 'a').single()).points, + 14, + ); + throw StateError('rollback'); + }), + throwsStateError, + ); + expect( + (await db.authorStats(minimum: 0, author: 'a').single()).points, + 4, + ); + }); + + test('named sources reject table mutations and require explicit watch reads', () async { + await expectLater( + db + .authorStats(minimum: 0) + .update((s) => [s.points.set(1)]) + .execute(), + throwsA(isA()), + ); + await expectLater( + db.authorStats(minimum: 0).watch().first, + throwsA( + isA().having((e) => e.code, 'code', 'WATCH.READS'), + ), + ); + final snapshots = StreamIterator( + db.authorStats(minimum: 0, author: 'a').watch(reads: [posts]), + ); + try { + expect(await snapshots.moveNext(), true); + expect(snapshots.current.single.points, 4); + final next = snapshots.moveNext(); + await db.execute( + SqlCommand("INSERT INTO posts VALUES ('a', 2)"), + changedTables: [posts], + ); + expect(await next.timeout(const Duration(seconds: 3)), true); + expect(snapshots.current.single.points, 6); + } finally { + await snapshots.cancel(); + } + }); + + test('native checks accept declared shape and preserve prepared-statement state', () async { + final result = await checkSqlQueries( + db.sql, + await generateQueries(source), + ); + expect(result.length, dialect == SqlDialect.sqlite ? 3 : 4); + expect( + result.every( + (r) => + r['structureChecked'] == true && + r['nullabilityChecked'] == false, + ), + true, + ); + if (dialect == SqlDialect.postgres) { + final left = await db.execute( + SqlCommand( + "SELECT COUNT(*) FROM pg_catalog.pg_prepared_statements WHERE name LIKE '_orm_check_%'", + ), + ); + expect(left.rows.single.single, 0); + expect(await (db as Database).postgresOnly().single(), ( + n: 42, + )); + } + }); + + test('native checks reject syntax, missing tables and missing result aliases', () async { + for (final sql in [ + 'SELECT FROM', + 'SELECT n FROM missing_table', + 'SELECT 1 AS wrong', + ]) { + await expectLater( + checkSqlQueries(db.sql, fixed(dialect, sql)), + throwsA(isA()), + reason: sql, + ); + } + if (dialect == SqlDialect.postgres) { + await expectLater( + checkSqlQueries( + db.sql, + fixed(dialect, "SELECT 'text'::text AS n"), + ), + throwsA(isA()), + ); + } + expect((await db.constant().single()).n, 42); + }); + + test('native checks do not evaluate application expressions', () async { + final sql = dialect == SqlDialect.sqlite + ? 'SELECT abs(-9223372036854775808) AS n' + : 'SELECT 1 / 0 AS n'; + expect( + await checkSqlQueries(db.sql, fixed(dialect, sql)), + hasLength(1), + ); + await expectLater(db.execute(SqlCommand(sql)), throwsA(anything)); + if (dialect == SqlDialect.postgres) { + await db.execute(SqlCommand('CREATE SEQUENCE query_check_counter')); + await checkSqlQueries( + db.sql, + fixed(dialect, "SELECT nextval('query_check_counter') AS n"), + ); + expect( + (await db.execute( + SqlCommand('SELECT is_called FROM query_check_counter'), + )).rows.single.single, + false, + ); + } + }); + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES for native PostgreSQL checks.' + : false, + ); + } +} diff --git a/test/nominal_database_test.dart b/test/nominal_database_test.dart new file mode 100644 index 00000000..2b86edab --- /dev/null +++ b/test/nominal_database_test.dart @@ -0,0 +1,122 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/nominal/schema.dart' show Email; +import 'support/nominal/schema.orm.dart'; + +void main() { + runNominalTests('sqlite', () => sqlite(const SqliteOptions.memory())); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + const schema = 'orm_nominal_tests'; + late Database admin; + setUpAll(() async { + admin = postgres( + PostgresOptions(url: Uri.parse(url), tls: PostgresTls.disable), + ); + await admin.execute(SqlCommand('CREATE SCHEMA IF NOT EXISTS "$schema"')); + }); + tearDownAll(() async { + await admin.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + await admin.close(); + }); + runNominalTests( + 'postgres', + () async => postgres( + PostgresOptions( + url: Uri.parse(url), + tls: PostgresTls.disable, + schema: schema, + ), + ), + ); + } +} + +void runNominalTests(String name, Future> Function() open) { + group('nominal models $name', () { + late Database db; + setUp(() async { + db = await open(); + for (final table in ['nominal_notes', 'nominal_accounts']) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + for (final command in createSchema(appSchema, db.dialect)) { + await db.execute(command); + } + }); + tearDown(() => db.close()); + + test( + 'rows, insert defaults, codecs and patches retain nominal types', + () async { + final Account account = await db.accounts.create( + email: const Email('a@example.com'), + a: 2, + b: 3, + c: 4, + ); + expect(account, isA()); + expect(account.email.value, 'a@example.com'); + expect(account.label, isNull); + expect(account.enabled, false); + expect(account.marker, 'seed'); + expect(account.total, 5); + await db.accounts + .byId(account.id) + .patch(label: .set('Account'), enabled: .set(true)); + final Account updated = await db.accounts.byId(account.id).single(); + expect(updated.label, 'Account'); + expect(updated.enabled, true); + await db.accounts.byId(account.id).patch(label: .set(null)); + expect((await db.accounts.byId(account.id).single()).label, isNull); + }, + ); + + test( + 'relations return nominal rows while projections retain their own types', + () async { + final Account owner = await db.transaction((tx) async { + final Account owner = await tx.accounts.create( + email: const Email('owner@example.com'), + a: 1, + b: 2, + c: 3, + ); + final Note note = await tx.notes.create( + accountId: owner.id, + body: 'hello', + ); + expect(note, isA()); + return owner; + }); + final List children = await db.accounts + .byId(owner.id) + .select((a) => a.notes.many()) + .single(); + expect(children.single.body, 'hello'); + final Account related = await db.notes + .select((n) => n.account.required()) + .single(); + expect(related.email.value, 'owner@example.com'); + final ({int id, List notes}) card = await db.accounts + .byId(owner.id) + .select( + (a) => ( + a.id, + a.notes.select((n) => n.body).many(), + ).map((id, notes) => (id: id, notes: notes)), + ) + .single(); + expect(card.id, owner.id); + expect(card.notes, ['hello']); + await db.accounts.byId(owner.id).delete().execute(); + expect(await db.notes.count(), 0); + }, + ); + }); +} diff --git a/test/nominal_generation_test.dart b/test/nominal_generation_test.dart new file mode 100644 index 00000000..a0766cbc --- /dev/null +++ b/test/nominal_generation_test.dart @@ -0,0 +1,202 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory fixtures; + setUpAll(() async { + fixtures = await Directory('.dart_tool/orm-nominal-tests') + .create(recursive: true); + }); + tearDownAll(() => fixtures.delete(recursive: true)); + + Future generate(String name, String source) async { + final file = File('${fixtures.path}/$name.dart'); + await file.writeAsString("import 'package:orm/schema.dart';\n$source"); + return generateSchema(file.path); + } + + Future analyze(String path) => + Process.run(Platform.resolvedExecutable, ['analyze', path]); + + test( + 'wide nominal rows and metadata match the committed generated fixture', + () async { + const source = 'test/support/nominal/schema.dart'; + final result = await generateSchema(source); + expect( + result.dart, + await File('test/support/nominal/schema.orm.dart').readAsString(), + ); + expect( + result.snapshotDart, + await File('test/support/nominal/schema.snapshot.dart').readAsString(), + ); + expect(result.dart, contains('models.Account(')); + expect(result.dart, contains('models.emailCodec')); + expect(result.dart, contains('models.defaultMarker')); + expect( + result.dart, + contains('Relation get notes'), + ); + expect(result.dart, isNot(contains("import 'package:orm/orm.dart'"))); + final analysis = await analyze('test/support/nominal/schema.orm.dart'); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + }, + ); + + test('named, positional, mixed and single-field constructors materialize directly', () async { + await generate('constructors', ''' +final class Named({@Id() required final int id, required final String models}); +final class Positional(@Id() final int id, final String models); +final class Mixed(@Id() final int id, {required final String name}); +final class Single(@Id() final int id); +final class const Constant(@Id() final int id); +final named = entity(); +final positional = entity(); +final mixed = entity(); +final single = entity(); +final constants = entity(); +'''); + final source = '${fixtures.path}/constructors.dart'; + await writeGeneratedSchema(source); + final result = await analyze('${fixtures.path}/constructors.orm.dart'); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + final generated = await File('${fixtures.path}/constructors.orm.dart') + .readAsString(); + expect(generated, contains('models.Named(id: v0, models: v1)')); + expect(generated, contains('models.Positional(v0, v1)')); + expect(generated, contains('models.Mixed(v0, name: v1)')); + expect(generated, contains('models.Single(value)')); + expect(generated, contains('models.Constant(value)')); + }); + + test('nominal rows and generated writes reject structural and field type mistakes', () async { + await generate('types', ''' +final class User(@Id.generated() final int id, final String email, final String? name); +final class Audit(@Id.generated() final int id, final String email, final String? name); +final users = entity(); +final audits = entity(); +'''); + await writeGeneratedSchema('${fixtures.path}/types.dart'); + final consumer = File('${fixtures.path}/consumer.dart'); + const prefix = ''' +import 'package:orm/orm.dart'; +import 'types.orm.dart'; +Future consume(Database db) async { +'''; + await consumer.writeAsString(''' +$prefix + final User user = await db.users.create(email: 'a'); + await db.users.byId(user.id).patch(name: .set(null)); + final List<({int id, String email})> cards = await db.users + .select((u) => (u.id, u.email).map((id, email) => (id: id, email: email))) + .get(); + print(cards); +} +'''); + final valid = await analyze(consumer.path); + expect(valid.exitCode, 0, reason: '${valid.stdout}\n${valid.stderr}'); + for (final statement in [ + "final User user = await db.audits.create(email: 'a'); print(user);", + "final User user = (id: 1, email: 'a', name: null); print(user);", + 'await db.users.create(email: 42);', + 'await db.users.byId(1).patch(name: .set(42));', + 'await db.users.byId(1).patch(id: .set(42));', + 'await db.users.select((u) => u.missing).get();', + ]) { + await consumer.writeAsString('$prefix$statement\n}\n'); + final invalid = await analyze(consumer.path); + expect(invalid.exitCode, isNot(0), reason: statement); + expect('${invalid.stdout}', contains('error -'), reason: statement); + } + await consumer.delete(); + }); + + test( + 'class and Record declarations produce the same frozen physical schema', + () async { + final record = await generate('record_snapshot', ''' +typedef User = ({@Id.generated() int id, @Unique() String email, @ColumnName('display_name') String? name, @Default.sql('false') bool enabled}); +final users = entity(); +'''); + final nominal = await generate('nominal_snapshot', ''' +final class User({@Id.generated() required final int id, @Unique() required final String email, @ColumnName('display_name') required final String? name, @Default.sql('false') required final bool enabled}); +final users = entity(); +'''); + expect(nominal.snapshotDart, record.snapshotDart); + expect(nominal.snapshotDart, isNot(contains('models.'))); + final renamed = await generate('nominal_rename', ''' +final class User({@Id.generated() required final int id, @Unique() @ColumnName('email') required final String contactEmail, @ColumnName('display_name') required final String? name, @Default.sql('false') required final bool enabled}); +final users = entity(); +'''); + expect(renamed.snapshotDart, nominal.snapshotDart); + }, + ); + + test( + 'class models reject ambiguous construction and unsupported behavior', + () async { + final cases = [ + 'class User(final int id);', + 'final class User(var int id);', + 'final class User(int id);', + 'final class User(final id);', + 'final class User({final int id = 0});', + 'final class User({final int? id});', + 'final class User(final int _id);', + 'final class User.named(final int id);', + 'final class User(final int id) { this { print(id); } }', + 'final class User(final int id) { int get computed => id + 1; }', + 'final class User { final int id; User(this.id); }', + ]; + for (var i = 0; i < cases.length; i++) { + await expectLater( + generate('invalid_$i', '${cases[i]}\nfinal users = entity();'), + throwsA(isA()), + reason: cases[i], + ); + } + await expectLater( + generate( + 'generic', + 'final class User(final T id); final users = entity>();', + ), + throwsA(isA()), + ); + await expectLater( + generate( + 'private', + 'final class _User(final int id); final users = entity<_User>();', + ), + throwsA(isA()), + ); + }, + ); + + test( + 'class metadata and relationship validation use the production validators', + () async { + for (final source in [ + 'final class User(@Id() final int? id); final users = entity();', + 'final class User(@ColumnName("id") final int a, @ColumnName("id") final int b); final users = entity();', + 'final class User(final int id); final users = entity(); final bad = users.primaryKey((u) => u.id + 1);', + 'final class User(final int id); final users = entity(); final bad = users.unique((u) => (u.id, u.id));', + 'final class User(final int id); final class Post(final int owner); final users = entity(); final posts = entity(); final owner = posts.key((p) => p.owner).references(users.key((u) => u.id));', + 'final class User(@Id() final int id); final class Post(final String owner); final users = entity(); final posts = entity(); final owner = posts.key((p) => p.owner).references(users.key((u) => u.id));', + 'final class User(@Computed.sql("1") @Default.sql("0") final int id); final users = entity();', + ]) { + await expectLater( + generate('bad_metadata', source), + throwsA(isA()), + reason: source, + ); + } + }, + ); +} diff --git a/test/observation_test.dart b/test/observation_test.dart new file mode 100644 index 00000000..aa2de06f --- /dev/null +++ b/test/observation_test.dart @@ -0,0 +1,308 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import '../example/teams/schema.orm.dart'; + +void main() { + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + group( + 'observation ${dialect.name}', + () { + late Database db; + final acquired = []; + final decoded = []; + final statements = []; + setUp(() async { + if (dialect == SqlDialect.sqlite) { + db = await sqlite( + const SqliteOptions.memory(), + onQuery: statements.add, + onAcquire: acquired.add, + onDecode: decoded.add, + ); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_observation_tests', + maxConnections: 1, + ), + onQuery: statements.add, + onAcquire: acquired.add, + onDecode: decoded.add, + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_observation_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_observation_tests')); + } + await Migrator(db.sql).apply([ + Migration.create('0001_teams', appSchema, dialect: db.dialect), + ]); + await db.users.create(id: 1, name: 'Ada'); + await db.teams.create(id: 10, name: 'Core'); + await db.memberships.create( + teamId: 10, + userId: 1, + joinedAt: DateTime.utc(2026), + ); + acquired.clear(); + decoded.clear(); + statements.clear(); + }); + tearDown(() => db.close()); + + test( + 'actual lease queue and synchronous mapping have separate clocks', + () async { + final held = Completer(), release = Completer(); + final holder = db.session((session) async { + held.complete(); + await release.future; + }); + await held.future; + acquired.clear(); + final pending = db.users.select((u) => u.name).map((name) { + final clock = Stopwatch()..start(); + while (clock.elapsedMilliseconds < + 20) {} // Deliberate synchronous mapper cost. + return name; + }).get(); + try { + await Future.delayed(const Duration(milliseconds: 30)); + expect(acquired, isEmpty); + expect(statements, isEmpty); + expect(decoded, isEmpty); + } finally { + release.complete(); + await holder; + } + expect(await pending, ['Ada']); + expect(acquired, hasLength(1)); + expect( + acquired.single.elapsed.inMilliseconds, + greaterThanOrEqualTo(25), + ); + expect(acquired.single.reusedConnection, false); + expect(decoded, hasLength(1)); + expect( + decoded.single.elapsed.inMilliseconds, + greaterThanOrEqualTo(20), + ); + expect(decoded.single.inputRows, 1); + expect(decoded.single.sql, statements.single.sql); + expect( + [ + acquired.single.error, + decoded.single.error, + statements.single.error, + ], + [null, null, null], + ); + }, + ); + + for (final cancel in [false, true]) { + test( + '${cancel ? 'cancelled' : 'expired'} acquisition emits one failure and no late SQL', + () async { + final held = Completer(), release = Completer(); + final holder = db.session((session) async { + held.complete(); + await release.future; + }); + await held.future; + acquired.clear(); + final token = CancellationToken(); + final pending = db.users.get( + options: ExecutionOptions( + cancellation: cancel ? token : null, + acquireTimeout: cancel + ? null + : const Duration(milliseconds: 25), + ), + ); + if (cancel) token.cancel(); + try { + await expectLater( + pending, + throwsA( + isA().having( + (e) => e.code, + 'code', + cancel ? 'OPERATION.CANCELLED' : 'CONNECTION.TIMEOUT', + ), + ), + ); + expect(acquired, hasLength(1)); + expect(acquired.single.error, isA()); + expect(statements, isEmpty); + expect(decoded, isEmpty); + } finally { + release.complete(); + await holder; + } + // Queue a later operation after the abandoned request has drained. + await db.session((session) async {}); + expect(acquired.where((e) => e.error != null), hasLength(1)); + expect(statements, isEmpty); + expect(decoded, isEmpty); + }, + ); + } + + test('sessions transactions and savepoints inherit hooks and report reused leases', () async { + await db.session((session) async { + expect(await session.users.select((u) => u.name).single(), 'Ada'); + await session.transaction( + (tx) => tx.savepoint((child) async { + expect(await child.users.select((u) => u.id).single(), 1); + }), + ); + }); + expect(acquired.first.reusedConnection, false); + expect( + acquired + .skip(1) + .every((e) => e.reusedConnection && e.elapsed == Duration.zero), + true, + ); + expect(decoded.map((e) => e.inputRows), [1, 1]); + }); + + test('nested reads attribute decoding to each statement without repeating SQL', () async { + final query = db.users.select( + (u) => ( + u.name, + u.memberships + .select((m) => m.team.select((t) => t.name).required()) + .many(), + ).map((name, teams) => (name, teams)), + ); + final rows = await query.get(); + expect(rows.single.$2, ['Core']); + expect(acquired, hasLength(1)); + expect(statements, hasLength(2)); + expect(decoded.map((e) => e.sql), [ + statements[1].sql, + statements[0].sql, + ]); + expect( + decoded.every((e) => e.inputRows == 1 && e.error == null), + true, + ); + }); + + test('SQL and mapper failures belong to their own phases', () async { + await expectLater( + db.execute(SqlCommand('SELECT * FROM missing_table')), + throwsA(isA()), + ); + expect(acquired.single.error, isNull); + expect(statements.single.error, isA()); + expect(decoded, isEmpty); + final failure = StateError('mapper failed'); + await expectLater( + db.users.map((row) => throw failure).get(), + throwsA(same(failure)), + ); + expect(acquired.last.error, isNull); + expect(statements.last.error, isNull); + expect(decoded.single.error, same(failure)); + expect(decoded.single.inputRows, 1); + }); + + test( + 'cursor batches and returning writes produce decoding observations', + () async { + await db.users + .insertMany([ + 2, + 3, + ], (u, id) => [u.id.set(id), u.name.set('member')]) + .returning((u) => u.id) + .get(); + expect(decoded.single.sql, isNull); + expect(decoded.single.inputRows, 2); + decoded.clear(); + statements.clear(); + acquired.clear(); + expect( + await db.users + .orderBy((u) => [u.id.asc()]) + .select((u) => u.id) + .stream(batchSize: 2) + .toList(), + [1, 2, 3], + ); + expect(acquired, hasLength(1)); + expect(decoded.map((e) => e.inputRows), [2, 1]); + expect( + statements + .where((e) => e.operation == QueryOperation.cursorFetch) + .map((e) => e.rowCount), + [2, 1], + ); + decoded.clear(); + final row = await db.users.create(id: 4, name: 'new'); + expect(row.id, 4); + expect(decoded.single.inputRows, 1); + expect(decoded.single.sql, startsWith('INSERT')); + }, + ); + + test('observer exceptions preserve successful commits and original mapper errors', () async { + final observed = Database( + db.driver, + onAcquire: (_) => throw StateError('observer'), + onQuery: (_) => throw StateError('observer'), + onDecode: (_) => throw StateError('observer'), + ); + await observed.transaction( + (tx) => tx.users.create(id: 2, name: 'committed'), + ); + expect((await db.users.byId(2).single()).name, 'committed'); + final failure = StateError('actual mapper'); + await expectLater( + observed.users.map((row) => throw failure).get(), + throwsA(same(failure)), + ); + }); + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } + + test('synchronous driver acquisition failures are reported once', () async { + final events = []; + final failure = StateError('driver unavailable'); + final db = Database(_FailedDriver(failure), onAcquire: events.add); + await expectLater( + db.execute(SqlCommand('SELECT 1')), + throwsA(same(failure)), + ); + expect(events, hasLength(1)); + expect(events.single.error, same(failure)); + await db.close(); + }); +} + +final class _FailedDriver(final Object failure) implements Driver { + @override + Capabilities get capabilities => + const Capabilities(dialect: SqlDialect.sqlite, maxParameters: 10); + @override + Future run(Future Function(SqlConnection) action) => throw failure; + @override + Future close() async {} +} diff --git a/test/plan_test.dart b/test/plan_test.dart new file mode 100644 index 00000000..eb4f0c69 --- /dev/null +++ b/test/plan_test.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import '../example/teams/schema.dart'; +import '../example/teams/schema.orm.dart'; +import 'support/relations/schema.orm.dart' as composite; + +void main() { + for (final dialect in SqlDialect.values) { + test( + 'inspect ${dialect.name} reports composite key width and validates each potential batch', + () { + final db = Database(_NoConnection(dialect)); + final query = db + .table(composite.accountsTable) + .select( + (a) => a.events + .orderBy((e) => [e.id.asc()]) + .take(1) + .select((e) => e.title) + .many(), + ); + final load = query.inspect().loads.single; + expect(load.keyWidth, 2); + expect(load.parentColumns, hasLength(2)); + expect(load.childColumns, hasLength(2)); + expect(load.fixedParameters, 2); + expect(load.maxKeysPerBatch, 1); + expect(load.query!.sql, contains('PARTITION BY')); + expect( + () => db.users.select((u) => u.memberships.take(1).many()).inspect(), + throwsA( + isA().having((e) => e.code, 'code', 'RELATION.ORDER'), + ), + ); + expect( + () => db + .table(composite.accountsTable) + .select( + (a) => a.events + .where((e) => e.id.gt(0).and(e.score.gt(0))) + .orderBy((e) => [e.id.asc()]) + .take(1) + .many(), + ) + .inspect(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'QUERY.PARAMETERS', + ), + ), + ); + }, + ); + test( + 'inspect ${dialect.name} compiles nested plans without acquiring or decoding', + () { + final driver = _NoConnection(dialect), db = Database(driver); + var decoded = false; + final query = db.users + .where((u) => u.name.eq('bound-secret')) + .select( + (u) => + ( + u.name, + u.memberships + .where((m) => m.role.eq(MembershipRole.member)) + .orderBy((m) => [m.joinedAt.desc(), m.teamId.desc()]) + .take(2) + .select( + (m) => m.team + .select( + (t) => + ( + t.name, + t.memberships + .orderBy((m) => [m.userId.asc()]) + .select( + (m) => m.user + .select((u) => u.name) + .required(), + ) + .many(), + ).map( + (name, members) => + (name: name, members: members), + ), + ) + .required(), + ) + .many(), + ).map((name, teams) { + decoded = true; + return (name, teams); + }), + ); + final plan = query.inspect(); + expect(decoded, false); + expect(plan.sql, query.compile().sql); + expect(plan.parameterCount, 1); + expect(plan.sqlTemplateCount, 3); + expect(plan.reads, ['users']); + expect(plan.opaqueReads, false); + final load = plan.loads.single; + expect(load.keyWidth, 1); + expect(load.fixedParameters, 3); + expect(load.maxKeysPerBatch, 2); + expect(load.limitPerParent, 2); + expect(load.query!.parameterCount, 4); + expect(load.query!.sql, contains('PARTITION BY')); + expect(load.query!.joins.single, ( + table: 'teams', + left: true, + relation: true, + )); + expect(load.query!.reads, ['memberships', 'teams']); + expect(plan.columns[load.parentColumns.single].column, 'id'); + expect(load.query!.columns[load.childColumns.single].column, 'user_id'); + expect(load.query!.columns.any((c) => c.presence), true); + expect(load.query!.loads.single.query!.joins.single.table, 'users'); + expect(jsonEncode(plan.toJson()), isNot(contains('bound-secret'))); + expect(jsonEncode(plan.toJson()), jsonEncode(query.inspect().toJson())); + expect(() => plan.loads.clear(), throwsUnsupportedError); + expect(() => load.parentColumns.clear(), throwsUnsupportedError); + }, + ); + + test( + 'inspect ${dialect.name} distinguishes same-statement joins and skipped batches', + () { + final db = Database(_NoConnection(dialect)); + final plan = db.memberships + .select( + (m) => ( + m.team.select((t) => t.name).one(), + m.user.select((u) => u.name).required(), + ).map((team, user) => (team, user)), + ) + .inspect(); + expect(plan.sqlTemplateCount, 1); + expect(plan.loads, isEmpty); + expect(plan.joins.map((j) => j.table), ['teams', 'users']); + final skipped = db.users + .select((u) => u.memberships.take(0).many()) + .inspect(); + expect(skipped.loads.single.skipped, true); + expect(skipped.loads.single.query, isNull); + expect(skipped.sqlTemplateCount, 1); + }, + ); + + test( + 'inspect ${dialect.name} retains CTE, UNION and raw SQL boundaries', + () { + final db = Database(_NoConnection(dialect)); + final cte = db.users.select((u) => u.name).asCte('names'); + final plan = cte.query.union(db.teams.select((t) => t.name)).inspect(); + expect(plan.reads, ['teams', 'users']); + expect(plan.sql, contains('UNION')); + expect(plan.sqlTemplateCount, 1); + final raw = db.users + .select((u) => sql(['length(name)'], [], Codecs.integer)) + .inspect(); + expect(raw.opaqueReads, true); + }, + ); + + group( + 'plan execution ${dialect.name}', + () { + late Database db; + final events = []; + setUp(() async { + if (dialect == SqlDialect.sqlite) { + db = await sqlite( + const SqliteOptions.memory(), + onQuery: events.add, + ); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + schema: 'orm_plan_tests', + tls: .disable, + ), + onQuery: events.add, + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_plan_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_plan_tests')); + } + await Migrator(db.sql).apply([ + Migration.create('0001_teams', appSchema, dialect: db.dialect), + ]); + await db.users.create(id: 1, name: 'Ada'); + await db.teams.create(id: 10, name: 'Core'); + await db.memberships.create( + teamId: 10, + userId: 1, + joinedAt: DateTime.utc(2026), + ); + events.clear(); + }); + tearDown(() => db.close()); + test( + 'one-key SQL templates match executed SQL and internal slots', + () async { + final query = db.users.select( + (u) => u.memberships + .orderBy((m) => [m.teamId.asc()]) + .take(2) + .select((m) => m.team.select((t) => t.name).required()) + .many(), + ); + final plan = query.inspect(); + expect(events, isEmpty); + expect(await query.get(), [ + ['Core'], + ]); + expect(events.map((e) => e.sql), [ + plan.sql, + plan.loads.single.query!.sql, + ]); + expect(events.map((e) => e.parameterCount), [ + plan.parameterCount, + plan.loads.single.query!.parameterCount, + ]); + expect( + plan.loads.single.maxKeysPerBatch, + db.capabilities.maxParameters - 2, + ); + }, + ); + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } +} + +final class _NoConnection(final SqlDialect dialect) implements Driver { + @override + Capabilities get capabilities => Capabilities( + dialect: dialect, + maxParameters: 5, + temporal: true, + exactDecimal: true, + ); + @override + Future run(Future Function(SqlConnection) action) => + throw StateError('Inspection acquired a connection.'); + @override + Future close() async {} +} diff --git a/test/postgres_pid_timeout_test.dart b/test/postgres_pid_timeout_test.dart new file mode 100644 index 00000000..88b516ee --- /dev/null +++ b/test/postgres_pid_timeout_test.dart @@ -0,0 +1,253 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:orm/drivers/postgres.dart'; +import 'package:orm/runtime.dart'; +import 'package:test/test.dart'; + +Matcher _code(String code) => + isA().having((error) => error.code, 'code', code); + +void main() { + final address = Platform.environment['ORM_TEST_POSTGRES']; + group( + 'PostgreSQL PID discovery deadline', + () { + late SqlDatabase admin, db; + late _PidProxy proxy; + final table = 'orm_pid_deadline_$pid'; + setUp(() async { + final url = Uri.parse(address!); + admin = SqlDatabase( + PostgresDriver(PostgresOptions(url: url, tls: .disable)), + ); + await admin.execute( + SqlCommand('CREATE TABLE "$table" (id BIGINT PRIMARY KEY)'), + ); + proxy = await _PidProxy.open(url); + }); + tearDown(() async { + // Closing the test transport also unblocks the old buggy implementation, + // so a failing regression cannot hang pool shutdown. + await proxy.close(); + await db.close(); + try { + await admin.execute(SqlCommand('DROP TABLE "$table"')); + } finally { + await admin.close(); + } + }); + + for (final mode in [ + 'explicit timeout', + 'default timeout', + 'cancellation', + ]) { + test('$mode stops a stalled first PID query before any write', () async { + db = SqlDatabase( + PostgresDriver( + PostgresOptions( + url: proxy.url, + tls: .disable, + maxConnections: 1, + queryTimeout: mode == 'default timeout' + ? const Duration(milliseconds: 100) + : const Duration(seconds: 2), + ), + ), + ); + final token = CancellationToken(); + final pending = db.execute( + SqlCommand('INSERT INTO "$table" VALUES (1)'), + options: ExecutionOptions( + timeout: mode == 'explicit timeout' + ? const Duration(milliseconds: 100) + : null, + cancellation: mode == 'cancellation' ? token : null, + ), + ); + final expected = expectLater( + pending.timeout(const Duration(seconds: 3)), + throwsA( + _code( + mode == 'cancellation' + ? 'OPERATION.CANCELLED' + : 'OPERATION.TIMEOUT', + ), + ), + ); + await proxy.blocked.future.timeout(const Duration(seconds: 2)); + if (mode == 'cancellation') token.cancel(); + await expected; + expect( + proxy.userQueries.where((sql) => sql.contains('INSERT')), + isEmpty, + ); + expect( + (await admin.execute(SqlCommand('SELECT count(*) FROM "$table"'))) + .rows + .single + .single, + 0, + ); + // The stalled physical connection must be discarded. Its replacement + // discovers one PID, then reuses that cached value for later work. + await db.execute( + SqlCommand('INSERT INTO "$table" VALUES (2)'), + options: const ExecutionOptions(timeout: Duration(seconds: 2)), + ); + expect( + (await db.execute(SqlCommand('SELECT id FROM "$table"'))) + .rows + .single + .single, + 2, + ); + expect(proxy.pidQueries, 2); + }); + } + + test( + 'PID discovery and application SQL share one timeout budget', + () async { + db = SqlDatabase( + PostgresDriver( + PostgresOptions(url: proxy.url, tls: .disable, maxConnections: 1), + ), + ); + await db.session((session) async { + final pending = session.execute( + SqlCommand('SELECT pg_sleep(0.3)'), + options: const ExecutionOptions( + timeout: Duration(milliseconds: 400), + ), + ); + final expected = expectLater( + pending.timeout(const Duration(seconds: 3)), + throwsA(_code('OPERATION.TIMEOUT')), + ); + await proxy.blocked.future.timeout(const Duration(seconds: 2)); + await Future.delayed(const Duration(milliseconds: 250)); + proxy.release(); + // Catch inside the lease: package:postgres discards connections if + // any error escapes the withConnection callback. + await expected; + }); + expect( + (await db.execute(SqlCommand('SELECT 42'))).rows.single.single, + 42, + ); + expect( + proxy.pidQueries, + 1, + reason: 'a confirmed cancellation keeps the cached live connection reusable', + ); + }, + ); + }, + skip: address == null + ? 'Set ORM_TEST_POSTGRES for real transport regressions.' + : false, + ); +} + +/// Forward the real PostgreSQL wire protocol, pausing the first PID Parse frame +/// and everything after it. No server reply can accidentally resolve that probe. +final class _PidProxy { + final Uri target; + final ServerSocket listener; + final List<_ProxyConnection> connections = []; + final blocked = Completer(); + final userQueries = []; + int pidQueries = 0; + bool holdNextPid = true; + _ProxyConnection? held; + _PidProxy(this.target, this.listener); + Uri get url => target.replace(host: '127.0.0.1', port: listener.port); + static Future<_PidProxy> open(Uri target) async { + final result = _PidProxy( + target, + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + ); + result.listener.listen((client) async { + final server = await Socket.connect( + target.host, + target.hasPort ? target.port : 5432, + ); + final connection = _ProxyConnection(result, client, server); + result.connections.add(connection); + connection.start(); + }); + return result; + } + + void release() => held?.release(); + Future close() async { + await listener.close(); + for (final connection in connections) { + connection.close(); + } + } +} + +final class _ProxyConnection { + final _PidProxy proxy; + final Socket client, server; + final List pending = [], held = []; + bool startup = true, paused = false; + _ProxyConnection(this.proxy, this.client, this.server); + void start() { + server.listen(client.add, onError: (Object _) => close(), onDone: close); + client.listen(_receive, onError: (Object _) => close(), onDone: close); + } + + int _length(int offset) => ByteData.sublistView( + Uint8List.fromList(pending), + offset, + offset + 4, + ).getUint32(0); + void _receive(List bytes) { + pending.addAll(bytes); + while (pending.length >= (startup ? 4 : 5)) { + final length = startup ? _length(0) : _length(1) + 1; + if (pending.length < length) return; + final frame = pending.sublist(0, length); + pending.removeRange(0, length); + if (!startup && frame[0] == 80) { + final nameEnd = frame.indexOf(0, 5); + final sqlEnd = frame.indexOf(0, nameEnd + 1); + final sql = utf8.decode(frame.sublist(nameEnd + 1, sqlEnd)); + if (sql == 'SELECT pg_backend_pid()') { + proxy.pidQueries++; + if (proxy.holdNextPid) { + proxy.holdNextPid = false; + paused = true; + proxy.held = this; + proxy.blocked.complete(); + } + } else { + proxy.userQueries.add(sql); + } + } + startup = false; + if (paused) { + held.addAll(frame); + } else { + server.add(frame); + } + } + } + + void release() { + paused = false; + server.add(List.of(held)); + held.clear(); + } + + void close() { + client.destroy(); + server.destroy(); + } +} diff --git a/test/public_api_test.dart b/test/public_api_test.dart new file mode 100644 index 00000000..3711e897 --- /dev/null +++ b/test/public_api_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:test/test.dart'; + +import '../example/schema.orm.dart'; + +void main() { + test('generated clients expose row types and an immutable schema', () { + final user = User(id: 1, email: 'a', nickname: null, score: 0); + expect(user.id, 1); + expect(appSchema.clear, throwsUnsupportedError); + }); + + test( + 'entrypoint exports and runtime dependency boundaries stay explicit', + () async { + final contexts = AnalysisContextCollection( + includedPaths: [Directory.current.absolute.path], + ); + try { + Future library(String path) async { + final absolute = File(path).absolute.path; + final result = await contexts + .contextFor(absolute) + .currentSession + .getResolvedLibrary(absolute); + return (result as ResolvedLibraryResult).element; + } + + Set exports(LibraryElement library) => + library.exportNamespace.definedNames2.keys.toSet(); + final runtime = await library('lib/orm.dart'); + final schema = await library('lib/schema.dart'); + final migrate = await library('lib/migrate.dart'); + final generate = await library('lib/generate.dart'); + final builder = await library('lib/builder.dart'); + final sqlite = exports(await library('lib/sqlite.dart')); + expect( + sqlite, + containsAll([ + 'sqlite', + 'SqliteDriver', + 'SqliteOptions', + 'SqliteWebOptions', + ]), + ); + expect( + sqlite, + isNot( + anyOf( + contains('SqliteExecutor'), + contains('OpenedSqlite'), + contains('sqliteWorkerBuild'), + ), + ), + ); + expect(exports(schema), isNot(contains('Database'))); + expect(exports(runtime), isNot(contains('Migration'))); + expect( + exports(migrate), + containsAll(['Migration', 'SqlDialect', 'TableSchema', 'Codecs']), + ); + expect(exports(migrate), isNot(contains('generateSchema'))); + expect(exports(generate), isNot(contains('ormBuilder'))); + expect(exports(builder), {'ormBuilder', 'ormQueryBuilder'}); + final client = exports(await library('example/schema.orm.dart')); + expect( + client, + containsAll(['User', 'Post', 'UsersFields', 'AppTables']), + ); + expect( + client, + isNot(contains('users')), + ); // Declaration values stay in schema.dart. + expect( + exports(await library('test/support/named_sql/queries.queries.dart')), + containsAll(['AuthorStats', 'Echo']), + ); + + final visited = {}; + void visit(LibraryElement library) { + if (!visited.add(library.uri)) return; + // SDK implementation imports depend on the analyzer's host platform. + if (library.uri.scheme == 'dart') return; + for (final dependency in [ + ...library.exportedLibraries, + for (final fragment in library.fragments) + ...fragment.importedLibraries, + ]) { + visit(dependency); + } + } + + for (final entry in [runtime, schema, migrate]) { + visit(entry); + } + for (final uri in visited) { + expect(uri.toString(), isNot(startsWith('package:analyzer/'))); + expect(uri.toString(), isNot(startsWith('package:build/'))); + expect( + uri.toString(), + isNot(anyOf('dart:io', 'dart:ffi', 'dart:isolate')), + ); + } + } finally { + await contexts.dispose(); + } + }, + ); +} diff --git a/test/query_boundary_test.dart b/test/query_boundary_test.dart new file mode 100644 index 00000000..5297845b --- /dev/null +++ b/test/query_boundary_test.dart @@ -0,0 +1,240 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/tables.dart'; + +Matcher code(String value) => + isA().having((e) => e.code, 'code', value); + +void main() { + test('one failing cancellation listener cannot block other operations', () { + final token = CancellationToken(); + var notified = 0; + token.listen(() => throw StateError('listener')); + token.listen(() => notified++); + token.cancel(); + token.cancel(); + token.listen(() => notified++); + expect(notified, 2); + }); + run( + 'sqlite', + (observe) => sqlite(const SqliteOptions.memory(), onQuery: observe), + ); + if (Platform.environment['ORM_TEST_POSTGRES'] case final url?) { + run('postgres', (observe) async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_boundary_tests', + ), + onQuery: observe, + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_boundary_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_boundary_tests')); + return db; + }); + } +} + +void run( + String name, + Future> Function(void Function(QueryEvent)) open, +) { + group(name, () { + late Database db; + final events = []; + setUp(() async { + db = await open(events.add); + await Migrator(db.sql).apply([ + Migration.create('0001_initial', [ + usersSchema, + postsSchema, + ], dialect: db.dialect), + ]); + await db.table(users).createRow((u) => [u.email.set('one')]); + await db.table(users).createRow((u) => [u.email.set('two')]); + events.clear(); + }); + tearDown(() => db.close()); + + test('explicit Backend construction rejects wrong transaction options before SQL', () { + final TransactionOptions wrong = db.dialect == .sqlite + ? const PostgresTransaction() + : const SqliteTransaction(); + final wide = Database(db.driver, onQuery: events.add); + expect( + () => wide.transaction((tx) async {}, options: wrong), + throwsA(code('TRANSACTION.OPTIONS')), + ); + expect(events, isEmpty); + }); + + test( + 'every close caller waits until the same pending lease is drained', + () async { + final entered = Completer(), release = Completer(); + final lease = db.session((session) async { + entered.complete(); + await release.future; + }); + await entered.future; + var closed = false; + final first = db.close(); + final second = db.close().then((_) => closed = true); + try { + await Future.delayed(Duration.zero); + expect(closed, false); + } finally { + release.complete(); + await lease; + await first; + await second; + } + expect(closed, true); + }, + ); + + test( + 'subqueries and CTEs cannot escape their database or transaction', + () async { + await db.transaction((tx) async { + final rootIds = db.table(users).select((u) => u.id); + final cte = rootIds.asCte('root_ids').alias(); + final queries = [ + tx.table(users).where((u) => u.id.isInQuery(rootIds)), + tx.table(users).select((_) => rootIds.take(1).scalar()), + tx.table(users).where((_) => rootIds.existsExpression()), + tx + .table(users) + .join(cte, on: (u, c) => u.id.equals(c.ref((u) => u.id))), + ]; + events.clear(); + for (final query in queries) { + expect(query.compile, throwsA(code('QUERY.SESSION'))); + await expectLater(query.get(), throwsA(code('QUERY.SESSION'))); + } + await expectLater( + tx + .table(users) + .where((u) => u.id.isInQuery(rootIds)) + .delete() + .execute(), + throwsA(code('QUERY.SESSION')), + ); + expect(events, isEmpty); + final localIds = tx + .table(users) + .where((u) => u.email.eq('one')) + .select((u) => u.id); + expect( + await tx + .table(users) + .where((u) => u.id.isInQuery(localIds)) + .count(), + 1, + ); + }); + }, + ); + + test('expired query cannot be embedded in its former parent', () async { + final expired = await db.session( + (session) async => session.table(users).select((u) => u.id), + ); + expect( + () => db.table(users).where((u) => u.id.isInQuery(expired)).compile(), + throwsA(code('QUERY.SESSION')), + ); + expect(events, isEmpty); + }); + + test('optional presence covers only its own left join', () async { + final present = users.alias(), absent = users.alias(); + final query = db + .table(users) + .leftJoin(present, on: (u, p) => u.id.equals(p.id)) + .leftJoin(absent, on: (u, a) => a.id.eq(-1)); + expect( + () => query + .select((_) => present.optional(absent.fields.email)) + .compile(), + throwsA(code('QUERY.NULLABILITY')), + ); + expect(events, isEmpty); + expect( + await query + .select( + (_) => present.optional( + ( + present.fields.email, + absent.optional(absent.fields.email), + ).rowMap, + ), + ) + .get(), + [('one', null), ('two', null)], + ); + }); + + test('nested CTE decoding retains its nullable presence guards', () async { + final alias = users.alias(); + final source = db + .table(users) + .leftJoin(alias, on: (u, a) => u.id.equals(a.id).and(a.id.eq(1))) + .select((_) => alias.optional(alias.fields.email)) + .asCte('optional_email'); + expect(await source.query.asCte('rebound_email').query.get(), [ + 'one', + null, + ]); + }); + + test('mutation predicates and assignments reject aggregate/window SQL before execution', () async { + for (final mutation in [ + db.table(users).where((u) => u.id.count().gt(0)).delete(), + db.table(users).update((u) => [u.score.setExpression(u.id.count())]), + db.table(users).update((u) => [u.score.setExpression(rowNumber())]), + ]) { + await expectLater(mutation.execute(), throwsA(code('QUERY.AGGREGATE'))); + } + expect(events, isEmpty); + expect(await db.table(users).count(), 2); + }); + + test( + 'returning rejects empty selections and joined relationships', + () async { + await expectLater( + db + .table(users) + .insert((u) => [u.email.set('three')]) + .returning((_) => fields({})) + .get(), + throwsA(code('QUERY.EMPTY_SELECTION')), + ); + await expectLater( + db + .table(postsTable) + .insert((p) => [p.authorId.set(1), p.title.set('title')]) + .returning((p) => p.author.one()) + .get(), + throwsA(code('MUTATION.RELATION')), + ); + expect(events, isEmpty); + }, + ); + }); +} + +extension on (Selection, Selection) { + Selection<(A, B)> get rowMap => map((a, b) => (a, b)); +} diff --git a/test/relation_test.dart b/test/relation_test.dart new file mode 100644 index 00000000..89d8c6d4 --- /dev/null +++ b/test/relation_test.dart @@ -0,0 +1,461 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/relations/schema.orm.dart'; + +Matcher code(String expected) => + isA().having((e) => e.code, 'code', expected); + +void main() { + runTests( + 'sqlite', + (observe) => sqlite(const SqliteOptions.memory(), onQuery: observe), + ); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + runTests('postgres', (observe) async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: PostgresTls.disable, + schema: 'orm_relation_tests', + ), + onQuery: observe, + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_relation_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_relation_tests')); + return db; + }); + } +} + +void runTests( + String name, + Future> Function(void Function(QueryEvent)) open, +) { + group(name, () { + late Database db; + final events = []; + setUp(() async { + db = await open(events.add); + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + await db.execute( + SqlCommand( + 'INSERT INTO accounts(tenant,id,label,manager_id) VALUES ' + "(1,1,NULL,NULL),(1,2,'A2',1),(1,3,'A3',NULL),(2,1,'B1',NULL),(2,2,'B2',1)", + ), + ); + await db.execute( + SqlCommand( + 'INSERT INTO events(id,tenant,owner,reviewer,title,score) VALUES ' + "(1,1,1,2,'e1',1),(2,1,1,NULL,'e2',2),(3,1,2,1,'e3',3),(4,1,2,NULL,'e4',4)," + "(5,2,1,2,'e5',5),(6,2,1,NULL,'e6',6),(7,2,2,1,'e7',7),(8,2,2,NULL,'e8',8)," + "(9,NULL,1,NULL,'e9',9),(10,1,NULL,NULL,'e10',10)", + ), + ); + events.clear(); + }); + tearDown(() => db.close()); + + test( + 'automatic unique joins preserve root pagination in one statement', + () async { + final rows = await db.events + .orderBy((e) => [e.id.asc()]) + .skip(2) + .take(4) + .select( + (e) => ( + e.id, + e.author + .select( + (a) => ( + a.tenant, + a.id, + ).map((tenant, id) => (tenant: tenant, id: id)), + ) + .required(), + ).map((id, owner) => (id: id, owner: owner)), + ) + .get(); + expect(rows.map((r) => r.id), [3, 4, 5, 6]); + expect(rows.map((r) => r.owner), [ + (tenant: 1, id: 2), + (tenant: 1, id: 2), + (tenant: 2, id: 1), + (tenant: 2, id: 1), + ]); + expect(events.length, 1); + expect(events.single.sql, contains('LEFT JOIN')); + }, + ); + + test( + 'all-null projections still distinguish a present row from absence', + () async { + final rows = await db.events + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.author + .select( + (a) => ( + a.label, + a.note, + ).map((label, note) => (label: label, note: note)), + ) + .one(), + ) + .get(); + expect(rows[0], (label: null, note: null)); + expect(rows[8], isNull); + expect(rows[9], isNull); + expect(events.length, 1); + }, + ); + + test( + 'explicit batch deduplicates composite keys and skips partial nulls', + () async { + final limited = Database( + _LimitedDriver(db.driver, 7), + onQuery: events.add, + ); + final rows = await limited.events + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.author + .select( + (a) => ( + a.tenant, + a.id, + ).map((tenant, id) => (tenant: tenant, id: id)), + ) + .one(strategy: .batch), + ) + .get(); + expect(rows, [ + (tenant: 1, id: 1), + (tenant: 1, id: 1), + (tenant: 1, id: 2), + (tenant: 1, id: 2), + (tenant: 2, id: 1), + (tenant: 2, id: 1), + (tenant: 2, id: 2), + (tenant: 2, id: 2), + null, + null, + ]); + expect(events.length, 3); + expect(events.skip(1).every((e) => e.parameterCount <= 7), isTrue); + events.clear(); + expect( + await limited.events + .where((e) => e.id.gt(8)) + .select((e) => e.author.one(strategy: .batch)) + .get(), + [null, null], + ); + expect(events.length, 1); + }, + ); + + test( + 'composite chunks include filter and window parameters with nested joins', + () async { + final limited = Database( + _LimitedDriver(db.driver, 9), + onQuery: events.add, + ); + final rows = await limited.accounts + .orderBy((a) => [a.tenant.asc(), a.id.asc()]) + .select( + (a) => a.events + .where((e) => e.score.gt(0)) + .orderBy((e) => [e.id.desc()]) + .skip(1) + .take(1) + .select( + (e) => ( + e.title, + e.author + .where((a) => a.id.gt(0)) + .select((a) => a.tenant) + .required(), + ).map((title, tenant) => (title: title, tenant: tenant)), + ) + .many(), + ) + .get(); + expect(rows, [ + [(title: 'e1', tenant: 1)], + [(title: 'e3', tenant: 1)], + <({String title, int tenant})>[], + [(title: 'e5', tenant: 2)], + [(title: 'e7', tenant: 2)], + ]); + expect(events.length, 4); + expect(events.skip(1).every((e) => e.parameterCount <= 9), isTrue); + expect( + events + .skip(1) + .every( + (e) => + e.sql.contains('LEFT JOIN') && + e.sql.contains('PARTITION BY'), + ), + isTrue, + ); + }, + ); + + test( + 'nested self joins and two edges to the same table use distinct aliases', + () async { + final rows = await db.events + .where((e) => e.id.eq(3)) + .select( + (e) => ( + e.author + .select( + (a) => ( + a.id, + a.manager.select((m) => m.id).one(), + ).map((id, manager) => (id: id, manager: manager)), + ) + .required(), + e.reviewerAccount.select((a) => a.id).required(), + ).map((author, reviewer) => (author: author, reviewer: reviewer)), + ) + .get(); + expect(rows, [(author: (id: 2, manager: 1), reviewer: 1)]); + expect(events.length, 1); + expect('LEFT JOIN'.allMatches(events.single.sql).length, 3); + }, + ); + + test( + 'batch collection below a joined optional parent loads on the same plan', + () async { + final rows = await db.events + .where((e) => e.id.eq(1).or(e.id.eq(9))) + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.author + .select( + (a) => ( + a.id, + a.events + .orderBy((e) => [e.id.asc()]) + .take(1) + .select((e) => e.title) + .many(), + ).map((id, titles) => (id: id, titles: titles)), + ) + .one(), + ) + .get(); + expect(rows.first!.id, 1); + expect(rows.first!.titles, ['e1']); + expect(rows.last, isNull); + expect(events.length, 2); + }, + ); + + test( + 'join refuses unproven cardinality while automatic batch checks it', + () async { + expect( + () => db.accounts.select((a) => a.events.one(strategy: .join)), + throwsA(code('RELATION.JOIN_KEY')), + ); + expect(events, isEmpty); + await expectLater( + db.accounts + .byId(tenant: 1, id: 1) + .select((a) => a.events.one()) + .get(), + throwsA(code('RELATION.CARDINALITY')), + ); + }, + ); + + test('filters and to-one pagination do not drop root rows', () async { + for (final strategy in [ToOneStrategy.join, ToOneStrategy.batch]) { + final query = db.events.where((e) => e.id.eq(3)); + expect( + await query + .select( + (e) => + e.author.where((a) => a.id.eq(99)).one(strategy: strategy), + ) + .single(), + isNull, + ); + expect( + await query + .select((e) => e.author.take(0).one(strategy: strategy)) + .single(), + isNull, + ); + expect( + await query + .select((e) => e.author.skip(1).one(strategy: strategy)) + .single(), + isNull, + ); + await expectLater( + query + .select( + (e) => e.author + .where((a) => a.id.eq(99)) + .required(strategy: strategy), + ) + .get(), + throwsA(code('RELATION.MISSING')), + ); + } + }); + + test('joined records survive CTE projection and cursor decoding', () async { + final cte = db.events + .orderBy((e) => [e.id.asc()]) + .select( + (e) => ( + e.id, + e.author.select((a) => a.id).one(), + ).map((id, owner) => (id: id, owner: owner)), + ) + .asCte('cards'); + final rows = await cte.query + .orderBy((c) => [c.ref((e) => e.id).asc()]) + .stream(batchSize: 3) + .toList(); + expect(rows.map((r) => r.owner), [1, 1, 2, 2, 1, 1, 2, 2, null, null]); + expect(events.where((e) => e.operation == .cursorOpen).length, 1); + expect( + events.where((e) => e.operation == .cursorFetch).map((e) => e.rowCount), + [3, 3, 3, 1], + ); + }); + + test( + 'required tests row presence independently of nullable selected values', + () async { + for (final strategy in [ToOneStrategy.join, ToOneStrategy.batch]) { + final result = await db.events + .byId(1) + .select( + (e) => e.author + .select((a) => a.label) + .required(strategy: strategy), + ) + .single(); + expect(result, isNull); + await expectLater( + db.events + .byId(9) + .select( + (e) => e.author + .select((a) => a.label) + .required(strategy: strategy), + ) + .get(), + throwsA(code('RELATION.MISSING')), + ); + } + }, + ); + + test( + 'relation aggregates restore the alias used by a joined selection', + () async { + final row = await db.events.byId(3).select((e) { + final author = e.author; + return ( + author.select((a) => a.id).required(), + author.count(), + author.any(), + ).map((id, count, exists) => (id: id, count: count, exists: exists)); + }).single(); + expect(row, (id: 2, count: 1, exists: true)); + expect(events.length, 1); + }, + ); + + test('reusing an identical edge deduplicates its join', () async { + final query = db.events.where((e) => e.id.eq(3)).select((e) { + final author = e.author; + return ( + author.select((a) => a.id).required(), + author.select((a) => a.label).one(), + ).map((id, label) => (id: id, label: label)); + }); + expect(await query.single(), (id: 2, label: 'A2')); + expect('LEFT JOIN'.allMatches(events.single.sql).length, 1); + }); + + test('different filters need fresh relation occurrences', () async { + final query = db.events.select((e) { + final author = e.author; + return ( + author.where((a) => a.id.eq(1)).one(), + author.where((a) => a.id.eq(2)).one(), + ).map((a, b) => (a, b)); + }); + await expectLater(query.get(), throwsA(code('RELATION.ALIAS'))); + expect(events, isEmpty); + }); + + test('large composite batches avoid expression-depth failure', () async { + await db.execute( + SqlCommand( + 'WITH RECURSIVE n(x) AS (' + 'SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1200) ' + 'INSERT INTO accounts(tenant,id) SELECT 3,x FROM n', + ), + ); + await db.execute( + SqlCommand( + 'INSERT INTO events(id,tenant,owner,title,score) ' + "SELECT id+100,3,id,'large'||id,id FROM accounts WHERE tenant=3", + ), + ); + events.clear(); + final rows = await db.accounts + .where((a) => a.tenant.eq(3)) + .orderBy((a) => [a.id.asc()]) + .select((a) => a.events.select((e) => e.title).many()) + .get(); + expect(rows.length, 1200); + expect(rows.first, ['large1']); + expect(rows.last, ['large1200']); + expect(events.length, 2); + expect(events.last.parameterCount, 2400); + expect(events.last.sql, isNot(contains(' OR '))); + }); + }); +} + +final class _LimitedDriver(final Driver inner, final int limit) + implements Driver { + @override + Capabilities get capabilities => Capabilities( + dialect: inner.capabilities.dialect, + maxParameters: limit, + streaming: inner.capabilities.streaming, + cancellation: inner.capabilities.cancellation, + ); + @override + Future run(Future Function(SqlConnection) action) => + inner.run(action); + @override + Future close() async {} +} diff --git a/test/retry_test.dart b/test/retry_test.dart new file mode 100644 index 00000000..810be775 --- /dev/null +++ b/test/retry_test.dart @@ -0,0 +1,760 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/tables.dart'; + +Matcher code(String code) => + isA().having((e) => e.code, 'code', code); +const retry = TransactionRetry(delay: Duration.zero); + +Future main() async { + final probe = await sqlite(const SqliteOptions.memory()); + final sqliteCancellation = probe.capabilities.cancellation; + await probe.close(); + runTests('sqlite', () => sqlite(const SqliteOptions.memory())); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + runTests('postgres', () async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_retry_tests', + maxConnections: 1, + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_retry_tests'), + ); + return db; + }); + } + test( + 'SQLite WAL snapshot conflict replays reads after rollback', + () async { + final directory = await Directory.systemTemp.createTemp( + 'orm-retry-snapshot-', + ); + final options = SqliteOptions.file('${directory.path}/db.sqlite'); + final base = await sqlite(options), other = await sqlite(options); + final errors = []; + final db = Database( + base.driver, + onQuery: (event) { + if (event.error case final SqliteFailure failure) errors.add(failure); + }, + ); + try { + await createTables(db); + await db.table(users).createRow((u) => [u.email.set('initial')]); + var calls = 0; + final reads = []; + await db.transaction((tx) async { + calls++; + final row = await tx.table(users).single(); + reads.add(row.score); + if (calls == 1) { + await other.table(users).update((u) => [u.score.set(10)]).execute(); + } + await tx + .table(users) + .update((u) => [u.score.set(row.score + 1)]) + .execute(); + }, retry: retry); + expect(errors.map((e) => e.extendedCode), [517]); + expect(reads, [0, 10]); + expect(calls, 2); + expect((await db.table(users).single()).score, 11); + } finally { + await other.close(); + await db.close(); + await directory.delete(recursive: true); + } + }, + skip: sqliteCancellation + ? false + : 'Bounded retries require sqlite3_interrupt.', + ); + for (final mode in [ + 'success', + 'exhausted', + 'deadline', + 'cancel', + 'shared-budget', + ]) { + test( + 'SQLite real busy COMMIT: $mode', + () async { + final directory = await Directory.systemTemp.createTemp( + 'orm-retry-commit-', + ); + final options = SqliteOptions.file( + '${directory.path}/db.sqlite', + journal: .delete, + busyTimeout: const Duration(milliseconds: 20), + ); + final base = await sqlite(options), reader = await sqlite(options); + final driver = _FaultDriver(base.driver); + final release = Completer(), entered = Completer(); + final cancellation = CancellationToken(); + var callbacks = 0, busyCommits = 0; + final writer = Database( + driver, + onQuery: (event) { + if (event.sql == 'COMMIT' && event.error is SqliteFailure) { + busyCommits++; + if (mode == 'success' && !release.isCompleted) release.complete(); + if (mode == 'cancel') cancellation.cancel(); + } + }, + ); + await createTables(writer); + await writer.table(users).createRow((u) => [u.email.set('initial')]); + final owner = reader.transaction((tx) async { + await tx.table(users).count(); + entered.complete(); + await release.future; + }); + try { + await entered.future; + driver.commands.clear(); + if (mode == 'shared-budget') { + driver.after = (command) { + if (command.sql.startsWith('UPDATE') && callbacks == 1) { + throw const _Transient(); + } + }; + } + final future = writer.transaction( + (tx) async { + callbacks++; + await tx + .table(users) + .update((u) => [u.score.increment(1)]) + .execute(); + return 'committed'; + }, + cancellation: cancellation, + retry: TransactionRetry( + maxAttempts: mode == 'deadline' ? 100 : 3, + timeout: mode == 'deadline' + ? const Duration(milliseconds: 80) + : const Duration(seconds: 3), + delay: const Duration(milliseconds: 20), + ), + ); + if (mode == 'success') { + expect(await future, 'committed'); + expect(callbacks, 1); + expect(busyCommits, 1); + expect(driver.commands.where((s) => s == 'COMMIT').length, 2); + expect(driver.commands, isNot(contains('ROLLBACK'))); + } else { + await expectLater( + future, + throwsA(switch (mode) { + 'deadline' => code('TRANSACTION.TIMEOUT'), + 'cancel' => code('TRANSACTION.CANCELLED'), + _ => isA().having((e) => e.code, 'code', 5), + }), + ).timeout(const Duration(seconds: 2)); + expect(callbacks, mode == 'shared-budget' ? 2 : 1); + if (mode == 'exhausted') expect(busyCommits, 3); + if (mode == 'shared-budget') expect(busyCommits, 2); + } + expect( + (await writer.table(users).single()).score, + mode == 'success' ? 1 : 0, + ); + } finally { + if (!release.isCompleted) release.complete(); + await owner; + await reader.close(); + await writer.close(); + await directory.delete(recursive: true); + } + }, + skip: sqliteCancellation + ? false + : 'Bounded retries require sqlite3_interrupt.', + ); + } +} + +void runTests(String name, Future> Function() open) { + group('retry $name', () { + late Database db; + late _FaultDriver driver; + setUp(() async { + final base = await open(); + driver = _FaultDriver(base.driver); + db = Database(driver); + await db.execute(SqlCommand('DROP TABLE IF EXISTS users')); + await createTables(db); + await db.table(users).createRow((u) => [u.email.set('initial')]); + driver.commands.clear(); + }); + tearDown(() => db.close()); + void retryTest(String description, Future Function() body) { + test(description, () async { + if (!db.capabilities.cancellation) { + markTestSkipped('Bounded retries require sqlite3_interrupt.'); + return; + } + await body(); + }); + } + + Future score() async => + (await db.table(users).where((u) => u.id.eq(1)).single()).score; + Future write(Database tx) async => tx + .table(users) + .where((u) => u.id.eq(1)) + .update((u) => [u.score.increment(1)]) + .execute() + .then((_) {}); + + retryTest( + 'opt-in retries roll back all writes and use new closed-over views', + () async { + var callbacks = 0; + final views = >[]; + driver.after = (command) { + if (command.sql.startsWith('UPDATE') && callbacks == 1) { + throw const _Transient(); + } + }; + final result = await db.transaction((tx) async { + callbacks++; + views.add(tx); + await tx + .table(users) + .createRow((u) => [u.email.set('attempt$callbacks')]); + await write(tx); + return ( + attempt: callbacks, + value: + (await tx.table(users).where((u) => u.id.eq(1)).single()).score, + ); + }, retry: retry); + expect(result, (attempt: 2, value: 1)); + expect(views.toSet().length, 2); + for (final view in views) { + expect( + () => view.execute(SqlCommand('SELECT 1')), + throwsA(code('SESSION.CLOSED')), + ); + } + expect( + await db.table(users).select((u) => u.email).get(), + unorderedEquals(['initial', 'attempt2']), + ); + expect(driver.commands.where((s) => s == 'ROLLBACK').length, 1); + expect(driver.commands.where((s) => s == 'COMMIT').length, 1); + }, + ); + + retryTest( + 'default is one attempt; an explicit attempt limit is exact', + () async { + var calls = 0; + driver.after = (command) { + if (command.sql.startsWith('UPDATE')) throw const _Transient(); + }; + Future action(Database tx) async { + calls++; + await write(tx); + } + + await expectLater(db.transaction(action), throwsA(isA<_Transient>())); + expect(calls, 1); + calls = 0; + driver.commands.clear(); + await expectLater( + db.transaction(action, retry: retry), + throwsA(isA<_Transient>()), + ); + expect(calls, 3); + expect(driver.commands.where((s) => s == 'ROLLBACK').length, 3); + expect(await score(), 0); + }, + ); + + retryTest('application and constraint errors and caught statement failures do not replay', () async { + var calls = 0; + await expectLater( + db.transaction((tx) async { + calls++; + await tx.table(users).createRow((u) => [u.email.set('initial')]); + }, retry: retry), + throwsA( + isA().having( + (e) => e.retryTransaction, + 'retryTransaction', + false, + ), + ), + ); + expect(calls, 1); + calls = 0; + await expectLater( + db.transaction((tx) async { + calls++; + throw StateError('application'); + }, retry: retry), + throwsStateError, + ); + expect(calls, 1); + calls = 0; + driver.after = (command) { + if (command.sql.startsWith('UPDATE')) throw const _Transient(); + }; + await expectLater( + db.transaction((tx) async { + calls++; + try { + await write(tx); + } on _Transient { + /* Deliberately caught by the app. */ + } + }, retry: retry), + throwsA(code('TRANSACTION.FAILED')), + ); + expect(calls, 1); + expect(await score(), 0); + }); + + retryTest( + 'unknown commit and unconfirmed rollback prohibit replay', + () async { + var calls = 0; + driver.after = (command) { + if (command.sql == 'COMMIT') throw StateError('Lost acknowledgement'); + }; + await expectLater( + db.transaction((tx) async { + calls++; + await write(tx); + }, retry: retry), + throwsA(code('TRANSACTION.COMMIT')), + ); + expect(calls, 1); + expect(await score(), 1); + driver.after = (command) { + if (command.sql.startsWith('UPDATE')) throw const _Transient(); + }; + driver.before = (command) { + if (command.sql == 'ROLLBACK') { + throw StateError('Rollback transport failed'); + } + }; + calls = 0; + await expectLater( + db.transaction((tx) async { + calls++; + await write(tx); + }, retry: retry), + throwsA(code('TRANSACTION.ROLLBACK')), + ); + expect(calls, 1); + expect(driver.discards, 1); + }, + ); + + retryTest('retry time budget bounds active SQL and does not retry cancellation', () async { + final slow = name == 'postgres' + ? 'SELECT pg_sleep(10)' + : 'WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<100000000) SELECT sum(x) FROM n'; + var calls = 0; + await expectLater( + db.transaction((tx) async { + calls++; + await write(tx); + await tx.execute(SqlCommand(slow)); + }, retry: const TransactionRetry(timeout: Duration(milliseconds: 80))), + throwsA(code('TRANSACTION.TIMEOUT')), + ).timeout(const Duration(seconds: 3)); + expect(calls, 1); + expect(await score(), 0); + }); + + retryTest( + 'deadline and cancellation interrupt backoff without another callback', + () async { + var calls = 0; + driver.after = (command) { + if (command.sql.startsWith('UPDATE')) throw const _Transient(); + }; + await expectLater( + db.transaction( + (tx) async { + calls++; + await write(tx); + }, + retry: const TransactionRetry( + timeout: Duration(milliseconds: 80), + delay: Duration(seconds: 5), + maxDelay: Duration(seconds: 5), + ), + ), + throwsA(code('TRANSACTION.TIMEOUT')), + ).timeout(const Duration(seconds: 2)); + expect(calls, 1); + expect(await score(), 0); + calls = 0; + final token = CancellationToken(); + Timer? cancelling; + driver.after = (command) { + if (command.sql.startsWith('UPDATE')) throw const _Transient(); + if (command.sql == 'ROLLBACK') { + cancelling = Timer(const Duration(milliseconds: 20), token.cancel); + } + }; + try { + await expectLater( + db.transaction( + (tx) async { + calls++; + await write(tx); + }, + cancellation: token, + retry: const TransactionRetry( + delay: Duration(seconds: 5), + maxDelay: Duration(seconds: 5), + ), + ), + throwsA(code('TRANSACTION.CANCELLED')), + ).timeout(const Duration(seconds: 2)); + expect(calls, 1); + expect(await score(), 0); + } finally { + cancelling?.cancel(); + } + }, + ); + + retryTest( + 'time budget includes acquisition and abandons late callbacks', + () async { + final entered = Completer(), release = Completer(); + final owner = db.session((s) async { + entered.complete(); + await release.future; + }); + await entered.future; + var calls = 0; + try { + await expectLater( + db.transaction( + (tx) async { + calls++; + }, + retry: const TransactionRetry( + timeout: Duration(milliseconds: 40), + ), + ), + throwsA(code('TRANSACTION.TIMEOUT')), + ).timeout(const Duration(seconds: 2)); + await expectLater( + db.transaction( + (tx) async { + calls++; + }, + acquire: const AcquisitionOptions( + timeout: Duration(milliseconds: 10), + ), + retry: const TransactionRetry(timeout: Duration(seconds: 1)), + ), + throwsA(code('CONNECTION.TIMEOUT')), + ); + } finally { + release.complete(); + await owner; + } + await db.execute(SqlCommand('SELECT 1')); + expect(calls, 0); + expect(driver.commands, isNot(contains('BEGIN'))); + }, + ); + + retryTest('transaction timeout is shared across attempts and sessions recover after expiry', () async { + var calls = 0; + driver.after = (command) { + if (command.sql.startsWith('UPDATE')) throw const _Transient(); + }; + await expectLater( + db.transaction( + (tx) async { + calls++; + await Future.delayed(const Duration(milliseconds: 40)); + await write(tx); + }, + timeout: const Duration(milliseconds: 70), + retry: const TransactionRetry(maxAttempts: 10, delay: Duration.zero), + ), + throwsA(code('TRANSACTION.TIMEOUT')), + ); + expect(calls, lessThan(3)); + await Future.delayed(const Duration(milliseconds: 50)); + expect(await score(), 0); + await db.session((session) async { + await expectLater( + Future.sync( + () => session.transaction( + (tx) async {}, + retry: const TransactionRetry(timeout: Duration(microseconds: 1)), + ), + ), + throwsA(code('TRANSACTION.TIMEOUT')), + ); + expect((await session.execute(SqlCommand('SELECT 1'))).rows, [ + [1], + ]); + }); + }); + + retryTest( + 'subscriptions publish only the final committed retry result', + () async { + var calls = 0; + driver.after = (command) { + if (command.sql.startsWith('UPDATE') && calls == 1) { + throw const _Transient(); + } + }; + final snapshots = >[]; + final initial = Completer(), committed = Completer(); + final subscription = db + .table(users) + .select((u) => u.score) + .watch() + .listen((rows) { + snapshots.add(rows); + if (snapshots.length == 1) { + initial.complete(); + } else { + committed.complete(); + } + }); + try { + await initial.future; + await db.transaction((tx) async { + calls++; + await write(tx); + }, retry: retry); + await committed.future.timeout(const Duration(seconds: 2)); + expect(snapshots, [ + [0], + [1], + ]); + } finally { + await subscription.cancel(); + } + }, + ); + + test('unsupported bounded retry rejects before BEGIN or callback', () { + if (db.capabilities.cancellation) { + markTestSkipped( + 'This driver supports bounded retry; behavior is tested above.', + ); + return; + } + var entered = false; + expect( + () => db.transaction((tx) async { + entered = true; + }, retry: retry), + throwsA(code('CAPABILITY.CANCEL')), + ); + expect(entered, isFalse); + expect(driver.commands, isEmpty); + }); + + test('invalid policies reject before SQL', () { + for (final policy in [ + const TransactionRetry(maxAttempts: 0), + const TransactionRetry(timeout: Duration.zero), + const TransactionRetry(delay: Duration(milliseconds: -1)), + const TransactionRetry(delay: Duration(seconds: 2)), + ]) { + expect( + () => db.transaction((tx) async {}, retry: policy), + throwsArgumentError, + ); + } + expect(driver.commands, isEmpty); + }); + + if (name == 'postgres') { + Future> concurrent() async => postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_retry_tests', + maxConnections: 1, + ), + ); + test('real unknown COMMIT response prohibits opted-in retry', () async { + await db.execute( + SqlCommand( + r"CREATE OR REPLACE FUNCTION uncertain_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'uncertain result' USING ERRCODE = '40003'; END $$", + ), + ); + await db.execute( + SqlCommand( + 'CREATE CONSTRAINT TRIGGER uncertain AFTER UPDATE ON users DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION uncertain_commit()', + ), + ); + var calls = 0; + await expectLater( + db.transaction((tx) async { + calls++; + await write(tx); + }, retry: retry), + throwsA(code('TRANSACTION.COMMIT')), + ); + expect(calls, 1); + expect(await score(), 0); + }); + test( + 'real serialization conflict repeats the reads and decisions', + () async { + final other = await concurrent(); + var calls = 0; + final reads = []; + try { + final result = await db.transaction( + (tx) async { + calls++; + final user = await tx.table(users).single(); + reads.add(user.score); + if (calls == 1) { + await other + .table(users) + .update((u) => [u.score.set(10)]) + .execute(); + } + await tx + .table(users) + .update((u) => [u.score.set(user.score + 1)]) + .execute(); + return user.score + 1; + }, + options: const PostgresTransaction(isolation: .repeatableRead), + retry: retry, + ); + expect(calls, 2); + expect(reads, [0, 10]); + expect(result, 11); + expect(await score(), 11); + } finally { + await other.close(); + } + }, + ); + test('real deadlock retries only the aborted callback', () async { + final other = await concurrent(); + await db.table(users).createRow((u) => [u.email.set('second')]); + final first = Completer(), second = Completer(); + Future run( + Database source, + int a, + int b, + Completer ready, + Completer peer, + ) async { + var calls = 0; + await source.transaction((tx) async { + calls++; + await tx + .table(users) + .where((u) => u.id.eq(a)) + .update((u) => [u.score.increment(1)]) + .execute(); + if (calls == 1) { + ready.complete(); + await peer.future; + } + await tx + .table(users) + .where((u) => u.id.eq(b)) + .update((u) => [u.score.increment(1)]) + .execute(); + }, retry: retry); + return calls; + } + + try { + final calls = await Future.wait([ + run(db, 1, 2, first, second), + run(other, 2, 1, second, first), + ]); + expect(calls, unorderedEquals([1, 2])); + expect(await db.table(users).select((u) => u.score).get(), [2, 2]); + } finally { + await other.close(); + } + }); + } + }); +} + +/// Controlled adapter failure after real SQL, to exercise cleanup/budget paths. +final class _Transient implements SqlFailure { + const _Transient(); + @override + bool get retryTransaction => true; + @override + bool get retryCommit => false; + @override + bool get commitRejected => true; +} + +final class _FaultDriver(final Driver source) + implements Driver { + void Function(SqlCommand)? before, after; + final commands = []; + int discards = 0; + @override + Capabilities get capabilities => source.capabilities; + @override + Future run(Future Function(SqlConnection) action) => + source.run((c) => action(_FaultConnection(c, this))); + @override + Future close() => source.close(); +} + +final class _FaultConnection( + final SqlConnection inner, + final _FaultDriver driver, +) implements SqlConnection { + @override + bool? get transactionActive => inner.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + driver.commands.add(command.sql); + driver.before?.call(command); + final result = await inner.execute(command, options: options); + driver.after?.call(command); + return result; + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => inner.openCursor(command, options: options); + @override + Future invalidate() { + driver.discards++; + return inner.invalidate(); + } +} diff --git a/test/runtime_lifecycle_review_test.dart b/test/runtime_lifecycle_review_test.dart new file mode 100644 index 00000000..bc4e02ed --- /dev/null +++ b/test/runtime_lifecycle_review_test.dart @@ -0,0 +1,169 @@ +import 'dart:async'; + +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +final _id = Column('id', Codecs.integer); +final _schema = TableSchema('lifecycle_rows', columns: [_id]); +final _table = Table(_schema, _Fields.new, (row) => row.id); + +final class _Fields(super.table) extends Fields { + late final id = column(_id); +} + +void main() { + test('borrowed sessions reject root-resource close listeners', () async { + final raw = SqlDatabase(_Driver()); + void checkBorrowed(SqlDatabase view) { + expect( + () => view.addCloseListener(() async {}), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'SESSION.BORROWED', + ), + ), + ); + } + + try { + await raw.session((session) async { + checkBorrowed(session); + await session.transaction((tx) async { + checkBorrowed(tx); + await tx.savepoint((child) async => checkBorrowed(child)); + }); + }); + } finally { + await raw.close(); + } + }); + + test( + 'a synchronous close-listener failure still closes bound query watches', + () async { + final driver = _Driver(); + final raw = SqlDatabase(driver); + raw.addCloseListener(() => throw StateError('resource cleanup failed')); + final db = Database.fromSql(raw); + final first = Completer(); + var done = false; + final subscription = SqlBuilder(SqlDialect.sqlite) + .table(_table) + .bind(db) + .watch() + .listen((_) => first.complete(), onDone: () => done = true); + try { + await first.future; + await expectLater(raw.close(), throwsStateError); + await Future.delayed(Duration.zero); + expect(driver.closed, isTrue); + expect(done, isTrue, reason: 'Every cleanup listener must run.'); + } finally { + await subscription.cancel(); + } + }, + ); + + test( + 'raw and typed invalidations coalesce across wrappers and savepoints', + () async { + final driver = _Driver(); + final raw = SqlDatabase(driver); + final db = Database.fromSql(raw); + Database.fromSql(raw); // A second ORM view must not duplicate snapshots. + final initial = Completer(); + final changed = Completer(); + final rows = >[]; + final subscription = SqlBuilder(SqlDialect.sqlite) + .table(_table) + .bind(db) + .watch() + .listen((value) { + rows.add(value); + if (rows.length == 1) initial.complete(); + if (rows.length == 2) changed.complete(); + }); + try { + await initial.future; + await raw.transaction((tx) async { + final orm = Database.fromSql(tx); + await orm.execute( + SqlCommand('UPDATE lifecycle_rows SET id = 1'), + changedTables: [_schema], + ); + tx.notifyChanged(['lifecycle_rows']); + await tx.savepoint((child) async { + Database.fromSql(child).invalidate([_schema]); + child.notifyChanged(['lifecycle_rows']); + }); + expect(rows, hasLength(1)); + }); + await changed.future; + await Future.delayed(Duration.zero); + expect(rows, hasLength(2)); + expect(driver.reads, 2); + await expectLater( + raw.transaction((tx) async { + await tx.savepoint((child) async { + Database.fromSql(child).invalidate([_schema]); + }); + throw StateError('rollback outer transaction'); + }), + throwsStateError, + ); + await Future.delayed(Duration.zero); + expect(rows, hasLength(2)); + } finally { + await subscription.cancel(); + await db.close(); + } + }, + ); +} + +final class _Driver implements Driver { + bool closed = false; + int reads = 0; + @override + final capabilities = const Capabilities( + dialect: SqlDialect.sqlite, + maxParameters: 999, + ); + @override + Future run(Future Function(SqlConnection) action) => + action(_Connection(this)); + @override + Future close() async => closed = true; +} + +final class _Connection(final _Driver driver) implements SqlConnection { + bool active = false; + @override + bool get transactionActive => active; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + if (command.sql == 'BEGIN') active = true; + if (command.sql == 'COMMIT' || command.sql == 'ROLLBACK') active = false; + if (command.sql.startsWith('SELECT')) { + driver.reads++; + return const SqlResult([ + [1], + ]); + } + return const SqlResult([], affectedRows: 1); + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => + throw UnsupportedError('No cursor needed for this lifecycle regression.'); + @override + Future invalidate() async {} +} diff --git a/test/runtime_test.dart b/test/runtime_test.dart new file mode 100644 index 00000000..ccd99803 --- /dev/null +++ b/test/runtime_test.dart @@ -0,0 +1,287 @@ +import 'dart:async'; + +import 'package:orm/runtime.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'raw sessions retain one connection and expire borrowed views', + () async { + final driver = _Driver(SqlDialect.sqlite); + final db = SqlDatabase(driver); + SqlDatabase? borrowed; + await db.session((session) async { + borrowed = session; + final result = await session.execute(SqlCommand('SELECT value', [7])); + expect(result.rows, [ + [7], + ]); + await session.transaction((tx) async { + await tx.execute(SqlCommand('SELECT value', [8])); + }); + }); + expect(driver.leases, 1); + expect( + () => borrowed!.execute(SqlCommand('SELECT value')), + throwsA(_code('SESSION.CLOSED')), + ); + await db.close(); + expect(driver.closed, isTrue); + }, + ); + + test( + 'raw table notifications merge through successful savepoints only', + () async { + final db = SqlDatabase(_Driver(SqlDialect.sqlite)); + final changes = >[]; + db.addChangeListener(changes.add); + await db.transaction((tx) async { + tx.notifyChanged(['parent']); + try { + await tx.savepoint((child) async { + child.notifyChanged(['rolled_back']); + throw StateError('undo child'); + }); + } on StateError catch (_) {} + await tx.savepoint((child) async { + await child.execute( + SqlCommand('UPDATE data'), + changedTables: ['child'], + ); + }); + expect(changes, isEmpty); + }); + expect(changes, [ + {'parent'}, + {'child'}, + ]); + await db.close(); + }, + ); + + test( + 'rollback discards runtime invalidation hooks and raw notifications', + () async { + final db = SqlDatabase(_Driver(SqlDialect.sqlite)); + var hooks = 0; + final changes = >[]; + db.addChangeListener(changes.add); + await expectLater( + db.transaction((tx) async { + tx.deferInvalidation(() => hooks++); + tx.notifyChanged(['data']); + throw StateError('rollback'); + }), + throwsStateError, + ); + expect(hooks, 0); + expect(changes, isEmpty); + await db.close(); + }, + ); + + test( + 'uncertain commit invalidates once without replaying a callback', + () async { + final driver = _Driver(SqlDialect.sqlite); + driver.connection.onExecute = (command) { + if (command.sql == 'COMMIT') { + driver.connection.active = false; + throw StateError('acknowledgement lost'); + } + }; + final db = SqlDatabase(driver); + var calls = 0, hooks = 0; + final changes = >[]; + db.addChangeListener(changes.add); + await expectLater( + db.transaction((tx) async { + calls++; + tx.deferInvalidation(() => hooks++); + tx.notifyChanged(['data']); + }), + throwsA(_code('TRANSACTION.COMMIT')), + ); + expect(calls, 1); + expect(hooks, 1); + expect(changes, [ + {'data'}, + ]); + await db.close(); + }, + ); + + test( + 'retry discards invalidation callbacks from the rolled back attempt', + () async { + final driver = _Driver(SqlDialect.sqlite); + var executions = 0; + driver.connection.onExecute = (command) { + if (command.sql == 'UPDATE retry' && ++executions == 1) { + throw const _RetryableFailure(); + } + }; + final db = SqlDatabase(driver); + var calls = 0; + final hooks = []; + await db.transaction((tx) async { + final attempt = ++calls; + tx.deferInvalidation(() => hooks.add(attempt)); + await tx.execute(SqlCommand('UPDATE retry')); + }, retry: const TransactionRetry(delay: Duration.zero)); + expect(calls, 2); + expect(hooks, [2]); + expect( + driver.connection.commands.where((sql) => sql == 'ROLLBACK'), + hasLength(1), + ); + await db.close(); + }, + ); + + test('MySQL transaction setup is ordered on the leased connection', () async { + final driver = _Driver(SqlDialect.mysql); + final db = SqlDatabase(driver); + await db.transaction( + (tx) async { + await tx.execute(SqlCommand('SELECT value', [1])); + }, + options: const MysqlTransaction(isolation: .serializable, readOnly: true), + ); + expect(driver.connection.commands, [ + 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE', + 'START TRANSACTION READ ONLY', + 'SELECT value', + 'COMMIT', + ]); + expect(driver.leases, 1); + await db.close(); + }); + + test( + 'failed multi-statement transaction setup discards changed session state', + () async { + final driver = _Driver(SqlDialect.mariadb); + driver.connection.onExecute = (command) { + if (command.sql.startsWith('START TRANSACTION')) { + throw StateError('start rejected'); + } + }; + final db = SqlDatabase(driver); + var calls = 0; + await expectLater( + db.transaction((_) async { + calls++; + }, options: const MariadbTransaction()), + throwsStateError, + ); + expect(calls, 0); + expect(driver.connection.invalidated, isTrue); + await db.close(); + }, + ); + + test( + 'close stops registered resources once and clears change listeners', + () async { + final db = SqlDatabase(_Driver(SqlDialect.sqlite)); + final stopped = Completer(); + var stops = 0, notifications = 0; + db.addChangeListener((_) => notifications++); + db.addCloseListener(() async { + stops++; + await stopped.future; + }); + final closing = db.close(); + final second = db.close(); + expect(stops, 1); + expect( + () => db.notifyChanged(['data']), + throwsA(_code('SESSION.CLOSED')), + ); + stopped.complete(); + await Future.wait([closing, second]); + expect(stops, 1); + expect(notifications, 0); + }, + ); +} + +Matcher _code(String code) => + isA().having((e) => e.code, 'code', code); + +final class _Driver implements Driver { + @override + final Capabilities capabilities; + final connection = _Connection(); + int leases = 0; + bool closed = false; + _Driver(SqlDialect dialect) + : capabilities = Capabilities( + dialect: dialect, + maxParameters: 999, + returning: + dialect == SqlDialect.sqlite || dialect == SqlDialect.postgres, + cancellation: true, + ); + @override + Future run(Future Function(SqlConnection) action) { + leases++; + return Future.sync(() => action(connection)); + } + + @override + Future close() async { + closed = true; + } +} + +final class _Connection implements SqlConnection { + final commands = []; + bool active = false; + bool invalidated = false; + void Function(SqlCommand)? onExecute; + @override + bool get transactionActive => active; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + options.check(); + commands.add(command.sql); + onExecute?.call(command); + if (command.sql.startsWith('BEGIN') || + command.sql.startsWith('START TRANSACTION')) { + active = true; + } else if (command.sql == 'COMMIT' || command.sql == 'ROLLBACK') { + active = false; + } + return SqlResult( + command.sql.startsWith('SELECT') ? [command.parameters] : [], + columns: command.sql.startsWith('SELECT') ? ['value'] : const [], + affectedRows: command.sql.startsWith('UPDATE') ? 1 : 0, + ); + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => throw UnsupportedError('This test connection has no cursor.'); + @override + Future invalidate() async { + invalidated = true; + } +} + +final class _RetryableFailure implements SqlFailure { + const _RetryableFailure(); + @override + bool get retryTransaction => true; + @override + bool get retryCommit => false; + @override + bool get commitRejected => true; +} diff --git a/test/schema_boundary_test.dart b/test/schema_boundary_test.dart new file mode 100644 index 00000000..7c208440 --- /dev/null +++ b/test/schema_boundary_test.dart @@ -0,0 +1,180 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'schema rejects malformed names, keys and local FK definitions offline', + () { + final id = Column('id', Codecs.integer); + for (final table in [ + TableSchema('', columns: [id]), + TableSchema('bad\u0000name', columns: [id]), + TableSchema('items', columns: [Column('', Codecs.integer)]), + TableSchema('items', columns: [id], uniqueKeys: [[]]), + TableSchema('items', columns: [id], primaryKey: ['id', 'id']), + TableSchema( + 'items', + columns: [Column('id', Codecs.integer.nullable(), nullable: true)], + primaryKey: ['id'], + ), + TableSchema( + 'items', + columns: [id], + indexes: [ + const IndexSchema('items', ['id']), + ], + ), + TableSchema( + 'items', + columns: [id], + foreignKeys: [ + const ForeignKey(['missing'], 'other', ['id']), + ], + ), + TableSchema( + 'items', + columns: [id], + foreignKeys: [ + const ForeignKey(['id'], 'other', []), + ], + ), + ]) { + expect(() => SchemaSnapshot([table]), throwsA(isA())); + } + final long = SchemaSnapshot([ + TableSchema('界' * 22, columns: [id]), + ]); + expect(long.forDialect(.sqlite).tables, hasLength(1)); + expect(() => long.forDialect(.postgres), throwsA(isA())); + }, + ); + + test('identifier case is resolved for the selected database', () async { + TableSchema table(String name) => + TableSchema(name, columns: [Column('id', Codecs.integer)]); + for (final snapshot in [ + SchemaSnapshot([table('Items'), table('items')]), + SchemaSnapshot([ + TableSchema( + 'items', + columns: [Column('id', Codecs.integer), Column('ID', Codecs.integer)], + ), + ]), + SchemaSnapshot([ + TableSchema( + 'items', + columns: [Column('id', Codecs.integer)], + checks: [ + const CheckSchema('valid', 'id > 0'), + const CheckSchema('VALID', 'id < 10'), + ], + ), + ]), + ]) { + expect(snapshot.forDialect(.postgres).tables, isNotEmpty); + expect(() => snapshot.forDialect(.sqlite), throwsA(isA())); + } + }); + + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + test( + 'generated ${dialect.name} schema executes its native computed-key contract', + () async { + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (dialect == .postgres && url == null) return; + final directory = await Directory('.dart_tool') + .createTemp('schema-boundary-'); + final file = File('${directory.path}/schema.dart'); + await file.writeAsString( + "import 'package:orm/schema.dart';\n${dialect == .sqlite ? ''' +typedef Item = ({int id, @Unique() @Computed.sql('id + 1', storage: ComputedStorage.virtual) int value}); +final items = entity(); +''' : ''' +typedef Item = ({int source, @Id() @Computed.sql('source + 1') int id}); +final items = entity(table: 'Items'); +final lower = items.check('source > 0', name: 'valid'); +final upper = items.check('source < 10', name: 'VALID'); +'''}", + ); + final generated = await generateSchema(file.path); + final Database db = dialect == .sqlite + ? await sqlite(const SqliteOptions.memory()) + : postgres( + PostgresOptions( + url: Uri.parse(url!), + tls: .disable, + schema: 'orm_schema_boundary_tests', + ), + ); + try { + if (dialect == .postgres) { + await db.execute( + SqlCommand( + 'DROP SCHEMA IF EXISTS orm_schema_boundary_tests CASCADE', + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA orm_schema_boundary_tests'), + ); + } + await Migrator(db.sql).apply([ + Migration.create( + '0001_native', + generated.snapshot.tables, + dialect: dialect, + ), + ]); + await db.execute( + SqlCommand( + dialect == .sqlite + ? 'INSERT INTO items (id) VALUES (1)' + : 'INSERT INTO "Items" (source) VALUES (1)', + ), + ); + final result = await db.execute( + SqlCommand( + dialect == .sqlite + ? 'SELECT value FROM items' + : 'SELECT id FROM "Items"', + ), + ); + expect(result.rows.single.single, 2); + expect( + (await verifySchema(db.sql, generated.snapshot)).matches, + true, + ); + await db.execute( + SqlCommand('CREATE TABLE on_query (id INTEGER NOT NULL)'), + ); + final imported = await importSchema(db.sql); + expect( + imported.hasBlockingIssues, + false, + reason: imported.issues.map((i) => i.code).join(', '), + ); + expect(imported.entities['on_query'], isNot('onQuery')); + final importedFile = File('${directory.path}/imported.dart'); + await importedFile.writeAsString(imported.dart); + final regenerated = await generateSchema(importedFile.path); + expect( + (await verifySchema(db.sql, regenerated.snapshot)).matches, + true, + ); + } finally { + await db.close(); + await directory.delete(recursive: true); + } + }, + skip: + dialect == .postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } +} diff --git a/test/schema_version_test.dart b/test/schema_version_test.dart new file mode 100644 index 00000000..3d898735 --- /dev/null +++ b/test/schema_version_test.dart @@ -0,0 +1,438 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/tables.dart'; + +Matcher code(String value) => + isA().having((e) => e.code, 'code', value); +List versionHistory(SqlDialect dialect) { + final initial = Migration.create('0001_initial', [ + usersSchema, + ], dialect: dialect); + final expand = Migration( + '0002_expand', + ['ALTER TABLE users ADD COLUMN label TEXT'], + previous: initial.checksum, + dialect: dialect, + ); + final contract = Migration( + '0003_contract', + ['ALTER TABLE users DROP COLUMN nickname'], + previous: expand.checksum, + dialect: dialect, + ); + return [initial, expand, contract]; +} + +final history = versionHistory(SqlDialect.sqlite); +final initial = history[0], expand = history[1], contract = history[2]; + +void main() { + runTests('sqlite', () => sqlite(const SqliteOptions.memory())); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + runTests('postgres', () async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_version_tests', + maxConnections: 1, + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_version_tests'), + ); + return db; + }); + } + test('SQLite version gate works on an explicitly read-only file', () async { + final directory = await Directory.systemTemp.createTemp('orm-version-'); + final path = '${directory.path}/db.sqlite'; + final writer = await sqlite(SqliteOptions.file(path)); + try { + await Migrator(writer.sql).apply([initial]); + await writer.table(users).createRow((u) => [u.email.set('retained')]); + } finally { + await writer.close(); + } + final reader = await sqlite(SqliteOptions.readOnly(path)); + try { + expect( + (await Migrator(reader.sql).requireVersion([initial])).id, + initial.id, + ); + expect((await reader.table(users).single()).email, 'retained'); + await expectLater( + Migrator(reader.sql).requireVersion(history), + throwsA(code('MIGRATION.VERSION')), + ); + expect((await Migrator(reader.sql).history()).length, 1); + } finally { + await reader.close(); + await directory.delete(recursive: true); + } + }); +} + +void runTests(String name, Future> Function() open) { + group('schema version $name', () { + final history = versionHistory(SqlDialect.values.byName(name)); + final [initial, expand, contract] = history; + late Database db; + late Migrator runner; + final commands = []; + setUp(() async { + final base = await open(); + db = Database(base.driver, onQuery: (event) => commands.add(event.sql)); + runner = Migrator(db.sql); + for (final table in [ + 'users', + '_orm_migration_steps', + '_orm_migrations', + ]) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + commands.clear(); + }); + tearDown(() => db.close()); + Future seed(List migrations) async { + await runner.apply(migrations); + await db.table(users).createRow((u) => [u.email.set('retained')]); + } + + test( + 'invalid ranges fail before SQL; caller transactions stay usable', + () async { + expect(() => runner.requireVersion([]), throwsArgumentError); + expect( + () => runner.requireVersion(history, minimum: 'missing'), + throwsArgumentError, + ); + expect( + () => runner.requireVersion(history, maximum: 'missing'), + throwsArgumentError, + ); + expect( + () => runner.requireVersion( + history, + minimum: contract.id, + maximum: initial.id, + ), + throwsArgumentError, + ); + expect(commands, isEmpty); + await db.transaction((tx) async { + expect( + () => Migrator(tx.sql).requireVersion(history), + throwsA(code('MIGRATION.SESSION')), + ); + expect( + (await tx.execute(SqlCommand('SELECT 1'))).rows.single.single, + 1, + ); + }); + }, + ); + + test( + 'unversioned existing data is rejected without creating history', + () async { + await createTables(db); + await db.table(users).createRow((u) => [u.email.set('retained')]); + commands.clear(); + await expectLater( + runner.requireVersion(history), + throwsA(code('MIGRATION.VERSION')), + ); + expect(await inspectColumns(db.sql, '_orm_migrations'), isEmpty); + expect((await db.table(users).single()).email, 'retained'); + expect( + commands.any( + (sql) => sql.startsWith('CREATE') || sql.startsWith('INSERT'), + ), + false, + ); + }, + ); + + test( + 'default requires latest; inclusive compatibility range is explicit', + () async { + await seed([initial]); + await expectLater( + runner.requireVersion(history), + throwsA(code('MIGRATION.VERSION')), + ); + expect( + (await runner.requireVersion(history, minimum: initial.id)).id, + initial.id, + ); + await runner.apply([initial, expand]); + final accepted = await runner.requireVersion( + history, + minimum: initial.id, + maximum: expand.id, + ); + expect((accepted.id, accepted.checksum), (expand.id, expand.checksum)); + expect( + (await runner.requireVersion(history, maximum: expand.id)).id, + expand.id, + ); + await expectLater( + runner.requireVersion(history, maximum: initial.id), + throwsA(code('MIGRATION.VERSION')), + ); + await expectLater( + runner.requireVersion(history), + throwsA(code('MIGRATION.VERSION')), + ); + expect((await db.table(users).single()).email, 'retained'); + }, + ); + + test( + 'newer database rejects downgrade without changing data or schema', + () async { + await seed([initial]); + await runner.apply(history); + await expectLater( + runner.requireVersion([initial, expand]), + throwsA(code('MIGRATION.VERSION')), + ); + await expectLater( + runner.requireVersion( + history, + minimum: initial.id, + maximum: expand.id, + ), + throwsA(code('MIGRATION.VERSION')), + ); + expect((await runner.requireVersion(history)).id, contract.id); + expect((await db.execute(SqlCommand('SELECT email FROM users'))).rows, [ + ['retained'], + ]); + expect( + (await inspectColumns( + db.sql, + 'users', + )).any((c) => c.name == 'nickname'), + false, + ); + expect((await runner.history()).length, 3); + }, + ); + + test('complete applied prefix is checked, including old checksums and missing entries', () async { + await seed([initial, expand]); + await db.execute( + SqlCommand( + "UPDATE _orm_migrations SET checksum = 'altered' WHERE id = '0001_initial'", + ), + ); + await expectLater( + runner.requireVersion(history, minimum: initial.id), + throwsA(code('MIGRATION.CHECKSUM')), + ); + await db.execute( + SqlCommand("DELETE FROM _orm_migrations WHERE id = '0001_initial'"), + ); + await expectLater( + runner.requireVersion(history, minimum: initial.id), + throwsA(code('MIGRATION.CHECKSUM')), + ); + expect((await db.table(users).single()).email, 'retained'); + }); + + test( + 'matching version does not claim that the catalog is free of drift', + () async { + await seed([initial]); + await db.execute( + SqlCommand('ALTER TABLE users RENAME COLUMN email TO renamed_email'), + ); + expect((await runner.requireVersion([initial])).id, initial.id); + expect((await verifySchema(db.sql, initial.snapshot!)).matches, false); + }, + ); + + test( + 'verified baseline is compatible without replaying creation', + () async { + for (final command in createSchema([usersSchema], db.dialect)) { + await db.execute(command); + } + await db.table(users).createRow((u) => [u.email.set('retained')]); + await runner.baseline([initial], expected: initial.snapshot!); + expect( + (await runner.requireVersion([initial])).checksum, + initial.checksum, + ); + expect((await db.table(users).single()).email, 'retained'); + }, + ); + + test( + 'borrowed session uses a read snapshot and survives rejected versions', + () async { + await seed([initial]); + commands.clear(); + await db.session((session) async { + expect( + (await Migrator(session.sql).requireVersion([initial])).id, + initial.id, + ); + await expectLater( + Migrator(session.sql).requireVersion(history), + throwsA(code('MIGRATION.VERSION')), + ); + expect( + (await session.execute(SqlCommand('SELECT 1'))).rows.single.single, + 1, + ); + }); + expect( + commands.first, + name == 'postgres' + ? 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY' + : 'BEGIN DEFERRED', + ); + expect( + commands.every( + (sql) => + ['BEGIN', 'SELECT', 'COMMIT', 'ROLLBACK'].any(sql.startsWith), + ), + true, + ); + }, + ); + + if (name == 'postgres') { + final pending = Migration.steps( + '0002_index', + [ + CheckedSql( + 'UPDATE users SET score = score + 1', + readyWhen: 'SELECT EXISTS(SELECT 1 FROM users WHERE score = 0)', + doneWhen: 'SELECT EXISTS(SELECT 1 FROM users WHERE score = 1)', + ), + ExecuteSql("INSERT INTO users(email) VALUES ('retained')"), + ], + previous: initial.checksum, + dialect: SqlDialect.postgres, + ); + + test('unfinished recoverable migration blocks startup until repaired and completed', () async { + await seed([initial]); + await expectLater( + runner.apply([initial, pending]), + throwsA(code('MIGRATION.STEP')), + ); + await expectLater( + runner.requireVersion([initial, pending], maximum: initial.id), + throwsA(code('MIGRATION.INCOMPLETE')), + ); + // Even an older app must reject recovery work it cannot recognize. + await expectLater( + runner.requireVersion([initial]), + throwsA(code('MIGRATION.INCOMPLETE')), + ); + await db.execute(SqlCommand("UPDATE users SET email = 'repaired'")); + await runner.apply([initial, pending]); + expect( + (await runner.requireVersion([initial, pending])).id, + pending.id, + ); + expect(await db.table(users).count(), 2); + await db.execute( + SqlCommand( + "UPDATE _orm_migration_steps SET state = 'failed' WHERE step = 0", + ), + ); + await expectLater( + runner.requireVersion([initial, pending]), + throwsA(code('MIGRATION.HISTORY')), + ); + }); + + test('catalog and history share a snapshot and the supplied history is frozen', () async { + await seed([initial]); + final other = await open(); + var upgraded = false; + final supplied = [initial, pending]; + final driver = _ReadHook(db.driver, () async { + if (upgraded) return; + upgraded = true; + supplied.clear(); + await expectLater( + Migrator(other.sql).apply([initial, pending]), + throwsA(code('MIGRATION.STEP')), + ); + }); + try { + // After the history SELECT, another runner creates recovery checkpoints. + final accepted = await Migrator(SqlDatabase(driver)) + .requireVersion(supplied, maximum: initial.id); + expect(accepted.id, initial.id); + expect(upgraded, true); + await expectLater( + runner.requireVersion([initial]), + throwsA(code('MIGRATION.INCOMPLETE')), + ); + await other.execute( + SqlCommand("UPDATE users SET email = 'repaired'"), + ); + await Migrator(other.sql).apply([initial, pending]); + expect( + (await runner.requireVersion([initial, pending])).id, + pending.id, + ); + } finally { + await other.close(); + } + }); + } + }); +} + +final class _ReadHook( + final Driver source, + final Future Function() after, +) implements Driver { + @override + Capabilities get capabilities => source.capabilities; + @override + Future run(Future Function(SqlConnection) action) => + source.run((c) => action(_ReadConnection(c, after))); + @override + Future close() => source.close(); +} + +final class _ReadConnection( + final SqlConnection source, + final Future Function() after, +) implements SqlConnection { + @override + bool? get transactionActive => source.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + final result = await source.execute(command, options: options); + if (command.sql.startsWith('SELECT id, checksum FROM')) await after(); + return result; + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => source.openCursor(command, options: options); + @override + Future invalidate() => source.invalidate(); +} diff --git a/test/selection_test.dart b/test/selection_test.dart new file mode 100644 index 00000000..ad59481d --- /dev/null +++ b/test/selection_test.dart @@ -0,0 +1,206 @@ +import 'dart:io'; + +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/tables.dart'; + +void main() { + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + group( + 'selection ${dialect.name}', + () { + late Database db; + final sqlEvents = [], decodeEvents = []; + setUp(() async { + if (dialect == .sqlite) { + db = await sqlite( + const SqliteOptions.memory(), + onQuery: sqlEvents.add, + onDecode: decodeEvents.add, + ); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_selection_tests', + ), + onQuery: sqlEvents.add, + onDecode: decodeEvents.add, + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_selection_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_selection_tests')); + } + await createTables(db); + await db + .table(users) + .createRow((u) => [u.email.set('one'), u.score.set(2)]); + sqlEvents.clear(); + decodeEvents.clear(); + }); + tearDown(() => db.close()); + + test('all typed arities defer mapping and preserve left-to-right evaluation', () async { + for (var arity = 2; arity <= 6; arity++) { + final calls = []; + Selection track(Selection field, String label) => + field.map((value) { + calls.add(label); + return value; + }); + final query = db.table(users).select((u) { + final a = track(u.id, 'id'), + b = track(u.email, 'email'), + c = track(u.nickname, 'nullable'), + d = track(u.score, 'score'), + e = track(u.score.gt(1), 'active'); + List done(List values) { + calls.add('result'); + return values; + } + + return switch (arity) { + 2 => (a, b).map((a, b) => done([a, b])), + 3 => (a, b, c).map((a, b, c) => done([a, b, c])), + 4 => (a, b, c, d).map((a, b, c, d) => done([a, b, c, d])), + 5 => ( + a, + b, + c, + d, + e, + ).map((a, b, c, d, e) => done([a, b, c, d, e])), + _ => ( + a, + b, + c, + d, + e, + a, + ).map((a, b, c, d, e, f) => done([a, b, c, d, e, f])), + }; + }); + final command = query.compile(), plan = query.inspect(); + expect(calls, isEmpty); + expect(plan.sql, command.sql); + expect(plan.columns, hasLength(arity == 6 ? 5 : arity)); + expect( + await query.single(), + [1, 'one', null, 2, true, 1].take(arity), + ); + expect(calls, [ + ...[ + 'id', + 'email', + 'nullable', + 'score', + 'active', + 'id', + ].take(arity), + 'result', + ]); + calls.clear(); + // Reusing a selection must re-evaluate each output occurrence per row. + expect( + await query.stream(batchSize: 1).single, + [1, 'one', null, 2, true, 1].take(arity), + ); + expect(calls, [ + ...[ + 'id', + 'email', + 'nullable', + 'score', + 'active', + 'id', + ].take(arity), + 'result', + ]); + } + }); + + test( + 'first decoder failure prevents later fields and outer mapper', + () async { + final failure = StateError('decode failed'), calls = []; + final query = db + .table(users) + .select( + (u) => + ( + u.id.map((_) { + calls.add('first'); + throw failure; + }), + u.email.map((value) { + calls.add('second'); + return value; + }), + ).map((id, email) { + calls.add('result'); + return (id, email); + }), + ); + query.inspect(); + expect(calls, isEmpty); + await expectLater(query.get(), throwsA(same(failure))); + expect(calls, ['first']); + expect(sqlEvents.single.error, isNull); + expect(decodeEvents.single.error, same(failure)); + expect(await db.table(users).count(), 1); + }, + ); + + test( + 'dynamic fields snapshot keys and preserve nulls and mapping order', + () async { + final selected = >{}, calls = []; + final query = db.table(users).select((u) { + selected.addAll({ + 'label': u.email.map((v) { + calls.add('label'); + return v; + }), + 'empty': u.nickname.map((v) { + calls.add('empty'); + return v; + }), + 'repeated': u.email.map((v) { + calls.add('repeated'); + return v; + }), + }); + return fields(selected); + }); + selected.clear(); + expect(query.inspect().columns, hasLength(2)); + expect(calls, isEmpty); + final result = await query.single(); + expect(result, {'label': 'one', 'empty': null, 'repeated': 'one'}); + expect(result.keys, ['label', 'empty', 'repeated']); + expect(calls, ['label', 'empty', 'repeated']); + expect( + () => db.table(users).select((_) => fields({})).compile(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'QUERY.EMPTY_SELECTION', + ), + ), + ); + }, + ); + }, + skip: + dialect == .postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } +} diff --git a/test/session_connection_test.dart b/test/session_connection_test.dart new file mode 100644 index 00000000..7a60095e --- /dev/null +++ b/test/session_connection_test.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/drivers/postgres.dart'; +import 'package:orm/drivers/sqlite.dart'; +import 'package:orm/runtime.dart'; +import 'package:test/test.dart'; + +Matcher _code(String code) => + isA().having((error) => error.code, 'code', code); + +void main() { + for (final engine in ['sqlite', 'postgres']) { + final address = Platform.environment['ORM_TEST_POSTGRES']; + group( + '$engine owned session connections', + () { + late SqlDatabase db; + final schema = 'orm_session_connections_$pid'; + setUp(() async { + db = SqlDatabase( + engine == 'sqlite' + ? await SqliteDriver.open(const SqliteOptions.memory()) + : PostgresDriver( + PostgresOptions( + url: Uri.parse(address!), + tls: .disable, + schema: schema, + ), + ), + ); + if (engine == 'postgres') { + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS "$schema"'), + ); + } + await db.execute( + SqlCommand('CREATE TABLE session_items (id BIGINT PRIMARY KEY)'), + ); + }); + tearDown(() async { + try { + if (engine == 'postgres') { + await db.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + } + } finally { + await db.close(); + } + }); + Future> ids() async => (await db.execute( + SqlCommand('SELECT id FROM session_items ORDER BY id'), + )).rows.map((row) => row.single).toList(); + + test( + 'borrowed session connections expire with their callback', + () async { + late SqlConnection saved; + await db.session( + (session) => session.run((connection) async { + saved = connection; + }), + ); + await expectLater( + saved.execute(SqlCommand('INSERT INTO session_items VALUES (1)')), + throwsA(_code('SESSION.CLOSED')), + ); + expect(await ids(), isEmpty); + }, + ); + + for (final rollback in [false, true]) { + test( + 'savepoint connection expires after ${rollback ? 'rollback' : 'release'}', + () async { + late SqlConnection saved; + await db.transaction((tx) async { + try { + await tx.savepoint( + (child) => child.run((connection) async { + saved = connection; + if (rollback) { + await connection.execute( + SqlCommand('INSERT INTO session_items VALUES (1)'), + ); + throw StateError('rollback child'); + } + }), + ); + } on StateError catch (_) {} + await expectLater( + saved.execute( + SqlCommand('INSERT INTO session_items VALUES (2)'), + ), + throwsA(_code('SESSION.CLOSED')), + ); + await tx.execute( + SqlCommand('INSERT INTO session_items VALUES (3)'), + ); + }); + expect(await ids(), [3]); + }, + ); + } + + test( + 'expired executeOn rejects an otherwise active physical lease', + () async { + late SqlDatabase expired; + await db.session((session) async { + await session.transaction((tx) async { + expired = tx; + }); + await session.run((connection) async { + await expectLater( + Future.sync( + () => expired.executeOn( + connection, + SqlCommand('INSERT INTO session_items VALUES (1)'), + ), + ), + throwsA(_code('SESSION.CLOSED')), + ); + }); + }); + expect(await ids(), isEmpty); + }, + ); + + test( + 'escaped parent connection cannot cross an active savepoint', + () async { + late SqlConnection parent; + await db.transaction((tx) async { + await tx.run((connection) async { + parent = connection; + }); + await tx.savepoint((child) async { + await expectLater( + parent.execute( + SqlCommand('INSERT INTO session_items VALUES (1)'), + ), + throwsA(_code('SESSION.SAVEPOINT')), + ); + await expectLater( + Future.sync( + () => tx.executeOn( + parent, + SqlCommand('INSERT INTO session_items VALUES (2)'), + ), + ), + throwsA(_code('SESSION.SAVEPOINT')), + ); + await child.execute( + SqlCommand('INSERT INTO session_items VALUES (3)'), + ); + }); + }); + expect(await ids(), [3]); + }, + ); + + test( + 'executeOn refuses a connection outside its transaction', + () async { + final foreign = _Connection(); + await db.transaction((tx) async { + await expectLater( + Future.sync( + () => tx.executeOn( + foreign, + SqlCommand('INSERT INTO session_items VALUES (1)'), + ), + ), + throwsA(_code('SESSION.CONNECTION')), + ); + await tx.execute( + SqlCommand('INSERT INTO session_items VALUES (2)'), + ); + }); + expect(foreign.commands, isEmpty); + expect(await ids(), [2]); + }, + ); + + test( + 'caught raw statement errors cannot turn rollback into success', + () async { + await expectLater( + db.transaction((tx) async { + await tx.execute( + SqlCommand('INSERT INTO session_items VALUES (1)'), + ); + await tx.run((connection) async { + try { + await connection.execute( + SqlCommand('INSERT INTO session_items VALUES (1)'), + ); + } catch (_) {} + }); + }), + throwsA(_code('TRANSACTION.FAILED')), + ); + expect(await ids(), isEmpty); + }, + ); + + test( + 'unclosed raw cursor is drained and expires before rollback', + () async { + late SqlCursor cursor; + await expectLater( + db.transaction( + (tx) => tx.run((connection) async { + cursor = await connection.openCursor( + SqlCommand('SELECT id FROM session_items'), + ); + }), + ), + throwsA(_code('TRANSACTION.UNAWAITED')), + ); + await expectLater( + cursor.fetch(1), + throwsA(_code('SESSION.CLOSED')), + ); + await cursor.close(); + expect(await ids(), isEmpty); + }, + ); + }, + skip: engine == 'postgres' && address == null + ? 'Set ORM_TEST_POSTGRES for PostgreSQL scope checks.' + : false, + ); + } + + for (final useExecuteOn in [false, true]) { + test( + 'unawaited ${useExecuteOn ? 'executeOn' : 'run connection'} work drains before rollback', + () async { + final driver = _Driver(); + final db = SqlDatabase(driver); + final pending = db.transaction((tx) async { + if (useExecuteOn) { + unawaited( + tx.executeOn(driver.connection, SqlCommand('UPDATE blocked')), + ); + } else { + await tx.run((connection) async { + unawaited(connection.execute(SqlCommand('UPDATE blocked'))); + }); + } + }); + final failure = expectLater( + pending, + throwsA(_code('TRANSACTION.UNAWAITED')), + ); + await driver.connection.started.future; + await Future.delayed(Duration.zero); + expect(driver.connection.commands, ['BEGIN', 'UPDATE blocked']); + driver.connection.finished.complete( + const SqlResult([], affectedRows: 1), + ); + await failure; + expect(driver.connection.commands, [ + 'BEGIN', + 'UPDATE blocked', + 'ROLLBACK', + ]); + await db.close(); + }, + ); + } +} + +final class _Driver implements Driver { + final connection = _Connection(); + @override + Capabilities get capabilities => + const Capabilities(dialect: SqlDialect.postgres, maxParameters: 999); + @override + Future run(Future Function(SqlConnection) action) => + action(connection); + @override + Future close() async {} +} + +final class _Connection implements SqlConnection { + final commands = []; + final started = Completer(); + final finished = Completer(); + @override + bool? get transactionActive => null; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + commands.add(command.sql); + if (command.sql == 'UPDATE blocked') { + started.complete(); + return finished.future; + } + return const SqlResult([]); + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => throw UnsupportedError('No test cursor.'); + @override + Future invalidate() async {} +} diff --git a/test/sql_builder_test.dart b/test/sql_builder_test.dart new file mode 100644 index 00000000..98388b2e --- /dev/null +++ b/test/sql_builder_test.dart @@ -0,0 +1,114 @@ +import 'package:orm/sql.dart'; +import 'package:test/test.dart'; + +final _id = Column('id', Codecs.integer); +final _name = Column('name', Codecs.text.nullable(), nullable: true); +final _json = Column('data', Codecs.json.nullable(), nullable: true); +final _time = Column('created_at', Codecs.dateTime); +final _schema = TableSchema( + 'items', + columns: [_id, _name, _json, _time], + primaryKey: ['id'], +); + +final class _Fields(super.table) extends Fields { + late final id = column(_id); + late final name = column(_name); + late final data = column(_json); + late final createdAt = column(_time); +} + +final _table = Table<(int, String?), _Fields>( + _schema, + _Fields.new, + (f) => (f.id, f.name).row, +); + +void main() { + for (final dialect in SqlDialect.values) { + test('$dialect builds independently and rejects execution', () { + final builder = SqlBuilder(dialect); + final query = builder.table(_table).where((r) => r.id.gt(3)).take(2); + expect(query.compile().parameters, [3, 2]); + expect(query.inspect().reads, ['items']); + expect( + query.get, + throwsA( + isA().having((e) => e.code, 'code', 'QUERY.UNBOUND'), + ), + ); + }); + } + for (final dialect in [SqlDialect.mysql, SqlDialect.mariadb]) { + test('$dialect positional parameters follow final SQL order', () { + final query = SqlBuilder(dialect) + .table(_table) + .where((r) => r.id.gt(10)) + .orderBy((r) => [r.id.plus(5).asc(nulls: NullOrder.last)]) + .select((r) => r.id.plus(2)) + .take(3); + final command = query.compile(); + expect(command.parameters, [2, 10, 5, 5, 3]); + expect(command.sql, isNot(contains('NULLS'))); + expect(command.sql, contains('`items`')); + expect('?'.allMatches(command.sql).length, command.parameters.length); + expect(command.sql, isNot(contains('\u0001'))); + }); + test('$dialect JSON projection preserves text and SQL NULL', () { + final command = SqlBuilder(dialect) + .table(_table) + .select((r) => r.data) + .compile(); + expect( + command.sql, + contains('CAST(`t0`.`data` AS CHAR CHARACTER SET utf8mb4)'), + ); + }); + test('$dialect instant conversion is typed, text is unchanged', () { + final time = DateTime.utc(2026, 9, 19, 1, 2, 3, 4, 5); + final command = SqlBuilder(dialect) + .table(_table) + .insert( + (r) => [ + r.id.set(1), + r.name.set('2026-09-19 01:02:03.004005+00'), + r.createdAt.set(time), + ], + ) + .compile(); + expect(command.parameters, [ + 1, + '2026-09-19 01:02:03.004005+00', + '2026-09-19 01:02:03.004005', + ]); + expect(command.sql, isNot(contains('INSERT INTO `items` AS'))); + }); + test('$dialect conflict targets cannot silently change meaning', () { + final table = SqlBuilder(dialect).table(_table); + expect( + () => table + .insert((r) => [r.id.set(1)]) + .onConflictUpdate( + target: (r) => [r.id], + set: (a, b) => [a.name.setExpression(b.name)], + ) + .compile(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'CAPABILITY.CONFLICT_TARGET', + ), + ), + ); + final command = table + .insert((r) => [r.id.set(1)]) + .onDuplicateKeyUpdate(set: (a, b) => [a.name.setExpression(b.name)]) + .compile(); + expect( + command.sql, + contains('ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)'), + ); + }); + } +} diff --git a/test/sql_mysql_review_test.dart b/test/sql_mysql_review_test.dart new file mode 100644 index 00000000..90cc564f --- /dev/null +++ b/test/sql_mysql_review_test.dart @@ -0,0 +1,438 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +final _id = Column('id', Codecs.integer, generated: true); +final _name = Column('name', Codecs.text.nullable(), nullable: true); +final _amount = Column('amount', Codecs.decimal); +final _document = Column('document', Codecs.jsonDocument); +final _time = Column('time', Codecs.time, temporalPrecision: 3); +final _schema = TableSchema( + 'review_items', + columns: [_id, _name, _amount, _document, _time], + primaryKey: ['id'], +); + +final class _Fields(super.table) extends Fields { + late final id = column(_id); + late final name = column(_name); + late final amount = column(_amount); + late final document = column(_document); + late final time = column(_time); +} + +final _table = Table<(int, String?), _Fields>( + _schema, + _Fields.new, + (r) => (r.id, r.name).row, +); + +final class _PrecisionKeyFields(super.table, this.definition) + extends Fields { + final Column definition; + late final key = column(definition); +} + +Table> _precisionKeyTable(Column key) => Table( + TableSchema('precision_keys', columns: [key], primaryKey: ['key']), + (table) => _PrecisionKeyFields(table, key), + (fields) => fields.key, +); + +void main() { + for (final dialect in [SqlDialect.mysql, SqlDialect.mariadb]) { + group('$dialect review regressions', () { + test( + 'binding uses the explicit transaction and expires with it', + () async { + final driver = _Driver(dialect); + driver.connection.result = (command) => + command.sql.startsWith('SELECT') + ? const SqlResult([ + [1, 'bound'], + ]) + : const SqlResult([]); + final db = Database(driver); + final builder = SqlBuilder(dialect); + final description = builder.table(_table).where((r) => r.id.eq(1)); + late Query<(int, String?), _Fields> borrowed; + try { + await db.transaction((tx) async { + borrowed = description.bind(tx); + expect(await borrowed.get(), [(1, 'bound')]); + }); + expect(driver.leases, 1); + expect( + borrowed.get, + throwsA( + isA().having( + (error) => error.code, + 'code', + 'SESSION.CLOSED', + ), + ), + ); + final nested = builder + .table(_table) + .where( + (r) => + r.id.isInQuery(builder.table(_table).select((c) => c.id)), + ); + expect( + () => nested.bind(db).compile(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'QUERY.SESSION', + ), + ), + ); + } finally { + await db.close(); + } + }, + ); + + test('insert scalar subqueries retain their own qualified aliases', () { + final builder = SqlBuilder(dialect); + final joined = _table.alias(); + final source = builder + .table(_table) + .join(joined, on: (a, b) => a.id.equals(b.id)) + .select((r) => r.name) + .take(1); + final command = builder + .table(_table) + .insert((r) => [r.id.set(3), r.name.setExpression(source.scalar())]) + .compile(); + expect(command.sql, contains('SELECT `t1`.`name`')); + expect(command.sql, contains('`t1`.`id` = `t2`.`id`')); + expect(command.parameters, [3, 1]); + }); + + test('do nothing cannot silently execute update triggers', () { + expect( + () => + SqlBuilder(dialect) + .table(_table) + .insert((r) => [r.id.set(1)]) + .onConflictDoNothing() + .compile(), + throwsA(isA()), + ); + }); + + test('decimal arithmetic rejects silent engine precision loss', () { + final table = SqlBuilder(dialect).table(_table); + for (final query in [ + table.select((r) => r.amount.plus(Decimal.parse('1'))), + table.select((r) => r.amount.minus(Decimal.parse('1'))), + table.select((r) => r.amount.times(Decimal.parse('1'))), + table.select((r) => r.amount.sum()), + table.select((r) => r.amount.constrained(2, 0)), + ]) { + expect( + query.compile, + throwsA( + isA().having( + (e) => e.code, + 'code', + 'CAPABILITY.DECIMAL_PRECISION', + ), + ), + ); + } + }); + + test('decimal set operations reject implicit precision narrowing', () { + final table = SqlBuilder(dialect).table(_table); + expect( + () => table + .select((r) => r.amount) + .unionAll(table.select((r) => r.amount)), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'CAPABILITY.DECIMAL_PRECISION', + ), + ), + ); + }); + + test('decimal average rejects unsupported exact rounding', () { + expect( + () => + SqlBuilder(dialect) + .table(_table) + .select((r) => r.amount.average(scale: 2)) + .compile(), + throwsA(isA()), + ); + }); + + test('decimal casts reject invalid MySQL scales', () { + for (final scale in [-1, 3]) { + expect( + () => + SqlBuilder(dialect) + .table(_table) + .select((r) => r.amount.constrained(2, scale)) + .compile(), + throwsA(isA()), + ); + } + }); + + test('BigInt comparisons use exact decimal parameters', () { + final number = BigInt.parse('9007199254740993'); + final command = SqlBuilder(dialect) + .table(_table) + .select((_) => value(number, Codecs.bigint).eq(number + BigInt.one)) + .compile(); + expect( + 'CAST(? AS DECIMAL(65,0))'.allMatches(command.sql), + hasLength(2), + ); + expect(command.parameters, ['9007199254740993', '9007199254740994']); + }); + + test('a declared temporal precision remains writable', () { + final command = SqlBuilder(dialect) + .table(_table) + .insert( + (r) => [r.id.set(1), r.time.set(LocalTime.parse('01:02:03.456'))], + ) + .compile(); + expect(command.parameters, [1, '01:02:03.456000']); + }); + + test( + 'precision-coerced createRow keys reject before acquiring a lease', + () async { + final driver = _Driver(dialect); + final db = Database(driver); + final decimal = _precisionKeyTable( + Column('key', Codecs.decimal, decimalPrecision: 6, decimalScale: 2), + ); + final time = _precisionKeyTable( + Column('key', Codecs.time, temporalPrecision: 3), + ); + try { + for (final create in Function()>[ + () => db + .table(decimal) + .createRow( + (fields) => [fields.key.set(Decimal.parse('1.234'))], + ), + () => db + .table(time) + .createRow( + (fields) => [ + fields.key.set(LocalTime.parse('01:02:03.456789')), + ], + ), + ]) { + await expectLater( + create, + throwsA( + isA().having( + (error) => error.code, + 'code', + 'CAPABILITY.CREATE_KEY', + ), + ), + ); + } + expect(driver.leases, 0); + expect(driver.connection.commands, isEmpty); + } finally { + await db.close(); + } + }, + ); + + test('composite relation decimal keys bind as exact decimal', () async { + final driver = _Driver(dialect); + var selects = 0; + driver.connection.result = (command) { + if (!command.sql.startsWith('SELECT')) return const SqlResult([]); + return ++selects == 1 + ? const SqlResult([ + ['9007199254740993', 1], + ]) + : const SqlResult([]); + }; + final db = Database(driver); + try { + await db + .table(_table) + .select( + (r) => Relation( + _table, + parent: [r.amount, r.id], + child: (c) => [c.amount, c.id], + ).select((c) => c.id).many(), + ) + .get(); + final child = driver.connection.commands.last; + expect(child.sql, contains('CAST(? AS DECIMAL(')); + expect(child.parameters, ['9007199254740993', 1]); + } finally { + await db.close(); + } + }); + + for (final failure in ['identity', 'cardinality', 'decode']) { + test( + 'caught createRow $failure failure prevents partial commit', + () async { + final driver = _Driver(dialect); + driver.connection.result = (command) { + if (command.sql.startsWith('INSERT')) { + return SqlResult( + const [], + affectedRows: 1, + lastInsertId: failure == 'identity' ? null : 1, + ); + } + if (command.sql.startsWith('SELECT')) { + return SqlResult( + failure == 'cardinality' + ? const [] + : const [ + [1, 42], + ], + ); + } + return const SqlResult([]); + }; + final db = Database(driver); + try { + await expectLater( + db.transaction((tx) async { + await expectLater( + tx.table(_table).createRow((r) => [r.name.set('inserted')]), + throwsA(anything), + ); + }), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'TRANSACTION.FAILED', + ), + ), + ); + expect( + driver.connection.commands.map((c) => c.sql), + contains('ROLLBACK'), + ); + expect( + driver.connection.commands.map((c) => c.sql), + isNot(contains('COMMIT')), + ); + expect(driver.leases, 1); + } finally { + await db.close(); + } + }, + ); + } + }); + } + + test('MySQL JSON comparison parses the encoded JSON parameter', () { + final command = SqlBuilder(SqlDialect.mysql) + .table(_table) + .where((r) => r.document.eq(const SqlJson({'a': 1}))) + .compile(); + expect(command.sql, contains('CAST(? AS JSON)')); + expect(command.parameters, ['{"a":1}']); + }); + + test('MySQL CTE retains JSON storage until the driver result boundary', () { + final query = SqlBuilder(SqlDialect.mysql) + .table(_table) + .select((r) => r.document) + .asCte('documents') + .query; + final command = query.compile(); + expect( + 'AS CHAR CHARACTER SET utf8mb4'.allMatches(command.sql), + hasLength(1), + ); + }); + + test('JSON DISTINCT runs on native storage before result conversion', () { + final query = SqlBuilder(SqlDialect.mysql) + .table(_table) + .select((r) => r.document) + .distinct(); + expect( + query.compile, + throwsA( + isA().having( + (error) => error.code, + 'code', + 'CAPABILITY.JSON_DISTINCT', + ), + ), + ); + final command = query.asCte('distinct_documents').query.compile(); + expect(command.sql, contains('SELECT DISTINCT `t1`.`document`')); + expect( + 'AS CHAR CHARACTER SET utf8mb4'.allMatches(command.sql), + hasLength(1), + ); + }); +} + +final class _Driver implements Driver { + @override + final Capabilities capabilities; + final connection = _Connection(); + int leases = 0; + _Driver(SqlDialect dialect) + : capabilities = Capabilities( + dialect: dialect, + maxParameters: 65535, + returning: false, + exactDecimal: true, + temporal: true, + ); + @override + Future run(Future Function(SqlConnection) action) { + leases++; + return Future.sync(() => action(connection)); + } + + @override + Future close() async {} +} + +final class _Connection implements SqlConnection { + final commands = []; + SqlResult Function(SqlCommand) result = (_) => const SqlResult([]); + bool active = false; + @override + bool get transactionActive => active; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + commands.add(command); + if (command.sql.startsWith('START TRANSACTION')) active = true; + if (command.sql == 'COMMIT' || command.sql == 'ROLLBACK') active = false; + return result(command); + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => throw UnsupportedError('No cursor in this regression fixture.'); + @override + Future invalidate() async {} +} diff --git a/test/sql_real_test.dart b/test/sql_real_test.dart new file mode 100644 index 00000000..9d36a721 --- /dev/null +++ b/test/sql_real_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +void main() { + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + test( + '${dialect.name} explicitly typed REAL parameters preserve floating storage', + () async { + final Database db = dialect == SqlDialect.sqlite + ? await sqlite(const SqliteOptions.memory()) + : postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: PostgresTls.disable, + ), + ); + try { + final result = await db.execute( + SqlCommand( + dialect == SqlDialect.sqlite + ? 'SELECT typeof(?1), ?1' + : r'SELECT pg_typeof($1::float8)::text, $1::float8', + [const SqlReal(1e20)], + ), + ); + expect(result.rows.single, [ + dialect == SqlDialect.sqlite ? 'real' : 'double precision', + 1e20, + ]); + } finally { + await db.close(); + } + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } +} diff --git a/test/sqlite_options_test.dart b/test/sqlite_options_test.dart new file mode 100644 index 00000000..07730285 --- /dev/null +++ b/test/sqlite_options_test.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'persistent uses the explicit native path and retains committed data', + () async { + final directory = await Directory.systemTemp.createTemp('orm-options-'); + addTearDown(() => directory.delete(recursive: true)); + final path = '${directory.path}/data.sqlite'; + final db = await sqlite( + SqliteOptions.persistent('app', nativePath: path), + ); + try { + await db.execute( + SqlCommand('CREATE TABLE item (id INTEGER PRIMARY KEY)'), + ); + await db.transaction( + (tx) => tx.execute(SqlCommand('INSERT INTO item VALUES (7)')), + ); + } finally { + await db.close(); + } + final read = await sqlite(SqliteOptions.readOnly(path)); + try { + expect((await read.execute(SqlCommand('SELECT id FROM item'))).rows, [ + [7], + ]); + } finally { + await read.close(); + } + }, + ); + + test( + 'native persistent storage never guesses a path or falls back to memory', + () async { + await expectLater( + sqlite(const SqliteOptions.persistent('app')), + throwsArgumentError, + ); + await expectLater( + sqlite(const SqliteOptions.persistent('app', nativePath: ':memory:')), + throwsArgumentError, + ); + }, + ); + + test('all driver close callers wait for active work and shutdown', () async { + final driver = await SqliteDriver.open(const SqliteOptions.memory()); + final entered = Completer(), release = Completer(); + final operation = driver.run((connection) async { + entered.complete(); + await release.future; + return connection.execute(SqlCommand('SELECT 1')); + }); + await entered.future; + final first = driver.close(); + final second = driver.close(); + expect(identical(first, second), isTrue); + var closed = false; + first.then((_) => closed = true); + await Future.delayed(Duration.zero); + expect(closed, isFalse); + release.complete(); + expect((await operation).rows, [ + [1], + ]); + await Future.wait([first, second]); + expect(() => driver.run((_) async {}), throwsA(isA())); + }); +} diff --git a/test/stream_test.dart b/test/stream_test.dart new file mode 100644 index 00000000..21f03b13 --- /dev/null +++ b/test/stream_test.dart @@ -0,0 +1,633 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:postgres/postgres.dart' as pg; +import 'package:test/test.dart'; + +import 'support/tables.dart'; + +Matcher code(String value) => + isA().having((e) => e.code, 'code', value); + +pg.Endpoint endpointFor(String url) { + final uri = Uri.parse(url), colon = Uri.parse(url).userInfo.indexOf(':'); + return pg.Endpoint( + host: uri.host, + port: uri.hasPort ? uri.port : 5432, + database: uri.pathSegments.single, + username: uri.userInfo.isEmpty + ? null + : Uri.decodeComponent( + colon < 0 ? uri.userInfo : uri.userInfo.substring(0, colon), + ), + password: colon < 0 + ? null + : Uri.decodeComponent(uri.userInfo.substring(colon + 1)), + ); +} + +void main() { + runTests( + 'sqlite', + (observe) => sqlite(const SqliteOptions.memory(), onQuery: observe), + ); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + runTests('postgres', (observe) async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: PostgresTls.disable, + schema: 'orm_stream_tests', + maxConnections: 1, + ), + onQuery: observe, + ); + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_stream_tests'), + ); + return db; + }); + test( + 'late PostgreSQL cancellation cannot reach the next statement', + () async { + final endpoint = endpointFor(url); + final pool = pg.Pool.withEndpoints( + [endpoint], + settings: pg.PoolSettings( + sslMode: pg.SslMode.disable, + maxConnectionCount: 1, + ), + ); + var controls = 0; + final db = Database( + PostgresDriver.borrow( + pool, + cancellationConnection: () async { + controls++; + await Future.delayed(const Duration(milliseconds: 150)); + return pg.Connection.open( + endpoint, + settings: const pg.ConnectionSettings( + sslMode: pg.SslMode.disable, + ), + ); + }, + ), + ); + try { + // SQL finishes while the deliberately delayed cancellation connects. + await db.execute( + SqlCommand('SELECT pg_sleep(0.08)'), + options: const ExecutionOptions( + timeout: Duration(milliseconds: 20), + ), + ); + expect(controls, 1); + await db.execute(SqlCommand('SELECT pg_sleep(0.2)')); + expect(controls, 1); + } finally { + await db.close(); + await pool.close(); + } + }, + ); + test( + 'borrowed pool requires cancellation support and retains ownership', + () async { + final pool = pg.Pool.withEndpoints( + [endpointFor(url)], + settings: pg.PoolSettings( + sslMode: pg.SslMode.disable, + maxConnectionCount: 1, + ), + ); + final db = Database(PostgresDriver.borrow(pool)); + try { + expect(db.capabilities.cancellation, isFalse); + expect( + () => db.execute( + SqlCommand('SELECT 1'), + options: const ExecutionOptions(timeout: Duration(seconds: 1)), + ), + throwsA(code('CAPABILITY.CANCEL')), + ); + await db.close(); + expect((await pool.execute('SELECT 7')).single.single, 7); + } finally { + await db.close(); + await pool.close(); + } + }, + ); + test( + 'failed cancellation control discards the physical connection', + () async { + final pool = pg.Pool.withEndpoints( + [endpointFor(url)], + settings: pg.PoolSettings( + sslMode: pg.SslMode.disable, + maxConnectionCount: 1, + ), + ); + final db = Database( + PostgresDriver.borrow( + pool, + cancellationConnection: () async => + throw StateError('control unavailable'), + ), + ); + try { + final before = (await db.execute( + SqlCommand('SELECT pg_backend_pid()'), + )).rows.single.single; + await expectLater( + db.execute( + SqlCommand('SELECT pg_sleep(10)'), + options: const ExecutionOptions( + timeout: Duration(milliseconds: 30), + ), + ), + throwsStateError, + ); + final after = (await db.execute( + SqlCommand('SELECT pg_backend_pid()'), + )).rows.single.single; + expect(after, isNot(before)); + } finally { + await db.close(); + await pool.close(); + } + }, + ); + } +} + +void runTests( + String name, + Future> Function(void Function(QueryEvent)) open, +) { + group(name, () { + late Database db; + final events = []; + void Function(QueryEvent)? hook; + setUp(() async { + hook = null; + db = await open((event) { + events.add(event); + hook?.call(event); + }); + await db.execute(SqlCommand('DROP TABLE IF EXISTS posts')); + await db.execute(SqlCommand('DROP TABLE IF EXISTS users')); + await createTables(db); + await db.execute( + SqlCommand(''' + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<31) + INSERT INTO users(id,email,score) SELECT x,'user'||x,x FROM n + '''), + ); + events.clear(); + }); + tearDown(() => db.close()); + Query ids(Database db) => + db.table(users).orderBy((u) => [u.id.asc()]).select((u) => u.id); + Iterable getFetches() => + events.where((e) => e.operation == QueryOperation.cursorFetch); + void cancellationTest(String description, Future Function() body) { + test(description, () async { + if (!db.capabilities.cancellation) { + markTestSkipped( + 'This SQLite build does not export sqlite3_interrupt.', + ); + return; + } + await body(); + }); + } + + final slow = name == 'postgres' + ? 'SELECT pg_sleep(10)' + : '''WITH RECURSIVE n(x) AS ( + SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1000000000 + ) SELECT sum(x) FROM n'''; + final slowValue = sql( + [name == 'postgres' ? '(SELECT 1 FROM pg_sleep(10))' : '($slow)'], + const [], + Codecs.integer, + ); + + cancellationTest( + 'cancellation between batch chunks prevents a partial commit', + () async { + final token = CancellationToken(); + hook = (event) { + if (event.sql.startsWith('INSERT') && event.error == null) { + token.cancel(); + } + }; + await expectLater( + db.transaction((tx) async { + await expectLater( + tx + .table(users) + .insertMany( + [40, 41, 42], + (u, i) => [ + u.id.set(i), + u.email.set('new$i'), + if (i == 41) u.nickname.set('second'), + ], + ) + .execute(options: ExecutionOptions(cancellation: token)), + throwsA(code('OPERATION.CANCELLED')), + ); + }), + throwsA(code('TRANSACTION.FAILED')), + ); + expect(await db.table(users).count(), 31); + }, + ); + + cancellationTest( + 'cancellation interrupts an active cursor fetch', + () async { + final token = CancellationToken(); + final timer = Timer(const Duration(milliseconds: 50), token.cancel); + try { + await expectLater( + db + .table(users) + .select((_) => slowValue) + .take(1) + .stream( + batchSize: 1, + options: ExecutionOptions(cancellation: token), + ) + .toList(), + throwsA(code('OPERATION.CANCELLED')), + ); + } finally { + timer.cancel(); + } + expect(getFetches().single.error, isNotNull); + expect(await db.table(users).count(), 31); + }, + ); + + cancellationTest('timed-out mutations return no partial write', () async { + await expectLater( + db + .table(users) + .where((u) => u.id.eq(1)) + .update((u) => [u.score.setExpression(slowValue)]) + .returning((u) => u.score) + .single( + options: const ExecutionOptions( + timeout: Duration(milliseconds: 40), + ), + ), + throwsA(code('OPERATION.TIMEOUT')), + ); + expect( + (await db.table(users).where((u) => u.id.eq(1)).single()).score, + 1, + ); + }); + + test( + 'database close stops a paused cursor and releases the driver', + () async { + final first = Completer(); + late StreamSubscription subscription; + subscription = ids(db).stream(batchSize: 4).listen((_) { + subscription.pause(); + first.complete(); + }); + await first.future; + await db.close(); + await subscription.cancel(); + expect(events.last.sql, 'ROLLBACK'); + }, + ); + + cancellationTest( + 'cancellation between stream creation and listening needs no rollback', + () async { + final token = CancellationToken(); + final stream = ids(db) + .stream(options: ExecutionOptions(cancellation: token)); + token.cancel(); + await expectLater( + stream.toList(), + throwsA(code('OPERATION.CANCELLED')), + ); + expect(events, isEmpty); + expect(await db.table(users).count(), 31); + }, + ); + + test('bounded cursor fetches preserve parameters and typed rows', () async { + expect(db.capabilities.streaming, isTrue); + final rows = await ids(db) + .where((u) => u.id.gt(3)) + .stream(batchSize: 6) + .toList(); + expect(rows, List.generate(28, (i) => i + 4)); + expect(getFetches().map((e) => e.rowCount), [6, 6, 6, 6, 4]); + expect( + events.where((e) => e.operation == .cursorOpen).single.parameterCount, + 1, + ); + expect(events.last.sql, 'COMMIT'); + expect(await db.table(users).count(), 31); + }); + + test( + 'pause stops fetches and early cancellation releases the lease', + () async { + final received = []; + final first = Completer(); + late StreamSubscription subscription; + subscription = ids(db).stream(batchSize: 4).listen((row) { + received.add(row); + subscription.pause(); + if (!first.isCompleted) first.complete(); + }); + await first.future; + await Future.delayed(const Duration(milliseconds: 35)); + expect(received, [1]); + expect(getFetches().length, 1); + await subscription.cancel(); + expect(events.where((e) => e.operation == .cursorClose).length, 1); + expect(events.last.sql, 'ROLLBACK'); + expect(await db.table(users).count(), 31); + }, + ); + + test( + 'await-for provides demand and take closes without exhausting', + () async { + final rows = []; + await for (final row in ids(db).stream(batchSize: 3).take(7)) { + rows.add(row); + await Future.delayed(const Duration(milliseconds: 1)); + } + expect(rows, [1, 2, 3, 4, 5, 6, 7]); + expect(getFetches().length, 3); + expect(await db.table(users).count(), 31); + }, + ); + + test('relation projections load once per root batch', () async { + await db.execute( + SqlCommand( + 'CREATE TABLE posts (' + 'id INTEGER PRIMARY KEY, author_id INTEGER REFERENCES users(id), ' + 'title TEXT NOT NULL, tag TEXT)', + ), + ); + await db.execute( + SqlCommand( + 'INSERT INTO posts(id,author_id,title) ' + "SELECT id,id,email FROM users", + ), + ); + events.clear(); + final rows = await db + .table(users) + .orderBy((u) => [u.id.asc()]) + .select( + (u) => ( + u.id, + u.posts.select((p) => p.title).many(), + ).map((id, titles) => (id: id, titles: titles)), + ) + .stream(batchSize: 10) + .toList(); + expect(rows.map((r) => r.id), List.generate(31, (i) => i + 1)); + for (final row in rows) { + expect(row.titles, ['user${row.id}']); + } + expect( + events + .where( + (e) => e.operation == .execute && e.sql.contains('FROM "posts"'), + ) + .length, + 4, + ); + }); + + test('mapper failure closes the cursor and rolls back', () async { + await expectLater( + db + .table(users) + .select((u) => u.id.map((_) => throw StateError('decode'))) + .stream(batchSize: 2) + .toList(), + throwsStateError, + ); + expect(events.last.sql, 'ROLLBACK'); + expect(await db.table(users).count(), 31); + }); + + test( + 'early stream end inside a transaction preserves other work', + () async { + await db.transaction((tx) async { + expect(await ids(tx).stream(batchSize: 4).take(2).toList(), [1, 2]); + await tx + .table(users) + .where((u) => u.id.eq(1)) + .update((u) => [u.score.set(90)]) + .execute(); + }); + expect( + (await db.table(users).where((u) => u.id.eq(1)).single()).score, + 90, + ); + }, + ); + + test('escaping paused stream cannot keep a transaction alive', () async { + late StreamSubscription subscription; + await expectLater( + db.transaction((tx) async { + await tx + .table(users) + .where((u) => u.id.eq(1)) + .update((u) => [u.score.set(90)]) + .execute(); + final first = Completer(); + subscription = ids(tx).stream(batchSize: 4).listen((_) { + subscription.pause(); + if (!first.isCompleted) first.complete(); + }); + await first.future; + }), + throwsA(code('TRANSACTION.UNAWAITED')), + ); + await subscription.cancel(); + expect( + (await db.table(users).where((u) => u.id.eq(1)).single()).score, + 1, + ); + }); + + cancellationTest( + 'cancels executing SQL and allows the next statement', + () async { + expect(db.capabilities.cancellation, isTrue); + final token = CancellationToken(); + final timer = Timer(const Duration(milliseconds: 45), token.cancel); + final elapsed = Stopwatch()..start(); + try { + await expectLater( + db.execute( + SqlCommand(slow), + options: ExecutionOptions(cancellation: token), + ), + throwsA(code('OPERATION.CANCELLED')), + ); + } finally { + timer.cancel(); + } + expect(elapsed.elapsed, lessThan(const Duration(seconds: 3))); + expect(await db.table(users).count(), 31); + }, + ); + + cancellationTest( + 'statement timeout stops SQL and leaves a usable connection', + () async { + await expectLater( + db.execute( + SqlCommand(slow), + options: const ExecutionOptions( + timeout: Duration(milliseconds: 40), + ), + ), + throwsA(code('OPERATION.TIMEOUT')), + ); + expect(await db.table(users).count(), 31); + }, + ); + + cancellationTest( + 'cancelled statement poisons a transaction even if caught', + () async { + await expectLater( + db.transaction((tx) async { + await tx + .table(users) + .where((u) => u.id.eq(1)) + .update((u) => [u.score.set(90)]) + .execute(); + final token = CancellationToken(); + final timer = Timer(const Duration(milliseconds: 40), token.cancel); + try { + await expectLater( + tx.execute( + SqlCommand(slow), + options: ExecutionOptions(cancellation: token), + ), + throwsA(code('OPERATION.CANCELLED')), + ); + } finally { + timer.cancel(); + } + }), + throwsA(code('TRANSACTION.FAILED')), + ); + expect( + (await db.table(users).where((u) => u.id.eq(1)).single()).score, + 1, + ); + }, + ); + + cancellationTest( + 'cancelling a paused stream releases its connection before resume', + () async { + final token = CancellationToken(); + final first = Completer(); + final stopped = Completer(); + final errors = []; + late StreamSubscription subscription; + subscription = ids(db) + .stream( + batchSize: 4, + options: ExecutionOptions(cancellation: token), + ) + .listen( + (_) { + subscription.pause(); + first.complete(); + }, + onError: errors.add, + onDone: stopped.complete, + ); + await first.future; + token.cancel(); + expect(await db.table(users).count(), 31); + subscription.resume(); + await stopped.future; + expect(errors.single, code('OPERATION.CANCELLED')); + }, + ); + + test('unsupported interruption rejects options before SQL or cursor acquisition', () async { + if (db.capabilities.cancellation) { + markTestSkipped( + 'This build supports interruption; behavior is tested above.', + ); + return; + } + for (final options in [ + const ExecutionOptions(timeout: Duration(milliseconds: 40)), + ExecutionOptions(cancellation: CancellationToken()), + ]) { + expect( + () => db.execute( + SqlCommand('UPDATE users SET score=0'), + options: options, + ), + throwsA(code('CAPABILITY.CANCEL')), + ); + expect( + () => ids(db).stream(options: options), + throwsA(code('CAPABILITY.CANCEL')), + ); + } + expect(events, isEmpty); + expect( + (await db.table(users).where((u) => u.id.eq(1)).single()).score, + 1, + ); + }); + + test('cancellation before starting performs no SQL', () async { + final token = CancellationToken()..cancel(); + expect( + () => db.execute( + SqlCommand(slow), + options: ExecutionOptions(cancellation: token), + ), + throwsA(code('OPERATION.CANCELLED')), + ); + expect( + () => ids(db).stream(options: ExecutionOptions(cancellation: token)), + throwsA( + code( + db.capabilities.cancellation + ? 'OPERATION.CANCELLED' + : 'CAPABILITY.CANCEL', + ), + ), + ); + expect(events, isEmpty); + }); + }); +} diff --git a/test/support/advanced.dart b/test/support/advanced.dart new file mode 100644 index 00000000..dad1aeb7 --- /dev/null +++ b/test/support/advanced.dart @@ -0,0 +1,225 @@ +import 'package:orm/orm.dart'; +import 'package:test/test.dart'; + +import 'tables.dart'; + +void advancedScenarios(Database Function() database) { + test( + 'CTEs preserve SQL column identity through mapped projections', + () async { + final db = database(); + final counts = db + .table(postsTable) + .groupBy((p) => [p.authorId]) + .select( + (p) => ( + p.authorId, + p.id.count(), + ).map((id, count) => (id: id, count: count)), + ) + .asCte('post_counts'); + final rows = await counts.query + .where((c) => c.ref((p) => p.id.count()).gt(3)) + .orderBy((c) => [c.ref((p) => p.authorId).asc()]) + .get(); + expect(rows, [(id: 1, count: 4), (id: 2, count: 4), (id: 3, count: 4)]); + expect( + () => counts.query.select((c) => c.ref((p) => p.title)), + throwsA(isA()), + ); + final alias = counts.alias(); + final joined = await db + .table(users) + .join(alias, on: (u, c) => u.id.equals(c.ref((p) => p.authorId))) + .orderBy((u) => [u.id.asc()]) + .select( + (u) => ( + u.email, + alias.fields.ref((p) => p.id.count()), + ).map((email, count) => (email: email, count: count)), + ) + .get(); + expect(joined.first, (email: 'user0', count: 4)); + expect(joined.length, 3); + final post = postsTable.alias(); + final optional = db + .table(users) + .leftJoin(post, on: (u, p) => u.id.equals(p.authorId).and(p.id.eq(1))) + .select((u) => post.optional(post.fields.title)) + .asCte('optional_posts'); + expect(await optional.query.get(), ['post1', null, null, null]); + expect( + () => optional.query.select((c) => c.ref((u) => post.fields.title)), + throwsA(isA()), + ); + expect( + await optional.query + .select((c) => c.ref((u) => post.nullable(post.fields.title))) + .get(), + ['post1', null, null, null], + ); + }, + ); + + test('explicit aliases support multiple joins and self joins', () async { + final db = database(); + final a = users.alias(), b = users.alias(); + final result = await db + .table(users) + .join(a, on: (u, a) => u.id.equals(a.id)) + .join(b, on: (u, b) => b.id.equals(a.fields.id)) + .orderBy((u) => [u.id.asc()]) + .select( + (u) => ( + u.id, + a.fields.email, + b.fields.email, + ).map((id, a, b) => (id: id, a: a, b: b)), + ) + .get(); + expect(result.length, 4); + expect(result.first, (id: 1, a: 'user0', b: 'user0')); + expect( + () => db + .table(users) + .join(a, on: (u, a) => a.id.equals(b.fields.id)) + .join(b, on: (u, b) => u.id.equals(b.id)) + .compile(), + throwsA(isA()), + ); + }); + + test( + 'left join distinguishes a missing row from an all-null selection', + () async { + final db = database(); + final post = postsTable.alias(); + final rows = await db + .table(users) + .leftJoin(post, on: (u, p) => u.id.equals(p.authorId).and(p.id.eq(1))) + .orderBy((u) => [u.id.asc()]) + .select( + (u) => post.optional(post.fields.tag.map((tag) => (tag: tag))), + ) + .get(); + expect(rows, [(tag: null), null, null, null]); + final missing = await db + .table(users) + .leftJoin(post, on: (u, p) => u.id.equals(p.authorId)) + .where((u) => post.isPresent.not()) + .select((u) => u.id) + .get(); + expect(missing, [4]); + expect( + () => db + .table(users) + .leftJoin(post, on: (u, p) => u.id.equals(p.authorId)) + .select((u) => post.fields.title) + .compile(), + throwsA(isA()), + ); + }, + ); + + test('correlated scalar, IN and EXISTS use nested SQL scopes', () async { + final db = database(); + final counts = await db + .table(users) + .orderBy((u) => [u.id.asc()]) + .select( + (u) => db + .table(postsTable) + .where((p) => p.authorId.equals(u.id)) + .select((p) => p.id.count()) + .scalar(), + ) + .get(); + expect(counts, [4, 4, 4, 0]); + final hasPosts = db.table(postsTable).select((p) => p.authorId); + expect( + await db.table(users).where((u) => u.id.isInQuery(hasPosts)).count(), + 3, + ); + expect( + await db + .table(users) + .where( + (u) => db + .table(postsTable) + .where((p) => p.authorId.equals(u.id)) + .existsExpression(), + ) + .count(), + 3, + ); + final nullable = await db + .table(users) + .where((u) => u.id.eq(4)) + .select( + (u) => db + .table(postsTable) + .where((p) => p.authorId.equals(u.id)) + .select((p) => p.title) + .take(1) + .scalar(), + ) + .single(); + expect(nullable, null); + expect( + () => db.table(postsTable).select((p) => p.title).scalar(), + throwsA(isA()), + ); + }); + + test( + 'grouping is checked and window functions preserve row cardinality', + () async { + final db = database(); + final counts = await db + .table(postsTable) + .groupBy((p) => [p.authorId]) + .having((p) => p.id.count().gt(3)) + .orderBy((p) => [p.authorId.asc()]) + .select( + (p) => ( + p.authorId, + p.id.count(), + ).map((id, count) => (id: id, count: count)), + ) + .get(); + expect(counts, [(id: 1, count: 4), (id: 2, count: 4), (id: 3, count: 4)]); + final windowed = await db + .table(postsTable) + .where((p) => p.authorId.eq(1)) + .orderBy((p) => [p.id.asc()]) + .select( + (p) => ( + rowNumber(partitionBy: [p.authorId], orderBy: [p.id.asc()]), + p.id.sum().over( + partitionBy: [p.authorId], + orderBy: [p.id.asc()], + frame: .rowsToCurrent, + ), + ).map((rank, sum) => (rank: rank, sum: sum)), + ) + .get(); + expect(windowed, [ + (rank: 1, sum: 1), + (rank: 2, sum: 3), + (rank: 3, sum: 6), + (rank: 4, sum: 10), + ]); + expect( + () => db + .table(postsTable) + .select((p) => (p.title, p.id.count()).map((a, b) => (a, b))) + .compile(), + throwsA(isA()), + ); + expect( + () => db.table(postsTable).where((p) => p.id.count().gt(1)).compile(), + throwsA(isA()), + ); + }, + ); +} diff --git a/test/support/backfill.dart b/test/support/backfill.dart new file mode 100644 index 00000000..8df2d35c --- /dev/null +++ b/test/support/backfill.dart @@ -0,0 +1,46 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/orm.dart'; + +final payload = TableSchema( + 'payload', + columns: [ + Column('id', Codecs.integer), + Column('source', Codecs.text.nullable(), nullable: true), + Column('value', Codecs.text.nullable(), nullable: true), + Column('touches', Codecs.integer, defaultSql: '0'), + ], + primaryKey: ['id'], +); +Migration initialFor(SqlDialect dialect) => + Migration.create('0001_initial', [payload], dialect: dialect); +Migration fill({ + required SqlDialect dialect, + int batchSize = 3, + String? doneWhen, + Map? set, +}) => Migration.steps( + '0002_fill', + [ + Backfill( + payload, + set: set ?? {'value': 'upper(source)', 'touches': 'touches + 1'}, + where: 'value IS NULL', + batchSize: batchSize, + doneWhen: doneWhen ?? 'SELECT NOT EXISTS(SELECT 1 FROM payload WHERE value IS NULL OR touches <> 1)', + ), + ], + previous: initialFor(dialect).checksum, + dialect: dialect, +); + +Future seed(Database db, {int count = 8}) async { + await Migrator(db.sql).apply([initialFor(db.dialect)]); + for (var i = 1; i <= count; i++) { + await db.execute( + SqlCommand( + 'INSERT INTO payload(id, source) VALUES (${db.dialect == SqlDialect.sqlite ? '?1, ?2' : r'$1, $2'})', + [i, 'v$i'], + ), + ); + } +} diff --git a/test/support/backfill_crash.dart b/test/support/backfill_crash.dart new file mode 100644 index 00000000..bd8efdac --- /dev/null +++ b/test/support/backfill_crash.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; + +Future crashBackfill( + List migrations, + List args, +) async { + final Database base = args[0] == 'sqlite' + ? await sqlite(SqliteOptions.file(args[1])) + : postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: args[1], + maxConnections: 1, + ), + ); + var writes = 0; + final db = Database( + base.driver, + onQuery: (event) { + if (event.error != null) return; + if (event.sql.startsWith('UPDATE "payload" SET')) writes++; + if (writes == 2 && (args[2] == 'write' || event.sql == 'COMMIT')) { + exit(91); + } + }, + ); + try { + await Migrator(db.sql).apply(migrations); + } finally { + await db.close(); + } + exitCode = 92; +} diff --git a/test/support/backfill_history/m0001_initial.dart b/test/support/backfill_history/m0001_initial.dart new file mode 100644 index 00000000..01c02f59 --- /dev/null +++ b/test/support/backfill_history/m0001_initial.dart @@ -0,0 +1,29 @@ +// Review before applying. Applied migrations must remain unchanged. +import 'package:orm/migrate.dart'; + +const migrationChecksum = + "6ae96fc86455fff28e371472abe7df745f461f67cd4a6fd1fb0600b7cb5dcccb"; +final migration = Migration.steps( + "0001_initial", + [ + ExecuteSql( + "CREATE TABLE \"payload\" (\"id\" INTEGER NOT NULL, \"source\" TEXT, \"value\" TEXT, \"touches\" INTEGER NOT NULL DEFAULT (0), PRIMARY KEY (\"id\"))", + ), + ], + dialect: SqlDialect.sqlite, + + snapshot: _schema, +); + +final _schema = SchemaSnapshot([ + TableSchema( + "payload", + columns: [ + Column("id", Codecs.integer), + Column("source", Codecs.text.nullable(), nullable: true), + Column("value", Codecs.text.nullable(), nullable: true), + Column("touches", Codecs.integer, defaultSql: "0"), + ], + primaryKey: ["id"], + ), +]); diff --git a/test/support/backfill_history/m0002_fill.dart b/test/support/backfill_history/m0002_fill.dart new file mode 100644 index 00000000..1aba75b4 --- /dev/null +++ b/test/support/backfill_history/m0002_fill.dart @@ -0,0 +1,28 @@ +// Review before applying. Applied migrations must remain unchanged. +import 'package:orm/migrate.dart'; + +const migrationChecksum = + "76c394c428c4609366b3028b1770b0919b48276b037438b9ce2cea878035c08b"; +final migration = Migration.steps( + "0002_fill", + [ + Backfill( + TableSchema( + "payload", + columns: [ + Column("id", Codecs.integer), + Column("source", Codecs.text.nullable(), nullable: true), + Column("value", Codecs.text.nullable(), nullable: true), + Column("touches", Codecs.integer, defaultSql: "0"), + ], + primaryKey: ["id"], + ), + set: {"value": "upper(source)", "touches": "touches + 1"}, + where: "value IS NULL", + doneWhen: "SELECT NOT EXISTS(SELECT 1 FROM payload WHERE value IS NULL OR touches <> 1)", + batchSize: 3, + ), + ], + dialect: SqlDialect.sqlite, + previous: "6ae96fc86455fff28e371472abe7df745f461f67cd4a6fd1fb0600b7cb5dcccb", +); diff --git a/test/support/backfill_history/migrations.g.dart b/test/support/backfill_history/migrations.g.dart new file mode 100644 index 00000000..3659eaf1 --- /dev/null +++ b/test/support/backfill_history/migrations.g.dart @@ -0,0 +1,11 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +import 'package:orm/migrate.dart'; + +import 'm0001_initial.dart' as m0; +import 'm0002_fill.dart' as m1; + +const migrationDialect = SqlDialect.sqlite; +final migrationHistory = MigrationHistory([ + (m0.migration, m0.migrationChecksum), + (m1.migration, m1.migrationChecksum), +], dialect: migrationDialect); diff --git a/test/support/browser/main.dart b/test/support/browser/main.dart new file mode 100644 index 00000000..171edfa9 --- /dev/null +++ b/test/support/browser/main.dart @@ -0,0 +1,717 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; +import 'package:web/web.dart' as web; + +import 'schema.orm.dart'; +import 'schema.dart' as models; +import 'teams.dart'; +import 'precision.dart'; + +final checks = >[]; +void expect(bool condition, String message) { + if (!condition) throw StateError(message); +} + +Future rejects(Future Function() action, {String? code}) async { + try { + await action(); + } catch (e) { + if (code != null) { + expect(e is OrmException && e.code == code, 'Expected $code, got $e'); + } + return; + } + throw StateError('Expected rejection${code == null ? '' : ': $code'}'); +} + +Future check(String name, Future Function() body) async { + final watch = Stopwatch()..start(); + await body(); + checks.add({'name': name, 'milliseconds': watch.elapsedMilliseconds}); +} + +Future> memory() => sqlite(const SqliteOptions.memory()); +Future initialize(Database db) => Migrator(db.sql) + .apply([ + Migration.create('0001_browser', appSchema, dialect: SqlDialect.sqlite), + ]) + .then((_) {}); + +Future main() async { + try { + if (Uri.base.queryParameters['phase'] == 'recover') { + checks.addAll( + (jsonDecode(web.window.sessionStorage.getItem('orm_checks')!) as List) + .cast>(), + ); + final name = web.window.sessionStorage.getItem('orm_database')!; + final recovered = await sqlite(SqliteOptions.persistent(name)); + try { + await check('page reload recovers committed rows and rolls back interrupted transaction', () async { + expect( + (await recovered.users.single()).email == 'durable', + 'Committed rows changed or interrupted write survived', + ); + expect( + (await recovered.execute(SqlCommand('PRAGMA integrity_check'))) + .rows + .single + .single == + 'ok', + 'OPFS integrity failed', + ); + }); + await check( + 'persistent schema upgrades after reload preserve existing data', + () async { + final first = Migration.create( + '0001_browser', + appSchema, + dialect: SqlDialect.sqlite, + ); + final target = SchemaSnapshot([ + ...appSchema, + TableSchema( + 'upgraded', + columns: [Column('version', Codecs.integer)], + ), + ]); + final second = Migration.diff( + '0002_browser', + from: SchemaSnapshot(appSchema), + to: target, + previous: first.checksum, + dialect: SqlDialect.sqlite, + ); + await Migrator(recovered.sql).apply([first, second]); + expect( + (await verifySchema(recovered.sql, target)).matches, + 'Upgrade schema differs', + ); + expect( + (await recovered.users.single()).email == 'durable', + 'Upgrade removed existing data', + ); + }, + ); + } finally { + await recovered.close(); + } + await report({'passed': true, 'checks': checks}); + return; + } + await check( + 'many-to-many plans, phase observations, payloads, pagination and transaction writes preserve query counts', + () => checkTeams(), + ); + await check( + 'temporal column precision preserves epoch ties, extended ranges, defaults and keys', + () => checkTemporalPrecision(), + ); + final db = await memory(); + try { + await check('memory migrations and verified foreign keys', () async { + await initialize(db); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, + 'Schema differs', + ); + await rejects(() => db.posts.create(authorId: 999, title: 'invalid')); + }); + await check('query ownership, optional guards and write validation survive JS and WASM', () async { + final isolated = await memory(); + try { + await initialize(isolated); + await isolated.users.create(email: 'boundary'); + await isolated.transaction((tx) async { + final ids = isolated.users.select((u) => u.id); + await rejects( + () => tx.users.where((u) => u.id.isInQuery(ids)).get(), + code: 'QUERY.SESSION', + ); + final cte = ids.asCte('root_ids').alias(); + await rejects( + () => tx.users + .join(cte, on: (u, c) => u.id.equals(c.ref((u) => u.id))) + .get(), + code: 'QUERY.SESSION', + ); + expect( + await tx.users.count() == 1, + 'Rejected queries damaged the transaction', + ); + }); + final present = usersTable.alias(), absent = usersTable.alias(); + final query = isolated.users + .leftJoin(present, on: (u, p) => u.id.equals(p.id)) + .leftJoin(absent, on: (u, a) => a.id.eq(-1)); + await rejects( + () => query + .select((_) => present.optional(absent.fields.email)) + .get(), + code: 'QUERY.NULLABILITY', + ); + final result = await query + .select( + (_) => present.optional( + ( + present.fields.email, + absent.optional(absent.fields.email), + ).map((a, b) => (a, b)), + ), + ) + .get(); + expect( + result.single == ('boundary', null), + 'Nested optional decoding differs', + ); + await rejects( + () => isolated.users + .where((u) => u.id.count().gt(0)) + .delete() + .execute(), + code: 'QUERY.AGGREGATE', + ); + await rejects( + () => isolated.users + .insert((u) => [u.email.set('invalid')]) + .returning((_) => fields({})) + .get(), + code: 'QUERY.EMPTY_SELECTION', + ); + expect( + await isolated.users.count() == 1, + 'Rejected writes changed data', + ); + } finally { + await isolated.close(); + } + }); + await check('client factories distinguish omission, explicit null and prepared batch values', () async { + final isolated = await memory(); + try { + await initialize(isolated); + final initialCalls = models.nicknameCalls; + final row = await isolated.users.create(email: 'default'); + expect( + row.nickname == 'guest', + 'Omitted value did not use Dart factory', + ); + final explicit = await isolated.users.create( + email: 'explicit', + nickname: .set(null), + ); + expect(explicit.nickname == null, 'Explicit null used Dart factory'); + final batch = isolated.users.insertMany([ + 'batch-a', + 'batch-b', + ], (u, email) => [u.email.set(email)]); + expect( + models.nicknameCalls == initialCalls + 3, + 'Factory invocation count differs', + ); + batch.compile(); + batch.compile(); + await batch.execute(); + expect( + models.nicknameCalls == initialCalls + 3, + 'Compiling or executing reran factories', + ); + expect( + await isolated.users.where((u) => u.nickname.eq('guest')).count() == + 3, + 'Batch defaults were not stored', + ); + } finally { + await isolated.close(); + } + }); + await check('computed stored and virtual fields recompute across CRUD and migrations', () async { + final isolated = await memory(); + try { + await initialize(isolated); + final row = await isolated.users.create(email: 'computed'); + expect( + row.emailSize == 8 && row.upperNickname == 'GUEST', + 'Computed creation differs', + ); + await isolated.users + .byId(row.id) + .patch(email: .set('edited'), nickname: .set(null)); + final updated = await isolated.users.single(); + expect( + updated.emailSize == 6 && updated.upperNickname == null, + 'Computed update differs', + ); + final start = SchemaSnapshot(appSchema); + final initial = Migration.create( + '0001_browser', + appSchema, + dialect: SqlDialect.sqlite, + ); + final target = SchemaSnapshot([ + for (final table in appSchema) + if (table.name != 'users') + table + else + TableSchema( + table.name, + columns: [ + for (final c in table.columns) + if (c.name != 'email_size') + c + else + Column( + 'email_size', + Codecs.integer, + computed: const ComputedColumn('length(email) + 1'), + ), + ], + primaryKey: table.primaryKey, + uniqueKeys: table.uniqueKeys, + foreignKeys: table.foreignKeys, + indexes: table.indexes, + checks: table.checks, + ), + ]); + final migration = Migration.diff( + '0002_computed', + from: start, + to: target, + previous: initial.checksum, + dialect: SqlDialect.sqlite, + ); + await Migrator(isolated.sql).apply([initial, migration]); + expect( + (await isolated.users.single()).emailSize == 7, + 'Migration did not recompute existing row', + ); + expect( + (await verifySchema(isolated.sql, target)).matches, + 'Computed catalog differs', + ); + } finally { + await isolated.close(); + } + }); + await check( + 'generated CHECK enforcement and atomic constraint migrations', + () async { + await rejects(() => db.users.create(email: '')); + expect( + (await inspectTable(db.sql, 'users')).checks.length == 1, + 'Generated CHECK missing', + ); + final isolated = await memory(); + try { + TableSchema table(List constraints) => TableSchema( + 'scores', + columns: [Column('value', Codecs.integer)], + checks: constraints, + ); + final start = SchemaSnapshot([table(const [])]); + final first = Migration.create( + '0001_initial', + start.tables, + dialect: SqlDialect.sqlite, + ); + await Migrator(isolated.sql).apply([first]); + await isolated.execute( + SqlCommand('INSERT INTO scores VALUES (-1)'), + ); + final target = SchemaSnapshot([ + table(const [CheckSchema('positive', 'value >= 0')]), + ]); + final second = Migration.diff( + '0002_check', + from: start, + to: target, + previous: first.checksum, + dialect: SqlDialect.sqlite, + ); + await rejects(() => Migrator(isolated.sql).apply([first, second])); + expect( + (await verifySchema(isolated.sql, start)).matches, + 'Failed CHECK migration changed schema', + ); + expect( + (await Migrator(isolated.sql).history()).length == 1, + 'Failed CHECK migration changed history', + ); + await isolated.execute(SqlCommand('UPDATE scores SET value = 2')); + await Migrator(isolated.sql).apply([first, second]); + expect( + (await verifySchema(isolated.sql, target)).matches, + 'CHECK migration differs', + ); + await rejects( + () => + isolated.execute(SqlCommand('UPDATE scores SET value = -1')), + ); + } finally { + await isolated.close(); + } + }, + ); + await check( + 'generated records, projections and typed relation batches', + () async { + final a = await db.users.create(email: 'a'); + final b = await db.users.create(email: 'b'); + for (final user in [a, b]) { + for (var i = 0; i < 5; i++) { + await db.posts.create( + authorId: user.id, + title: '${user.email}$i', + ); + } + } + final rows = await db.users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => ( + u.email, + u.posts + .orderBy((p) => [p.id.desc()]) + .take(2) + .select((p) => p.title) + .many(), + ).map((email, posts) => (email: email, posts: posts)), + ) + .get(); + expect( + rows.length == 2 && + rows[0].posts.join(',') == 'a4,a3' && + rows[1].posts.join(',') == 'b4,b3', + '$rows', + ); + await db.users.byId(a.id).patch(nickname: .set('named')); + await db.users.byId(a.id).patch(nickname: .set(null)); + expect( + (await db.users.byId(a.id).single()).nickname == null, + 'Explicit NULL failed', + ); + }, + ); + await check('rollback, savepoints and transaction lifetime', () async { + Database? escaped; + await rejects( + () => db.transaction((tx) async { + escaped = tx; + await tx.users.create(email: 'rolled-back'); + throw StateError('rollback'); + }), + ); + expect( + !await db.users.where((u) => u.email.eq('rolled-back')).exists(), + 'Rollback leaked data', + ); + await rejects(() => escaped!.users.get(), code: 'SESSION.CLOSED'); + await db.transaction((tx) async { + await rejects( + () => tx.savepoint((sp) async { + await sp.users.create(email: 'savepoint'); + throw StateError('rollback'); + }), + ); + expect( + !await tx.users.where((u) => u.email.eq('savepoint')).exists(), + 'Savepoint leaked data', + ); + }); + }); + await check('stream cursor batches and early release', () async { + final all = await db.posts + .orderBy((p) => [p.id.asc()]) + .select((p) => p.title) + .stream(batchSize: 3) + .toList(); + expect(all.length == 10, 'Stream truncated'); + await db.posts.stream(batchSize: 2).take(1).drain(); + expect(await db.posts.count() == 10, 'Cursor retained the lease'); + }); + await check('commit invalidation and watch snapshots', () async { + final stream = StreamIterator(db.users.select((u) => u.email).watch()); + try { + expect(await stream.moveNext(), 'Missing first snapshot'); + final next = stream.moveNext(); + await db.transaction((tx) async { + await tx.users.create(email: 'watched'); + }); + expect(await next, 'Missing commit snapshot'); + expect(stream.current.contains('watched'), 'Watch missed commit'); + } finally { + await stream.cancel(); + } + }); + await check( + 'exact BigInt, Decimal, blobs and calendar transport', + () async { + final big = BigInt.parse('9223372036854775807'); + final amount = Decimal.parse('123456789012345678901.000000001'); + final row = await db.values.create( + wide: big, + bytes: Uint8List.fromList([0, 127, 255]), + amount: amount, + day: LocalDate.parse('0001-01-01 BC'), + time: LocalTime.parse('23:59:59.123456'), + stamp: LocalDateTime.parse('0001-01-01 12:00:00.123456 BC'), + instant: DateTime.utc(2024), + ); + expect( + row.wide == big && + row.amount == amount && + row.bytes.join(',') == '0,127,255', + 'Scalar codec mismatch', + ); + expect( + row.day == LocalDate.parse('0001-01-01 BC') && + row.time.microseconds % 1000000 == 123456, + 'Calendar precision changed', + ); + expect( + row.stamp == LocalDateTime.parse('0001-01-01 12:00:00.123456 BC') && + row.instant == DateTime.utc(2024), + 'Timestamp changed', + ); + final raw = await db.execute( + SqlCommand('SELECT 9223372036854775807, -9223372036854775808'), + ); + expect( + raw.rows.single[0] == big && + raw.rows.single[1] == -big - BigInt.one, + 'Raw int64 was rounded', + ); + final total = await db.values.select((v) => v.amount.sum()).single(); + expect(total == amount, 'Decimal aggregate changed'); + }, + ); + await check('explicit unsupported cancellation', () async { + expect( + !db.capabilities.cancellation, + 'Cancellation was falsely advertised', + ); + await rejects( + () => db.users.get( + options: const ExecutionOptions(timeout: Duration(seconds: 1)), + ), + code: 'CAPABILITY.CANCEL', + ); + }); + await check('large real values retain floating storage', () async { + final result = await db.users + .take(1) + .select((u) => value(1e20, Codecs.real)) + .single(); + expect(result == 1e20, 'Large REAL changed'); + final raw = await db.execute( + SqlCommand('SELECT typeof(?1), ?1', [const SqlReal(1e20)]), + ); + expect( + raw.rows.single[0] == 'real' && raw.rows.single[1] == 1e20, + 'Floating storage was lost', + ); + await rejects( + () => db.execute( + SqlCommand('SELECT ?1', [int.parse('9007199254740993')]), + ), + code: 'CODEC.INTEGER', + ); + await db.readings.create(id: 1, value: 1e20); + await db.readings.create(id: 2, value: 1e20); + await db.readings.create(id: 3, value: 2e20); + final token = db.readings.cursorToken( + (r) => [r.value.cursor(1e20), r.id.cursor(1)], + ); + final next = await db.readings + .seekToken(token, orderBy: (r) => [r.value.asc(), r.id.asc()]) + .get(); + expect( + next.length == 2 && next[0].id == 2 && next[1].id == 3, + 'REAL cursor lost its value or stable tie breaker', + ); + final peers = await db.readings + .orderBy((r) => [r.id.asc()]) + .select( + (r) => r.peers + .orderBy((p) => [p.id.asc()]) + .select((p) => p.id) + .many(), + ) + .get(); + expect( + peers.toString() == '[[1, 2], [1, 2], [3]]', + 'Query-only REAL relation keys lost their storage intent or equality', + ); + final same = await db.readings + .orderBy((r) => [r.id.asc()]) + .select((r) => r.sameReading.select((p) => p.id).many()) + .get(); + expect( + same.toString() == '[[1], [2], [3]]', + 'Composite REAL relation keys lost their storage intent', + ); + }); + await check( + 'instant microseconds are exact or explicitly rejected', + () async { + for (final text in [ + '2024-01-01 00:00:00.000001+00', + '0001-01-01 00:00:00.000001+00 BC', + '275760-09-12 23:59:59.999999+00', + ]) { + final decoded = Codecs.dateTime.decode(text); + expect( + Codecs.dateTime.encode(decoded) == text, + 'Instant silently lost microseconds: $text', + ); + } + }, + ); + await check('worker does not block the browser event loop', () async { + var ticks = 0; + final timer = Timer.periodic(const Duration(milliseconds: 1), (_) { + ticks++; + }); + try { + await db.execute( + SqlCommand( + 'WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<1000000) SELECT SUM(x) FROM n', + ), + ); + expect(ticks > 0, 'Main event loop was blocked'); + } finally { + timer.cancel(); + } + }); + } finally { + await db.close(); + } + await check( + 'startup failures are bounded and release failed workers', + () async { + await rejects( + () => sqlite( + SqliteOptions.memory( + web: SqliteWebOptions(wasm: Uri.parse('/missing.wasm')), + ), + ), + ); + await rejects( + () => sqlite( + SqliteOptions.memory( + web: SqliteWebOptions( + worker: Uri.parse('/missing-worker.js'), + openTimeout: const Duration(seconds: 2), + ), + ), + ), + ); + final healthy = await memory(); + await healthy.close(); + }, + ); + await check( + 'unified entry rejects native paths and mismatched browser assets', + () async { + await rejects( + () => sqlite(const SqliteOptions.file('native.sqlite')), + code: 'CAPABILITY.STORAGE', + ); + await rejects( + () => sqlite( + SqliteOptions.memory( + web: SqliteWebOptions(worker: Uri.parse('/mismatched-worker.js')), + ), + ), + code: 'DRIVER.PROTOCOL', + ); + await rejects( + () => sqlite( + SqliteOptions.memory( + web: SqliteWebOptions( + worker: Uri.parse('/mismatched-worker.js?protocol=old'), + ), + ), + ), + code: 'DRIVER.PROTOCOL', + ); + await rejects( + () => sqlite( + const SqliteOptions.memory( + web: SqliteWebOptions( + wasmIntegrity: + 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + ), + ), + ), + code: 'DRIVER.ASSET', + ); + final healthy = await memory(); + await healthy.close(); + }, + ); + await check( + 'OPFS commit survives a closed worker and new connection', + () async { + final options = SqliteOptions.persistent( + 'browser-${DateTime.now().millisecondsSinceEpoch}', + ); + var persistent = await sqlite(options); + try { + await initialize(persistent); + await persistent.users.create(email: 'persisted'); + await rejects(() => sqlite(options)); + expect( + (await persistent.users.single()).email == 'persisted', + 'Failed competing open disturbed the owner', + ); + } finally { + await persistent.close(); + } + persistent = await sqlite(options); + try { + expect( + (await persistent.users.single()).email == 'persisted', + 'OPFS lost committed data', + ); + } finally { + await persistent.close(); + } + }, + ); + final name = 'reload-${DateTime.now().millisecondsSinceEpoch}'; + final persistent = await sqlite(SqliteOptions.persistent(name)); + await initialize(persistent); + await persistent.users.create(email: 'durable'); + await persistent.transaction((tx) async { + await tx.users.create(email: 'interrupted'); + web.window.sessionStorage.setItem('orm_checks', jsonEncode(checks)); + web.window.sessionStorage.setItem('orm_database', name); + // Reload without closing this connection or committing its transaction. + web.window.location.replace('/?phase=recover'); + await Completer().future; + }); + } catch (error, stack) { + await report({ + 'passed': false, + 'error': error.toString(), + 'stack': stack.toString(), + 'checks': checks, + }); + } +} + +Future report(Map result) async { + result['browser'] = web.window.navigator.userAgent; + final body = jsonEncode(result); + web.document.body!.textContent = body; + await web.window + .fetch('/report'.toJS, web.RequestInit(method: 'POST', body: body.toJS)) + .toDart; +} diff --git a/test/support/browser/precision.dart b/test/support/browser/precision.dart new file mode 100644 index 00000000..b4880a85 --- /dev/null +++ b/test/support/browser/precision.dart @@ -0,0 +1,75 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import '../temporal_precision/schema.orm.dart'; + +Future checkTemporalPrecision() async { + final db = await sqlite(const SqliteOptions.memory()); + void expect(bool value, String message) { + if (!value) throw StateError(message); + } + + try { + await Migrator(db.sql).apply([ + Migration.create('0001_precision', appSchema, dialect: db.dialect), + ]); + final moment = await db.moments.create( + clock: LocalTime.parse('23:59:59.9995'), + local: LocalDateTime.parse('0001-01-01 00:00:00.0005 BC'), + instant: Codecs.dateTime.decode('1999-12-31 23:59:59.5Z'), + ); + expect( + moment.clock == LocalTime(24) && + moment.defaulted == LocalTime(24) && + moment.rounded == LocalTime(24), + 'Time precision, defaults or computed value changed', + ); + expect( + moment.local == LocalDateTime.parse('0001-01-01 00:00:00 BC'), + 'BC timestamp tie changed', + ); + expect( + moment.instant == DateTime.utc(1999, 12, 31, 23, 59, 59), + 'Instant epoch tie changed', + ); + for (final text in [ + '294276-12-31 23:59:59.999499', + '0001-01-01 00:00:00.0005 BC', + ]) { + final valueToRound = LocalDateTime.parse(text); + final actual = await db.moments + .select( + (_) => value(valueToRound, Codecs.localDateTime).withPrecision(3), + ) + .single(); + expect( + actual == valueToRound.withPrecision(3), + 'Extended timestamp lost precision across worker', + ); + } + final upper = Codecs.dateTime.decode('275760-09-12 23:59:59.999999+00'); + final rounded = await db.moments + .select((_) => value(upper, Codecs.dateTime).withPrecision(0)) + .single(); + expect( + rounded == DateTime.utc(275760, 9, 13) && + upper.withPrecision(0) == rounded, + 'Upper instant rounding lost a day or precision', + ); + await db.slots.create(time: LocalTime.parse('01:00:00.1235'), label: 'key'); + final booking = await db.bookings.create( + time: LocalTime.parse('01:00:00.1239'), + ); + final label = await db.bookings + .byId(booking.id) + .select((b) => b.slot.select((s) => s.label).one()) + .single(); + expect(label == 'key', 'Rounded relationship key failed'); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, + 'Precision catalog differs', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/browser/schema.dart b/test/support/browser/schema.dart new file mode 100644 index 00000000..1917d89c --- /dev/null +++ b/test/support/browser/schema.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; + +import 'package:orm/schema.dart'; + +typedef User = ({ + @Id.generated() int id, + @Unique() String email, + @ClientDefault(defaultNickname) String? nickname, + @Computed.sql('length(email)') int emailSize, + @Computed.sql('upper(nickname)', storage: ComputedStorage.virtual) + String? upperNickname, +}); +typedef Post = ({@Id.generated() int id, int authorId, String title}); +typedef Reading = ({@Id() int id, double value}); +typedef Value = ({ + @Id.generated() int id, + BigInt wide, + Uint8List bytes, + Decimal amount, + LocalDate day, + LocalTime time, + LocalDateTime stamp, + DateTime instant, +}); +final users = entity(); +final validEmail = users.check('length(email) > 0'); +final posts = entity(); +final values = entity(); +final readings = entity(); +final peers = readings + .key((r) => r.value) + .relatesTo(readings.key((r) => r.value)); +final sameReading = readings + .key((r) => (r.value, r.id)) + .relatesTo(readings.key((r) => (r.value, r.id))); +final author = posts + .key((p) => p.authorId) + .references(users.key((u) => u.id), inverse: 'posts', onDelete: .cascade); + +int nicknameCalls = 0; +String? defaultNickname() { + nicknameCalls++; + return 'guest'; +} diff --git a/test/support/browser/schema.orm.dart b/test/support/browser/schema.orm.dart new file mode 100644 index 00000000..03ce5bfd --- /dev/null +++ b/test/support/browser/schema.orm.dart @@ -0,0 +1,440 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show User, Post, Value, Reading; + +import 'dart:typed_data'; + +final _usersId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _usersEmail = Column( + "email", + Codecs.text, + nullable: false, + generated: false, +); +final _usersNickname = Column( + "nickname", + Codecs.text.nullable(), + nullable: true, + generated: false, + clientDefault: models.defaultNickname, +); +final _usersEmailSize = Column( + "email_size", + Codecs.integer, + nullable: false, + generated: false, + computed: ComputedColumn.forDialects( + sqlite: "length(email)", + postgres: "length(email)", + mysql: "length(email)", + mariadb: "length(email)", + storage: ComputedStorage.stored, + ), +); +final _usersUpperNickname = Column( + "upper_nickname", + Codecs.text.nullable(), + nullable: true, + generated: false, + computed: ComputedColumn.forDialects( + sqlite: "upper(nickname)", + postgres: "upper(nickname)", + mysql: "upper(nickname)", + mariadb: "upper(nickname)", + storage: ComputedStorage.virtual, + ), +); +final usersSchema = TableSchema( + "users", + columns: [ + _usersId, + _usersEmail, + _usersNickname, + _usersEmailSize, + _usersUpperNickname, + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + indexes: [], + checks: [ + CheckSchema.forDialects( + "valid_email", + sqlite: "length(email) > 0", + postgres: "length(email) > 0", + mysql: "length(email) > 0", + mariadb: "length(email) > 0", + ), + ], + foreignKeys: [], +); + +final class UsersFields extends Fields { + UsersFields(super.table); + late final id = column(_usersId); + late final email = column(_usersEmail); + late final nickname = column(_usersNickname); + late final emailSize = readColumn(_usersEmailSize); + late final upperNickname = readColumn(_usersUpperNickname); + Relation get posts => + Relation(postsTable, parent: [id], child: (row) => [row.authorId]); +} + +final usersTable = Table( + usersSchema, + UsersFields.new, + (row) => + (row.id, row.email, row.nickname, row.emailSize, row.upperNickname).map( + (id, email, nickname, emailSize, upperNickname) => ( + id: id, + email: email, + nickname: nickname, + emailSize: emailSize, + upperNickname: upperNickname, + ), + ), +); + +final class UsersTableSet extends TableSet { + UsersTableSet(QueryContext db) : super(db, usersTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required String email, + Change nickname = const Change.keep(), + }) => createRow( + (row) => [ + ...row.id.change(id), + row.email.set(email), + ...row.nickname.change(nickname), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension UsersUpdates on Query { + Future patch({ + Change email = const Change.keep(), + Change nickname = const Change.keep(), + }) => update( + (row) => [...row.email.change(email), ...row.nickname.change(nickname)], + ).execute(); +} + +final _postsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _postsAuthorId = Column( + "author_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _postsTitle = Column( + "title", + Codecs.text, + nullable: false, + generated: false, +); +final postsSchema = TableSchema( + "posts", + columns: [_postsId, _postsAuthorId, _postsTitle], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["author_id"], "users", ["id"], onDelete: "CASCADE"), + ], +); + +final class PostsFields extends Fields { + PostsFields(super.table); + late final id = column(_postsId); + late final authorId = column(_postsAuthorId); + late final title = column(_postsTitle); + Relation get author => + Relation(usersTable, parent: [authorId], child: (row) => [row.id]); +} + +final postsTable = Table( + postsSchema, + PostsFields.new, + (row) => ( + row.id, + row.authorId, + row.title, + ).map((id, authorId, title) => (id: id, authorId: authorId, title: title)), +); + +final class PostsTableSet extends TableSet { + PostsTableSet(QueryContext db) : super(db, postsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required int authorId, + required String title, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.authorId.set(authorId), + row.title.set(title), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension PostsUpdates on Query { + Future patch({ + Change authorId = const Change.keep(), + Change title = const Change.keep(), + }) => update( + (row) => [...row.authorId.change(authorId), ...row.title.change(title)], + ).execute(); +} + +final _valuesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _valuesWide = Column( + "wide", + Codecs.bigint, + nullable: false, + generated: false, +); +final _valuesBytes = Column( + "bytes", + Codecs.bytes, + nullable: false, + generated: false, +); +final _valuesAmount = Column( + "amount", + Codecs.decimal, + nullable: false, + generated: false, +); +final _valuesDay = Column( + "day", + Codecs.date, + nullable: false, + generated: false, +); +final _valuesTime = Column( + "time", + Codecs.time, + nullable: false, + generated: false, +); +final _valuesStamp = Column( + "stamp", + Codecs.localDateTime, + nullable: false, + generated: false, +); +final _valuesInstant = Column( + "instant", + Codecs.dateTime, + nullable: false, + generated: false, +); +final valuesSchema = TableSchema( + "values", + columns: [ + _valuesId, + _valuesWide, + _valuesBytes, + _valuesAmount, + _valuesDay, + _valuesTime, + _valuesStamp, + _valuesInstant, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class ValuesFields extends Fields { + ValuesFields(super.table); + late final id = column(_valuesId); + late final wide = column(_valuesWide); + late final bytes = column(_valuesBytes); + late final amount = column(_valuesAmount); + late final day = column(_valuesDay); + late final time = column(_valuesTime); + late final stamp = column(_valuesStamp); + late final instant = column(_valuesInstant); +} + +final valuesTable = Table( + valuesSchema, + ValuesFields.new, + (row) => + ( + (row.id, row.wide, row.bytes, row.amount, row.day).map( + (id, wide, bytes, amount, day) => + (id: id, wide: wide, bytes: bytes, amount: amount, day: day), + ), + (row.time, row.stamp, row.instant).map( + (time, stamp, instant) => + (time: time, stamp: stamp, instant: instant), + ), + ).map( + (left, right) => ( + id: left.id, + wide: left.wide, + bytes: left.bytes, + amount: left.amount, + day: left.day, + time: right.time, + stamp: right.stamp, + instant: right.instant, + ), + ), +); + +final class ValuesTableSet extends TableSet { + ValuesTableSet(QueryContext db) : super(db, valuesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required BigInt wide, + required Uint8List bytes, + required Decimal amount, + required LocalDate day, + required LocalTime time, + required LocalDateTime stamp, + required DateTime instant, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.wide.set(wide), + row.bytes.set(bytes), + row.amount.set(amount), + row.day.set(day), + row.time.set(time), + row.stamp.set(stamp), + row.instant.set(instant), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension ValuesUpdates on Query { + Future patch({ + Change wide = const Change.keep(), + Change bytes = const Change.keep(), + Change amount = const Change.keep(), + Change day = const Change.keep(), + Change time = const Change.keep(), + Change stamp = const Change.keep(), + Change instant = const Change.keep(), + }) => update( + (row) => [ + ...row.wide.change(wide), + ...row.bytes.change(bytes), + ...row.amount.change(amount), + ...row.day.change(day), + ...row.time.change(time), + ...row.stamp.change(stamp), + ...row.instant.change(instant), + ], + ).execute(); +} + +final _readingsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _readingsValue = Column( + "value", + Codecs.real, + nullable: false, + generated: false, +); +final readingsSchema = TableSchema( + "readings", + columns: [_readingsId, _readingsValue], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class ReadingsFields extends Fields { + ReadingsFields(super.table); + late final id = column(_readingsId); + late final value = column(_readingsValue); + + /// Read-only navigation; no database foreign key or write effects. + Relation get peers => + Relation(readingsTable, parent: [value], child: (row) => [row.value]); + + /// Read-only navigation; no database foreign key or write effects. + Relation get sameReading => Relation( + readingsTable, + parent: [value, id], + child: (row) => [row.value, row.id], + ); +} + +final readingsTable = Table( + readingsSchema, + ReadingsFields.new, + (row) => (row.id, row.value).map((id, value) => (id: id, value: value)), +); + +final class ReadingsTableSet extends TableSet { + ReadingsTableSet(QueryContext db) : super(db, readingsTable) { + db.registerSchema(appSchema); + } + Future create({required int id, required double value}) => + createRow((row) => [row.id.set(id), row.value.set(value)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension ReadingsUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change value = const Change.keep(), + }) => + update((row) => [...row.id.change(id), ...row.value.change(value)]) + .execute(); +} + +final appSchema = List.unmodifiable([ + usersSchema, + postsSchema, + valuesSchema, + readingsSchema, +]); + +extension AppTables on QueryContext { + UsersTableSet get users => UsersTableSet(this); + PostsTableSet get posts => PostsTableSet(this); + ValuesTableSet get values => ValuesTableSet(this); + ReadingsTableSet get readings => ReadingsTableSet(this); +} diff --git a/test/support/browser/schema.snapshot.dart b/test/support/browser/schema.snapshot.dart new file mode 100644 index 00000000..a3d37b4b --- /dev/null +++ b/test/support/browser/schema.snapshot.dart @@ -0,0 +1,70 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "users", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("email", Codecs.text), + Column("nickname", Codecs.text.nullable(), nullable: true), + Column( + "email_size", + Codecs.integer, + + computed: ComputedColumn( + "length(email)", + storage: ComputedStorage.stored, + ), + ), + Column( + "upper_nickname", + Codecs.text.nullable(), + nullable: true, + + computed: ComputedColumn( + "upper(nickname)", + storage: ComputedStorage.virtual, + ), + ), + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + + checks: [CheckSchema("valid_email", "length(email) > 0")], + ), + TableSchema( + "posts", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("author_id", Codecs.integer), + Column("title", Codecs.text), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["author_id"], "users", ["id"], onDelete: "CASCADE"), + ], + ), + TableSchema( + "values", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("wide", Codecs.bigint), + Column("bytes", Codecs.bytes), + Column("amount", Codecs.decimal), + Column("day", Codecs.date), + Column("time", Codecs.time), + Column("stamp", Codecs.localDateTime), + Column("instant", Codecs.dateTime), + ], + primaryKey: ["id"], + ), + TableSchema( + "readings", + columns: [Column("id", Codecs.integer), Column("value", Codecs.real)], + primaryKey: ["id"], + ), +]); diff --git a/test/support/browser/teams.dart b/test/support/browser/teams.dart new file mode 100644 index 00000000..cf4cfeca --- /dev/null +++ b/test/support/browser/teams.dart @@ -0,0 +1,168 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import '../../../example/teams/schema.dart'; +import '../../../example/teams/schema.orm.dart'; + +Future checkTeams() async { + final events = []; + final acquired = [], decoded = []; + final db = await sqlite( + const SqliteOptions.memory(), + onQuery: events.add, + onAcquire: acquired.add, + onDecode: decoded.add, + ); + void expect(bool value, String message) { + if (!value) throw StateError(message); + } + + try { + await Migrator( + db.sql, + ).apply([Migration.create('0001_teams', appSchema, dialect: db.dialect)]); + await db.transaction((tx) async { + await tx.users.create(id: 1, name: 'Ada'); + await tx.users.create(id: 2, name: 'Ben'); + await tx.users.create(id: 3, name: 'Cy'); + await tx.teams.create(id: 10, name: 'Core'); + await tx.teams.create(id: 20, name: 'Docs'); + await tx.memberships.create( + teamId: 10, + userId: 1, + role: .set(MembershipRole.owner), + joinedAt: DateTime.utc(2026, 1, 1), + ); + await tx.memberships.create( + teamId: 20, + userId: 1, + joinedAt: DateTime.utc(2026, 1, 2), + ); + await tx.memberships.create( + teamId: 10, + userId: 2, + joinedAt: DateTime.utc(2026, 1, 1), + ); + }); + events.clear(); + acquired.clear(); + decoded.clear(); + final query = db.users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => u.memberships + .orderBy((m) => [m.joinedAt.desc(), m.teamId.desc()]) + .take(1) + .select( + (m) => ( + m.team.select((t) => t.name).required(), + m.role, + ).map((team, role) => (team: team, role: role)), + ) + .many(), + ); + final plan = query.inspect(); + expect( + plan.sqlTemplateCount == 2 && plan.loads.single.limitPerParent == 1, + 'Inspection lost a batch or its per-parent limit', + ); + expect( + events.isEmpty && acquired.isEmpty && decoded.isEmpty, + 'Inspection performed execution work', + ); + final rows = await query.get(); + expect( + rows[0].single == (team: 'Docs', role: MembershipRole.member), + 'User 1 association differs', + ); + expect( + rows[1].single == (team: 'Core', role: MembershipRole.member) && + rows[2].isEmpty, + 'Shared/empty association differs', + ); + expect( + events.length == 2 && events[0].rowCount == 3 && events[1].rowCount == 2, + 'Relation counts or row volume differ', + ); + expect( + acquired.length == 1 && + !acquired.single.reusedConnection && + acquired.single.error == null, + 'Acquisition observation differs', + ); + expect( + decoded.length == 2 && + decoded[0].inputRows == 2 && + decoded[1].inputRows == 3 && + decoded.every((e) => e.error == null), + 'Decoding observations differ', + ); + final calls = []; + T mark(String label, T value) { + calls.add(label); + return value; + } + + final mixed = db.users + .where((u) => u.id.eq(1)) + .select( + (u) => + ( + u.id.map((v) => mark('id', v)), + u.name.map((v) => mark('name', v)), + u.id.map((v) => mark('active', v > 0)), + u.name.map((_) => mark('nullable', null)), + u.id.map((v) => mark('fraction', v + .5)), + u.name.map((v) => mark('tags', [v])), + ).map((id, name, active, nullable, fraction, tags) { + calls.add('result'); + return ( + id: id, + name: name, + active: active, + nullable: nullable, + fraction: fraction, + tags: tags, + ); + }), + ); + mixed.inspect(); + expect(calls.isEmpty, 'Inspection invoked a mixed projection mapper'); + final value = await mixed.single(); + expect( + value.id == 1 && + value.name == 'Ada' && + value.active && + value.nullable == null && + value.fraction == 1.5 && + value.tags.single == 'Ada', + 'Six-field mixed projection differs', + ); + expect( + calls.join(',') == 'id,name,active,nullable,fraction,tags,result', + 'Mixed projection evaluation order differs', + ); + await db.transaction((tx) async { + await tx.teams.byId(10).patch(name: .set('Kernel')); + await tx.memberships + .byId(teamId: 10, userId: 2) + .patch(role: .set(MembershipRole.owner)); + }); + expect( + (await query.get())[1].single == + (team: 'Kernel', role: MembershipRole.owner), + 'Transaction did not retain payload/endpoint changes', + ); + await db.teams.byId(10).delete().execute(); + expect( + await db.memberships.count() == 1 && await db.users.count() == 3, + 'Cascade removed an endpoint or retained invalid associations', + ); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, + 'Junction schema differs', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/checks/schema.dart b/test/support/checks/schema.dart new file mode 100644 index 00000000..b5e5c5d6 --- /dev/null +++ b/test/support/checks/schema.dart @@ -0,0 +1,28 @@ +import 'package:orm/schema.dart'; + +typedef Product = ({ + @Id.generated() int id, + @IntegerBits(16) @Default.sql('0') int stock, + double price, + double? discount, + String state, + String? label, +}); +typedef Line = ({@Id.generated() int id, int productId}); +final products = entity(); +final lines = entity(); +final nonnegativeStock = products.check( + 'stock >= 0', + name: 'stock_nonnegative', +); +final nonnegativePrice = products.check('price >= 0'); +final validDiscount = products.check('discount >= 0 AND discount <= price'); +final validState = products.check("state IN ('draft', 'published')"); +final shortLabel = products.check( + 'length(label) <= 20', + postgres: 'char_length(label) <= 20', +); +final literalLabel = products.check("label <> '; CHECK (0)'", name: null); +final product = lines + .key((l) => l.productId) + .references(products.key((p) => p.id), inverse: 'lines'); diff --git a/test/support/checks/schema.orm.dart b/test/support/checks/schema.orm.dart new file mode 100644 index 00000000..5e06b91e --- /dev/null +++ b/test/support/checks/schema.orm.dart @@ -0,0 +1,239 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Product, Line; + +final _productsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _productsStock = Column( + "stock", + Codecs.integer, + nullable: false, + generated: false, + defaultSql: "0", + integerBits: 16, +); +final _productsPrice = Column( + "price", + Codecs.real, + nullable: false, + generated: false, +); +final _productsDiscount = Column( + "discount", + Codecs.real.nullable(), + nullable: true, + generated: false, +); +final _productsState = Column( + "state", + Codecs.text, + nullable: false, + generated: false, +); +final _productsLabel = Column( + "label", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final productsSchema = TableSchema( + "products", + columns: [ + _productsId, + _productsStock, + _productsPrice, + _productsDiscount, + _productsState, + _productsLabel, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + checks: [ + CheckSchema.forDialects( + "stock_nonnegative", + sqlite: "stock >= 0", + postgres: "stock >= 0", + mysql: "stock >= 0", + mariadb: "stock >= 0", + ), + CheckSchema.forDialects( + "nonnegative_price", + sqlite: "price >= 0", + postgres: "price >= 0", + mysql: "price >= 0", + mariadb: "price >= 0", + ), + CheckSchema.forDialects( + "valid_discount", + sqlite: "discount >= 0 AND discount <= price", + postgres: "discount >= 0 AND discount <= price", + mysql: "discount >= 0 AND discount <= price", + mariadb: "discount >= 0 AND discount <= price", + ), + CheckSchema.forDialects( + "valid_state", + sqlite: "state IN ('draft', 'published')", + postgres: "state IN ('draft', 'published')", + mysql: "state IN ('draft', 'published')", + mariadb: "state IN ('draft', 'published')", + ), + CheckSchema.forDialects( + "short_label", + sqlite: "length(label) <= 20", + postgres: "char_length(label) <= 20", + mysql: "length(label) <= 20", + mariadb: "length(label) <= 20", + ), + CheckSchema.forDialects( + null, + sqlite: "label <> '; CHECK (0)'", + postgres: "label <> '; CHECK (0)'", + mysql: "label <> '; CHECK (0)'", + mariadb: "label <> '; CHECK (0)'", + ), + ], + foreignKeys: [], +); + +final class ProductsFields extends Fields { + ProductsFields(super.table); + late final id = column(_productsId); + late final stock = column(_productsStock); + late final price = column(_productsPrice); + late final discount = column(_productsDiscount); + late final state = column(_productsState); + late final label = column(_productsLabel); + Relation get lines => + Relation(linesTable, parent: [id], child: (row) => [row.productId]); +} + +final productsTable = Table( + productsSchema, + ProductsFields.new, + (row) => + (row.id, row.stock, row.price, row.discount, row.state, row.label).map( + (id, stock, price, discount, state, label) => ( + id: id, + stock: stock, + price: price, + discount: discount, + state: state, + label: label, + ), + ), +); + +final class ProductsTableSet extends TableSet { + ProductsTableSet(QueryContext db) : super(db, productsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + Change stock = const Change.keep(), + required double price, + double? discount, + required String state, + String? label, + }) => createRow( + (row) => [ + ...row.id.change(id), + ...row.stock.change(stock), + row.price.set(price), + row.discount.set(discount), + row.state.set(state), + row.label.set(label), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension ProductsUpdates on Query { + Future patch({ + Change stock = const Change.keep(), + Change price = const Change.keep(), + Change discount = const Change.keep(), + Change state = const Change.keep(), + Change label = const Change.keep(), + }) => update( + (row) => [ + ...row.stock.change(stock), + ...row.price.change(price), + ...row.discount.change(discount), + ...row.state.change(state), + ...row.label.change(label), + ], + ).execute(); +} + +final _linesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _linesProductId = Column( + "product_id", + Codecs.integer, + nullable: false, + generated: false, +); +final linesSchema = TableSchema( + "lines", + columns: [_linesId, _linesProductId], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["product_id"], "products", ["id"], onDelete: "RESTRICT"), + ], +); + +final class LinesFields extends Fields { + LinesFields(super.table); + late final id = column(_linesId); + late final productId = column(_linesProductId); + Relation get product => + Relation(productsTable, parent: [productId], child: (row) => [row.id]); +} + +final linesTable = Table( + linesSchema, + LinesFields.new, + (row) => ( + row.id, + row.productId, + ).map((id, productId) => (id: id, productId: productId)), +); + +final class LinesTableSet extends TableSet { + LinesTableSet(QueryContext db) : super(db, linesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required int productId, + }) => + createRow((row) => [...row.id.change(id), row.productId.set(productId)]); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension LinesUpdates on Query { + Future patch({Change productId = const Change.keep()}) => + update((row) => [...row.productId.change(productId)]).execute(); +} + +final appSchema = List.unmodifiable([productsSchema, linesSchema]); + +extension AppTables on QueryContext { + ProductsTableSet get products => ProductsTableSet(this); + LinesTableSet get lines => LinesTableSet(this); +} diff --git a/test/support/checks/schema.snapshot.dart b/test/support/checks/schema.snapshot.dart new file mode 100644 index 00000000..53a51db1 --- /dev/null +++ b/test/support/checks/schema.snapshot.dart @@ -0,0 +1,44 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "products", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("stock", Codecs.integer, defaultSql: "0", integerBits: 16), + Column("price", Codecs.real), + Column("discount", Codecs.real.nullable(), nullable: true), + Column("state", Codecs.text), + Column("label", Codecs.text.nullable(), nullable: true), + ], + primaryKey: ["id"], + + checks: [ + CheckSchema("stock_nonnegative", "stock >= 0"), + CheckSchema("nonnegative_price", "price >= 0"), + CheckSchema("valid_discount", "discount >= 0 AND discount <= price"), + CheckSchema("valid_state", "state IN ('draft', 'published')"), + CheckSchema.forDialects( + "short_label", + sqlite: "length(label) <= 20", + postgres: "char_length(label) <= 20", + mysql: "length(label) <= 20", + mariadb: "length(label) <= 20", + ), + CheckSchema(null, "label <> '; CHECK (0)'"), + ], + ), + TableSchema( + "lines", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("product_id", Codecs.integer), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["product_id"], "products", ["id"], onDelete: "RESTRICT"), + ], + ), +]); diff --git a/test/support/codecs/alternate.dart b/test/support/codecs/alternate.dart new file mode 100644 index 00000000..86cf09c6 --- /dev/null +++ b/test/support/codecs/alternate.dart @@ -0,0 +1,11 @@ +import 'package:orm/schema.dart'; + +// A distinct domain type with the same short name exercises generated imports. +final class Email { + final String label; + const Email(this.label); +} + +const emailCodec = Codec.text(_decode, _encode); +Email _decode(Object? value) => Email(value as String); +String _encode(Email value) => value.label; diff --git a/test/support/codecs/native.dart b/test/support/codecs/native.dart new file mode 100644 index 00000000..56912015 --- /dev/null +++ b/test/support/codecs/native.dart @@ -0,0 +1,60 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'alternate.dart' as alt; +import 'schema.orm.dart'; +import 'types.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await Migrator( + db.sql, + ).apply([Migration.create('0001_initial', appSchema, dialect: db.dialect)]); + final first = await db.people.create( + email: const Email('aot@example.com'), + membership: .pending, + tags: ['AOT', '类型'], + location: (city: '成都', zone: 8), + alternate: const alt.Email('other'), + details: const SqlJson('scalar'), + ); + if (first.id.value != 1 || + first.email.value != 'aot@example.com' || + first.membership != Membership.pending || + first.tags.last != '类型' || + first.location != (city: '成都', zone: 8) || + first.details?.value != 'scalar') { + throw StateError('Custom value decoding failed'); + } + await db.notes.create(ownerId: first.id, body: 'relation'); + final email = await db.notes + .select((n) => n.owner.select((p) => p.email).required()) + .single(); + if (email.value != first.email.value) { + throw StateError('Custom relation failed'); + } + final token = db.people.cursorToken((p) => [p.id.cursor(first.id)]); + final second = await db.people.create( + email: const Email('second@example.com'), + membership: .active, + tags: [], + alternate: const alt.Email('other'), + details: const SqlJson(null), + ); + final rows = await db.people + .seekToken(token, orderBy: (p) => [p.id.asc()]) + .stream(batchSize: 1) + .toList(); + if (rows.single.id != second.id || + rows.single.details == null || + rows.single.details!.value != null) { + throw StateError('Cursor/JSON presence failed'); + } + print( + 'Native AOT: extension IDs, custom classes, enums, records, relations, JSON and cursors passed.', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/codecs/schema.dart b/test/support/codecs/schema.dart new file mode 100644 index 00000000..56be4fff --- /dev/null +++ b/test/support/codecs/schema.dart @@ -0,0 +1,27 @@ +import 'package:orm/schema.dart'; + +import 'types.dart' as domain; +import 'alternate.dart' as alt; + +typedef Person = ({ + @Id.generated() @UseCodec(domain.PersonId.codec) domain.PersonId id, + @Unique() @UseCodec(domain.Email.codec) domain.Email email, + domain.Membership membership, + domain.Membership? previousMembership, + @UseCodec(domain.tagsCodec) List tags, + @UseCodec(domain.locationCodec) domain.Location? location, + @UseCodec(alt.emailCodec) alt.Email alternate, + @UseCodec(Codecs.jsonDocument) SqlJson? details, +}); + +typedef Note = ({ + @Id.generated() int id, + @UseCodec(domain.PersonId.codec) domain.PersonId ownerId, + String body, +}); + +final people = entity(); +final notes = entity(); +final owner = notes + .key((n) => n.ownerId) + .references(people.key((p) => p.id), inverse: 'notes'); diff --git a/test/support/codecs/schema.orm.dart b/test/support/codecs/schema.orm.dart new file mode 100644 index 00000000..07d771a8 --- /dev/null +++ b/test/support/codecs/schema.orm.dart @@ -0,0 +1,269 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Person, Note; +import "types.dart" as types0; +import "alternate.dart" as types1; + +final _peopleId = Column( + "id", + types0.PersonId.codec, + nullable: false, + generated: true, +); +final _peopleEmail = Column( + "email", + types0.Email.codec, + nullable: false, + generated: false, +); +final _peopleMembership = Column( + "membership", + Codecs.enumeration({ + types0.Membership.pending: "pending-payment", + types0.Membership.active: "active", + types0.Membership.cancelled: "closed", + }), + nullable: false, + generated: false, +); +final _peoplePreviousMembership = Column( + "previous_membership", + Codecs.enumeration({ + types0.Membership.pending: "pending-payment", + types0.Membership.active: "active", + types0.Membership.cancelled: "closed", + }).nullable(), + nullable: true, + generated: false, +); +final _peopleTags = Column>( + "tags", + types0.tagsCodec, + nullable: false, + generated: false, +); +final _peopleLocation = Column( + "location", + types0.locationCodec.nullable(), + nullable: true, + generated: false, +); +final _peopleAlternate = Column( + "alternate", + types1.emailCodec, + nullable: false, + generated: false, +); +final _peopleDetails = Column( + "details", + Codecs.jsonDocument.nullable(), + nullable: true, + generated: false, +); +final peopleSchema = TableSchema( + "people", + columns: [ + _peopleId, + _peopleEmail, + _peopleMembership, + _peoplePreviousMembership, + _peopleTags, + _peopleLocation, + _peopleAlternate, + _peopleDetails, + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + indexes: [], + foreignKeys: [], +); + +final class PeopleFields extends Fields { + PeopleFields(super.table); + late final id = column(_peopleId); + late final email = column(_peopleEmail); + late final membership = column(_peopleMembership); + late final previousMembership = column(_peoplePreviousMembership); + late final tags = column(_peopleTags); + late final location = column(_peopleLocation); + late final alternate = column(_peopleAlternate); + late final details = column(_peopleDetails); + Relation get notes => + Relation(notesTable, parent: [id], child: (row) => [row.ownerId]); +} + +final peopleTable = Table( + peopleSchema, + PeopleFields.new, + (row) => + ( + ( + row.id, + row.email, + row.membership, + row.previousMembership, + row.tags, + ).map( + (id, email, membership, previousMembership, tags) => ( + id: id, + email: email, + membership: membership, + previousMembership: previousMembership, + tags: tags, + ), + ), + (row.location, row.alternate, row.details).map( + (location, alternate, details) => + (location: location, alternate: alternate, details: details), + ), + ).map( + (left, right) => ( + id: left.id, + email: left.email, + membership: left.membership, + previousMembership: left.previousMembership, + tags: left.tags, + location: right.location, + alternate: right.alternate, + details: right.details, + ), + ), +); + +final class PeopleTableSet extends TableSet { + PeopleTableSet(QueryContext db) : super(db, peopleTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required types0.Email email, + required types0.Membership membership, + types0.Membership? previousMembership, + required List tags, + types0.Location? location, + required types1.Email alternate, + SqlJson? details, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.email.set(email), + row.membership.set(membership), + row.previousMembership.set(previousMembership), + row.tags.set(tags), + row.location.set(location), + row.alternate.set(alternate), + row.details.set(details), + ], + ); + Query byId(types0.PersonId id) => + where((row) => row.id.eq(id)); +} + +extension PeopleUpdates on Query { + Future patch({ + Change email = const Change.keep(), + Change membership = const Change.keep(), + Change previousMembership = const Change.keep(), + Change> tags = const Change.keep(), + Change location = const Change.keep(), + Change alternate = const Change.keep(), + Change details = const Change.keep(), + }) => update( + (row) => [ + ...row.email.change(email), + ...row.membership.change(membership), + ...row.previousMembership.change(previousMembership), + ...row.tags.change(tags), + ...row.location.change(location), + ...row.alternate.change(alternate), + ...row.details.change(details), + ], + ).execute(); +} + +final _notesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _notesOwnerId = Column( + "owner_id", + types0.PersonId.codec, + nullable: false, + generated: false, +); +final _notesBody = Column( + "body", + Codecs.text, + nullable: false, + generated: false, +); +final notesSchema = TableSchema( + "notes", + columns: [_notesId, _notesOwnerId, _notesBody], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["owner_id"], "people", ["id"], onDelete: "RESTRICT"), + ], +); + +final class NotesFields extends Fields { + NotesFields(super.table); + late final id = column(_notesId); + late final ownerId = column(_notesOwnerId); + late final body = column(_notesBody); + Relation get owner => + Relation(peopleTable, parent: [ownerId], child: (row) => [row.id]); +} + +final notesTable = Table( + notesSchema, + NotesFields.new, + (row) => ( + row.id, + row.ownerId, + row.body, + ).map((id, ownerId, body) => (id: id, ownerId: ownerId, body: body)), +); + +final class NotesTableSet extends TableSet { + NotesTableSet(QueryContext db) : super(db, notesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required types0.PersonId ownerId, + required String body, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.ownerId.set(ownerId), + row.body.set(body), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension NotesUpdates on Query { + Future patch({ + Change ownerId = const Change.keep(), + Change body = const Change.keep(), + }) => update( + (row) => [...row.ownerId.change(ownerId), ...row.body.change(body)], + ).execute(); +} + +final appSchema = List.unmodifiable([peopleSchema, notesSchema]); + +extension AppTables on QueryContext { + PeopleTableSet get people => PeopleTableSet(this); + NotesTableSet get notes => NotesTableSet(this); +} diff --git a/test/support/codecs/schema.snapshot.dart b/test/support/codecs/schema.snapshot.dart new file mode 100644 index 00000000..c31aa868 --- /dev/null +++ b/test/support/codecs/schema.snapshot.dart @@ -0,0 +1,35 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "people", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("email", Codecs.text), + Column("membership", Codecs.text), + Column("previous_membership", Codecs.text.nullable(), nullable: true), + Column("tags", Codecs.json), + Column("location", Codecs.json.nullable(), nullable: true), + Column("alternate", Codecs.text), + Column("details", Codecs.json.nullable(), nullable: true), + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + ), + TableSchema( + "notes", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("owner_id", Codecs.integer), + Column("body", Codecs.text), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["owner_id"], "people", ["id"], onDelete: "RESTRICT"), + ], + ), +]); diff --git a/test/support/codecs/types.dart b/test/support/codecs/types.dart new file mode 100644 index 00000000..0e190911 --- /dev/null +++ b/test/support/codecs/types.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:orm/schema.dart'; + +extension type const PersonId(int value) { + static const codec = Codec.integer(_decode, _encode); + static PersonId _decode(Object? value) => PersonId(value as int); + static int _encode(PersonId id) => id.value; +} + +final class Email { + final String value; + const Email(this.value); + static const codec = Codec.text(_decode, _encode); + static Email _decode(Object? value) => Email(_validate(value as String)); + static String _encode(Email email) => _validate(email.value); + static String _validate(String value) { + if (!value.contains('@')) { + throw const FormatException('Expected an email value.'); + } + return value; + } +} + +enum Membership { + @EnumValue('pending-payment') + pending, + active, + @EnumValue('closed') + cancelled, +} + +typedef Location = ({String city, int zone}); +const locationCodec = Codec('json', _location, _locationJson); +Location _location(Object? value) { + final json = Codecs.json.decode(value) as Map; + return (city: json['city'] as String, zone: json['zone'] as int); +} + +String _locationJson(Location value) => + jsonEncode({'city': value.city, 'zone': value.zone}); + +const tagsCodec = Codec>('json', _tags, jsonEncode); +List _tags(Object? value) => + List.unmodifiable(Codecs.json.decode(value) as List); diff --git a/test/support/computed/schema.dart b/test/support/computed/schema.dart new file mode 100644 index 00000000..6d57a49a --- /dev/null +++ b/test/support/computed/schema.dart @@ -0,0 +1,27 @@ +import 'package:orm/schema.dart'; + +typedef Line = ({ + @Id.generated() int id, + int price, + int quantity, + String label, + String? note, + @Computed.sql('price * quantity') int total, + @Computed.sql( + 'length(label)', + postgres: 'char_length(label)', + storage: ComputedStorage.virtual, + ) + int labelSize, + @Computed.sql('upper(note)') String? normalizedNote, +}); + +final lines = entity(); +final byTotal = lines.index((r) => r.total); +final nonnegative = lines.check('price >= 0 AND quantity >= 0'); + +typedef Band = ({@Id() int id, String name}); +final bands = entity(); +final band = lines + .key((r) => r.total) + .relatesTo(bands.key((b) => b.id), inverse: 'lines'); diff --git a/test/support/computed/schema.orm.dart b/test/support/computed/schema.orm.dart new file mode 100644 index 00000000..7ebaecc5 --- /dev/null +++ b/test/support/computed/schema.orm.dart @@ -0,0 +1,255 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Line, Band; + +final _linesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _linesPrice = Column( + "price", + Codecs.integer, + nullable: false, + generated: false, +); +final _linesQuantity = Column( + "quantity", + Codecs.integer, + nullable: false, + generated: false, +); +final _linesLabel = Column( + "label", + Codecs.text, + nullable: false, + generated: false, +); +final _linesNote = Column( + "note", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final _linesTotal = Column( + "total", + Codecs.integer, + nullable: false, + generated: false, + computed: ComputedColumn.forDialects( + sqlite: "price * quantity", + postgres: "price * quantity", + mysql: "price * quantity", + mariadb: "price * quantity", + storage: ComputedStorage.stored, + ), +); +final _linesLabelSize = Column( + "label_size", + Codecs.integer, + nullable: false, + generated: false, + computed: ComputedColumn.forDialects( + sqlite: "length(label)", + postgres: "char_length(label)", + mysql: "length(label)", + mariadb: "length(label)", + storage: ComputedStorage.virtual, + ), +); +final _linesNormalizedNote = Column( + "normalized_note", + Codecs.text.nullable(), + nullable: true, + generated: false, + computed: ComputedColumn.forDialects( + sqlite: "upper(note)", + postgres: "upper(note)", + mysql: "upper(note)", + mariadb: "upper(note)", + storage: ComputedStorage.stored, + ), +); +final linesSchema = TableSchema( + "lines", + columns: [ + _linesId, + _linesPrice, + _linesQuantity, + _linesLabel, + _linesNote, + _linesTotal, + _linesLabelSize, + _linesNormalizedNote, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [ + IndexSchema("by_total", ["total"], unique: false), + ], + checks: [ + CheckSchema.forDialects( + "nonnegative", + sqlite: "price >= 0 AND quantity >= 0", + postgres: "price >= 0 AND quantity >= 0", + mysql: "price >= 0 AND quantity >= 0", + mariadb: "price >= 0 AND quantity >= 0", + ), + ], + foreignKeys: [], +); + +final class LinesFields extends Fields { + LinesFields(super.table); + late final id = column(_linesId); + late final price = column(_linesPrice); + late final quantity = column(_linesQuantity); + late final label = column(_linesLabel); + late final note = column(_linesNote); + late final total = readColumn(_linesTotal); + late final labelSize = readColumn(_linesLabelSize); + late final normalizedNote = readColumn(_linesNormalizedNote); + + /// Read-only navigation; no database foreign key or write effects. + Relation get band => + Relation(bandsTable, parent: [total], child: (row) => [row.id]); +} + +final linesTable = Table( + linesSchema, + LinesFields.new, + (row) => + ( + (row.id, row.price, row.quantity, row.label, row.note).map( + (id, price, quantity, label, note) => ( + id: id, + price: price, + quantity: quantity, + label: label, + note: note, + ), + ), + (row.total, row.labelSize, row.normalizedNote).map( + (total, labelSize, normalizedNote) => ( + total: total, + labelSize: labelSize, + normalizedNote: normalizedNote, + ), + ), + ).map( + (left, right) => ( + id: left.id, + price: left.price, + quantity: left.quantity, + label: left.label, + note: left.note, + total: right.total, + labelSize: right.labelSize, + normalizedNote: right.normalizedNote, + ), + ), +); + +final class LinesTableSet extends TableSet { + LinesTableSet(QueryContext db) : super(db, linesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required int price, + required int quantity, + required String label, + String? note, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.price.set(price), + row.quantity.set(quantity), + row.label.set(label), + row.note.set(note), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension LinesUpdates on Query { + Future patch({ + Change price = const Change.keep(), + Change quantity = const Change.keep(), + Change label = const Change.keep(), + Change note = const Change.keep(), + }) => update( + (row) => [ + ...row.price.change(price), + ...row.quantity.change(quantity), + ...row.label.change(label), + ...row.note.change(note), + ], + ).execute(); +} + +final _bandsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _bandsName = Column( + "name", + Codecs.text, + nullable: false, + generated: false, +); +final bandsSchema = TableSchema( + "bands", + columns: [_bandsId, _bandsName], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class BandsFields extends Fields { + BandsFields(super.table); + late final id = column(_bandsId); + late final name = column(_bandsName); + + /// Read-only navigation; no database foreign key or write effects. + Relation get lines => + Relation(linesTable, parent: [id], child: (row) => [row.total]); +} + +final bandsTable = Table( + bandsSchema, + BandsFields.new, + (row) => (row.id, row.name).map((id, name) => (id: id, name: name)), +); + +final class BandsTableSet extends TableSet { + BandsTableSet(QueryContext db) : super(db, bandsTable) { + db.registerSchema(appSchema); + } + Future create({required int id, required String name}) => + createRow((row) => [row.id.set(id), row.name.set(name)]); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension BandsUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change name = const Change.keep(), + }) => + update((row) => [...row.id.change(id), ...row.name.change(name)]) + .execute(); +} + +final appSchema = List.unmodifiable([linesSchema, bandsSchema]); + +extension AppTables on QueryContext { + LinesTableSet get lines => LinesTableSet(this); + BandsTableSet get bands => BandsTableSet(this); +} diff --git a/test/support/computed/schema.snapshot.dart b/test/support/computed/schema.snapshot.dart new file mode 100644 index 00000000..d5a4d74c --- /dev/null +++ b/test/support/computed/schema.snapshot.dart @@ -0,0 +1,58 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "lines", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("price", Codecs.integer), + Column("quantity", Codecs.integer), + Column("label", Codecs.text), + Column("note", Codecs.text.nullable(), nullable: true), + Column( + "total", + Codecs.integer, + + computed: ComputedColumn( + "price * quantity", + storage: ComputedStorage.stored, + ), + ), + Column( + "label_size", + Codecs.integer, + + computed: ComputedColumn.forDialects( + sqlite: "length(label)", + postgres: "char_length(label)", + mysql: "length(label)", + mariadb: "length(label)", + storage: ComputedStorage.virtual, + ), + ), + Column( + "normalized_note", + Codecs.text.nullable(), + nullable: true, + + computed: ComputedColumn( + "upper(note)", + storage: ComputedStorage.stored, + ), + ), + ], + primaryKey: ["id"], + + indexes: [ + IndexSchema("by_total", ["total"], unique: false), + ], + + checks: [CheckSchema("nonnegative", "price >= 0 AND quantity >= 0")], + ), + TableSchema( + "bands", + columns: [Column("id", Codecs.integer), Column("name", Codecs.text)], + primaryKey: ["id"], + ), +]); diff --git a/test/support/decimals/native.dart b/test/support/decimals/native.dart new file mode 100644 index 00000000..eb5effda --- /dev/null +++ b/test/support/decimals/native.dart @@ -0,0 +1,88 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.orm.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await Migrator( + db.sql, + ).apply([Migration.create('0001_decimal', appSchema, dialect: db.dialect)]); + final exact = Decimal.parse('9007199254740993.1234567890123456789'); + await db.entries.create(amount: exact, bucket: 'a'); + await db.entries.create( + amount: Decimal.parse('.0000000000000000001'), + bucket: 'a', + ); + if (await db.entries.select((e) => e.amount.sum()).single() != + Decimal.parse('9007199254740993.123456789012345679')) { + throw StateError('Exact sum lost precision'); + } + final sorted = await db.entries.orderBy((e) => [e.amount.asc()]).get(); + if (sorted.last.amount != exact) throw StateError('Decimal order differs'); + await db.rates.create(id: Decimal.parse('2'), label: 'two'); + await db.execute( + SqlCommand("INSERT INTO allocations (rate_id) VALUES ('2.000')"), + ); + final children = await db.rates + .select((r) => r.allocations.select((a) => a.rateId).many()) + .single(); + if (children.single != Decimal.parse('2')) { + throw StateError('Decimal relation keys differ'); + } + final window = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => + e.amount.sum().over(orderBy: [e.id.asc()], frame: .rowsToCurrent), + ) + .get(); + if (window.last != Decimal.parse('9007199254740993.123456789012345679')) { + throw StateError('Window sum differs'); + } + final halves = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.amount + .sum() + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) + .divide(Decimal.parse('2'), scale: 20, rounding: .halfEven), + ) + .get(); + if (halves.last != Decimal.parse('4503599627370496.5617283945061728395')) { + throw StateError('Exact window division differs'); + } + final rounded = await db.entries + .orderBy((e) => [e.id.asc()]) + .select((e) => e.amount.rounded(2, rounding: .halfEven)) + .get(); + if (rounded.first != Decimal.parse('9007199254740993.12')) { + throw StateError('Exact SQL rounding differs'); + } + final average = await db.entries + .select((e) => e.amount.average(scale: 20, rounding: .halfEven)) + .single(); + if (average != halves.last) throw StateError('Exact SQL average differs'); + final runningMean = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.amount + .average(scale: 20, rounding: .halfEven) + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent), + ) + .get(); + if (runningMean.first != exact || runningMean.last != average) { + throw StateError('Exact window average differs'); + } + final verification = await verifySchema(db.sql, SchemaSnapshot(appSchema)); + if (!verification.matches || verification.unmanaged.isNotEmpty) { + throw StateError('Decimal catalog differs'); + } + print( + 'Native AOT: exact decimals, SQL division/rounding/averages, numeric ordering, aggregate/window functions, canonical relation keys and catalog verification passed.', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/decimals/schema.dart b/test/support/decimals/schema.dart new file mode 100644 index 00000000..144b0e05 --- /dev/null +++ b/test/support/decimals/schema.dart @@ -0,0 +1,17 @@ +import 'package:orm/schema.dart'; + +typedef Entry = ({ + @Id.generated() int id, + Decimal amount, + Decimal? fee, + @Default.sql("'0.10'") Decimal tax, + String bucket, +}); +typedef Rate = ({@Id() Decimal id, String label}); +typedef Allocation = ({@Id.generated() int id, Decimal rateId}); +final entries = entity(); +final rates = entity(); +final allocations = entity(); +final rate = allocations + .key((a) => a.rateId) + .references(rates.key((r) => r.id), inverse: 'allocations'); diff --git a/test/support/decimals/schema.orm.dart b/test/support/decimals/schema.orm.dart new file mode 100644 index 00000000..d1d26e37 --- /dev/null +++ b/test/support/decimals/schema.orm.dart @@ -0,0 +1,230 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Entry, Rate, Allocation; + +final _entriesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _entriesAmount = Column( + "amount", + Codecs.decimal, + nullable: false, + generated: false, +); +final _entriesFee = Column( + "fee", + Codecs.decimal.nullable(), + nullable: true, + generated: false, +); +final _entriesTax = Column( + "tax", + Codecs.decimal, + nullable: false, + generated: false, + defaultSql: "'0.10'", +); +final _entriesBucket = Column( + "bucket", + Codecs.text, + nullable: false, + generated: false, +); +final entriesSchema = TableSchema( + "entries", + columns: [ + _entriesId, + _entriesAmount, + _entriesFee, + _entriesTax, + _entriesBucket, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class EntriesFields extends Fields { + EntriesFields(super.table); + late final id = column(_entriesId); + late final amount = column(_entriesAmount); + late final fee = column(_entriesFee); + late final tax = column(_entriesTax); + late final bucket = column(_entriesBucket); +} + +final entriesTable = Table( + entriesSchema, + EntriesFields.new, + (row) => (row.id, row.amount, row.fee, row.tax, row.bucket).map( + (id, amount, fee, tax, bucket) => + (id: id, amount: amount, fee: fee, tax: tax, bucket: bucket), + ), +); + +final class EntriesTableSet extends TableSet { + EntriesTableSet(QueryContext db) : super(db, entriesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required Decimal amount, + Decimal? fee, + Change tax = const Change.keep(), + required String bucket, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.amount.set(amount), + row.fee.set(fee), + ...row.tax.change(tax), + row.bucket.set(bucket), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension EntriesUpdates on Query { + Future patch({ + Change amount = const Change.keep(), + Change fee = const Change.keep(), + Change tax = const Change.keep(), + Change bucket = const Change.keep(), + }) => update( + (row) => [ + ...row.amount.change(amount), + ...row.fee.change(fee), + ...row.tax.change(tax), + ...row.bucket.change(bucket), + ], + ).execute(); +} + +final _ratesId = Column( + "id", + Codecs.decimal, + nullable: false, + generated: false, +); +final _ratesLabel = Column( + "label", + Codecs.text, + nullable: false, + generated: false, +); +final ratesSchema = TableSchema( + "rates", + columns: [_ratesId, _ratesLabel], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class RatesFields extends Fields { + RatesFields(super.table); + late final id = column(_ratesId); + late final label = column(_ratesLabel); + Relation get allocations => + Relation(allocationsTable, parent: [id], child: (row) => [row.rateId]); +} + +final ratesTable = Table( + ratesSchema, + RatesFields.new, + (row) => (row.id, row.label).map((id, label) => (id: id, label: label)), +); + +final class RatesTableSet extends TableSet { + RatesTableSet(QueryContext db) : super(db, ratesTable) { + db.registerSchema(appSchema); + } + Future create({required Decimal id, required String label}) => + createRow((row) => [row.id.set(id), row.label.set(label)]); + Query byId(Decimal id) => + where((row) => row.id.eq(id)); +} + +extension RatesUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change label = const Change.keep(), + }) => + update((row) => [...row.id.change(id), ...row.label.change(label)]) + .execute(); +} + +final _allocationsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _allocationsRateId = Column( + "rate_id", + Codecs.decimal, + nullable: false, + generated: false, +); +final allocationsSchema = TableSchema( + "allocations", + columns: [_allocationsId, _allocationsRateId], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["rate_id"], "rates", ["id"], onDelete: "RESTRICT"), + ], +); + +final class AllocationsFields extends Fields { + AllocationsFields(super.table); + late final id = column(_allocationsId); + late final rateId = column(_allocationsRateId); + Relation get rate => + Relation(ratesTable, parent: [rateId], child: (row) => [row.id]); +} + +final allocationsTable = Table( + allocationsSchema, + AllocationsFields.new, + (row) => (row.id, row.rateId).map((id, rateId) => (id: id, rateId: rateId)), +); + +final class AllocationsTableSet + extends TableSet { + AllocationsTableSet(QueryContext db) : super(db, allocationsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required Decimal rateId, + }) => createRow((row) => [...row.id.change(id), row.rateId.set(rateId)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension AllocationsUpdates on Query { + Future patch({Change rateId = const Change.keep()}) => + update((row) => [...row.rateId.change(rateId)]).execute(); +} + +final appSchema = List.unmodifiable([ + entriesSchema, + ratesSchema, + allocationsSchema, +]); + +extension AppTables on QueryContext { + EntriesTableSet get entries => EntriesTableSet(this); + RatesTableSet get rates => RatesTableSet(this); + AllocationsTableSet get allocations => AllocationsTableSet(this); +} diff --git a/test/support/decimals/schema.snapshot.dart b/test/support/decimals/schema.snapshot.dart new file mode 100644 index 00000000..f93a5854 --- /dev/null +++ b/test/support/decimals/schema.snapshot.dart @@ -0,0 +1,33 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "entries", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("amount", Codecs.decimal), + Column("fee", Codecs.decimal.nullable(), nullable: true), + Column("tax", Codecs.decimal, defaultSql: "'0.10'"), + Column("bucket", Codecs.text), + ], + primaryKey: ["id"], + ), + TableSchema( + "rates", + columns: [Column("id", Codecs.decimal), Column("label", Codecs.text)], + primaryKey: ["id"], + ), + TableSchema( + "allocations", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("rate_id", Codecs.decimal), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["rate_id"], "rates", ["id"], onDelete: "RESTRICT"), + ], + ), +]); diff --git a/test/support/defaults/schema.dart b/test/support/defaults/schema.dart new file mode 100644 index 00000000..b93e6023 --- /dev/null +++ b/test/support/defaults/schema.dart @@ -0,0 +1,18 @@ +import 'package:orm/schema.dart'; + +import 'types.dart' as d; + +typedef Ticket = ({ + @Id() @UseCodec(d.TicketId.codec) @ClientDefault(d.nextId) d.TicketId id, + @ClientDefault(d.nameFactory) String name, + @ClientDefault(d.empty) String? label, + @Default.sql("'server'") @ClientDefault(d.Defaults.state) String state, + @ClientDefault(DateTime.now) DateTime createdAt, +}); + +final tickets = entity(); + +typedef SequenceRow = ({ + @Id.generated() @ClientDefault(d.clientIdentity) int id, +}); +final sequences = entity(); diff --git a/test/support/defaults/schema.orm.dart b/test/support/defaults/schema.orm.dart new file mode 100644 index 00000000..173df0e8 --- /dev/null +++ b/test/support/defaults/schema.orm.dart @@ -0,0 +1,165 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Ticket, SequenceRow; +import "types.dart" as types0; + +final _ticketsId = Column( + "id", + types0.TicketId.codec, + nullable: false, + generated: false, + clientDefault: types0.nextId, +); +final _ticketsName = Column( + "name", + Codecs.text, + nullable: false, + generated: false, + clientDefault: types0.nameFactory, +); +final _ticketsLabel = Column( + "label", + Codecs.text.nullable(), + nullable: true, + generated: false, + clientDefault: types0.empty, +); +final _ticketsState = Column( + "state", + Codecs.text, + nullable: false, + generated: false, + defaultSql: "'server'", + clientDefault: types0.Defaults.state, +); +final _ticketsCreatedAt = Column( + "created_at", + Codecs.dateTime, + nullable: false, + generated: false, + clientDefault: DateTime.now, +); +final ticketsSchema = TableSchema( + "tickets", + columns: [ + _ticketsId, + _ticketsName, + _ticketsLabel, + _ticketsState, + _ticketsCreatedAt, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class TicketsFields extends Fields { + TicketsFields(super.table); + late final id = column(_ticketsId); + late final name = column(_ticketsName); + late final label = column(_ticketsLabel); + late final state = column(_ticketsState); + late final createdAt = column(_ticketsCreatedAt); +} + +final ticketsTable = Table( + ticketsSchema, + TicketsFields.new, + (row) => (row.id, row.name, row.label, row.state, row.createdAt).map( + (id, name, label, state, createdAt) => + (id: id, name: name, label: label, state: state, createdAt: createdAt), + ), +); + +final class TicketsTableSet extends TableSet { + TicketsTableSet(QueryContext db) : super(db, ticketsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + Change name = const Change.keep(), + Change label = const Change.keep(), + Change state = const Change.keep(), + Change createdAt = const Change.keep(), + }) => createRow( + (row) => [ + ...row.id.change(id), + ...row.name.change(name), + ...row.label.change(label), + ...row.state.change(state), + ...row.createdAt.change(createdAt), + ], + ); + Query byId(types0.TicketId id) => + where((row) => row.id.eq(id)); +} + +extension TicketsUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change name = const Change.keep(), + Change label = const Change.keep(), + Change state = const Change.keep(), + Change createdAt = const Change.keep(), + }) => update( + (row) => [ + ...row.id.change(id), + ...row.name.change(name), + ...row.label.change(label), + ...row.state.change(state), + ...row.createdAt.change(createdAt), + ], + ).execute(); +} + +final _sequencesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, + clientDefault: types0.clientIdentity, +); +final sequencesSchema = TableSchema( + "sequences", + columns: [_sequencesId], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class SequencesFields extends Fields { + SequencesFields(super.table); + late final id = column(_sequencesId); +} + +final sequencesTable = Table( + sequencesSchema, + SequencesFields.new, + (row) => row.id.map((v) => (id: v)), +); + +final class SequencesTableSet + extends TableSet { + SequencesTableSet(QueryContext db) : super(db, sequencesTable) { + db.registerSchema(appSchema); + } + Future create({Change id = const Change.keep()}) => + createRow((row) => [...row.id.change(id)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +final appSchema = List.unmodifiable([ + ticketsSchema, + sequencesSchema, +]); + +extension AppTables on QueryContext { + TicketsTableSet get tickets => TicketsTableSet(this); + SequencesTableSet get sequences => SequencesTableSet(this); +} diff --git a/test/support/defaults/schema.snapshot.dart b/test/support/defaults/schema.snapshot.dart new file mode 100644 index 00000000..2120da5a --- /dev/null +++ b/test/support/defaults/schema.snapshot.dart @@ -0,0 +1,21 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "tickets", + columns: [ + Column("id", Codecs.integer), + Column("name", Codecs.text), + Column("label", Codecs.text.nullable(), nullable: true), + Column("state", Codecs.text, defaultSql: "'server'"), + Column("created_at", Codecs.dateTime), + ], + primaryKey: ["id"], + ), + TableSchema( + "sequences", + columns: [Column("id", Codecs.integer, generated: true)], + primaryKey: ["id"], + ), +]); diff --git a/test/support/defaults/types.dart b/test/support/defaults/types.dart new file mode 100644 index 00000000..2e487bf8 --- /dev/null +++ b/test/support/defaults/types.dart @@ -0,0 +1,37 @@ +import 'package:orm/orm.dart'; + +int idCalls = 0, + nameCalls = 0, + stateCalls = 0, + nullCalls = 0, + identityCalls = 0; +bool failName = false; +void reset() { + idCalls = nameCalls = stateCalls = nullCalls = identityCalls = 0; + failName = false; +} + +extension type const TicketId(int value) { + static const codec = Codec('integer', decode, encode); + static TicketId decode(Object? raw) => TicketId(Codecs.integer.decode(raw)); + static Object? encode(TicketId id) => id.value; +} + +TicketId nextId() => TicketId(++idCalls); +int clientIdentity() => 1000 + ++identityCalls; +String nextName() { + nameCalls++; + if (failName) throw StateError('factory failure'); + return 'ticket-$nameCalls'; +} + +const nameFactory = nextName; + +final class Defaults { + static String state([String prefix = 'client']) => '$prefix-${++stateCalls}'; +} + +T? empty() { + nullCalls++; + return null; +} diff --git a/test/support/instants/m0001_legacy.dart b/test/support/instants/m0001_legacy.dart new file mode 100644 index 00000000..421fe157 --- /dev/null +++ b/test/support/instants/m0001_legacy.dart @@ -0,0 +1,27 @@ +// Review before applying. Applied migrations must remain unchanged. +import 'package:orm/migrate.dart'; + +const migrationChecksum = + "f7c0a7ff8af0bf106e3deabaa36c4816337ae6937350dc14a44edcaab34456ab"; +final migration = Migration.steps( + "0001_legacy", + [ + ExecuteSql( + "CREATE TABLE \"moments\" (\"at\" TEXT NOT NULL, \"label\" TEXT NOT NULL DEFAULT ('pending'), PRIMARY KEY (\"at\"))", + ), + ], + dialect: SqlDialect.sqlite, + + snapshot: _schema, +); + +final _schema = SchemaSnapshot([ + TableSchema( + "moments", + columns: [ + Column("at", Codec('timestamp', (v) => v, (v) => v)), + Column("label", Codecs.text, defaultSql: "'pending'"), + ], + primaryKey: ["at"], + ), +]); diff --git a/test/support/instants/m0001_legacy_postgres.dart b/test/support/instants/m0001_legacy_postgres.dart new file mode 100644 index 00000000..d5fb3148 --- /dev/null +++ b/test/support/instants/m0001_legacy_postgres.dart @@ -0,0 +1,27 @@ +// Review before applying. Applied migrations must remain unchanged. +import 'package:orm/migrate.dart'; + +const migrationChecksum = + "7d8f21cfbd5b8679955009c1df45a46ab5d4dbbfec38b41f19dc5bc0c76864f9"; +final migration = Migration.steps( + "0001_legacy", + [ + ExecuteSql( + "CREATE TABLE \"moments\" (\"at\" TIMESTAMPTZ NOT NULL, \"label\" TEXT NOT NULL DEFAULT ('pending'), PRIMARY KEY (\"at\"))", + ), + ], + dialect: SqlDialect.postgres, + + snapshot: _schema, +); + +final _schema = SchemaSnapshot([ + TableSchema( + "moments", + columns: [ + Column("at", Codec('timestamp', (v) => v, (v) => v)), + Column("label", Codecs.text, defaultSql: "'pending'"), + ], + primaryKey: ["at"], + ), +]); diff --git a/test/support/instants/native.dart b/test/support/instants/native.dart new file mode 100644 index 00000000..a7b5fa2c --- /dev/null +++ b/test/support/instants/native.dart @@ -0,0 +1,57 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.orm.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await Migrator( + db.sql, + ).apply([Migration.create('0001_instant', appSchema, dialect: db.dialect)]); + final expected = DateTime.utc(2024); + if (Codecs.dateTime.decode('2024-01-01 00:00:00') != expected) { + throw StateError('UTC storage used process timezone'); + } + final local = DateTime(2024, 1, 1); + if (Codecs.dateTime.decode(Codecs.dateTime.encode(local)) != + local.toUtc()) { + throw StateError('Local input changed instant'); + } + final first = await db.events.create(at: expected); + final raw = + (await db.execute( + SqlCommand('SELECT created FROM events WHERE id = 1'), + )).rows.single.single + as String; + if (first.created != DateTime.parse('${raw}Z')) { + throw StateError('Default used process timezone'); + } + final next = DateTime.utc(2024, 1, 1, 0, 0, 0, 0, 1); + await db.events.create(at: next); + final ordered = await db.events.orderBy((e) => [e.at.asc()]).get(); + if (ordered.first.at != expected || ordered.last.at != next) { + throw StateError('Microsecond sorting changed'); + } + await db.moments.create(at: expected); + await db.execute( + SqlCommand("INSERT INTO links (at) VALUES ('2024-01-01 08:00+08')"), + ); + if ((await db.moments + .select((m) => m.links.select((l) => l.at).many()) + .single()) + .single != + expected) { + throw StateError('Equivalent instant keys differ'); + } + if ((await verifySchema( + db.sql, + SchemaSnapshot(appSchema), + )).differences.isNotEmpty) { + throw StateError('Instant catalog differs'); + } + print('Instant UTC, precision, ordering, relations and defaults verified.'); + } finally { + await db.close(); + } +} diff --git a/test/support/instants/schema.dart b/test/support/instants/schema.dart new file mode 100644 index 00000000..ad8471f2 --- /dev/null +++ b/test/support/instants/schema.dart @@ -0,0 +1,16 @@ +import 'package:orm/schema.dart'; + +typedef Event = ({ + @Id.generated() int id, + DateTime at, + @Default.sql('CURRENT_TIMESTAMP') DateTime created, + DateTime? optional, +}); +typedef Moment = ({@Id() DateTime at, @Default.sql("'pending'") String label}); +typedef Link = ({@Id.generated() int id, DateTime at}); +final events = entity(); +final moments = entity(); +final links = entity(); +final moment = links + .key((l) => l.at) + .references(moments.key((m) => m.at), inverse: 'links'); diff --git a/test/support/instants/schema.orm.dart b/test/support/instants/schema.orm.dart new file mode 100644 index 00000000..a2b64c7f --- /dev/null +++ b/test/support/instants/schema.orm.dart @@ -0,0 +1,214 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Event, Moment, Link; + +final _eventsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _eventsAt = Column( + "at", + Codecs.dateTime, + nullable: false, + generated: false, +); +final _eventsCreated = Column( + "created", + Codecs.dateTime, + nullable: false, + generated: false, + defaultSql: "CURRENT_TIMESTAMP", +); +final _eventsOptional = Column( + "optional", + Codecs.dateTime.nullable(), + nullable: true, + generated: false, +); +final eventsSchema = TableSchema( + "events", + columns: [_eventsId, _eventsAt, _eventsCreated, _eventsOptional], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class EventsFields extends Fields { + EventsFields(super.table); + late final id = column(_eventsId); + late final at = column(_eventsAt); + late final created = column(_eventsCreated); + late final optional = column(_eventsOptional); +} + +final eventsTable = Table( + eventsSchema, + EventsFields.new, + (row) => (row.id, row.at, row.created, row.optional).map( + (id, at, created, optional) => + (id: id, at: at, created: created, optional: optional), + ), +); + +final class EventsTableSet extends TableSet { + EventsTableSet(QueryContext db) : super(db, eventsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required DateTime at, + Change created = const Change.keep(), + DateTime? optional, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.at.set(at), + ...row.created.change(created), + row.optional.set(optional), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension EventsUpdates on Query { + Future patch({ + Change at = const Change.keep(), + Change created = const Change.keep(), + Change optional = const Change.keep(), + }) => update( + (row) => [ + ...row.at.change(at), + ...row.created.change(created), + ...row.optional.change(optional), + ], + ).execute(); +} + +final _momentsAt = Column( + "at", + Codecs.dateTime, + nullable: false, + generated: false, +); +final _momentsLabel = Column( + "label", + Codecs.text, + nullable: false, + generated: false, + defaultSql: "'pending'", +); +final momentsSchema = TableSchema( + "moments", + columns: [_momentsAt, _momentsLabel], + primaryKey: ["at"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class MomentsFields extends Fields { + MomentsFields(super.table); + late final at = column(_momentsAt); + late final label = column(_momentsLabel); + Relation get links => + Relation(linksTable, parent: [at], child: (row) => [row.at]); +} + +final momentsTable = Table( + momentsSchema, + MomentsFields.new, + (row) => (row.at, row.label).map((at, label) => (at: at, label: label)), +); + +final class MomentsTableSet extends TableSet { + MomentsTableSet(QueryContext db) : super(db, momentsTable) { + db.registerSchema(appSchema); + } + Future create({ + required DateTime at, + Change label = const Change.keep(), + }) => createRow((row) => [row.at.set(at), ...row.label.change(label)]); + Query byId(DateTime at) => + where((row) => row.at.eq(at)); +} + +extension MomentsUpdates on Query { + Future patch({ + Change at = const Change.keep(), + Change label = const Change.keep(), + }) => + update((row) => [...row.at.change(at), ...row.label.change(label)]) + .execute(); +} + +final _linksId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _linksAt = Column( + "at", + Codecs.dateTime, + nullable: false, + generated: false, +); +final linksSchema = TableSchema( + "links", + columns: [_linksId, _linksAt], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["at"], "moments", ["at"], onDelete: "RESTRICT"), + ], +); + +final class LinksFields extends Fields { + LinksFields(super.table); + late final id = column(_linksId); + late final at = column(_linksAt); + Relation get moment => + Relation(momentsTable, parent: [at], child: (row) => [row.at]); +} + +final linksTable = Table( + linksSchema, + LinksFields.new, + (row) => (row.id, row.at).map((id, at) => (id: id, at: at)), +); + +final class LinksTableSet extends TableSet { + LinksTableSet(QueryContext db) : super(db, linksTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required DateTime at, + }) => createRow((row) => [...row.id.change(id), row.at.set(at)]); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension LinksUpdates on Query { + Future patch({Change at = const Change.keep()}) => + update((row) => [...row.at.change(at)]).execute(); +} + +final appSchema = List.unmodifiable([ + eventsSchema, + momentsSchema, + linksSchema, +]); + +extension AppTables on QueryContext { + EventsTableSet get events => EventsTableSet(this); + MomentsTableSet get moments => MomentsTableSet(this); + LinksTableSet get links => LinksTableSet(this); +} diff --git a/test/support/instants/schema.snapshot.dart b/test/support/instants/schema.snapshot.dart new file mode 100644 index 00000000..e6c4eeaf --- /dev/null +++ b/test/support/instants/schema.snapshot.dart @@ -0,0 +1,35 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "events", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("at", Codecs.dateTime), + Column("created", Codecs.dateTime, defaultSql: "CURRENT_TIMESTAMP"), + Column("optional", Codecs.dateTime.nullable(), nullable: true), + ], + primaryKey: ["id"], + ), + TableSchema( + "moments", + columns: [ + Column("at", Codecs.dateTime), + Column("label", Codecs.text, defaultSql: "'pending'"), + ], + primaryKey: ["at"], + ), + TableSchema( + "links", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("at", Codecs.dateTime), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["at"], "moments", ["at"], onDelete: "RESTRICT"), + ], + ), +]); diff --git a/test/support/integers/native.dart b/test/support/integers/native.dart new file mode 100644 index 00000000..7512cd11 --- /dev/null +++ b/test/support/integers/native.dart @@ -0,0 +1,52 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.orm.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await Migrator( + db.sql, + ).apply([Migration.create('0001_initial', appSchema, dialect: db.dialect)]); + final first = await db.samples.create( + small: 32767, + medium: 2147483647, + large: 9007199254740993, + ); + await db.samples.create( + small: 32767, + medium: 2147483647, + large: -9007199254740993, + ); + final sums = await db.samples + .select((s) => (s.small.sum(), s.medium.sum(), s.large.sum()).row) + .single(); + if (sums != (65534, 4294967294, 0)) { + throw StateError('Integer sums were narrowed'); + } + await db.owners.create(id: 1, sampleId: first.id); + if (await db.owners + .select((o) => o.sample.select((s) => s.small).required()) + .single() != + 32767) { + throw StateError('Relation lost integer width'); + } + var rejected = false; + try { + await db.samples.create(small: 32768, medium: 0, large: 0); + } on SqlFailure { + rejected = true; + } + if (!rejected) throw StateError('Narrow integer accepted overflow'); + final verification = await verifySchema(db.sql, SchemaSnapshot(appSchema)); + if (!verification.matches) { + throw StateError(verification.differences.join('\n')); + } + print( + 'Native AOT: signed integer widths, wider aggregate results, relations, overflow rejection and catalog verification passed.', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/integers/schema.dart b/test/support/integers/schema.dart new file mode 100644 index 00000000..69455f21 --- /dev/null +++ b/test/support/integers/schema.dart @@ -0,0 +1,17 @@ +import 'package:orm/schema.dart'; + +typedef Sample = ({ + @Id.generated() @IntegerBits(32) int id, + @IntegerBits(16) int small, + @IntegerBits(32) int medium, + int large, + @IntegerBits(16) int? optional, +}); + +typedef Owner = ({@Id() int id, @IntegerBits(32) int sampleId}); + +final samples = entity(); +final owners = entity(); +final sample = owners + .key((o) => o.sampleId) + .references(samples.key((s) => s.id), inverse: 'owners'); diff --git a/test/support/integers/schema.orm.dart b/test/support/integers/schema.orm.dart new file mode 100644 index 00000000..db7f0df1 --- /dev/null +++ b/test/support/integers/schema.orm.dart @@ -0,0 +1,186 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Sample, Owner; + +final _samplesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, + integerBits: 32, +); +final _samplesSmall = Column( + "small", + Codecs.integer, + nullable: false, + generated: false, + integerBits: 16, +); +final _samplesMedium = Column( + "medium", + Codecs.integer, + nullable: false, + generated: false, + integerBits: 32, +); +final _samplesLarge = Column( + "large", + Codecs.integer, + nullable: false, + generated: false, +); +final _samplesOptional = Column( + "optional", + Codecs.integer.nullable(), + nullable: true, + generated: false, + integerBits: 16, +); +final samplesSchema = TableSchema( + "samples", + columns: [ + _samplesId, + _samplesSmall, + _samplesMedium, + _samplesLarge, + _samplesOptional, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class SamplesFields extends Fields { + SamplesFields(super.table); + late final id = column(_samplesId); + late final small = column(_samplesSmall); + late final medium = column(_samplesMedium); + late final large = column(_samplesLarge); + late final optional = column(_samplesOptional); + Relation get owners => + Relation(ownersTable, parent: [id], child: (row) => [row.sampleId]); +} + +final samplesTable = Table( + samplesSchema, + SamplesFields.new, + (row) => (row.id, row.small, row.medium, row.large, row.optional).map( + (id, small, medium, large, optional) => ( + id: id, + small: small, + medium: medium, + large: large, + optional: optional, + ), + ), +); + +final class SamplesTableSet extends TableSet { + SamplesTableSet(QueryContext db) : super(db, samplesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required int small, + required int medium, + required int large, + int? optional, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.small.set(small), + row.medium.set(medium), + row.large.set(large), + row.optional.set(optional), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension SamplesUpdates on Query { + Future patch({ + Change small = const Change.keep(), + Change medium = const Change.keep(), + Change large = const Change.keep(), + Change optional = const Change.keep(), + }) => update( + (row) => [ + ...row.small.change(small), + ...row.medium.change(medium), + ...row.large.change(large), + ...row.optional.change(optional), + ], + ).execute(); +} + +final _ownersId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _ownersSampleId = Column( + "sample_id", + Codecs.integer, + nullable: false, + generated: false, + integerBits: 32, +); +final ownersSchema = TableSchema( + "owners", + columns: [_ownersId, _ownersSampleId], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["sample_id"], "samples", ["id"], onDelete: "RESTRICT"), + ], +); + +final class OwnersFields extends Fields { + OwnersFields(super.table); + late final id = column(_ownersId); + late final sampleId = column(_ownersSampleId); + Relation get sample => + Relation(samplesTable, parent: [sampleId], child: (row) => [row.id]); +} + +final ownersTable = Table( + ownersSchema, + OwnersFields.new, + (row) => ( + row.id, + row.sampleId, + ).map((id, sampleId) => (id: id, sampleId: sampleId)), +); + +final class OwnersTableSet extends TableSet { + OwnersTableSet(QueryContext db) : super(db, ownersTable) { + db.registerSchema(appSchema); + } + Future create({required int id, required int sampleId}) => + createRow((row) => [row.id.set(id), row.sampleId.set(sampleId)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension OwnersUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change sampleId = const Change.keep(), + }) => + update((row) => [...row.id.change(id), ...row.sampleId.change(sampleId)]) + .execute(); +} + +final appSchema = List.unmodifiable([samplesSchema, ownersSchema]); + +extension AppTables on QueryContext { + SamplesTableSet get samples => SamplesTableSet(this); + OwnersTableSet get owners => OwnersTableSet(this); +} diff --git a/test/support/integers/schema.snapshot.dart b/test/support/integers/schema.snapshot.dart new file mode 100644 index 00000000..2328a75d --- /dev/null +++ b/test/support/integers/schema.snapshot.dart @@ -0,0 +1,34 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "samples", + columns: [ + Column("id", Codecs.integer, generated: true, integerBits: 32), + Column("small", Codecs.integer, integerBits: 16), + Column("medium", Codecs.integer, integerBits: 32), + Column("large", Codecs.integer), + Column( + "optional", + Codecs.integer.nullable(), + nullable: true, + + integerBits: 16, + ), + ], + primaryKey: ["id"], + ), + TableSchema( + "owners", + columns: [ + Column("id", Codecs.integer), + Column("sample_id", Codecs.integer, integerBits: 32), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["sample_id"], "samples", ["id"], onDelete: "RESTRICT"), + ], + ), +]); diff --git a/test/support/migration_crash.dart b/test/support/migration_crash.dart new file mode 100644 index 00000000..7ad9d765 --- /dev/null +++ b/test/support/migration_crash.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; + +// Real process termination at a successful SQL/commit boundary. The parent test +// verifies database state and resumes using the unchanged migration files. +Future crashMigration( + List migrations, + List args, +) async { + var observed = false; + final db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: args[0], + maxConnections: 1, + ), + onQuery: (event) { + if (event.error != null) return; + if (event.sql == args[1]) observed = true; + if (observed && (args[2] == 'statement' || event.sql == 'COMMIT')) { + exit(91); + } + }, + ); + try { + await Migrator(db.sql).apply(migrations); + } finally { + await db.close(); + } + exitCode = 92; // The requested failure boundary was not reached. +} diff --git a/test/support/migration_project.dart b/test/support/migration_project.dart new file mode 100644 index 00000000..fa62dc61 --- /dev/null +++ b/test/support/migration_project.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:test/test.dart'; + +import '../../tool/src/build_fixture.dart'; + +/// A real consumer entrypoint. It compiles Dart history instead of loading files +/// through an in-process test substitute for the public command workflow. +final class MigrationProject { + final BuildFixture fixture; + final SqlDialect dialect; + MigrationProject._(this.fixture, this.dialect); + String get path => fixture.directory.path; + String get databasePath => '$path/database.sqlite'; + String get migrationDirectory => '$path/lib/migrations'; + + static Future create({ + String? postgresSchema, + String? sqlitePath, + }) async { + final fixture = await BuildFixture.create(ormPath: Directory.current.path); + final project = MigrationProject._( + fixture, + postgresSchema == null ? SqlDialect.sqlite : SqlDialect.postgres, + ); + try { + await writeMigrationRegistry( + project.migrationDirectory, + dialect: project.dialect, + ); + await fixture.write('bin/migrate.dart', ''' +${postgresSchema == null ? '' : "import 'dart:io';"} +import 'package:orm/migrate_cli.dart'; +import 'package:orm/${postgresSchema == null ? 'sqlite' : 'postgres'}.dart'; +import '../lib/target.dart'; +import '../lib/migrations/migrations.g.dart'; +Future main(List args) => runMigrationCli(args, + directory: 'lib/migrations', history: migrationHistory, schema: schema, + connect: ({required readOnly}) async => (await ${postgresSchema == null ? "sqlite(readOnly ? SqliteOptions.readOnly(${jsonEncode(sqlitePath ?? 'database.sqlite')}) : SqliteOptions.file(${jsonEncode(sqlitePath ?? 'database.sqlite')}))" : "postgres(PostgresOptions(url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), tls: .disable, schema: ${jsonEncode(postgresSchema)}, maxConnections: 1))"}).sql, +); +'''); + return project; + } catch (_) { + await fixture.dispose(); + rethrow; + } + } + + Future target(SchemaSnapshot schema) => + fixture.write('lib/target.dart', schemaSource(schema)); + + Future append(Migration migration) async { + await fixture.write( + 'lib/migrations/m${migration.id}.dart', + migrationSource(migration), + ); + await writeMigrationRegistry(migrationDirectory); + } + + Future> run(List args, {int code = 0}) async { + final result = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/migrate.dart', + ...args, + ], workingDirectory: path); + expect( + result.exitCode, + code, + reason: '${args.join(' ')}\n${result.stdout}\n${result.stderr}', + ); + return code == 0 || code == 2 + ? jsonDecode(result.stdout as String) as Map + : {}; + } + + Future dispose() => fixture.dispose(); +} diff --git a/test/support/named_sql/constant.sql b/test/support/named_sql/constant.sql new file mode 100644 index 00000000..cf786d02 --- /dev/null +++ b/test/support/named_sql/constant.sql @@ -0,0 +1 @@ +SELECT 42 AS n diff --git a/test/support/named_sql/echo.sql b/test/support/named_sql/echo.sql new file mode 100644 index 00000000..88c3b844 --- /dev/null +++ b/test/support/named_sql/echo.sql @@ -0,0 +1 @@ +SELECT :value AS value, :at AS at, :amount AS amount, :day AS day diff --git a/test/support/named_sql/native.dart b/test/support/named_sql/native.dart new file mode 100644 index 00000000..9f7d840d --- /dev/null +++ b/test/support/named_sql/native.dart @@ -0,0 +1,50 @@ +import 'package:orm/sqlite.dart'; + +import 'queries.queries.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await db.execute( + SqlCommand('CREATE TABLE posts (author TEXT, points INTEGER)'), + ); + await db.execute( + SqlCommand("INSERT INTO posts VALUES ('a', 1), ('a', 3), ('b', 7)"), + ); + final rows = await db + .authorStats(minimum: 0) + .orderBy((r) => [r.author.asc()]) + .stream() + .toList(); + if (rows.length != 2 || rows.first.points != 4 || rows.last.author != 'b') { + throw StateError('Named SQL stream or parameters differ'); + } + final expected = ( + value: "' :value ; --", + at: DateTime.utc(2024, 1, 1, 0, 0, 0, 0, 1), + amount: Decimal.parse('12345678901234567890.00000001'), + day: LocalDate.parse('0001-01-01 BC'), + ); + final actual = await db + .echo( + value: expected.value, + at: expected.at, + amount: expected.amount, + day: expected.day, + ) + .single(); + if (actual != expected) throw StateError('Named SQL codecs differ'); + await db.transaction((tx) async { + await tx.execute(SqlCommand("INSERT INTO posts VALUES ('a', 2)")); + if ((await tx.authorStats(minimum: 0, author: 'a').single()).points != + 6) { + throw StateError('Named SQL escaped its transaction'); + } + }); + print( + 'Named SQL parameters, records, native codecs, streaming and transactions verified.', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/named_sql/queries.dart b/test/support/named_sql/queries.dart new file mode 100644 index 00000000..8846e5df --- /dev/null +++ b/test/support/named_sql/queries.dart @@ -0,0 +1,17 @@ +import 'package:orm/schema.dart'; + +typedef AuthorStats = ({String author, int postCount, int points}); +final authorStats = sqlQuery( + sqlite: 'stats.sqlite.sql', + postgres: 'stats.postgres.sql', +); + +typedef Echo = ({String value, DateTime at, Decimal amount, LocalDate day}); +final echo = sqlQuery(sqlite: 'echo.sql', postgres: 'echo.sql'); + +typedef Constant = ({int n}); +final constant = sqlQuery( + sqlite: 'constant.sql', + postgres: 'constant.sql', +); +final postgresOnly = sqlQuery(postgres: 'constant.sql'); diff --git a/test/support/named_sql/queries.queries.dart b/test/support/named_sql/queries.queries.dart new file mode 100644 index 00000000..db8563cf --- /dev/null +++ b/test/support/named_sql/queries.queries.dart @@ -0,0 +1,198 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "queries.dart" as models; +export "queries.dart" show AuthorStats, Echo, Constant; + +Expr _bindSqlParameter(T input, Codec codec) => value(input, codec); +final _sqlColumnAuthorStats_0 = Column( + "author", + Codecs.text, + nullable: false, +); +final _sqlColumnAuthorStats_1 = Column( + "post_count", + Codecs.integer, + nullable: false, +); +final _sqlColumnAuthorStats_2 = Column( + "points", + Codecs.integer, + nullable: false, +); + +final class AuthorStatsFields extends Fields { + AuthorStatsFields(super.table); + late final author = column(_sqlColumnAuthorStats_0); + late final postCount = column(_sqlColumnAuthorStats_1); + late final points = column(_sqlColumnAuthorStats_2); +} + +final _sqlDefinitionAuthorStats = + SqlQueryDefinition( + Table( + TableSchema( + "_orm_sql_author_stats", + columns: [ + _sqlColumnAuthorStats_0, + _sqlColumnAuthorStats_1, + _sqlColumnAuthorStats_2, + ], + ), + AuthorStatsFields.new, + (row) => (row.author, row.postCount, row.points).map( + (author, postCount, points) => + (author: author, postCount: postCount, points: points), + ), + ), + { + SqlDialect.sqlite: SqlTemplate( + "-- :not_a_parameter; fixed query with explicit result aliases\nSELECT author, COUNT(*) AS post_count, SUM(points) AS points\nFROM posts\nWHERE points >= :minimum AND (:author IS NULL OR author = :author)\nGROUP BY author;\n", + dialect: SqlDialect.sqlite, + ), + SqlDialect.postgres: SqlTemplate( + "-- :not_a_parameter; fixed query with explicit result aliases\nSELECT author, COUNT(*) AS post_count, SUM(points)::bigint AS points\nFROM posts\nWHERE points >= :minimum AND (:author IS NULL OR author = :author)\nGROUP BY author;\n", + dialect: SqlDialect.postgres, + ), + }, + ); + +extension AuthorStatsSql on QueryContext { + Query authorStats({ + required int minimum, + String? author, + }) => _sqlDefinitionAuthorStats.bind(this, { + "minimum": _bindSqlParameter(minimum, Codecs.integer), + "author": _bindSqlParameter(author, Codecs.text.nullable()), + }); +} + +final _sqlColumnEcho_0 = Column("value", Codecs.text, nullable: false); +final _sqlColumnEcho_1 = Column( + "at", + Codecs.dateTime, + nullable: false, +); +final _sqlColumnEcho_2 = Column( + "amount", + Codecs.decimal, + nullable: false, +); +final _sqlColumnEcho_3 = Column("day", Codecs.date, nullable: false); + +final class EchoFields extends Fields { + EchoFields(super.table); + late final value = column(_sqlColumnEcho_0); + late final at = column(_sqlColumnEcho_1); + late final amount = column(_sqlColumnEcho_2); + late final day = column(_sqlColumnEcho_3); +} + +final _sqlDefinitionEcho = SqlQueryDefinition( + Table( + TableSchema( + "_orm_sql_echo", + columns: [ + _sqlColumnEcho_0, + _sqlColumnEcho_1, + _sqlColumnEcho_2, + _sqlColumnEcho_3, + ], + ), + EchoFields.new, + (row) => (row.value, row.at, row.amount, row.day).map( + (value, at, amount, day) => + (value: value, at: at, amount: amount, day: day), + ), + ), + { + SqlDialect.sqlite: SqlTemplate( + "SELECT :value AS value, :at AS at, :amount AS amount, :day AS day\n", + dialect: SqlDialect.sqlite, + ), + SqlDialect.postgres: SqlTemplate( + "SELECT :value AS value, :at AS at, :amount AS amount, :day AS day\n", + dialect: SqlDialect.postgres, + ), + }, +); + +extension EchoSql on QueryContext { + Query echo({ + required String value, + required DateTime at, + required Decimal amount, + required LocalDate day, + }) => _sqlDefinitionEcho.bind(this, { + "value": _bindSqlParameter(value, Codecs.text), + "at": _bindSqlParameter(at, Codecs.dateTime), + "amount": _bindSqlParameter(amount, Codecs.decimal), + "day": _bindSqlParameter(day, Codecs.date), + }); +} + +final _sqlColumnConstant_0 = Column("n", Codecs.integer, nullable: false); + +final class ConstantFields extends Fields { + ConstantFields(super.table); + late final n = column(_sqlColumnConstant_0); +} + +final _sqlDefinitionConstant = + SqlQueryDefinition( + Table( + TableSchema("_orm_sql_constant", columns: [_sqlColumnConstant_0]), + ConstantFields.new, + (row) => row.n.map((v) => (n: v)), + ), + { + SqlDialect.sqlite: SqlTemplate( + "SELECT 42 AS n\n", + dialect: SqlDialect.sqlite, + ), + SqlDialect.postgres: SqlTemplate( + "SELECT 42 AS n\n", + dialect: SqlDialect.postgres, + ), + }, + ); + +extension ConstantSql on QueryContext { + Query constant() => + _sqlDefinitionConstant.bind(this, {}); +} + +final _sqlColumnPostgresOnly_0 = Column( + "n", + Codecs.integer, + nullable: false, +); + +final class PostgresOnlyFields extends Fields { + PostgresOnlyFields(super.table); + late final n = column(_sqlColumnPostgresOnly_0); +} + +final _sqlDefinitionPostgresOnly = + SqlQueryDefinition( + Table( + TableSchema( + "_orm_sql_postgres_only", + columns: [_sqlColumnPostgresOnly_0], + ), + PostgresOnlyFields.new, + (row) => row.n.map((v) => (n: v)), + ), + { + SqlDialect.postgres: SqlTemplate( + "SELECT 42 AS n\n", + dialect: SqlDialect.postgres, + ), + }, + ); + +extension PostgresOnlySql on QueryContext { + Query postgresOnly() => + _sqlDefinitionPostgresOnly.bind(this, {}); +} diff --git a/test/support/named_sql/stats.postgres.sql b/test/support/named_sql/stats.postgres.sql new file mode 100644 index 00000000..6c006c70 --- /dev/null +++ b/test/support/named_sql/stats.postgres.sql @@ -0,0 +1,5 @@ +-- :not_a_parameter; fixed query with explicit result aliases +SELECT author, COUNT(*) AS post_count, SUM(points)::bigint AS points +FROM posts +WHERE points >= :minimum AND (:author IS NULL OR author = :author) +GROUP BY author; diff --git a/test/support/named_sql/stats.sqlite.sql b/test/support/named_sql/stats.sqlite.sql new file mode 100644 index 00000000..955dcdbc --- /dev/null +++ b/test/support/named_sql/stats.sqlite.sql @@ -0,0 +1,5 @@ +-- :not_a_parameter; fixed query with explicit result aliases +SELECT author, COUNT(*) AS post_count, SUM(points) AS points +FROM posts +WHERE points >= :minimum AND (:author IS NULL OR author = :author) +GROUP BY author; diff --git a/test/support/native_backfill.dart b/test/support/native_backfill.dart new file mode 100644 index 00000000..738895f2 --- /dev/null +++ b/test/support/native_backfill.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'backfill.dart'; +import 'backfill_history/migrations.g.dart' as historical; + +final initial = initialFor(SqlDialect.sqlite); + +Future main() async { + final directory = await Directory.systemTemp.createTemp( + 'orm-native-backfill-', + ); + final options = SqliteOptions.file('${directory.path}/db.sqlite'); + final db = await sqlite(options), other = await sqlite(options); + try { + await seed(db, count: 25); + final migration = historical.migrationHistory.checked.last; + final runner = Migrator(db.sql); + await runner.apply([initial, migration], maxBackfillBatches: 1); + if ((await runner.progress()).single.backfill!.rows != 3) { + throw StateError('Backfill pause failed'); + } + final results = await Future.wait([ + runner.apply([initial, migration]), + Migrator(other.sql).apply([initial, migration]), + ]); + if (results.expand((r) => r).length != 1) { + throw StateError('Concurrent history recording failed'); + } + final rows = await db.execute(SqlCommand('SELECT touches FROM payload')); + if (rows.rows.length != 25 || rows.rows.any((r) => r.single != 1)) { + throw StateError('Backfill replayed or skipped data'); + } + await runner.requireVersion([initial, migration]); + print( + 'Native AOT: historical backfill declaration, bounded pause, concurrent resume and startup compatibility passed.', + ); + } finally { + await other.close(); + await db.close(); + await directory.delete(recursive: true); + } +} diff --git a/test/support/native_execution.dart b/test/support/native_execution.dart new file mode 100644 index 00000000..4320bfcd --- /dev/null +++ b/test/support/native_execution.dart @@ -0,0 +1,197 @@ +import 'dart:async'; + +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import '../../example/schema.orm.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + final initial = Migration.create( + '0001_initial', + appSchema, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([initial]); + if ((await Migrator(db.sql).requireVersion([initial])).checksum != + initial.checksum) { + throw StateError('Schema version compatibility failed'); + } + final pending = Migration( + '0002_pending', + ['SELECT 1'], + previous: initial.checksum, + dialect: SqlDialect.sqlite, + ); + try { + await Migrator(db.sql).requireVersion([initial, pending]); + throw StateError('Schema version requirement was ignored'); + } on OrmException catch (error) { + if (error.code != 'MIGRATION.VERSION') rethrow; + } + for (var i = 0; i < 5; i++) { + await db.users.create(email: 'aot$i@example.com'); + } + await db.posts.create( + authorId: 1, + title: 'AOT', + createdAt: DateTime.utc(2026), + ); + final author = await db.posts + .select( + (p) => p.author + .select( + (a) => + (a.id, a.nickname).map((id, name) => (id: id, name: name)), + ) + .required(), + ) + .single(); + if (author != (id: 1, name: null)) throw StateError('Joined record failed'); + final nullable = await db.posts + .select((p) => p.author.select((a) => a.nickname).required()) + .single(); + if (nullable != null) { + throw StateError('Nullable required projection failed'); + } + final absent = await db.posts + .select((p) => p.author.where((a) => a.id.eq(99)).one()) + .single(); + if (absent != null) throw StateError('Optional join failed'); + final rows = await db.users + .orderBy((u) => [u.id.asc()]) + .select((u) => u.email) + .stream(batchSize: 2) + .take(3) + .toList(); + if (rows.length != 3 || rows.last != 'aot2@example.com') { + throw StateError('Cursor decoding failed'); + } + final combined = db.users + .select((u) => (u.id, u.email).row) + .unionAll(db.posts.select((p) => (p.id, p.title).row)) + .orderBy( + (u) => [u.ref((u) => u.id).asc(), u.ref((u) => u.email).asc()], + ); + final List<(int, String)> combinedRows = await combined + .stream(batchSize: 2) + .toList(); + if (combinedRows.length != 6 || combinedRows.first != (1, 'AOT')) { + throw StateError('UNION Record decoding failed'); + } + final acquired = Completer(), release = Completer(); + final held = db.session((session) async { + acquired.complete(); + await release.future; + }); + await acquired.future; + try { + await db.users + .insert((u) => [u.email.set('must-not-execute@example.com')]) + .execute( + options: const ExecutionOptions( + acquireTimeout: Duration(milliseconds: 40), + ), + ); + throw StateError('Connection acquisition should time out'); + } on OrmException catch (error) { + if (error.code != 'CONNECTION.TIMEOUT') rethrow; + } finally { + release.complete(); + await held; + } + if (await db.users.count() != 5) throw StateError('Abandoned SQL executed'); + final resume = Completer(); + var deadlineCallback = false; + try { + await db.transaction((tx) async { + deadlineCallback = true; + await tx.users.create(email: 'deadline@example.com'); + await resume.future; + }, timeout: const Duration(milliseconds: 60)); + throw StateError('Transaction deadline failed'); + } on OrmException catch (error) { + if (error.code != + (db.capabilities.cancellation + ? 'TRANSACTION.TIMEOUT' + : 'CAPABILITY.CANCEL')) { + rethrow; + } + } finally { + resume.complete(); + } + if (!db.capabilities.cancellation && deadlineCallback) { + throw StateError('Unsupported transaction deadline entered the callback'); + } + if (await db.users.count() != 5) { + throw StateError('Timed-out transaction persisted'); + } + try { + await db.transaction((tx) async { + await tx.users.create(email: 'rollback@example.com'); + try { + await tx.savepoint( + (child) => child.execute( + SqlCommand( + "INSERT OR ROLLBACK INTO users(email) VALUES ('aot0@example.com')", + ), + ), + ); + } on SqliteFailure { + /* The outer transaction has also ended. */ + } + }); + throw StateError('Automatic rollback went undetected'); + } on OrmException catch (error) { + if (error.code != 'TRANSACTION.FAILED') rethrow; + } + if (await db.users.count() != 5) { + throw StateError('Automatic rollback lost connection state'); + } + final token = CancellationToken(); + final timer = Timer(const Duration(milliseconds: 40), token.cancel); + try { + await db.execute( + SqlCommand( + 'WITH RECURSIVE n(x) AS (' + 'SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1000000000) ' + 'SELECT sum(x) FROM n', + ), + options: ExecutionOptions(cancellation: token), + ); + throw StateError('Cancellation failed'); + } on OrmException catch (error) { + if (error.code != + (db.capabilities.cancellation + ? 'OPERATION.CANCELLED' + : 'CAPABILITY.CANCEL')) { + rethrow; + } + } finally { + timer.cancel(); + } + if (await db.users.count() != 5) { + throw StateError('Connection recovery failed'); + } + final changes = StreamIterator(db.users.select((u) => u.email).watch()); + if (!await changes.moveNext() || changes.current.length != 5) { + throw StateError('Initial watch snapshot failed'); + } + final changed = changes.moveNext(); + await db.transaction((tx) async { + await tx.users.create(email: 'watch1@example.com'); + await tx.users.create(email: 'watch2@example.com'); + }); + if (!await changed || changes.current.length != 7) { + throw StateError('Committed watch snapshot failed'); + } + await changes.cancel(); + print( + 'Native AOT: schema version compatibility, joined projections, typed UNION records, acquisition deadlines, automatic rollback, cursor demand, recovery and committed query watches passed. ' + 'Statement/transaction interruption: ${db.capabilities.cancellation ? 'passed' : 'unavailable; capability rejection verified'}.', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/native_retry.dart b/test/support/native_retry.dart new file mode 100644 index 00000000..c6ba0230 --- /dev/null +++ b/test/support/native_retry.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/sqlite.dart'; + +import 'tables.dart'; + +/// Real competing workers, compiled without the test runner for native AOT. +Future main() async { + final probe = await sqlite(const SqliteOptions.memory()); + try { + if (!probe.capabilities.cancellation) { + var entered = false; + try { + await probe.transaction((tx) async { + entered = true; + }, retry: const TransactionRetry()); + throw StateError('Unsupported bounded retry was accepted'); + } on OrmException catch (error) { + if (error.code != 'CAPABILITY.CANCEL' || entered) rethrow; + } + print( + 'Native AOT: bounded retry unavailable; capability rejection verified.', + ); + return; + } + } finally { + await probe.close(); + } + final directory = await Directory.systemTemp.createTemp('orm-native-retry-'); + try { + for (final journal in [SqliteJournal.wal, SqliteJournal.delete]) { + final options = SqliteOptions.file( + '${directory.path}/${journal.name}.sqlite', + journal: journal, + busyTimeout: const Duration(milliseconds: 20), + ); + final base = await sqlite(options), other = await sqlite(options); + final entered = Completer(), release = Completer(); + Future? reader; + var callbacks = 0, busy = 0; + final db = Database( + base.driver, + onQuery: (event) { + if (event.error case final SqliteFailure failure) { + if (journal == .wal && failure.extendedCode != 517) { + // onQuery intentionally cannot throw into an SQL operation. + busy = -100; + } else { + busy++; + } + if (journal == .delete && event.sql == 'COMMIT') release.complete(); + } + }, + ); + try { + await createTables(db); + await db.table(users).createRow((u) => [u.email.set('initial')]); + if (journal == .delete) { + reader = other.transaction((tx) async { + await tx.table(users).count(); + entered.complete(); + await release.future; + }); + await entered.future; + } + final reads = []; + await db.transaction((tx) async { + callbacks++; + final row = await tx.table(users).single(); + reads.add(row.score); + if (journal == .wal && callbacks == 1) { + await other.table(users).update((u) => [u.score.set(10)]).execute(); + } + await tx + .table(users) + .update((u) => [u.score.set(row.score + 1)]) + .execute(); + }, retry: const TransactionRetry(delay: Duration(milliseconds: 20))); + final score = (await db.table(users).single()).score; + if (busy != 1 || + (journal == .wal && + (callbacks != 2 || score != 11 || reads.join(',') != '0,10')) || + (journal == .delete && (callbacks != 1 || score != 1))) { + throw StateError( + '$journal: callbacks=$callbacks, busy=$busy, score=$score, reads=$reads', + ); + } + } finally { + if (!release.isCompleted) release.complete(); + await reader; + await other.close(); + await db.close(); + } + } + print( + 'Native AOT: SQLite WAL snapshot replay and COMMIT-only busy retry passed.', + ); + } finally { + await directory.delete(recursive: true); + } +} diff --git a/test/support/nominal/schema.dart b/test/support/nominal/schema.dart new file mode 100644 index 00000000..9fcf134e --- /dev/null +++ b/test/support/nominal/schema.dart @@ -0,0 +1,36 @@ +import 'package:orm/schema.dart'; + +extension type const Email(String value) {} + +const emailCodec = Codec('text', decodeEmail, encodeEmail); +Email decodeEmail(Object? raw) => Email(raw as String); +Object? encodeEmail(Email value) => value.value; +String defaultMarker() => 'seed'; + +final class Account( + @Id.generated() final int id, + @Unique() @UseCodec(emailCodec) final Email email, + @ColumnName('display_name') final String? label, + @Default.sql('false') final bool enabled, + @ClientDefault(defaultMarker) final String marker, + final int a, + final int b, + final int c, + @Computed.sql('a + b') final int total, +); + +final class Note({ + @Id.generated() required final int id, + required final int accountId, + required final String body, +}); + +final accounts = entity(table: 'nominal_accounts'); +final notes = entity(table: 'nominal_notes'); +final account = notes + .key((n) => n.accountId) + .references( + accounts.key((a) => a.id), + inverse: 'notes', + onDelete: .cascade, + ); diff --git a/test/support/nominal/schema.orm.dart b/test/support/nominal/schema.orm.dart new file mode 100644 index 00000000..c6bd30cc --- /dev/null +++ b/test/support/nominal/schema.orm.dart @@ -0,0 +1,273 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Account, Note; + +final _accountsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _accountsEmail = Column( + "email", + models.emailCodec, + nullable: false, + generated: false, +); +final _accountsLabel = Column( + "display_name", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final _accountsEnabled = Column( + "enabled", + Codecs.boolean, + nullable: false, + generated: false, + defaultSql: "false", +); +final _accountsMarker = Column( + "marker", + Codecs.text, + nullable: false, + generated: false, + clientDefault: models.defaultMarker, +); +final _accountsA = Column( + "a", + Codecs.integer, + nullable: false, + generated: false, +); +final _accountsB = Column( + "b", + Codecs.integer, + nullable: false, + generated: false, +); +final _accountsC = Column( + "c", + Codecs.integer, + nullable: false, + generated: false, +); +final _accountsTotal = Column( + "total", + Codecs.integer, + nullable: false, + generated: false, + computed: ComputedColumn.forDialects( + sqlite: "a + b", + postgres: "a + b", + mysql: "a + b", + mariadb: "a + b", + storage: ComputedStorage.stored, + ), +); +final accountsSchema = TableSchema( + "nominal_accounts", + columns: [ + _accountsId, + _accountsEmail, + _accountsLabel, + _accountsEnabled, + _accountsMarker, + _accountsA, + _accountsB, + _accountsC, + _accountsTotal, + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + indexes: [], + foreignKeys: [], +); + +final class AccountsFields extends Fields { + AccountsFields(super.table); + late final id = column(_accountsId); + late final email = column(_accountsEmail); + late final label = column(_accountsLabel); + late final enabled = column(_accountsEnabled); + late final marker = column(_accountsMarker); + late final a = column(_accountsA); + late final b = column(_accountsB); + late final c = column(_accountsC); + late final total = readColumn(_accountsTotal); + Relation get notes => + Relation(notesTable, parent: [id], child: (row) => [row.accountId]); +} + +final accountsTable = Table( + accountsSchema, + AccountsFields.new, + (row) => + ( + (row.id, row.email, row.label, row.enabled, row.marker).map( + (id, email, label, enabled, marker) => ( + id: id, + email: email, + label: label, + enabled: enabled, + marker: marker, + ), + ), + ( + row.a, + row.b, + row.c, + row.total, + ).map((a, b, c, total) => (a: a, b: b, c: c, total: total)), + ).map( + (left, right) => models.Account( + left.id, + left.email, + left.label, + left.enabled, + left.marker, + right.a, + right.b, + right.c, + right.total, + ), + ), +); + +final class AccountsTableSet extends TableSet { + AccountsTableSet(QueryContext db) : super(db, accountsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required models.Email email, + String? label, + Change enabled = const Change.keep(), + Change marker = const Change.keep(), + required int a, + required int b, + required int c, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.email.set(email), + row.label.set(label), + ...row.enabled.change(enabled), + ...row.marker.change(marker), + row.a.set(a), + row.b.set(b), + row.c.set(c), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension AccountsUpdates on Query { + Future patch({ + Change email = const Change.keep(), + Change label = const Change.keep(), + Change enabled = const Change.keep(), + Change marker = const Change.keep(), + Change a = const Change.keep(), + Change b = const Change.keep(), + Change c = const Change.keep(), + }) => update( + (row) => [ + ...row.email.change(email), + ...row.label.change(label), + ...row.enabled.change(enabled), + ...row.marker.change(marker), + ...row.a.change(a), + ...row.b.change(b), + ...row.c.change(c), + ], + ).execute(); +} + +final _notesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _notesAccountId = Column( + "account_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _notesBody = Column( + "body", + Codecs.text, + nullable: false, + generated: false, +); +final notesSchema = TableSchema( + "nominal_notes", + columns: [_notesId, _notesAccountId, _notesBody], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["account_id"], "nominal_accounts", ["id"], onDelete: "CASCADE"), + ], +); + +final class NotesFields extends Fields { + NotesFields(super.table); + late final id = column(_notesId); + late final accountId = column(_notesAccountId); + late final body = column(_notesBody); + Relation get account => + Relation(accountsTable, parent: [accountId], child: (row) => [row.id]); +} + +final notesTable = Table( + notesSchema, + NotesFields.new, + (row) => ( + row.id, + row.accountId, + row.body, + ).map((v0, v1, v2) => models.Note(id: v0, accountId: v1, body: v2)), +); + +final class NotesTableSet extends TableSet { + NotesTableSet(QueryContext db) : super(db, notesTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required int accountId, + required String body, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.accountId.set(accountId), + row.body.set(body), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension NotesUpdates on Query { + Future patch({ + Change accountId = const Change.keep(), + Change body = const Change.keep(), + }) => update( + (row) => [...row.accountId.change(accountId), ...row.body.change(body)], + ).execute(); +} + +final appSchema = List.unmodifiable([accountsSchema, notesSchema]); + +extension AppTables on QueryContext { + AccountsTableSet get accounts => AccountsTableSet(this); + NotesTableSet get notes => NotesTableSet(this); +} diff --git a/test/support/nominal/schema.snapshot.dart b/test/support/nominal/schema.snapshot.dart new file mode 100644 index 00000000..45f25af5 --- /dev/null +++ b/test/support/nominal/schema.snapshot.dart @@ -0,0 +1,46 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "nominal_accounts", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("email", Codecs.text), + Column("display_name", Codecs.text.nullable(), nullable: true), + Column("enabled", Codecs.boolean, defaultSql: "false"), + Column("marker", Codecs.text), + Column("a", Codecs.integer), + Column("b", Codecs.integer), + Column("c", Codecs.integer), + Column( + "total", + Codecs.integer, + + computed: ComputedColumn("a + b", storage: ComputedStorage.stored), + ), + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + ), + TableSchema( + "nominal_notes", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("account_id", Codecs.integer), + Column("body", Codecs.text), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey( + ["account_id"], + "nominal_accounts", + ["id"], + onDelete: "CASCADE", + ), + ], + ), +]); diff --git a/test/support/precision/native.dart b/test/support/precision/native.dart new file mode 100644 index 00000000..e8a123c1 --- /dev/null +++ b/test/support/precision/native.dart @@ -0,0 +1,50 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.orm.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await Migrator(db.sql).apply([ + Migration.create('0001_precision', appSchema, dialect: db.dialect), + ]); + final row = await db.wallets.create( + amount: Decimal.parse('1.235'), + hundreds: Decimal.parse('-12350'), + fraction: Decimal.parse('.001235'), + ); + if (row.amount != Decimal.parse('1.24') || + row.hundreds != Decimal.parse('-12400') || + row.fraction != Decimal.parse('.00124') || + row.defaulted != Decimal.parse('1.24')) { + throw StateError('Decimal column coercion differs'); + } + await db.prices.create(id: Decimal.parse('1.234'), label: 'one'); + await db.receipts.create(priceId: Decimal.parse('1.234')); + if (await db.receipts + .select((r) => r.price.select((p) => p.label).required()) + .single() != + 'one') { + throw StateError('Rounded relation keys differ'); + } + var rejected = false; + try { + await db.prices.create(id: Decimal.parse('99.995'), label: 'overflow'); + } on SqlFailure { + rejected = true; + } + if (!rejected || await db.prices.count() != 1) { + throw StateError('Precision overflow was not rejected'); + } + final check = await verifySchema(db.sql, SchemaSnapshot(appSchema)); + if (!check.matches || check.unmanaged.isNotEmpty) { + throw StateError('Constrained decimal catalog differs'); + } + print( + 'Native AOT: decimal precision/scale, signed coercion, defaults, relation keys, overflow and catalog checks passed.', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/precision/schema.dart b/test/support/precision/schema.dart new file mode 100644 index 00000000..9dc453d9 --- /dev/null +++ b/test/support/precision/schema.dart @@ -0,0 +1,21 @@ +import 'package:orm/schema.dart'; + +typedef Wallet = ({ + @Id.generated() int id, + @DecimalDigits(5, 2) Decimal amount, + @DecimalDigits(3, -2) Decimal hundreds, + @DecimalDigits(3, 5) Decimal fraction, + @DecimalDigits(5, 2) @Default.sql("'1.235'") Decimal defaulted, + @DecimalDigits(5, 2) Decimal? optional, +}); +typedef Price = ({@Id() @DecimalDigits(4, 2) Decimal id, String label}); +typedef Receipt = ({ + @Id.generated() int id, + @DecimalDigits(4, 2) Decimal priceId, +}); +final wallets = entity(); +final prices = entity(); +final receipts = entity(); +final price = receipts + .key((r) => r.priceId) + .references(prices.key((p) => p.id), inverse: 'receipts'); diff --git a/test/support/precision/schema.orm.dart b/test/support/precision/schema.orm.dart new file mode 100644 index 00000000..f79288c7 --- /dev/null +++ b/test/support/precision/schema.orm.dart @@ -0,0 +1,270 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Wallet, Price, Receipt; + +final _walletsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _walletsAmount = Column( + "amount", + Codecs.decimal, + nullable: false, + generated: false, + decimalPrecision: 5, + decimalScale: 2, +); +final _walletsHundreds = Column( + "hundreds", + Codecs.decimal, + nullable: false, + generated: false, + decimalPrecision: 3, + decimalScale: -2, +); +final _walletsFraction = Column( + "fraction", + Codecs.decimal, + nullable: false, + generated: false, + decimalPrecision: 3, + decimalScale: 5, +); +final _walletsDefaulted = Column( + "defaulted", + Codecs.decimal, + nullable: false, + generated: false, + defaultSql: "'1.235'", + decimalPrecision: 5, + decimalScale: 2, +); +final _walletsOptional = Column( + "optional", + Codecs.decimal.nullable(), + nullable: true, + generated: false, + decimalPrecision: 5, + decimalScale: 2, +); +final walletsSchema = TableSchema( + "wallets", + columns: [ + _walletsId, + _walletsAmount, + _walletsHundreds, + _walletsFraction, + _walletsDefaulted, + _walletsOptional, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class WalletsFields extends Fields { + WalletsFields(super.table); + late final id = column(_walletsId); + late final amount = column(_walletsAmount); + late final hundreds = column(_walletsHundreds); + late final fraction = column(_walletsFraction); + late final defaulted = column(_walletsDefaulted); + late final optional = column(_walletsOptional); +} + +final walletsTable = Table( + walletsSchema, + WalletsFields.new, + (row) => + ( + row.id, + row.amount, + row.hundreds, + row.fraction, + row.defaulted, + row.optional, + ).map( + (id, amount, hundreds, fraction, defaulted, optional) => ( + id: id, + amount: amount, + hundreds: hundreds, + fraction: fraction, + defaulted: defaulted, + optional: optional, + ), + ), +); + +final class WalletsTableSet extends TableSet { + WalletsTableSet(QueryContext db) : super(db, walletsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required Decimal amount, + required Decimal hundreds, + required Decimal fraction, + Change defaulted = const Change.keep(), + Decimal? optional, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.amount.set(amount), + row.hundreds.set(hundreds), + row.fraction.set(fraction), + ...row.defaulted.change(defaulted), + row.optional.set(optional), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension WalletsUpdates on Query { + Future patch({ + Change amount = const Change.keep(), + Change hundreds = const Change.keep(), + Change fraction = const Change.keep(), + Change defaulted = const Change.keep(), + Change optional = const Change.keep(), + }) => update( + (row) => [ + ...row.amount.change(amount), + ...row.hundreds.change(hundreds), + ...row.fraction.change(fraction), + ...row.defaulted.change(defaulted), + ...row.optional.change(optional), + ], + ).execute(); +} + +final _pricesId = Column( + "id", + Codecs.decimal, + nullable: false, + generated: false, + decimalPrecision: 4, + decimalScale: 2, +); +final _pricesLabel = Column( + "label", + Codecs.text, + nullable: false, + generated: false, +); +final pricesSchema = TableSchema( + "prices", + columns: [_pricesId, _pricesLabel], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class PricesFields extends Fields { + PricesFields(super.table); + late final id = column(_pricesId); + late final label = column(_pricesLabel); + Relation get receipts => + Relation(receiptsTable, parent: [id], child: (row) => [row.priceId]); +} + +final pricesTable = Table( + pricesSchema, + PricesFields.new, + (row) => (row.id, row.label).map((id, label) => (id: id, label: label)), +); + +final class PricesTableSet extends TableSet { + PricesTableSet(QueryContext db) : super(db, pricesTable) { + db.registerSchema(appSchema); + } + Future create({required Decimal id, required String label}) => + createRow((row) => [row.id.set(id), row.label.set(label)]); + Query byId(Decimal id) => + where((row) => row.id.eq(id)); +} + +extension PricesUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change label = const Change.keep(), + }) => + update((row) => [...row.id.change(id), ...row.label.change(label)]) + .execute(); +} + +final _receiptsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _receiptsPriceId = Column( + "price_id", + Codecs.decimal, + nullable: false, + generated: false, + decimalPrecision: 4, + decimalScale: 2, +); +final receiptsSchema = TableSchema( + "receipts", + columns: [_receiptsId, _receiptsPriceId], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["price_id"], "prices", ["id"], onDelete: "RESTRICT"), + ], +); + +final class ReceiptsFields extends Fields { + ReceiptsFields(super.table); + late final id = column(_receiptsId); + late final priceId = column(_receiptsPriceId); + Relation get price => + Relation(pricesTable, parent: [priceId], child: (row) => [row.id]); +} + +final receiptsTable = Table( + receiptsSchema, + ReceiptsFields.new, + (row) => + (row.id, row.priceId).map((id, priceId) => (id: id, priceId: priceId)), +); + +final class ReceiptsTableSet extends TableSet { + ReceiptsTableSet(QueryContext db) : super(db, receiptsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required Decimal priceId, + }) => createRow((row) => [...row.id.change(id), row.priceId.set(priceId)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension ReceiptsUpdates on Query { + Future patch({Change priceId = const Change.keep()}) => + update((row) => [...row.priceId.change(priceId)]).execute(); +} + +final appSchema = List.unmodifiable([ + walletsSchema, + pricesSchema, + receiptsSchema, +]); + +extension AppTables on QueryContext { + WalletsTableSet get wallets => WalletsTableSet(this); + PricesTableSet get prices => PricesTableSet(this); + ReceiptsTableSet get receipts => ReceiptsTableSet(this); +} diff --git a/test/support/precision/schema.snapshot.dart b/test/support/precision/schema.snapshot.dart new file mode 100644 index 00000000..d5dba5fd --- /dev/null +++ b/test/support/precision/schema.snapshot.dart @@ -0,0 +1,52 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "wallets", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("amount", Codecs.decimal, decimalPrecision: 5, decimalScale: 2), + Column("hundreds", Codecs.decimal, decimalPrecision: 3, decimalScale: -2), + Column("fraction", Codecs.decimal, decimalPrecision: 3, decimalScale: 5), + Column( + "defaulted", + Codecs.decimal, + + defaultSql: "'1.235'", + + decimalPrecision: 5, + decimalScale: 2, + ), + Column( + "optional", + Codecs.decimal.nullable(), + nullable: true, + + decimalPrecision: 5, + decimalScale: 2, + ), + ], + primaryKey: ["id"], + ), + TableSchema( + "prices", + columns: [ + Column("id", Codecs.decimal, decimalPrecision: 4, decimalScale: 2), + Column("label", Codecs.text), + ], + primaryKey: ["id"], + ), + TableSchema( + "receipts", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("price_id", Codecs.decimal, decimalPrecision: 4, decimalScale: 2), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["price_id"], "prices", ["id"], onDelete: "RESTRICT"), + ], + ), +]); diff --git a/test/support/relations/schema.dart b/test/support/relations/schema.dart new file mode 100644 index 00000000..6146c5e5 --- /dev/null +++ b/test/support/relations/schema.dart @@ -0,0 +1,32 @@ +import 'package:orm/schema.dart'; + +typedef Account = ({ + int tenant, + int id, + String? label, + String? note, + int? managerId, + @ColumnName('_ORM_PRESENT') String? marker, +}); + +typedef Event = ({ + @Id() int id, + int? tenant, + int? owner, + int? reviewer, + String title, + int score, +}); + +final accounts = entity(); +final events = entity(); +final accountKey = accounts.primaryKey((a) => (a.tenant, a.id)); +final manager = accounts + .key((a) => (a.tenant, a.managerId)) + .references(accounts.key((a) => (a.tenant, a.id)), inverse: 'reports'); +final author = events + .key((e) => (e.tenant, e.owner)) + .references(accounts.key((a) => (a.tenant, a.id)), inverse: 'events'); +final reviewerAccount = events + .key((e) => (e.tenant, e.reviewer)) + .references(accounts.key((a) => (a.tenant, a.id)), inverse: 'reviews'); diff --git a/test/support/relations/schema.orm.dart b/test/support/relations/schema.orm.dart new file mode 100644 index 00000000..b32fffa9 --- /dev/null +++ b/test/support/relations/schema.orm.dart @@ -0,0 +1,314 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Account, Event; + +final _accountsTenant = Column( + "tenant", + Codecs.integer, + nullable: false, + generated: false, +); +final _accountsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _accountsLabel = Column( + "label", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final _accountsNote = Column( + "note", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final _accountsManagerId = Column( + "manager_id", + Codecs.integer.nullable(), + nullable: true, + generated: false, +); +final _accountsMarker = Column( + "_ORM_PRESENT", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final accountsSchema = TableSchema( + "accounts", + columns: [ + _accountsTenant, + _accountsId, + _accountsLabel, + _accountsNote, + _accountsManagerId, + _accountsMarker, + ], + primaryKey: ["tenant", "id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey( + ["tenant", "manager_id"], + "accounts", + ["tenant", "id"], + onDelete: "RESTRICT", + ), + ], +); + +final class AccountsFields extends Fields { + AccountsFields(super.table); + late final tenant = column(_accountsTenant); + late final id = column(_accountsId); + late final label = column(_accountsLabel); + late final note = column(_accountsNote); + late final managerId = column(_accountsManagerId); + late final marker = column(_accountsMarker); + Relation get manager => Relation( + accountsTable, + parent: [tenant, managerId], + child: (row) => [row.tenant, row.id], + ); + Relation get reports => Relation( + accountsTable, + parent: [tenant, id], + child: (row) => [row.tenant, row.managerId], + ); + Relation get events => Relation( + eventsTable, + parent: [tenant, id], + child: (row) => [row.tenant, row.owner], + ); + Relation get reviews => Relation( + eventsTable, + parent: [tenant, id], + child: (row) => [row.tenant, row.reviewer], + ); +} + +final accountsTable = Table( + accountsSchema, + AccountsFields.new, + (row) => + (row.tenant, row.id, row.label, row.note, row.managerId, row.marker).map( + (tenant, id, label, note, managerId, marker) => ( + tenant: tenant, + id: id, + label: label, + note: note, + managerId: managerId, + marker: marker, + ), + ), +); + +final class AccountsTableSet extends TableSet { + AccountsTableSet(QueryContext db) : super(db, accountsTable) { + db.registerSchema(appSchema); + } + Future create({ + required int tenant, + required int id, + String? label, + String? note, + int? managerId, + String? marker, + }) => createRow( + (row) => [ + row.tenant.set(tenant), + row.id.set(id), + row.label.set(label), + row.note.set(note), + row.managerId.set(managerId), + row.marker.set(marker), + ], + ); + Query byId({ + required int tenant, + required int id, + }) => where((row) => row.tenant.eq(tenant).and(row.id.eq(id))); +} + +extension AccountsUpdates on Query { + Future patch({ + Change tenant = const Change.keep(), + Change id = const Change.keep(), + Change label = const Change.keep(), + Change note = const Change.keep(), + Change managerId = const Change.keep(), + Change marker = const Change.keep(), + }) => update( + (row) => [ + ...row.tenant.change(tenant), + ...row.id.change(id), + ...row.label.change(label), + ...row.note.change(note), + ...row.managerId.change(managerId), + ...row.marker.change(marker), + ], + ).execute(); +} + +final _eventsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _eventsTenant = Column( + "tenant", + Codecs.integer.nullable(), + nullable: true, + generated: false, +); +final _eventsOwner = Column( + "owner", + Codecs.integer.nullable(), + nullable: true, + generated: false, +); +final _eventsReviewer = Column( + "reviewer", + Codecs.integer.nullable(), + nullable: true, + generated: false, +); +final _eventsTitle = Column( + "title", + Codecs.text, + nullable: false, + generated: false, +); +final _eventsScore = Column( + "score", + Codecs.integer, + nullable: false, + generated: false, +); +final eventsSchema = TableSchema( + "events", + columns: [ + _eventsId, + _eventsTenant, + _eventsOwner, + _eventsReviewer, + _eventsTitle, + _eventsScore, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey( + ["tenant", "owner"], + "accounts", + ["tenant", "id"], + onDelete: "RESTRICT", + ), + ForeignKey( + ["tenant", "reviewer"], + "accounts", + ["tenant", "id"], + onDelete: "RESTRICT", + ), + ], +); + +final class EventsFields extends Fields { + EventsFields(super.table); + late final id = column(_eventsId); + late final tenant = column(_eventsTenant); + late final owner = column(_eventsOwner); + late final reviewer = column(_eventsReviewer); + late final title = column(_eventsTitle); + late final score = column(_eventsScore); + Relation get author => Relation( + accountsTable, + parent: [tenant, owner], + child: (row) => [row.tenant, row.id], + ); + Relation get reviewerAccount => Relation( + accountsTable, + parent: [tenant, reviewer], + child: (row) => [row.tenant, row.id], + ); +} + +final eventsTable = Table( + eventsSchema, + EventsFields.new, + (row) => + (row.id, row.tenant, row.owner, row.reviewer, row.title, row.score).map( + (id, tenant, owner, reviewer, title, score) => ( + id: id, + tenant: tenant, + owner: owner, + reviewer: reviewer, + title: title, + score: score, + ), + ), +); + +final class EventsTableSet extends TableSet { + EventsTableSet(QueryContext db) : super(db, eventsTable) { + db.registerSchema(appSchema); + } + Future create({ + required int id, + int? tenant, + int? owner, + int? reviewer, + required String title, + required int score, + }) => createRow( + (row) => [ + row.id.set(id), + row.tenant.set(tenant), + row.owner.set(owner), + row.reviewer.set(reviewer), + row.title.set(title), + row.score.set(score), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension EventsUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change tenant = const Change.keep(), + Change owner = const Change.keep(), + Change reviewer = const Change.keep(), + Change title = const Change.keep(), + Change score = const Change.keep(), + }) => update( + (row) => [ + ...row.id.change(id), + ...row.tenant.change(tenant), + ...row.owner.change(owner), + ...row.reviewer.change(reviewer), + ...row.title.change(title), + ...row.score.change(score), + ], + ).execute(); +} + +final appSchema = List.unmodifiable([ + accountsSchema, + eventsSchema, +]); + +extension AppTables on QueryContext { + AccountsTableSet get accounts => AccountsTableSet(this); + EventsTableSet get events => EventsTableSet(this); +} diff --git a/test/support/relations/schema.snapshot.dart b/test/support/relations/schema.snapshot.dart new file mode 100644 index 00000000..673f4fe3 --- /dev/null +++ b/test/support/relations/schema.snapshot.dart @@ -0,0 +1,53 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "accounts", + columns: [ + Column("tenant", Codecs.integer), + Column("id", Codecs.integer), + Column("label", Codecs.text.nullable(), nullable: true), + Column("note", Codecs.text.nullable(), nullable: true), + Column("manager_id", Codecs.integer.nullable(), nullable: true), + Column("_ORM_PRESENT", Codecs.text.nullable(), nullable: true), + ], + primaryKey: ["tenant", "id"], + + foreignKeys: [ + ForeignKey( + ["tenant", "manager_id"], + "accounts", + ["tenant", "id"], + onDelete: "RESTRICT", + ), + ], + ), + TableSchema( + "events", + columns: [ + Column("id", Codecs.integer), + Column("tenant", Codecs.integer.nullable(), nullable: true), + Column("owner", Codecs.integer.nullable(), nullable: true), + Column("reviewer", Codecs.integer.nullable(), nullable: true), + Column("title", Codecs.text), + Column("score", Codecs.integer), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey( + ["tenant", "owner"], + "accounts", + ["tenant", "id"], + onDelete: "RESTRICT", + ), + ForeignKey( + ["tenant", "reviewer"], + "accounts", + ["tenant", "id"], + onDelete: "RESTRICT", + ), + ], + ), +]); diff --git a/test/support/tables.dart b/test/support/tables.dart new file mode 100644 index 00000000..c8000d3d --- /dev/null +++ b/test/support/tables.dart @@ -0,0 +1,77 @@ +import 'package:orm/orm.dart'; + +typedef User = ({int id, String email, String? nickname, int score}); + +final userId = Column('id', Codecs.integer, generated: true); +final userEmail = Column('email', Codecs.text); +final userNickname = Column('nickname', Codecs.text.nullable(), nullable: true); +final userScore = Column('score', Codecs.integer, defaultSql: '0'); +final usersSchema = TableSchema( + 'users', + columns: [userId, userEmail, userNickname, userScore], + primaryKey: ['id'], + uniqueKeys: [ + ['email'], + ], +); + +final class UserFields extends Fields { + late final id = column(userId); + late final email = column(userEmail); + late final nickname = column(userNickname); + late final score = column(userScore); + UserFields(super.table); + Relation get posts => + Relation(postsTable, parent: [id], child: (p) => [p.authorId]); +} + +typedef Post = ({int id, int authorId, String title, String? tag}); +final postId = Column('id', Codecs.integer, generated: true); +final postAuthorId = Column('author_id', Codecs.integer); +final postTitle = Column('title', Codecs.text); +final postTag = Column('tag', Codecs.text.nullable(), nullable: true); +final postsSchema = TableSchema( + 'posts', + columns: [postId, postAuthorId, postTitle, postTag], + primaryKey: ['id'], + foreignKeys: [ + const ForeignKey(['author_id'], 'users', ['id']), + ], +); + +final class PostFields extends Fields { + late final id = column(postId); + late final authorId = column(postAuthorId); + late final title = column(postTitle); + late final tag = column(postTag); + PostFields(super.table); + Relation get author => + Relation(users, parent: [authorId], child: (u) => [u.id]); +} + +final postsTable = Table( + postsSchema, + PostFields.new, + (p) => (p.id, p.authorId, p.title, p.tag).map( + (id, authorId, title, tag) => + (id: id, authorId: authorId, title: title, tag: tag), + ), +); + +final users = Table( + usersSchema, + UserFields.new, + (u) => (u.id, u.email, u.nickname, u.score).map( + (id, email, nickname, score) => + (id: id, email: email, nickname: nickname, score: score), + ), +); + +Future createTables(Database db) => db + .execute( + SqlCommand( + 'CREATE TABLE users (id ${db.dialect == SqlDialect.sqlite ? 'INTEGER PRIMARY KEY' : 'BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY'}, ' + 'email TEXT NOT NULL UNIQUE, nickname TEXT, score INTEGER NOT NULL DEFAULT 0)', + ), + ) + .then((_) {}); diff --git a/test/support/temporal_precision/schema.dart b/test/support/temporal_precision/schema.dart new file mode 100644 index 00000000..53db1138 --- /dev/null +++ b/test/support/temporal_precision/schema.dart @@ -0,0 +1,22 @@ +import 'package:orm/schema.dart'; + +typedef Moment = ({ + @Id.generated() int id, + @TemporalPrecision(3) LocalTime clock, + @TemporalPrecision(3) LocalDateTime local, + @TemporalPrecision(0) DateTime instant, + @TemporalPrecision(2) LocalTime? optional, + @Default.sql("'23:59:59.9995'") @TemporalPrecision(3) LocalTime defaulted, + @Computed.sql('"clock"') @TemporalPrecision(0) LocalTime rounded, +}); +typedef Slot = ({@Id() @TemporalPrecision(3) LocalTime time, String label}); +typedef Booking = ({ + @Id.generated() int id, + @TemporalPrecision(3) LocalTime time, +}); +final moments = entity(); +final slots = entity(); +final bookings = entity(); +final slot = bookings + .key((b) => b.time) + .references(slots.key((s) => s.time), inverse: 'bookings'); diff --git a/test/support/temporal_precision/schema.orm.dart b/test/support/temporal_precision/schema.orm.dart new file mode 100644 index 00000000..ba034f57 --- /dev/null +++ b/test/support/temporal_precision/schema.orm.dart @@ -0,0 +1,289 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Moment, Slot, Booking; + +final _momentsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _momentsClock = Column( + "clock", + Codecs.time, + nullable: false, + generated: false, + temporalPrecision: 3, +); +final _momentsLocal = Column( + "local", + Codecs.localDateTime, + nullable: false, + generated: false, + temporalPrecision: 3, +); +final _momentsInstant = Column( + "instant", + Codecs.dateTime, + nullable: false, + generated: false, + temporalPrecision: 0, +); +final _momentsOptional = Column( + "optional", + Codecs.time.nullable(), + nullable: true, + generated: false, + temporalPrecision: 2, +); +final _momentsDefaulted = Column( + "defaulted", + Codecs.time, + nullable: false, + generated: false, + defaultSql: "'23:59:59.9995'", + temporalPrecision: 3, +); +final _momentsRounded = Column( + "rounded", + Codecs.time, + nullable: false, + generated: false, + computed: ComputedColumn.forDialects( + sqlite: "\"clock\"", + postgres: "\"clock\"", + mysql: "\"clock\"", + mariadb: "\"clock\"", + storage: ComputedStorage.stored, + ), + temporalPrecision: 0, +); +final momentsSchema = TableSchema( + "moments", + columns: [ + _momentsId, + _momentsClock, + _momentsLocal, + _momentsInstant, + _momentsOptional, + _momentsDefaulted, + _momentsRounded, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class MomentsFields extends Fields { + MomentsFields(super.table); + late final id = column(_momentsId); + late final clock = column(_momentsClock); + late final local = column(_momentsLocal); + late final instant = column(_momentsInstant); + late final optional = column(_momentsOptional); + late final defaulted = column(_momentsDefaulted); + late final rounded = readColumn(_momentsRounded); +} + +final momentsTable = Table( + momentsSchema, + MomentsFields.new, + (row) => + ( + (row.id, row.clock, row.local, row.instant, row.optional).map( + (id, clock, local, instant, optional) => ( + id: id, + clock: clock, + local: local, + instant: instant, + optional: optional, + ), + ), + ( + row.defaulted, + row.rounded, + ).map((defaulted, rounded) => (defaulted: defaulted, rounded: rounded)), + ).map( + (left, right) => ( + id: left.id, + clock: left.clock, + local: left.local, + instant: left.instant, + optional: left.optional, + defaulted: right.defaulted, + rounded: right.rounded, + ), + ), +); + +final class MomentsTableSet extends TableSet { + MomentsTableSet(QueryContext db) : super(db, momentsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required LocalTime clock, + required LocalDateTime local, + required DateTime instant, + LocalTime? optional, + Change defaulted = const Change.keep(), + }) => createRow( + (row) => [ + ...row.id.change(id), + row.clock.set(clock), + row.local.set(local), + row.instant.set(instant), + row.optional.set(optional), + ...row.defaulted.change(defaulted), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension MomentsUpdates on Query { + Future patch({ + Change clock = const Change.keep(), + Change local = const Change.keep(), + Change instant = const Change.keep(), + Change optional = const Change.keep(), + Change defaulted = const Change.keep(), + }) => update( + (row) => [ + ...row.clock.change(clock), + ...row.local.change(local), + ...row.instant.change(instant), + ...row.optional.change(optional), + ...row.defaulted.change(defaulted), + ], + ).execute(); +} + +final _slotsTime = Column( + "time", + Codecs.time, + nullable: false, + generated: false, + temporalPrecision: 3, +); +final _slotsLabel = Column( + "label", + Codecs.text, + nullable: false, + generated: false, +); +final slotsSchema = TableSchema( + "slots", + columns: [_slotsTime, _slotsLabel], + primaryKey: ["time"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class SlotsFields extends Fields { + SlotsFields(super.table); + late final time = column(_slotsTime); + late final label = column(_slotsLabel); + Relation get bookings => + Relation(bookingsTable, parent: [time], child: (row) => [row.time]); +} + +final slotsTable = Table( + slotsSchema, + SlotsFields.new, + (row) => + (row.time, row.label).map((time, label) => (time: time, label: label)), +); + +final class SlotsTableSet extends TableSet { + SlotsTableSet(QueryContext db) : super(db, slotsTable) { + db.registerSchema(appSchema); + } + Future create({ + required LocalTime time, + required String label, + }) => createRow((row) => [row.time.set(time), row.label.set(label)]); + Query byId(LocalTime time) => + where((row) => row.time.eq(time)); +} + +extension SlotsUpdates on Query { + Future patch({ + Change time = const Change.keep(), + Change label = const Change.keep(), + }) => + update((row) => [...row.time.change(time), ...row.label.change(label)]) + .execute(); +} + +final _bookingsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _bookingsTime = Column( + "time", + Codecs.time, + nullable: false, + generated: false, + temporalPrecision: 3, +); +final bookingsSchema = TableSchema( + "bookings", + columns: [_bookingsId, _bookingsTime], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["time"], "slots", ["time"], onDelete: "RESTRICT"), + ], +); + +final class BookingsFields extends Fields { + BookingsFields(super.table); + late final id = column(_bookingsId); + late final time = column(_bookingsTime); + Relation get slot => + Relation(slotsTable, parent: [time], child: (row) => [row.time]); +} + +final bookingsTable = Table( + bookingsSchema, + BookingsFields.new, + (row) => (row.id, row.time).map((id, time) => (id: id, time: time)), +); + +final class BookingsTableSet extends TableSet { + BookingsTableSet(QueryContext db) : super(db, bookingsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required LocalTime time, + }) => createRow((row) => [...row.id.change(id), row.time.set(time)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension BookingsUpdates on Query { + Future patch({Change time = const Change.keep()}) => + update((row) => [...row.time.change(time)]).execute(); +} + +final appSchema = List.unmodifiable([ + momentsSchema, + slotsSchema, + bookingsSchema, +]); + +extension AppTables on QueryContext { + MomentsTableSet get moments => MomentsTableSet(this); + SlotsTableSet get slots => SlotsTableSet(this); + BookingsTableSet get bookings => BookingsTableSet(this); +} diff --git a/test/support/temporal_precision/schema.snapshot.dart b/test/support/temporal_precision/schema.snapshot.dart new file mode 100644 index 00000000..7296b78f --- /dev/null +++ b/test/support/temporal_precision/schema.snapshot.dart @@ -0,0 +1,58 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "moments", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("clock", Codecs.time, temporalPrecision: 3), + Column("local", Codecs.localDateTime, temporalPrecision: 3), + Column("instant", Codecs.dateTime, temporalPrecision: 0), + Column( + "optional", + Codecs.time.nullable(), + nullable: true, + + temporalPrecision: 2, + ), + Column( + "defaulted", + Codecs.time, + + defaultSql: "'23:59:59.9995'", + + temporalPrecision: 3, + ), + Column( + "rounded", + Codecs.time, + + computed: ComputedColumn("\"clock\"", storage: ComputedStorage.stored), + + temporalPrecision: 0, + ), + ], + primaryKey: ["id"], + ), + TableSchema( + "slots", + columns: [ + Column("time", Codecs.time, temporalPrecision: 3), + Column("label", Codecs.text), + ], + primaryKey: ["time"], + ), + TableSchema( + "bookings", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("time", Codecs.time, temporalPrecision: 3), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["time"], "slots", ["time"], onDelete: "RESTRICT"), + ], + ), +]); diff --git a/test/support/temporals/native.dart b/test/support/temporals/native.dart new file mode 100644 index 00000000..155d9f3e --- /dev/null +++ b/test/support/temporals/native.dart @@ -0,0 +1,50 @@ +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; + +import 'schema.orm.dart'; + +Future main() async { + final db = await sqlite(const SqliteOptions.memory()); + try { + await Migrator( + db.sql, + ).apply([Migration.create('0001_local', appSchema, dialect: db.dialect)]); + final last = LocalDate.fromJulianDay(LocalDate.maxJulianDay); + final stamp = LocalDateTime.parse('294276-12-31 23:59:59.999999'); + final row = await db.appointments.create( + day: last, + time: Change.set(LocalTime(24)), + starts: stamp, + ); + if (row.day != last || row.time != LocalTime(24) || row.starts != stamp) { + throw StateError('Finite endpoint changed'); + } + await db.appointments.create(day: LocalDate.fromJulianDay(0)); + final sorted = await db.appointments + .orderBy((a) => [a.day.asc()]) + .stream(batchSize: 1) + .toList(); + if (sorted.first.day.julianDay != 0 || sorted.last.day != last) { + throw StateError('Calendar ordering changed'); + } + await db.holidays.create(day: LocalDate(0, 1, 1), label: 'era'); + await db.execute( + SqlCommand("INSERT INTO visits (day) VALUES ('0000-01-01')"), + ); + final children = await db.holidays + .select((h) => h.visits.select((v) => v.day).many()) + .single(); + if (children.single != LocalDate(0, 1, 1)) { + throw StateError('Equivalent relation keys differ'); + } + final catalog = await verifySchema(db.sql, SchemaSnapshot(appSchema)); + if (catalog.differences.isNotEmpty || catalog.unmanaged.isNotEmpty) { + throw StateError('Temporal catalog differs'); + } + print( + 'Native temporal endpoints, ordering, streaming, relations and catalog verified.', + ); + } finally { + await db.close(); + } +} diff --git a/test/support/temporals/schema.dart b/test/support/temporals/schema.dart new file mode 100644 index 00000000..68bb8063 --- /dev/null +++ b/test/support/temporals/schema.dart @@ -0,0 +1,16 @@ +import 'package:orm/schema.dart'; + +typedef Appointment = ({ + @Id.generated() int id, + LocalDate day, + @Default.sql("'12:30'") LocalTime time, + LocalDateTime? starts, +}); +typedef Holiday = ({@Id() LocalDate day, String label}); +typedef Visit = ({@Id.generated() int id, LocalDate day}); +final appointments = entity(); +final holidays = entity(); +final visits = entity(); +final holiday = visits + .key((v) => v.day) + .references(holidays.key((h) => h.day), inverse: 'visits'); diff --git a/test/support/temporals/schema.orm.dart b/test/support/temporals/schema.orm.dart new file mode 100644 index 00000000..a18bd482 --- /dev/null +++ b/test/support/temporals/schema.orm.dart @@ -0,0 +1,219 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Appointment, Holiday, Visit; + +final _appointmentsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _appointmentsDay = Column( + "day", + Codecs.date, + nullable: false, + generated: false, +); +final _appointmentsTime = Column( + "time", + Codecs.time, + nullable: false, + generated: false, + defaultSql: "'12:30'", +); +final _appointmentsStarts = Column( + "starts", + Codecs.localDateTime.nullable(), + nullable: true, + generated: false, +); +final appointmentsSchema = TableSchema( + "appointments", + columns: [ + _appointmentsId, + _appointmentsDay, + _appointmentsTime, + _appointmentsStarts, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class AppointmentsFields extends Fields { + AppointmentsFields(super.table); + late final id = column(_appointmentsId); + late final day = column(_appointmentsDay); + late final time = column(_appointmentsTime); + late final starts = column(_appointmentsStarts); +} + +final appointmentsTable = Table( + appointmentsSchema, + AppointmentsFields.new, + (row) => (row.id, row.day, row.time, row.starts).map( + (id, day, time, starts) => (id: id, day: day, time: time, starts: starts), + ), +); + +final class AppointmentsTableSet + extends TableSet { + AppointmentsTableSet(QueryContext db) : super(db, appointmentsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required LocalDate day, + Change time = const Change.keep(), + LocalDateTime? starts, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.day.set(day), + ...row.time.change(time), + row.starts.set(starts), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension AppointmentsUpdates on Query { + Future patch({ + Change day = const Change.keep(), + Change time = const Change.keep(), + Change starts = const Change.keep(), + }) => update( + (row) => [ + ...row.day.change(day), + ...row.time.change(time), + ...row.starts.change(starts), + ], + ).execute(); +} + +final _holidaysDay = Column( + "day", + Codecs.date, + nullable: false, + generated: false, +); +final _holidaysLabel = Column( + "label", + Codecs.text, + nullable: false, + generated: false, +); +final holidaysSchema = TableSchema( + "holidays", + columns: [_holidaysDay, _holidaysLabel], + primaryKey: ["day"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class HolidaysFields extends Fields { + HolidaysFields(super.table); + late final day = column(_holidaysDay); + late final label = column(_holidaysLabel); + Relation get visits => + Relation(visitsTable, parent: [day], child: (row) => [row.day]); +} + +final holidaysTable = Table( + holidaysSchema, + HolidaysFields.new, + (row) => (row.day, row.label).map((day, label) => (day: day, label: label)), +); + +final class HolidaysTableSet extends TableSet { + HolidaysTableSet(QueryContext db) : super(db, holidaysTable) { + db.registerSchema(appSchema); + } + Future create({ + required LocalDate day, + required String label, + }) => createRow((row) => [row.day.set(day), row.label.set(label)]); + Query byId(LocalDate day) => + where((row) => row.day.eq(day)); +} + +extension HolidaysUpdates on Query { + Future patch({ + Change day = const Change.keep(), + Change label = const Change.keep(), + }) => + update((row) => [...row.day.change(day), ...row.label.change(label)]) + .execute(); +} + +final _visitsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _visitsDay = Column( + "day", + Codecs.date, + nullable: false, + generated: false, +); +final visitsSchema = TableSchema( + "visits", + columns: [_visitsId, _visitsDay], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [ + ForeignKey(["day"], "holidays", ["day"], onDelete: "RESTRICT"), + ], +); + +final class VisitsFields extends Fields { + VisitsFields(super.table); + late final id = column(_visitsId); + late final day = column(_visitsDay); + Relation get holiday => + Relation(holidaysTable, parent: [day], child: (row) => [row.day]); +} + +final visitsTable = Table( + visitsSchema, + VisitsFields.new, + (row) => (row.id, row.day).map((id, day) => (id: id, day: day)), +); + +final class VisitsTableSet extends TableSet { + VisitsTableSet(QueryContext db) : super(db, visitsTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required LocalDate day, + }) => createRow((row) => [...row.id.change(id), row.day.set(day)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension VisitsUpdates on Query { + Future patch({Change day = const Change.keep()}) => + update((row) => [...row.day.change(day)]).execute(); +} + +final appSchema = List.unmodifiable([ + appointmentsSchema, + holidaysSchema, + visitsSchema, +]); + +extension AppTables on QueryContext { + AppointmentsTableSet get appointments => AppointmentsTableSet(this); + HolidaysTableSet get holidays => HolidaysTableSet(this); + VisitsTableSet get visits => VisitsTableSet(this); +} diff --git a/test/support/temporals/schema.snapshot.dart b/test/support/temporals/schema.snapshot.dart new file mode 100644 index 00000000..b52d6aa4 --- /dev/null +++ b/test/support/temporals/schema.snapshot.dart @@ -0,0 +1,32 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "appointments", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("day", Codecs.date), + Column("time", Codecs.time, defaultSql: "'12:30'"), + Column("starts", Codecs.localDateTime.nullable(), nullable: true), + ], + primaryKey: ["id"], + ), + TableSchema( + "holidays", + columns: [Column("day", Codecs.date), Column("label", Codecs.text)], + primaryKey: ["day"], + ), + TableSchema( + "visits", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("day", Codecs.date), + ], + primaryKey: ["id"], + + foreignKeys: [ + ForeignKey(["day"], "holidays", ["day"], onDelete: "RESTRICT"), + ], + ), +]); diff --git a/test/support/unconstrained/schema.dart b/test/support/unconstrained/schema.dart new file mode 100644 index 00000000..ed19c270 --- /dev/null +++ b/test/support/unconstrained/schema.dart @@ -0,0 +1,32 @@ +import 'package:orm/schema.dart'; + +typedef Account = ({ + int tenant, + int id, + @ColumnName('display_label') String? label, + int? managerId, +}); +typedef Entry = ({ + @Id() int id, + int? tenant, + int? owner, + @ColumnName('lookup_label') String? label, +}); +typedef Reading = ({@Id() int id, double value}); + +final accounts = entity(); +final entries = entity(); +final readings = entity(); +final accountKey = accounts.primaryKey((a) => (a.tenant, a.id)); +final ownerAccount = entries + .key((e) => (e.tenant, e.owner)) + .relatesTo(accounts.key((a) => (a.tenant, a.id)), inverse: 'entries'); +final matchingAccounts = entries + .key((e) => (e.tenant, e.label)) + .relatesTo(accounts.key((a) => (a.tenant, a.label)), inverse: 'matches'); +final manager = accounts + .key((a) => (a.tenant, a.managerId)) + .relatesTo(accounts.key((a) => (a.tenant, a.id)), inverse: 'reports'); +final peers = readings + .key((r) => r.value) + .relatesTo(readings.key((r) => r.value)); diff --git a/test/support/unconstrained/schema.orm.dart b/test/support/unconstrained/schema.orm.dart new file mode 100644 index 00000000..4510b545 --- /dev/null +++ b/test/support/unconstrained/schema.orm.dart @@ -0,0 +1,292 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show Account, Entry, Reading; + +final _accountsTenant = Column( + "tenant", + Codecs.integer, + nullable: false, + generated: false, +); +final _accountsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _accountsLabel = Column( + "display_label", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final _accountsManagerId = Column( + "manager_id", + Codecs.integer.nullable(), + nullable: true, + generated: false, +); +final accountsSchema = TableSchema( + "accounts", + columns: [_accountsTenant, _accountsId, _accountsLabel, _accountsManagerId], + primaryKey: ["tenant", "id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class AccountsFields extends Fields { + AccountsFields(super.table); + late final tenant = column(_accountsTenant); + late final id = column(_accountsId); + late final label = column(_accountsLabel); + late final managerId = column(_accountsManagerId); + + /// Read-only navigation; no database foreign key or write effects. + Relation get entries => Relation( + entriesTable, + parent: [tenant, id], + child: (row) => [row.tenant, row.owner], + ); + + /// Read-only navigation; no database foreign key or write effects. + Relation get matches => Relation( + entriesTable, + parent: [tenant, label], + child: (row) => [row.tenant, row.label], + ); + + /// Read-only navigation; no database foreign key or write effects. + Relation get manager => Relation( + accountsTable, + parent: [tenant, managerId], + child: (row) => [row.tenant, row.id], + ); + + /// Read-only navigation; no database foreign key or write effects. + Relation get reports => Relation( + accountsTable, + parent: [tenant, id], + child: (row) => [row.tenant, row.managerId], + ); +} + +final accountsTable = Table( + accountsSchema, + AccountsFields.new, + (row) => (row.tenant, row.id, row.label, row.managerId).map( + (tenant, id, label, managerId) => + (tenant: tenant, id: id, label: label, managerId: managerId), + ), +); + +final class AccountsTableSet extends TableSet { + AccountsTableSet(QueryContext db) : super(db, accountsTable) { + db.registerSchema(appSchema); + } + Future create({ + required int tenant, + required int id, + String? label, + int? managerId, + }) => createRow( + (row) => [ + row.tenant.set(tenant), + row.id.set(id), + row.label.set(label), + row.managerId.set(managerId), + ], + ); + Query byId({ + required int tenant, + required int id, + }) => where((row) => row.tenant.eq(tenant).and(row.id.eq(id))); +} + +extension AccountsUpdates on Query { + Future patch({ + Change tenant = const Change.keep(), + Change id = const Change.keep(), + Change label = const Change.keep(), + Change managerId = const Change.keep(), + }) => update( + (row) => [ + ...row.tenant.change(tenant), + ...row.id.change(id), + ...row.label.change(label), + ...row.managerId.change(managerId), + ], + ).execute(); +} + +final _entriesId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _entriesTenant = Column( + "tenant", + Codecs.integer.nullable(), + nullable: true, + generated: false, +); +final _entriesOwner = Column( + "owner", + Codecs.integer.nullable(), + nullable: true, + generated: false, +); +final _entriesLabel = Column( + "lookup_label", + Codecs.text.nullable(), + nullable: true, + generated: false, +); +final entriesSchema = TableSchema( + "entries", + columns: [_entriesId, _entriesTenant, _entriesOwner, _entriesLabel], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class EntriesFields extends Fields { + EntriesFields(super.table); + late final id = column(_entriesId); + late final tenant = column(_entriesTenant); + late final owner = column(_entriesOwner); + late final label = column(_entriesLabel); + + /// Read-only navigation; no database foreign key or write effects. + Relation get ownerAccount => Relation( + accountsTable, + parent: [tenant, owner], + child: (row) => [row.tenant, row.id], + ); + + /// Read-only navigation; no database foreign key or write effects. + Relation get matchingAccounts => Relation( + accountsTable, + parent: [tenant, label], + child: (row) => [row.tenant, row.label], + ); +} + +final entriesTable = Table( + entriesSchema, + EntriesFields.new, + (row) => (row.id, row.tenant, row.owner, row.label).map( + (id, tenant, owner, label) => + (id: id, tenant: tenant, owner: owner, label: label), + ), +); + +final class EntriesTableSet extends TableSet { + EntriesTableSet(QueryContext db) : super(db, entriesTable) { + db.registerSchema(appSchema); + } + Future create({ + required int id, + int? tenant, + int? owner, + String? label, + }) => createRow( + (row) => [ + row.id.set(id), + row.tenant.set(tenant), + row.owner.set(owner), + row.label.set(label), + ], + ); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension EntriesUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change tenant = const Change.keep(), + Change owner = const Change.keep(), + Change label = const Change.keep(), + }) => update( + (row) => [ + ...row.id.change(id), + ...row.tenant.change(tenant), + ...row.owner.change(owner), + ...row.label.change(label), + ], + ).execute(); +} + +final _readingsId = Column( + "id", + Codecs.integer, + nullable: false, + generated: false, +); +final _readingsValue = Column( + "value", + Codecs.real, + nullable: false, + generated: false, +); +final readingsSchema = TableSchema( + "readings", + columns: [_readingsId, _readingsValue], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [], + foreignKeys: [], +); + +final class ReadingsFields extends Fields { + ReadingsFields(super.table); + late final id = column(_readingsId); + late final value = column(_readingsValue); + + /// Read-only navigation; no database foreign key or write effects. + Relation get peers => + Relation(readingsTable, parent: [value], child: (row) => [row.value]); +} + +final readingsTable = Table( + readingsSchema, + ReadingsFields.new, + (row) => (row.id, row.value).map((id, value) => (id: id, value: value)), +); + +final class ReadingsTableSet extends TableSet { + ReadingsTableSet(QueryContext db) : super(db, readingsTable) { + db.registerSchema(appSchema); + } + Future create({required int id, required double value}) => + createRow((row) => [row.id.set(id), row.value.set(value)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension ReadingsUpdates on Query { + Future patch({ + Change id = const Change.keep(), + Change value = const Change.keep(), + }) => + update((row) => [...row.id.change(id), ...row.value.change(value)]) + .execute(); +} + +final appSchema = List.unmodifiable([ + accountsSchema, + entriesSchema, + readingsSchema, +]); + +extension AppTables on QueryContext { + AccountsTableSet get accounts => AccountsTableSet(this); + EntriesTableSet get entries => EntriesTableSet(this); + ReadingsTableSet get readings => ReadingsTableSet(this); +} diff --git a/test/support/unconstrained/schema.snapshot.dart b/test/support/unconstrained/schema.snapshot.dart new file mode 100644 index 00000000..a5b4164f --- /dev/null +++ b/test/support/unconstrained/schema.snapshot.dart @@ -0,0 +1,30 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "accounts", + columns: [ + Column("tenant", Codecs.integer), + Column("id", Codecs.integer), + Column("display_label", Codecs.text.nullable(), nullable: true), + Column("manager_id", Codecs.integer.nullable(), nullable: true), + ], + primaryKey: ["tenant", "id"], + ), + TableSchema( + "entries", + columns: [ + Column("id", Codecs.integer), + Column("tenant", Codecs.integer.nullable(), nullable: true), + Column("owner", Codecs.integer.nullable(), nullable: true), + Column("lookup_label", Codecs.text.nullable(), nullable: true), + ], + primaryKey: ["id"], + ), + TableSchema( + "readings", + columns: [Column("id", Codecs.integer), Column("value", Codecs.real)], + primaryKey: ["id"], + ), +]); diff --git a/test/temporal_precision_test.dart b/test/temporal_precision_test.dart new file mode 100644 index 00000000..30ccad1e --- /dev/null +++ b/test/temporal_precision_test.dart @@ -0,0 +1,586 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/temporal_precision/schema.dart' as models; +import 'support/temporal_precision/schema.orm.dart'; + +LocalTime t(String text) => LocalTime.parse(text); +LocalDateTime dt(String text) => LocalDateTime.parse(text); +DateTime instant(String text) => Codecs.dateTime.decode(text); + +void main() { + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('temporal precision $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_temporal_precision_tests', + ), + ); + await db.execute( + SqlCommand( + 'DROP SCHEMA IF EXISTS orm_temporal_precision_tests CASCADE', + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA orm_temporal_precision_tests'), + ); + } + }); + tearDown(() => db.close()); + Future create() => Migrator(db.sql).apply([ + Migration.create('0001_precision', appSchema, dialect: db.dialect), + ]); + Future sample() => db.moments.create( + clock: t('12:00:00.1235'), + local: dt('1999-12-31 23:59:59.9995'), + instant: instant('1999-12-31 23:59:59.5Z'), + ); + + test('generated writes, defaults and computed columns use database precision', () async { + await create(); + final a = await sample(); + expect(a.clock, t('12:00:00.124')); + expect(a.local, dt('1999-12-31 23:59:59.999')); + expect(a.instant, instant('1999-12-31 23:59:59Z')); + expect(a.optional, isNull); + expect(a.defaulted, t('24:00')); + expect(a.rounded, t('12:00')); + await db.moments + .byId(a.id) + .patch( + clock: Change.set(t('23:59:59.9995')), + optional: Change.set(t('01:02:03.125')), + ); + final updated = await db.moments.byId(a.id).single(); + expect(updated.clock, t('24:00')); + expect(updated.optional, t('01:02:03.13')); + await db.moments + .byId(a.id) + .update( + (m) => [ + m.clock.setExpression(value(t('01:00:00.7775'), Codecs.time)), + ], + ) + .execute(); + expect((await db.moments.byId(a.id).single()).clock, t('01:00:00.778')); + expect(await verifyColumns(db.sql, appSchema), isEmpty); + final verified = await verifySchema(db.sql, SchemaSnapshot(appSchema)); + expect(verified.differences, isEmpty); + expect(verified.unmanaged, isEmpty); + }); + + test( + 'SQL casts match all precisions on both sides of the PostgreSQL epoch', + () async { + await create(); + await sample(); + for (var p = 0; p <= 6; p++) { + final unit = [1000000, 100000, 10000, 1000, 100, 10, 1][p]; + for (final offset in { + 0, + unit ~/ 2, + unit ~/ 2 + (p == 6 ? 0 : 1), + unit - 1, + }) { + final time = LocalTime.fromMicroseconds(offset); + final after = LocalDateTime(LocalDate(2000, 1, 1), time); + final before = LocalDateTime( + LocalDate(2000, 1, 1), + LocalTime(0), + ).add(Duration(microseconds: -offset)); + final expectedTicks = offset * 2 >= unit ? unit : 0; + final expectedAfter = dt('2000-01-01 00:00') + .add(Duration(microseconds: expectedTicks)); + final expectedBefore = dt('2000-01-01 00:00') + .add(Duration(microseconds: -expectedTicks)); + final result = await db.moments + .select( + (_) => ( + value(time, Codecs.time).withPrecision(p), + value(after, Codecs.localDateTime).withPrecision(p), + value(before, Codecs.localDateTime).withPrecision(p), + value( + instant('${before}Z'), + Codecs.dateTime, + ).withPrecision(p), + ).map((a, b, c, d) => (a, b, c, d)), + ) + .single(); + expect(result, ( + LocalTime.fromMicroseconds(expectedTicks), + expectedAfter, + expectedBefore, + instant('${expectedBefore}Z'), + )); + expect(time.withPrecision(p), result.$1); + expect(after.withPrecision(p), result.$2); + expect(before.withPrecision(p), result.$3); + expect(instant('${before}Z').withPrecision(p), result.$4); + } + } + final end = await db.moments + .select( + (_) => + value(t('23:59:59.999999'), Codecs.time).withPrecision(0), + ) + .single(); + expect(end, t('24:00')); + }, + ); + + test('nulls, CTE references, grouping, streams and wrong scopes retain type semantics', () async { + await create(); + await sample(); + expect( + await db.moments.select((m) => m.optional.withPrecision(0)).single(), + isNull, + ); + final cte = db.moments + .select((m) => m.clock.withPrecision(0)) + .asCte('whole_seconds'); + expect( + await cte.query + .select((c) => c.ref((m) => m.clock.withPrecision(0))) + .single(), + t('12:00'), + ); + expect( + await db.moments + .select((m) => m.clock.withPrecision(0)) + .stream(batchSize: 1) + .single, + t('12:00'), + ); + expect( + await db.moments + .groupBy((m) => [m.clock.withPrecision(0)]) + .select( + (m) => ( + m.clock.withPrecision(0), + m.id.count(), + ).map((time, n) => (time, n)), + ) + .single(), + (t('12:00'), 1), + ); + expect( + () => db.moments + .groupBy((m) => [m.clock]) + .select((m) => m.clock.withPrecision(0)) + .compile(), + returnsNormally, + ); + expect( + () => db.moments + .groupBy((m) => [m.clock.withPrecision(1)]) + .select((m) => m.clock.withPrecision(0)) + .compile(), + throwsA(isA()), + ); + }); + + test('rounded keys work in batch writes, upserts and foreign-key relation loading', () async { + await create(); + await db.slots.insertMany([ + '01:00:00.1235', + '02:00:00.1235', + ], (s, v) => [s.time.set(t(v)), s.label.set(v)]).execute(); + final booking = await db.bookings.create(time: t('01:00:00.1239')); + final related = await db.bookings + .byId(booking.id) + .select((b) => b.slot.select((s) => s.label).one()) + .single(); + expect(related, '01:00:00.1235'); + await expectLater( + db.slots.insertMany([ + '03:00:00.1235', + '01:00:00.1236', + ], (s, v) => [s.time.set(t(v)), s.label.set(v)]).execute(), + throwsA(isA()), + ); + expect(await db.slots.count(), 2); + await db.slots + .insert( + (s) => [s.time.set(t('01:00:00.1238')), s.label.set('updated')], + ) + .onConflictUpdate( + target: (s) => [s.time], + set: (existing, incoming) => [ + existing.label.setExpression(incoming.label), + ], + ) + .execute(); + expect( + await db.slots + .byId(t('01:00:00.124')) + .select((s) => s.label) + .single(), + 'updated', + ); + }); + + test( + 'catalog import preserves precision, defaults and computed expressions', + () async { + await create(); + await sample(); + final info = await inspectColumns(db.sql, 'moments'); + expect( + {for (final c in info) c.name: c.temporalPrecision}, + { + 'id': null, + 'clock': 3, + 'local': 3, + 'instant': 0, + 'optional': 2, + 'defaulted': 3, + 'rounded': 0, + }, + ); + final draft = await importSchema( + db.sql, + tables: ['moments', 'slots', 'bookings'], + ); + expect(draft.issues.where((i) => i.blocking), isEmpty); + expect(draft.dart, contains('@TemporalPrecision(3)')); + final dir = await Directory( + '.dart_tool/temporal-precision-import-$backend', + ).create(recursive: true); + try { + final file = File('${dir.path}/schema.dart'); + await file.writeAsString(draft.dart); + final generated = await generateSchema(file.path); + final verification = await verifySchema(db.sql, generated.snapshot); + expect(verification.differences, isEmpty); + expect(verification.unmanaged, isEmpty); + } finally { + await dir.delete(recursive: true); + } + }, + ); + + test('narrowing precision requires a reviewed conversion and duplicate-key failure rolls back history', () async { + TableSchema schema(int? p, {String column = 'stamp'}) => TableSchema( + 'narrow', + columns: [ + Column('id', Codecs.integer), + Column(column, Codecs.localDateTime, temporalPrecision: p), + ], + primaryKey: ['id'], + uniqueKeys: [ + [column], + ], + ); + final first = Migration.create('0001_before', [ + schema(6), + ], dialect: db.dialect); + final oldChecksum = first.checksum; + await Migrator(db.sql).apply([first]); + await db.execute( + SqlCommand( + "INSERT INTO narrow VALUES (1,'2024-01-01 00:00:00.1231'),(2,'2024-01-01 00:00:00.1232')", + ), + ); + expect( + () => Migration.diff( + '0002_missing', + from: first.snapshot!, + to: SchemaSnapshot([schema(3)]), + previous: first.checksum, + dialect: db.dialect, + ), + throwsA(isA()), + ); + final next = Migration.diff( + '0002_narrow', + from: first.snapshot!, + to: SchemaSnapshot([schema(3, column: 'recorded')]), + previous: first.checksum, + renames: const SchemaRenames( + columns: { + 'narrow': {'stamp': 'recorded'}, + }, + ), + using: { + 'narrow': {'recorded': 'recorded'}, + }, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([first, next]), + throwsA(isA()), + ); + expect((await Migrator(db.sql).history()).length, 1); + expect( + (await verifySchema(db.sql, first.snapshot!)).differences, + isEmpty, + ); + expect(first.checksum, oldChecksum); + await db.execute(SqlCommand('DELETE FROM narrow WHERE id=2')); + await Migrator(db.sql).apply([first, next]); + expect( + (await verifySchema(db.sql, next.snapshot!)).differences, + isEmpty, + ); + final row = await db.execute(SqlCommand('SELECT recorded FROM narrow')); + expect( + Codecs.localDateTime.decode(row.rows.single.single), + dt('2024-01-01 00:00:00.123'), + ); + final wrong = SchemaSnapshot([schema(2, column: 'recorded')]); + expect( + (await verifySchema(db.sql, wrong)).differences.join(), + contains('precision'), + ); + expect( + (await verifyColumns(db.sql, wrong.tables)).join(), + contains('precision'), + ); + }); + + test( + 'timestamp defaults and explicit six-digit catalogs retain precision', + () async { + final table = TableSchema( + 'defaults', + columns: [ + Column( + 'stamp', + Codecs.localDateTime, + temporalPrecision: 3, + defaultSql: "'1999-12-31 23:59:59.9995'", + ), + Column( + 'instant', + Codecs.dateTime, + temporalPrecision: 0, + defaultSql: "'2000-01-01 07:59:59.5+08'", + ), + ], + ); + await Migrator(db.sql).apply([ + Migration.create('0001_defaults', [table], dialect: db.dialect), + ]); + await db.execute(SqlCommand('INSERT INTO defaults DEFAULT VALUES')); + final row = (await db.execute(SqlCommand('SELECT * FROM defaults'))) + .rows + .single; + expect( + Codecs.localDateTime.decode(row[0]), + dt('1999-12-31 23:59:59.999'), + ); + expect( + Codecs.dateTime.decode(row[1]), + instant('1999-12-31 23:59:59Z'), + ); + expect( + (await verifySchema(db.sql, SchemaSnapshot([table]))).differences, + isEmpty, + ); + if (backend == 'postgres') { + await db.execute( + SqlCommand( + 'CREATE TABLE explicit_six (clock TIME(6) WITHOUT TIME ZONE, stamp TIMESTAMP(6) WITHOUT TIME ZONE, instant TIMESTAMPTZ(6))', + ), + ); + final expected = TableSchema( + 'explicit_six', + columns: [ + Column('clock', Codecs.time.nullable(), nullable: true), + Column( + 'stamp', + Codecs.localDateTime.nullable(), + nullable: true, + ), + Column('instant', Codecs.dateTime.nullable(), nullable: true), + ], + ); + expect(await verifyColumns(db.sql, [expected]), isEmpty); + expect( + (await verifySchema( + db.sql, + SchemaSnapshot([expected]), + )).differences, + isEmpty, + ); + final draft = await importSchema(db.sql, tables: ['explicit_six']); + expect(draft.issues.where((i) => i.blocking), isEmpty); + expect(draft.dart, isNot(contains('@TemporalPrecision'))); + } + }, + ); + + test( + 'extended timestamps round in SQL without total-microsecond overflow', + () async { + await create(); + await sample(); + for (final text in [ + '0001-01-01 00:00:00.0005 BC', + '294276-12-31 23:59:59.999499', + ]) { + final original = dt(text); + expect( + await db.moments + .select( + (_) => + value(original, Codecs.localDateTime).withPrecision(3), + ) + .single(), + original.withPrecision(3), + ); + } + final upper = instant('275760-09-12 23:59:59.999999+00'); + expect( + await db.moments + .select((_) => value(upper, Codecs.dateTime).withPrecision(0)) + .single(), + DateTime.utc(275760, 9, 13), + ); + }, + ); + + test( + 'raw SQL respects native or managed precision constraints', + () async { + await create(); + if (backend == 'sqlite') { + await expectLater( + db.execute( + SqlCommand("INSERT INTO slots VALUES ('01:00:00.1234','raw')"), + ), + throwsA(isA()), + ); + await expectLater( + db.execute(SqlCommand("INSERT INTO slots VALUES ('bad','raw')")), + throwsA(isA()), + ); + } else { + await db.execute( + SqlCommand("INSERT INTO slots VALUES ('01:00:00.1234','raw')"), + ); + expect((await db.slots.single()).time, t('01:00:00.123')); + } + }, + ); + }); + } + + test('value rounding keeps extended ranges and rejects overflow/invalid precision', () { + expect( + dt('0001-01-01 00:00:00.0005 BC').withPrecision(3), + dt('0001-01-01 00:00:00 BC'), + ); + expect( + dt('294276-12-31 23:59:59.999499').withPrecision(3), + dt('294276-12-31 23:59:59.999'), + ); + expect( + () => dt('294276-12-31 23:59:59.9995').withPrecision(3), + throwsRangeError, + ); + for (final p in [-1, 7]) { + expect(() => t('12:00').withPrecision(p), throwsRangeError); + expect(() => dt('2000-01-01 00:00').withPrecision(p), throwsRangeError); + expect(() => DateTime.utc(2000).withPrecision(p), throwsRangeError); + expect( + () => value(t('12:00'), Codecs.time).withPrecision(p), + throwsRangeError, + ); + } + expect( + DateTime.utc(275760, 9, 13).withPrecision(0), + DateTime.utc(275760, 9, 13), + ); + }); + + test('default and explicit six preserve historical snapshots and migration checksums', () { + TableSchema schema(int? p) => TableSchema( + 'old', + columns: [Column('clock', Codecs.time, temporalPrecision: p)], + ); + final before = Migration.create('0001_legacy', [ + schema(null), + ], dialect: SqlDialect.sqlite), + after = Migration.create('0001_legacy', [ + schema(6), + ], dialect: SqlDialect.sqlite); + expect(before.checksum, after.checksum); + expect( + jsonEncode(before.snapshot!.toJson()), + jsonEncode(after.snapshot!.toJson()), + ); + for (final d in SqlDialect.values) { + expect( + createSchema([schema(null)], d).map((c) => c.sql), + createSchema([schema(6)], d).map((c) => c.sql), + ); + } + for (final column in [ + Column('c', Codecs.time, temporalPrecision: 7), + Column('c', Codecs.date, temporalPrecision: 3), + Column('c', Codecs.text, temporalPrecision: 3), + ]) { + expect( + () => createSchema([ + TableSchema('invalid', columns: [column]), + ], .sqlite), + throwsA(isA()), + ); + } + }); + + test('generator rejects invalid annotations and preserves generated fixture metadata', () async { + final generated = await generateSchema( + 'test/support/temporal_precision/schema.dart', + ); + expect( + generated.dart, + File('test/support/temporal_precision/schema.orm.dart') + .readAsStringSync(), + ); + expect( + generated.snapshotDart, + File('test/support/temporal_precision/schema.snapshot.dart') + .readAsStringSync(), + ); + final dir = await Directory('.dart_tool/temporal-precision-invalid') + .create(recursive: true); + try { + final file = File('${dir.path}/schema.dart'); + for (final field in [ + '@TemporalPrecision(-1) LocalTime t', + '@TemporalPrecision(7) LocalDateTime t', + '@TemporalPrecision(3) LocalDate t', + '@TemporalPrecision(3) String t', + '@TemporalPrecision(3) @TemporalPrecision(2) DateTime t', + ]) { + await file.writeAsString( + "import 'package:orm/schema.dart'; typedef Row=({$field}); final rows=entity();", + ); + await expectLater( + generateSchema(file.path), + throwsA(isA()), + ); + } + } finally { + await dir.delete(recursive: true); + } + }); +} diff --git a/test/temporal_test.dart b/test/temporal_test.dart new file mode 100644 index 00000000..e723cb04 --- /dev/null +++ b/test/temporal_test.dart @@ -0,0 +1,665 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:orm/generate.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:postgres/postgres.dart' as pg; +import 'package:test/test.dart'; + +import 'support/temporals/schema.orm.dart'; +import 'support/temporals/schema.snapshot.dart' as physical; + +void main() { + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + for (final enabled in [false, true]) { + test( + 'borrowed PostgreSQL pool local calendar capability: $enabled', + () async { + final uri = Uri.parse(url), colon = uri.userInfo.indexOf(':'); + final pool = pg.Pool.withEndpoints( + [ + pg.Endpoint( + host: uri.host, + port: uri.hasPort ? uri.port : 5432, + database: uri.pathSegments.single, + username: uri.userInfo.isEmpty + ? null + : Uri.decodeComponent( + colon < 0 + ? uri.userInfo + : uri.userInfo.substring(0, colon), + ), + password: colon < 0 + ? null + : Uri.decodeComponent(uri.userInfo.substring(colon + 1)), + ), + ], + settings: pg.PoolSettings( + sslMode: pg.SslMode.disable, + maxConnectionCount: 1, + typeRegistry: enabled ? postgresTypeRegistry() : null, + ), + ); + final db = Database(PostgresDriver.borrow(pool, temporal: enabled)); + try { + if (!enabled) { + expect( + () => db.appointments.select((a) => a.day).compile(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'CAPABILITY.TEMPORAL', + ), + ), + ); + } else { + await db.session((session) async { + await session.execute( + SqlCommand( + 'CREATE TEMPORARY TABLE appointments (id BIGINT, day DATE, time TIME WITHOUT TIME ZONE, starts TIMESTAMP WITHOUT TIME ZONE)', + ), + ); + await session.execute( + SqlCommand( + "INSERT INTO appointments VALUES (1, '5874897-12-31', '24:00', '294276-12-31 23:59:59.999999')", + ), + ); + final row = await session.appointments.single(); + expect(row.day, LocalDate(5874897, 12, 31)); + expect(row.time, LocalTime(24)); + expect( + row.starts, + LocalDateTime.parse('294276-12-31 23:59:59.999999'), + ); + }); + } + await db.close(); + expect((await pool.execute('SELECT 7')).single.single, 7); + } finally { + await db.close(); + await pool.close(); + } + }, + ); + } + } + + test('Gregorian cycles, era boundaries and full finite date range', () { + expect(LocalDate(-4713, 11, 24).julianDay, 0); + expect(LocalDate(5874897, 12, 31).julianDay, LocalDate.maxJulianDay); + expect(LocalDate(2000, 1, 1).julianDay, 2451545); + expect(LocalDate(1970, 1, 1).julianDay, 2440588); + expect(LocalDate(0, 12, 31).addDays(1), LocalDate(1, 1, 1)); + expect(LocalDate.parse('0001-01-01 BC'), LocalDate(0, 1, 1)); + expect(LocalDate.parse('-0001-01-01'), LocalDate(-1, 1, 1)); + expect(LocalDate(0, 2, 28).addDays(1), LocalDate(0, 2, 29)); + expect(LocalDate(1900, 2, 28).addDays(1), LocalDate(1900, 3, 1)); + final random = Random(194); + for (var i = 0; i < 10000; i++) { + final ordinal = random.nextInt(LocalDate.maxJulianDay + 1); + final date = LocalDate.fromJulianDay(ordinal); + expect(LocalDate(date.year, date.month, date.day).julianDay, ordinal); + expect(LocalDate.parse(date.toString()), date); + } + for (var i = -150000; i < 150000; i += 53) { + final expected = DateTime.utc(2000).add(Duration(days: i)); + expect( + LocalDate.fromJulianDay(2451545 + i), + LocalDate(expected.year, expected.month, expected.day), + ); + } + expect(() => LocalDate(-4713, 11, 23), throwsRangeError); + expect(() => LocalDate(5874898, 1, 1), throwsRangeError); + expect(() => LocalDate(2025, 2, 29), throwsRangeError); + expect( + () => LocalDate(2000, 1, 1).addDays(9223372036854775807), + throwsRangeError, + ); + expect(LocalDate.tryParse('2024-13-01'), null); + expect(LocalDate.tryParse('2024-01-01Z'), null); + expect(LocalDate.tryParse('2024-01-01\n'), null); + }); + + test('local time precision, day endpoint and timestamp arithmetic', () { + expect(LocalTime.parse('12:34'), LocalTime(12, 34)); + expect(LocalTime.parse('12:34:56.000001').microsecond, 1); + expect(LocalTime(24).microseconds, 86400000000); + expect(LocalTime(24).compareTo(LocalTime(0)), greaterThan(0)); + expect(LocalTime.tryParse('24:00:00.000001'), null); + expect(LocalTime.tryParse('12:00:60'), null); + expect(LocalTime.tryParse('12:00:00.0000001'), null); + expect(LocalTime.tryParse('12:00:00+08:00'), null); + final midnight = LocalDateTime.parse('0001-01-01 00:00'); + expect( + midnight.add(const Duration(microseconds: -1)), + LocalDateTime.parse('0001-12-31 23:59:59.999999 BC'), + ); + expect( + midnight + .add(const Duration(microseconds: -1)) + .add(const Duration(microseconds: 1)), + midnight, + ); + expect( + LocalDateTime.parse('2024-02-29T24:00'), + LocalDateTime.parse('2024-03-01 00:00'), + ); + expect(LocalDateTime.tryParse('2024-01-01 00:00Z'), null); + expect(LocalDateTime.tryParse('294276-12-31 24:00'), null); + expect( + () => + LocalDateTime.parse('4714-11-24 00:00 BC') + .add(const Duration(microseconds: -1)), + throwsRangeError, + ); + expect(() => Codecs.date.decode(DateTime.utc(2024)), throwsFormatException); + expect( + () => Codecs.localDateTime.decode(DateTime.utc(2024)), + throwsFormatException, + ); + }); + + test( + 'PostgreSQL binary registry covers boundaries without DateTime', + () async { + final registry = postgresTypeRegistry(); + final context = pg.CodecContext.withDefaults(typeRegistry: registry); + // Use the public registry API; no dependency on private driver internals. + final limits = [-211813488000000000, -1, 0, 9223371331199999999]; + for (final ticks in limits) { + final data = ByteData(8)..setInt64(0, ticks); + final decoded = await registry.decode( + pg.EncodedValue.binary( + data.buffer.asUint8List(), + typeOid: pg.Type.timestampWithoutTimezone.oid, + ), + context, + ); + expect(decoded, isA()); + } + }, + ); + + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + group('local temporal $backend', () { + late Database db; + setUp(() async { + if (backend == 'sqlite') { + db = await sqlite(const SqliteOptions.memory()); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_temporal_tests', + ), + ); + await db.execute( + SqlCommand('DROP SCHEMA IF EXISTS orm_temporal_tests CASCADE'), + ); + await db.execute(SqlCommand('CREATE SCHEMA orm_temporal_tests')); + } + }); + tearDown(() => db.close()); + Future create() => Migrator( + db.sql, + ).apply([Migration.create('0001_local', appSchema, dialect: db.dialect)]); + + if (backend == 'postgres') { + test( + 'catalog retains time-truncating casts in expression defaults', + () async { + final schema = TableSchema( + 'defaults', + columns: [ + Column( + 'moment', + Codecs.localDateTime, + defaultSql: 'CURRENT_TIMESTAMP', + ), + ], + ); + final first = Migration.create('0001_default', [ + schema, + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + expect( + (await verifySchema(db.sql, first.snapshot!)).differences, + isEmpty, + ); + await db.execute( + SqlCommand( + 'ALTER TABLE defaults ALTER COLUMN moment SET DEFAULT (CURRENT_TIMESTAMP)::date', + ), + ); + expect( + (await verifySchema(db.sql, first.snapshot!)).differences, + contains('defaults.moment default differs'), + ); + }, + ); + } + + test( + 'generated writes, defaults, null patches and typed parameters', + () async { + await create(); + final day = LocalDate(2024, 2, 29); + final row = await db.appointments.create(day: day); + expect(row, ( + id: row.id, + day: day, + time: LocalTime(12, 30), + starts: null, + )); + final stamp = LocalDateTime.parse('2024-02-29 12:34:56.000001'); + await db.appointments.byId(row.id).patch(starts: Change.set(stamp)); + expect((await db.appointments.byId(row.id).single()).starts, stamp); + expect( + await db.appointments + .select( + (a) => ( + value(day, Codecs.date), + value(LocalTime(24), Codecs.time), + value(stamp, Codecs.localDateTime), + ).row, + ) + .single(), + (day, LocalTime(24), stamp), + ); + await db.appointments + .byId(row.id) + .patch(starts: const Change.set(null)); + expect((await db.appointments.single()).starts, null); + }, + ); + + test( + 'finite extremes and microseconds survive native rows and streams', + () async { + await create(); + final dates = [ + LocalDate.fromJulianDay(0), + LocalDate(0, 1, 1), + LocalDate(10000, 1, 1), + LocalDate.fromJulianDay(LocalDate.maxJulianDay), + ]; + final stamps = [ + LocalDateTime.parse('4714-11-24 00:00 BC'), + LocalDateTime.parse('0001-12-31 23:59:59.999999 BC'), + LocalDateTime.parse('2000-01-01 00:00:00.000001'), + LocalDateTime.parse('294276-12-31 23:59:59.999999'), + ]; + for (var i = 0; i < dates.length; i++) { + final time = i == 3 ? LocalTime(24) : LocalTime(0, 0, 0, i); + expect( + (await db.appointments.create( + day: dates[i], + time: Change.set(time), + starts: stamps[i], + )).starts, + stamps[i], + ); + } + final query = db.appointments.orderBy((a) => [a.id.asc()]); + expect((await query.get()).map((a) => a.day), dates); + expect( + (await query.stream(batchSize: 1).toList()).map((a) => a.starts), + stamps, + ); + if (backend == 'postgres') { + await db.session((s) async { + await s.execute(SqlCommand("SET DateStyle TO 'SQL, DMY'")); + await s.execute(SqlCommand("SET TIME ZONE 'Pacific/Auckland'")); + expect( + (await s.appointments.orderBy((a) => [a.id.asc()]).get()).map( + (a) => a.starts, + ), + stamps, + ); + }); + } + }, + ); + + test( + 'calendar ordering, min/max, grouping and stable date cursors', + () async { + await create(); + final dates = [ + LocalDate(10000, 1, 1), + LocalDate(2, 1, 1), + LocalDate(-10, 1, 1), + LocalDate(0, 1, 1), + ]; + for (final day in dates) { + await db.appointments.create(day: day); + } + final ordered = [...dates]..sort(); + expect( + await db.appointments + .orderBy((a) => [a.day.asc()]) + .select((a) => a.day) + .get(), + ordered, + ); + expect( + await db.appointments + .select((a) => (a.day.min(), a.day.max()).row) + .single(), + (ordered.first, ordered.last), + ); + expect( + await db.appointments + .where((a) => a.day.isIn(dates.take(2))) + .count(), + 2, + ); + final token = db.appointments.cursorToken( + (a) => [a.day.cursor(ordered[1]), a.id.cursor(4)], + ); + expect( + (await db.appointments + .seekToken(token, orderBy: (a) => [a.day.asc(), a.id.asc()]) + .get()) + .map((a) => a.day), + ordered.skip(2), + ); + final rolling = await db.appointments + .orderBy((a) => [a.id.asc()]) + .select( + (a) => a.day.min().over( + orderBy: [a.id.asc()], + frame: .rowsToCurrent, + ), + ) + .get(); + expect(rolling, [dates[0], dates[1], dates[2], dates[2]]); + }, + ); + + test( + 'equivalent date keys drive batched relations and unique indexes', + () async { + await create(); + final bc = LocalDate(0, 1, 1); + await db.holidays.create(day: bc, label: 'era'); + await db.execute( + SqlCommand("INSERT INTO visits (day) VALUES ('0001-01-01 BC')"), + ); + expect( + await db.holidays + .select((h) => h.visits.select((v) => v.day).many()) + .single(), + [bc], + ); + expect( + await db.visits + .select((v) => v.holiday.select((h) => h.label).one()) + .single(), + 'era', + ); + await expectLater( + db.holidays.create(day: bc, label: 'duplicate'), + throwsA(isA()), + ); + if (backend == 'sqlite') { + await db.execute( + SqlCommand("UPDATE visits SET day = '0000-01-01'"), + ); + expect( + await db.holidays + .select((h) => h.visits.select((v) => v.day).many()) + .single(), + [bc], + ); + final command = db.holidays.byId(bc).compile(); + final plan = await db.execute( + SqlCommand( + 'EXPLAIN QUERY PLAN ${command.sql}', + command.parameters, + ), + ); + expect( + plan.rows.map((r) => r.last).join(' '), + contains('USING INDEX'), + ); + } + }, + ); + + test( + 'time and local timestamp comparisons retain calendar semantics', + () async { + await create(); + final times = [ + LocalTime(24), + LocalTime(0, 0, 0, 1), + LocalTime(0), + LocalTime(23, 59, 59, 999999), + ]; + final stamps = [ + LocalDateTime.parse('10000-01-01 00:00'), + LocalDateTime.parse('0001-01-01 00:00 BC'), + LocalDateTime.parse('0002-01-01 00:00 BC'), + LocalDateTime.parse('2024-01-01 00:00'), + ]; + for (var i = 0; i < times.length; i++) { + await db.appointments.create( + day: LocalDate(2024, 1, 1), + time: Change.set(times[i]), + starts: stamps[i], + ); + } + expect( + await db.appointments + .orderBy((a) => [a.time.asc()]) + .select((a) => a.time) + .get(), + [...times]..sort(), + ); + expect( + await db.appointments + .orderBy((a) => [a.starts.asc()]) + .select((a) => a.starts) + .get(), + [...stamps]..sort(), + ); + expect( + await db.appointments.where((a) => a.time.gt(LocalTime(0))).count(), + 3, + ); + expect( + await db.appointments + .select( + (a) => ( + a.time.min(), + a.time.max(), + a.starts.min(), + a.starts.max(), + ).row, + ) + .single(), + (times[2], times[0], stamps[2], stamps[0]), + ); + final grouped = await db.appointments + .groupBy((a) => [a.day]) + .select((a) => (a.day, a.time.count()).row) + .single(); + expect(grouped, (LocalDate(2024, 1, 1), 4)); + final cte = db.appointments + .select((a) => a.starts) + .asCte('local_starts'); + expect( + await cte.query + .where((a) => a.ref((s) => s.starts).gt(stamps[3])) + .get(), + [stamps[0]], + ); + final query = db.appointments.select((a) => a.time); + expect(await query.union(query).get(), unorderedEquals(times)); + }, + ); + + test('reviewed text-to-time migration rolls back duplicate clock values', () async { + TableSchema schema(Codec codec) => TableSchema( + 'converted', + columns: [Column('id', Codecs.integer), Column('clock', codec)], + primaryKey: ['id'], + uniqueKeys: [ + ['clock'], + ], + ); + final first = Migration.create('0001_text', [ + schema(Codecs.text), + ], dialect: db.dialect); + await Migrator(db.sql).apply([first]); + await db.execute( + SqlCommand( + "INSERT INTO converted VALUES (1, '12:30'), (2, '12:30:00.000000')", + ), + ); + final second = Migration.diff( + '0002_time', + from: first.snapshot!, + to: SchemaSnapshot([schema(Codecs.time)]), + previous: first.checksum, + using: (db.dialect == SqlDialect.sqlite + ? { + 'converted': {'clock': 'clock'}, + } + : { + 'converted': { + 'clock': 'CAST(clock AS TIME WITHOUT TIME ZONE)', + }, + }), + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([first, second]), + throwsA(isA()), + ); + expect((await Migrator(db.sql).history()).length, 1); + expect( + (await verifySchema(db.sql, first.snapshot!)).differences, + isEmpty, + ); + await db.execute(SqlCommand('DELETE FROM converted WHERE id = 2')); + await Migrator(db.sql).apply([first, second]); + expect( + (await verifySchema(db.sql, second.snapshot!)).differences, + isEmpty, + ); + expect( + Codecs.time.decode( + (await db.execute(SqlCommand('SELECT clock FROM converted'))) + .rows + .single + .single, + ), + LocalTime(12, 30), + ); + }); + + test('invalid external dates fail typed reads without damaging the connection', () async { + await create(); + await db.execute( + SqlCommand( + "INSERT INTO appointments (day, starts) VALUES ('${backend == 'sqlite' ? 'invalid' : 'infinity'}', '${backend == 'sqlite' ? 'invalid' : '-infinity'}')", + ), + ); + await expectLater( + db.appointments.select((a) => a.day).get(), + throwsFormatException, + ); + await expectLater( + db.appointments.select((a) => a.starts).get(), + throwsFormatException, + ); + await db.appointments.create(day: LocalDate(2024, 1, 1)); + expect( + await db.appointments + .where((a) => a.day.eq(LocalDate(2024, 1, 1))) + .count(), + 1, + ); + }); + + test( + 'snapshots, catalog import and regenerated defaults agree', + () async { + await create(); + final snapshot = SchemaSnapshot(appSchema); + expect(physical.schema.checksum, snapshot.checksum); + expect((await verifySchema(db.sql, snapshot)).differences, isEmpty); + expect((await verifySchema(db.sql, snapshot)).unmanaged, isEmpty); + expect(await verifyColumns(db.sql, appSchema), isEmpty); + final imported = await importSchema(db.sql); + expect(imported.issues, isEmpty); + expect(imported.dart, contains('LocalDateTime')); + final directory = await Directory( + '.dart_tool/orm-temporal-import-$backend', + ).create(recursive: true); + try { + final file = File('${directory.path}/schema.dart'); + await file.writeAsString(imported.dart); + final generated = await generateSchema(file.path); + expect( + (await verifySchema(db.sql, generated.snapshot)).differences, + isEmpty, + ); + } finally { + await directory.delete(recursive: true); + } + }, + ); + + test( + 'historical date keys resume a bounded backfill in calendar order', + () async { + await create(); + for (final year in [10000, 2, -10, 0]) { + await db.holidays.create( + day: LocalDate(year, 1, 1), + label: 'pending', + ); + } + final first = Migration.create( + '0001_local', + appSchema, + dialect: db.dialect, + ); + final second = Migration.steps( + '0002_backfill', + [ + Backfill( + holidaysSchema, + set: {'label': "'done'"}, + where: "label = 'pending'", + doneWhen: "SELECT NOT EXISTS (SELECT 1 FROM holidays WHERE label = 'pending')", + batchSize: 1, + ), + ], + previous: first.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, second], maxBackfillBatches: 1); + expect( + (await db.holidays.where((h) => h.label.eq('done')).single()).day, + LocalDate(-10, 1, 1), + ); + await Migrator(db.sql).apply([first, second]); + expect(await db.holidays.where((h) => h.label.eq('done')).count(), 4); + }, + ); + }); + } +} diff --git a/test/transaction_test.dart b/test/transaction_test.dart new file mode 100644 index 00000000..6a6a97a6 --- /dev/null +++ b/test/transaction_test.dart @@ -0,0 +1,861 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/tables.dart'; + +Matcher code(String code) => + isA().having((e) => e.code, 'code', code); + +void main() { + runTests( + 'sqlite', + (observe) => sqlite(const SqliteOptions.memory(), onQuery: observe), + ); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + runTests('postgres', (observe) async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_transaction_tests', + maxConnections: 1, + ), + onQuery: observe, + ); + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_transaction_tests'), + ); + return db; + }); + } + test( + 'SQLite COMMIT busy is a known rejection followed by verified rollback', + () async { + final directory = await Directory.systemTemp.createTemp( + 'orm-commit-busy-', + ); + final options = SqliteOptions.file( + '${directory.path}/db.sqlite', + journal: .delete, + busyTimeout: const Duration(milliseconds: 40), + ); + final writer = await sqlite(options), reader = await sqlite(options); + await createTables(writer); + await writer.table(users).createRow((u) => [u.email.set('initial')]); + final entered = Completer(), release = Completer(); + final owner = reader.transaction((tx) async { + await tx.table(users).count(); + entered.complete(); + await release.future; + }); + try { + await entered.future; + await expectLater( + writer.transaction((tx) async { + await tx.table(users).update((u) => [u.score.set(9)]).execute(); + }), + throwsA( + isA() + .having((e) => e.code, 'code', 5) + .having((e) => e.commitRejected, 'commitRejected', true) + .having((e) => e.retryTransaction, 'retryTransaction', true), + ), + ); + expect((await writer.table(users).single()).score, 0); + // An actual deadline during the same known rejection remains a timeout, + // rather than an unknown-commit report. + await expectLater( + Future.sync( + () => writer.transaction((tx) async { + await tx.table(users).update((u) => [u.score.set(9)]).execute(); + }, timeout: const Duration(milliseconds: 20)), + ), + throwsA( + code( + writer.capabilities.cancellation + ? 'TRANSACTION.TIMEOUT' + : 'CAPABILITY.CANCEL', + ), + ), + ); + expect((await writer.table(users).single()).score, 0); + } finally { + release.complete(); + await owner; + await reader.close(); + await writer.close(); + await directory.delete(recursive: true); + } + }, + ); + test( + 'SQLite deadline while BEGIN waits for a writer preserves its connection', + () async { + final directory = await Directory.systemTemp.createTemp( + 'orm-begin-deadline-', + ); + final options = SqliteOptions.file( + '${directory.path}/db.sqlite', + busyTimeout: const Duration(milliseconds: 150), + ); + final writer = await sqlite(options), blocked = await sqlite(options); + final entered = Completer(), release = Completer(); + final owner = writer.transaction((tx) async { + entered.complete(); + await release.future; + }, options: const SqliteTransaction(mode: .immediate)); + var calls = 0; + try { + await entered.future; + await expectLater( + Future.sync( + () => blocked.transaction( + (tx) async { + calls++; + }, + options: const SqliteTransaction(mode: .immediate), + timeout: const Duration(milliseconds: 40), + ), + ), + throwsA( + code( + blocked.capabilities.cancellation + ? 'TRANSACTION.TIMEOUT' + : 'CAPABILITY.CANCEL', + ), + ), + ).timeout(const Duration(seconds: 2)); + expect(calls, 0); + release.complete(); + await owner; + expect( + await blocked.transaction( + (tx) async => (await tx.execute(SqlCommand('SELECT 1'))).rows, + ), + [ + [1], + ], + ); + } finally { + if (!release.isCompleted) release.complete(); + await owner; + await blocked.close(); + await writer.close(); + await directory.delete(recursive: true); + } + }, + ); +} + +void runTests( + String name, + Future> Function(void Function(QueryEvent)) open, +) { + group('transaction control $name', () { + late Database db; + final events = []; + final slow = name == 'postgres' + ? 'SELECT pg_sleep(10)' + : 'WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<100000000) SELECT sum(x) FROM n'; + setUp(() async { + db = await open(events.add); + await db.execute(SqlCommand('DROP TABLE IF EXISTS children')); + await db.execute(SqlCommand('DROP TABLE IF EXISTS users')); + await createTables(db); + await db.table(users).createRow((u) => [u.email.set('original')]); + events.clear(); + }); + tearDown(() => db.close()); + Future score() async => (await db.table(users).single()).score; + Future write(Database tx) async { + await tx.table(users).update((u) => [u.score.set(8)]).execute(); + } + + bool skipUnsupportedCancellation() { + if (db.capabilities.cancellation) return false; + markTestSkipped('This SQLite build does not export sqlite3_interrupt.'); + return true; + } + + test( + 'idle callback expires, rolls back and cannot issue late SQL', + () async { + if (skipUnsupportedCancellation()) return; + final entered = Completer(), + resume = Completer(), + finished = Completer(); + Database? escaped; + final future = db.transaction((tx) async { + escaped = tx; + await write(tx); + entered.complete(); + await resume.future; + try { + await write(tx); + } finally { + finished.complete(); + } + }, timeout: const Duration(milliseconds: 100)); + final failure = expectLater( + future, + throwsA(code('TRANSACTION.TIMEOUT')), + ); + await entered.future; + try { + await failure.timeout(const Duration(seconds: 2)); + expect(await score(), 0); + expect( + () => escaped!.execute(SqlCommand('SELECT 1')), + throwsA(code('SESSION.CLOSED')), + ); + } finally { + resume.complete(); + await finished.future; + } + expect(await score(), 0); + expect(events.where((e) => e.sql == 'COMMIT'), isEmpty); + }, + ); + + test( + 'deadline interrupts active SQL before releasing and reusing the lease', + () async { + if (skipUnsupportedCancellation()) return; + await expectLater( + db.transaction((tx) async { + await write(tx); + await tx.execute(SqlCommand(slow)); + }, timeout: const Duration(milliseconds: 80)), + throwsA(code('TRANSACTION.TIMEOUT')), + ).timeout(const Duration(seconds: 3)); + expect(await score(), 0); + expect(events.any((e) => e.sql == slow && e.error != null), true); + expect( + await db.transaction( + (tx) async => (await tx.execute(SqlCommand('SELECT 2'))).rows, + ), + [ + [2], + ], + ); + }, + ); + + test('interrupted write rolls back and leaves the connection usable', () async { + if (skipUnsupportedCancellation()) return; + final writing = name == 'postgres' + ? 'UPDATE users SET score = (SELECT 9 FROM pg_sleep(10))' + : 'WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<100000000) UPDATE users SET score=(SELECT sum(x) FROM n)'; + await expectLater( + db.transaction((tx) async { + await write(tx); + await tx.execute(SqlCommand(writing)); + }, timeout: const Duration(milliseconds: 80)), + throwsA(code('TRANSACTION.TIMEOUT')), + ).timeout(const Duration(seconds: 3)); + expect(await score(), 0); + }); + + test( + 'explicit transaction cancellation stops an idle callback and active SQL', + () async { + if (skipUnsupportedCancellation()) return; + final token = CancellationToken(), + entered = Completer(), + resume = Completer(); + final future = db.transaction((tx) async { + await write(tx); + entered.complete(); + await resume.future; + }, cancellation: token); + final failure = expectLater( + future, + throwsA(code('TRANSACTION.CANCELLED')), + ); + await entered.future; + token.cancel(); + try { + await failure.timeout(const Duration(seconds: 2)); + } finally { + resume.complete(); + } + expect(await score(), 0); + final running = CancellationToken(); + final timer = Timer(const Duration(milliseconds: 80), running.cancel); + try { + await expectLater( + db.transaction((tx) async { + await write(tx); + await tx.execute(SqlCommand(slow)); + }, cancellation: running), + throwsA(code('TRANSACTION.CANCELLED')), + ).timeout(const Duration(seconds: 3)); + } finally { + timer.cancel(); + } + expect(await score(), 0); + }, + ); + + test('global transaction cancellation also abandons acquisition', () async { + if (skipUnsupportedCancellation()) return; + final entered = Completer(), release = Completer(); + final owner = db.session((s) async { + entered.complete(); + await release.future; + }); + await entered.future; + var calls = 0; + final token = CancellationToken(); + final request = db.transaction((tx) async { + calls++; + }, cancellation: token); + final failure = expectLater( + request, + throwsA(code('OPERATION.CANCELLED')), + ); + token.cancel(); + try { + await failure.timeout(const Duration(seconds: 2)); + } finally { + release.complete(); + await owner; + } + await db.execute(SqlCommand('SELECT 1')); + expect(calls, 0); + }); + + test( + 'savepoint inherits deadline, drains and rolls back through raw cleanup', + () async { + if (skipUnsupportedCancellation()) return; + final resume = Completer(); + try { + await expectLater( + db.transaction((tx) async { + await write(tx); + await tx.savepoint((child) async { + await child.execute(SqlCommand('SELECT 1')); + await resume.future; + }); + }, timeout: const Duration(milliseconds: 100)), + throwsA(code('TRANSACTION.TIMEOUT')), + ).timeout(const Duration(seconds: 2)); + expect(await score(), 0); + expect( + events.any((e) => e.sql.startsWith('ROLLBACK TO SAVEPOINT')), + true, + ); + } finally { + resume.complete(); + } + }, + ); + + test( + 'deadline closes a paused transactional cursor before rollback', + () async { + if (skipUnsupportedCancellation()) return; + StreamSubscription? subscription; + final resume = Completer(); + try { + await expectLater( + db.transaction((tx) async { + await write(tx); + final first = Completer(); + subscription = tx.table(users).stream(batchSize: 1).listen((row) { + subscription!.pause(); + first.complete(); + }); + await first.future; + await resume.future; + }, timeout: const Duration(milliseconds: 100)), + throwsA(code('TRANSACTION.TIMEOUT')), + ).timeout(const Duration(seconds: 2)); + expect(await score(), 0); + } finally { + resume.complete(); + await subscription?.cancel(); + } + }, + ); + + test('deadline interrupts an active transactional cursor fetch', () async { + if (skipUnsupportedCancellation()) return; + final expression = sql( + [name == 'postgres' ? '(SELECT 1 FROM pg_sleep(10))' : '($slow)'], + [], + Codecs.integer, + ); + await expectLater( + db.transaction((tx) async { + await write(tx); + await tx + .table(users) + .select((u) => expression) + .stream(batchSize: 1) + .toList(); + }, timeout: const Duration(milliseconds: 80)), + throwsA(code('TRANSACTION.TIMEOUT')), + ).timeout(const Duration(seconds: 3)); + expect(await score(), 0); + }); + + test('transaction clock starts after acquisition and a borrowed session recovers', () async { + if (skipUnsupportedCancellation()) return; + final entered = Completer(), release = Completer(); + final owner = db.session((s) async { + entered.complete(); + await release.future; + }); + await entered.future; + final result = db.transaction( + (tx) async => (await tx.execute(SqlCommand('SELECT 1'))).rows, + timeout: const Duration(milliseconds: 100), + acquire: const AcquisitionOptions(timeout: Duration(seconds: 2)), + ); + await Future.delayed(const Duration(milliseconds: 150)); + release.complete(); + await owner; + expect(await result, [ + [1], + ]); + await db.session((session) async { + final resume = Completer(); + try { + await expectLater( + session.transaction((tx) async { + await write(tx); + await resume.future; + }, timeout: const Duration(milliseconds: 50)), + throwsA(code('TRANSACTION.TIMEOUT')), + ); + expect((await session.table(users).single()).score, 0); + } finally { + resume.complete(); + } + }); + }); + + test( + 'failed rollback is explicit and discards an uncertain connection', + () async { + final driver = _AfterCommitDriver(db.driver, () {}) + ..before = (command) { + if (command.sql == 'ROLLBACK') { + throw StateError('Rollback transport failed'); + } + }; + final hooked = Database(driver); + await expectLater( + hooked.transaction((tx) async { + await write(tx); + throw StateError('Original callback failure'); + }), + throwsA(code('TRANSACTION.ROLLBACK')), + ); + expect(driver.discards, 1); + if (name == 'postgres') { + expect(await score(), 0); + } else { + await expectLater( + db.table(users).count(), + throwsA(code('DRIVER.CLOSED')), + ); + } + }, + ); + + test( + 'transaction controls reject adapters without actual cancellation', + () { + final driver = _AfterCommitDriver(db.driver, () {}) + ..supportsCancellation = false; + final hooked = Database(driver); + expect( + () => hooked.transaction( + (tx) async {}, + retry: const TransactionRetry(), + ), + throwsA(code('CAPABILITY.CANCEL')), + ); + expect( + () => hooked.transaction( + (tx) async {}, + timeout: const Duration(seconds: 1), + ), + throwsA(code('CAPABILITY.CANCEL')), + ); + expect(events, isEmpty); + }, + ); + + test('shorter statement limit retains its error and rolls back the transaction', () async { + if (skipUnsupportedCancellation()) return; + await expectLater( + db.transaction((tx) async { + await write(tx); + await tx.execute( + SqlCommand(slow), + options: const ExecutionOptions( + timeout: Duration(milliseconds: 40), + ), + ); + }, timeout: const Duration(seconds: 2)), + throwsA(code('OPERATION.TIMEOUT')), + ); + expect(await score(), 0); + }); + + test( + 'monotonic deadline prevents commit after a CPU-bound callback', + () async { + if (skipUnsupportedCancellation()) return; + await expectLater( + db.transaction((tx) async { + final busy = Stopwatch()..start(); + while (busy.elapsed < const Duration(milliseconds: 150)) {} + }, timeout: const Duration(milliseconds: 100)), + throwsA(code('TRANSACTION.TIMEOUT')), + ); + expect(events.where((e) => e.sql == 'COMMIT'), isEmpty); + }, + ); + + test( + 'completed scopes remove their timers and cancellation listeners', + () async { + if (skipUnsupportedCancellation()) return; + final token = CancellationToken(); + await db.transaction( + (tx) async { + await write(tx); + }, + timeout: const Duration(milliseconds: 100), + cancellation: token, + ); + token.cancel(); + await Future.delayed(const Duration(milliseconds: 120)); + expect(await score(), 8); + await db.transaction((tx) async { + await tx.table(users).update((u) => [u.score.increment(1)]).execute(); + }); + expect(await score(), 9); + }, + ); + + test('confirmed commit wins a cancellation race', () async { + if (skipUnsupportedCancellation()) return; + final token = CancellationToken(); + final driver = _AfterCommitDriver(db.driver, () => token.cancel()); + final hooked = Database(driver); + await hooked.transaction((tx) async { + await write(tx); + }, cancellation: token); + expect(await score(), 8); + }); + + test('lost COMMIT acknowledgement stays unknown', () async { + final driver = _AfterCommitDriver( + db.driver, + () => throw StateError('Lost COMMIT acknowledgement'), + ); + final hooked = Database(driver); + await expectLater( + hooked.transaction((tx) async { + await tx.table(users).update((u) => [u.score.set(12)]).execute(); + }), + throwsA(code('TRANSACTION.COMMIT')), + ); + expect(await score(), 12); + }); + + test( + 'deferred constraint rejection at COMMIT is a known failure', + () async { + await db.execute( + SqlCommand( + 'CREATE TABLE children (id INTEGER PRIMARY KEY, user_id BIGINT REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED)', + ), + ); + await expectLater( + db.transaction((tx) async { + await tx.execute( + SqlCommand('INSERT INTO children(id, user_id) VALUES (1, 99)'), + ); + }), + throwsA( + isA() + .having((e) => e.commitRejected, 'commitRejected', true) + .having((e) => e.retryTransaction, 'retryTransaction', false), + ), + ); + expect( + (await db.execute(SqlCommand('SELECT count(*) FROM children'))).rows, + [ + [0], + ], + ); + }, + ); + + test( + 'native unsupported transaction controls reject before BEGIN or callback', + () { + if (db.capabilities.cancellation) { + markTestSkipped('This build supports transaction controls.'); + return; + } + var calls = 0; + Future action(Database tx) async { + calls++; + await write(tx); + } + + expect( + () => + db.transaction(action, timeout: const Duration(milliseconds: 20)), + throwsA(code('CAPABILITY.CANCEL')), + ); + expect( + () => db.transaction(action, cancellation: CancellationToken()), + throwsA(code('CAPABILITY.CANCEL')), + ); + expect( + () => db.transaction(action, retry: const TransactionRetry()), + throwsA(code('CAPABILITY.CANCEL')), + ); + expect(calls, 0); + expect(events, isEmpty); + }, + ); + + test('invalid deadlines and already cancelled tokens never begin a transaction', () { + expect( + () => db.transaction((tx) async {}, timeout: Duration.zero), + throwsArgumentError, + ); + expect( + () => db.transaction( + (tx) async {}, + cancellation: CancellationToken()..cancel(), + ), + throwsA(code('TRANSACTION.CANCELLED')), + ); + expect(events, isEmpty); + }); + + if (name == 'postgres') { + Future> concurrent() async => postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_transaction_tests', + maxConnections: 1, + ), + ); + test( + 'SQLSTATE 40003 at COMMIT is never classified as a confirmed rejection', + () async { + await db.execute( + SqlCommand( + r"CREATE OR REPLACE FUNCTION uncertain_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'uncertain result' USING ERRCODE = '40003'; END $$", + ), + ); + await db.execute( + SqlCommand( + 'CREATE CONSTRAINT TRIGGER uncertain AFTER UPDATE ON users DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION uncertain_commit()', + ), + ); + await expectLater( + db.transaction(write), + throwsA(code('TRANSACTION.COMMIT')), + ); + expect(await score(), 0); + }, + ); + + test( + 'real PostgreSQL serialization failure is classified after rollback', + () async { + final other = await concurrent(); + try { + await expectLater( + db.transaction( + (tx) async { + await tx.table(users).single(); + await other + .table(users) + .update((u) => [u.score.set(9)]) + .execute(); + await tx + .table(users) + .update((u) => [u.score.set(1)]) + .execute(); + }, + options: const PostgresTransaction(isolation: .repeatableRead), + ), + throwsA( + isA() + .having((e) => e.code, 'SQLSTATE', '40001') + .having( + (e) => e.retryTransaction, + 'retryTransaction', + true, + ), + ), + ); + expect(await score(), 9); + } finally { + await other.close(); + } + }, + ); + test( + 'real PostgreSQL deadlock rolls back exactly one transaction', + () async { + final other = await concurrent(); + await db.table(users).createRow((u) => [u.email.set('second')]); + final firstReady = Completer(), secondReady = Completer(); + Future attempt( + Database source, + int first, + int second, + Completer own, + Completer peer, + ) async { + try { + await source.transaction((tx) async { + await tx + .table(users) + .where((u) => u.id.eq(first)) + .update((u) => [u.score.increment(1)]) + .execute(); + own.complete(); + await peer.future; + await tx + .table(users) + .where((u) => u.id.eq(second)) + .update((u) => [u.score.increment(1)]) + .execute(); + }); + return 'committed'; + } catch (error) { + return error; + } + } + + try { + final outcomes = await Future.wait([ + attempt(db, 1, 2, firstReady, secondReady), + attempt(other, 2, 1, secondReady, firstReady), + ]).timeout(const Duration(seconds: 5)); + expect(outcomes.whereType(), ['committed']); + final error = outcomes.whereType().single; + expect(error.code, '40P01'); + expect(error.retryTransaction, true); + expect(await db.table(users).select((u) => u.score).get(), [1, 1]); + } finally { + await other.close(); + } + }, + ); + } + + if (name == 'sqlite') { + for (final nested in [false, true]) { + test( + 'automatic SQLite rollback prevents late autocommit writes, nested=$nested', + () async { + await expectLater( + db.transaction((tx) async { + await write(tx); + Future fail(Database scope) => scope + .execute( + SqlCommand( + "INSERT OR ROLLBACK INTO users(email) VALUES ('original')", + ), + ) + .then((_) {}); + try { + if (nested) { + await tx.savepoint(fail); + } else { + await fail(tx); + } + } on SqliteFailure { + /* The application catches the statement error. */ + } + expect( + () => tx.execute( + SqlCommand( + "INSERT INTO users(email) VALUES ('must-not-commit')", + ), + ), + throwsA(code('TRANSACTION.ENDED')), + ); + }), + throwsA(code('TRANSACTION.FAILED')), + ); + expect(await score(), 0); + expect(events.where((e) => e.sql == 'ROLLBACK'), isEmpty); + }, + ); + } + } + }); +} + +final class _AfterCommitDriver(final Driver source, this.after) + implements Driver { + void Function() after; + void Function(SqlCommand)? before; + bool supportsCancellation = true; + int discards = 0; + @override + Capabilities get capabilities => supportsCancellation + ? source.capabilities + : Capabilities( + dialect: source.capabilities.dialect, + maxParameters: source.capabilities.maxParameters, + ); + @override + Future run(Future Function(SqlConnection) action) => + source.run((c) => action(_AfterCommitConnection(c, this))); + @override + Future close() => source.close(); +} + +final class _AfterCommitConnection( + final SqlConnection inner, + final _AfterCommitDriver driver, +) implements SqlConnection { + @override + bool? get transactionActive => inner.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + driver.before?.call(command); + final result = await inner.execute(command, options: options); + if (command.sql == 'COMMIT') driver.after(); + return result; + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => inner.openCursor(command, options: options); + @override + Future invalidate() { + driver.discards++; + return inner.invalidate(); + } +} diff --git a/test/types_test.dart b/test/types_test.dart new file mode 100644 index 00000000..5734dd08 --- /dev/null +++ b/test/types_test.dart @@ -0,0 +1,328 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:test/test.dart'; + +void main() { + test('junction-table keys, enum payloads and nested results keep generated types', () async { + final dir = await Directory('.dart_tool/orm-many-types') + .create(recursive: true); + final file = File('${dir.path}/negative.dart').absolute; + final invalid = [ + 'db.memberships.byId(1);', + 'db.memberships.byId(teamId: 1);', + "db.memberships.create(teamId: 'wrong', userId: 1, joinedAt: DateTime.now());", + "db.memberships.byId(teamId: 1, userId: 1).patch(role: const Change.set('owner'));", + 'db.users.select((u) => u.memberships.connect(1));', + 'final Future> rows = db.users.select((u) => u.memberships.many()).get();', + ]; + await file.writeAsString( + "import 'package:orm/orm.dart';\nimport '../../example/teams/schema.orm.dart';\nvoid wrong(Database db) {\n${invalid.join('\n')}\n}\n", + ); + final contexts = AnalysisContextCollection(includedPaths: [file.path]); + try { + final result = + await contexts + .contextFor(file.path) + .currentSession + .getResolvedUnit(file.path) + as ResolvedUnitResult; + final errors = result.diagnostics + .where((e) => e.severity.name.toLowerCase() == 'error') + .toList(); + expect( + errors.any( + (e) => e.diagnosticCode.lowerCaseName.contains('uri_does_not_exist'), + ), + false, + ); + for (var i = 0; i < invalid.length; i++) { + expect( + errors.any( + (e) => result.lineInfo.getLocation(e.offset).lineNumber == i + 4, + ), + true, + reason: invalid[i], + ); + } + } finally { + await contexts.dispose(); + await dir.delete(recursive: true); + } + }); + test('computed generated fields are statically readable and never writable', () async { + final dir = await Directory('.dart_tool/orm-computed-types') + .create(recursive: true); + final file = File('${dir.path}/negative.dart').absolute; + final invalid = [ + "db.lines.create(price: 1, quantity: 1, label: '', total: 1);", + 'db.lines.byId(1).patch(total: const Change.set(1));', + 'db.lines.update((r) => [r.total.set(1)]);', + 'db.lines.update((r) => [r.total.increment(1)]);', + 'db.lines.update((r) => [r.total.defaultValue()]);', + 'db.lines.update((r) => [r.total.setExpression(r.price)]);', + 'db.lines.select((r) => r.total.eq("wrong"));', + ]; + await file.writeAsString( + "import 'package:orm/orm.dart';\nimport '../../test/support/computed/schema.orm.dart';\nvoid wrong(Database db) {\n${invalid.join('\n')}\n}\n", + ); + final contexts = AnalysisContextCollection(includedPaths: [file.path]); + try { + final result = + await contexts + .contextFor(file.path) + .currentSession + .getResolvedUnit(file.path) + as ResolvedUnitResult; + final errors = result.diagnostics + .where((e) => e.severity.name.toLowerCase() == 'error') + .toList(); + expect( + errors.any( + (e) => e.diagnosticCode.lowerCaseName.contains('uri_does_not_exist'), + ), + false, + ); + for (var i = 0; i < invalid.length; i++) { + expect( + errors.any( + (e) => result.lineInfo.getLocation(e.offset).lineNumber == i + 4, + ), + true, + reason: invalid[i], + ); + } + } finally { + await contexts.dispose(); + await dir.delete(recursive: true); + } + }); + test('client-default create parameters preserve domain, nullable and timestamp types', () async { + final directory = await Directory('.dart_tool/orm-default-type-tests') + .create(recursive: true); + final file = File('${directory.path}/negative.dart').absolute; + final invalid = [ + 'db.tickets.create(id: const Change.set(1));', + 'db.tickets.create(name: const Change.set(null));', + "db.tickets.create(createdAt: const Change.set('now'));", + "db.tickets.create(label: 'value');", + ]; + await file.writeAsString( + "import 'package:orm/orm.dart';\nimport '../../test/support/defaults/schema.orm.dart';\nvoid wrong(Database db) {\n${invalid.join('\n')}\n}\n", + ); + final contexts = AnalysisContextCollection(includedPaths: [file.path]); + try { + final result = + await contexts + .contextFor(file.path) + .currentSession + .getResolvedUnit(file.path) + as ResolvedUnitResult; + final errors = result.diagnostics + .where((e) => e.severity.name.toLowerCase() == 'error') + .toList(); + expect( + errors.any( + (e) => e.diagnosticCode.lowerCaseName.contains('uri_does_not_exist'), + ), + false, + ); + for (var i = 0; i < invalid.length; i++) { + expect( + errors.any( + (e) => result.lineInfo.getLocation(e.offset).lineNumber == i + 4, + ), + true, + reason: invalid[i], + ); + } + } finally { + await contexts.dispose(); + await directory.delete(recursive: true); + } + }); + test('query-only relation APIs retain result types and expose no graph writes', () async { + final directory = await Directory('.dart_tool/orm-relation-type-tests') + .create(recursive: true); + final file = File('${directory.path}/negative.dart').absolute; + final invalid = [ + 'db.entries.select((e) => e.ownerAccount.update((a) => [a.id.set(1)]));', + 'db.entries.select((e) => e.ownerAccount.delete());', + 'db.entries.select((e) => e.ownerAccount.connect(1));', + 'final Future> values = db.entries.select((e) => e.ownerAccount.select((a) => a.id).one()).get();', + 'final Future> rows = db.entries.select((e) => e.matchingAccounts.select((a) => a.id).many()).get();', + ]; + await file.writeAsString( + "import 'package:orm/orm.dart';\nimport '../../test/support/unconstrained/schema.orm.dart';\nvoid wrong(Database db) {\n${invalid.join('\n')}\n}\n", + ); + final contexts = AnalysisContextCollection(includedPaths: [file.path]); + try { + final result = + await contexts + .contextFor(file.path) + .currentSession + .getResolvedUnit(file.path) + as ResolvedUnitResult; + final errors = result.diagnostics + .where((e) => e.severity.name.toLowerCase() == 'error') + .toList(); + expect( + errors.any( + (e) => e.diagnosticCode.lowerCaseName.contains('uri_does_not_exist'), + ), + false, + ); + for (var i = 0; i < invalid.length; i++) { + expect( + errors.any( + (e) => result.lineInfo.getLocation(e.offset).lineNumber == i + 4, + ), + true, + reason: invalid[i], + ); + } + } finally { + await contexts.dispose(); + await directory.delete(recursive: true); + } + }); + + test('calendar APIs reject instants, strings and mixed temporal types', () async { + final directory = await Directory('.dart_tool/orm-temporal-type-tests') + .create(recursive: true); + final file = File('${directory.path}/negative.dart').absolute; + final invalid = [ + "db.appointments.create(day: DateTime.utc(2024));", + "db.appointments.byId(1).patch(starts: const Change.set('2024-01-01'));", + "db.appointments.where((a) => a.time.eq(LocalDate(2024, 1, 1)));", + "db.appointments.where((a) => a.day.eq('2024-01-01'));", + "db.appointments.select((a) => a.day).union(db.appointments.select((a) => a.time));", + ]; + await file.writeAsString( + "import 'package:orm/orm.dart';\nimport '../../test/support/temporals/schema.orm.dart';\nvoid wrong(Database db) {\n${invalid.join('\n')}\n}\n", + ); + final contexts = AnalysisContextCollection(includedPaths: [file.path]); + try { + final result = + await contexts + .contextFor(file.path) + .currentSession + .getResolvedUnit(file.path) + as ResolvedUnitResult; + final errors = result.diagnostics + .where((e) => e.severity.name.toLowerCase() == 'error') + .toList(); + expect( + errors.any( + (e) => e.diagnosticCode.lowerCaseName.contains('uri_does_not_exist'), + ), + false, + ); + for (var i = 0; i < invalid.length; i++) { + expect( + errors.any( + (e) => result.lineInfo.getLocation(e.offset).lineNumber == i + 4, + ), + true, + reason: invalid[i], + ); + } + } finally { + await contexts.dispose(); + await directory.delete(recursive: true); + } + }); + + test( + 'actual generated APIs reject wrong inputs, results and backend options', + () async { + final directory = await Directory('.dart_tool/orm-type-tests') + .create(recursive: true); + final file = File('${directory.path}/negative.dart').absolute; + await file.writeAsString(''' +import 'package:orm/orm.dart'; +import '../../example/schema.orm.dart'; +import '../../test/support/codecs/schema.orm.dart'; +import '../../test/support/codecs/types.dart'; +void wrong(Database db) { + db.users.create(email: 1); + db.users.byId('wrong'); + db.users.byId(1).patch(email: const Change.set(null)); + db.users.select((u) => u.email.eq(1)); + db.transaction((tx) async {}, options: const PostgresTransaction()); + final Query bad = db.users.select((u) => u.email); + db.users.seekAfter((u) => [u.id.cursor('wrong')]); + print(bad); + db.people.byId(1); + db.people.byId(const PersonId(1)).patch(email: const Change.set('wrong')); + db.people.byId(const PersonId(1)).patch(membership: const Change.set('active')); + db.people.select((p) => p.id.eq(1)); + db.people.select((p) => p.email.eq('wrong')); + db.people.byId(const PersonId(1)).patch(tags: const Change.set([1])); + db.people.byId(const PersonId(1)).patch(details: const Change.set('raw-json')); + final Stream> wrongWatch = db.users.select((u) => u.email).watch(); + db.users.watch(reads: ['users']); + print(wrongWatch); + db.users.select((u) => u.id).union(db.users.select((u) => u.email)); + db.users.select((u) => (u.id, u.email).row).union(db.users.select((u) => (u.email, u.id).row)); + final Future> wrongUnion = db.users.select((u) => (u.id, u.email).row).union(db.users.select((u) => (u.id, u.email).row)).get(); + print(wrongUnion); +} +'''); + final contexts = AnalysisContextCollection(includedPaths: [file.path]); + try { + final result = + await contexts + .contextFor(file.path) + .currentSession + .getResolvedUnit(file.path) + as ResolvedUnitResult; + final errors = result.diagnostics + .where((e) => e.severity.name.toLowerCase() == 'error') + .toList(); + expect(errors.length, greaterThanOrEqualTo(19)); + expect( + errors.every( + (e) => + !e.diagnosticCode.lowerCaseName.contains('uri_does_not_exist'), + ), + true, + reason: errors.join('\n'), + ); + for (final line in [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 24, + 25, + 26, + ]) { + expect( + errors.any( + (e) => result.lineInfo.getLocation(e.offset).lineNumber == line, + ), + true, + reason: 'Expected a type error on line $line: ${errors.join('\n')}', + ); + } + } finally { + await contexts.dispose(); + await directory.delete(recursive: true); + } + }, + ); +} diff --git a/test/unconstrained_relation_test.dart b/test/unconstrained_relation_test.dart new file mode 100644 index 00000000..4a33eb60 --- /dev/null +++ b/test/unconstrained_relation_test.dart @@ -0,0 +1,489 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/unconstrained/schema.orm.dart'; + +Matcher code(String value) => + isA().having((e) => e.code, 'code', value); + +void main() { + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + group( + 'unconstrained ${dialect.name}', + () { + late Database db; + final events = []; + setUp(() async { + if (dialect == SqlDialect.sqlite) { + db = await sqlite( + const SqliteOptions.memory(), + onQuery: events.add, + ); + } else { + db = postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: 'orm_unconstrained_tests', + ), + onQuery: events.add, + ); + await db.execute( + SqlCommand( + 'DROP SCHEMA IF EXISTS orm_unconstrained_tests CASCADE', + ), + ); + await db.execute( + SqlCommand('CREATE SCHEMA orm_unconstrained_tests'), + ); + } + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + await db.execute( + SqlCommand( + 'INSERT INTO accounts(tenant,id,display_label,manager_id) VALUES ' + "(1,1,'dup',NULL),(1,2,'dup',1),(1,3,NULL,404),(2,1,'dup',NULL)", + ), + ); + await db.execute( + SqlCommand( + 'INSERT INTO entries(id,tenant,owner,lookup_label) VALUES ' + "(1,1,1,'dup'),(2,1,99,'dup'),(3,NULL,1,'dup'),(4,1,NULL,NULL)," + "(5,2,1,'dup'),(6,3,1,'absent'),(7,1,3,NULL)", + ), + ); + events.clear(); + }); + tearDown(() => db.close()); + + test( + 'catalogs contain no FK, implicit index or uniqueness promise', + () async { + for (final table in appSchema) { + expect(table.foreignKeys, isEmpty); + final actual = await inspectTable(db.sql, table.name); + expect(actual.foreignKeys, isEmpty); + expect(actual.uniqueKeys, isEmpty); + expect(actual.indexes, isEmpty); + } + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, + true, + ); + // A dangling reference and duplicate lookup keys are valid stored data. + expect(await db.entries.count(), 7); + expect( + await db.accounts.where((a) => a.label.eq('dup')).count(), + 3, + ); + }, + ); + + test( + 'adding and removing a real FK remains a reviewed physical migration', + () async { + final constrained = [ + accountsSchema, + TableSchema( + entriesSchema.name, + columns: entriesSchema.columns, + primaryKey: entriesSchema.primaryKey, + foreignKeys: [ + const ForeignKey( + ['tenant', 'owner'], + 'accounts', + ['tenant', 'id'], + ), + ], + ), + readingsSchema, + ]; + final first = Migration.create( + '0001_initial', + appSchema, + dialect: db.dialect, + ); + final enforce = Migration.diff( + '0002_enforce', + from: SchemaSnapshot(appSchema), + to: SchemaSnapshot(constrained), + previous: first.checksum, + dialect: db.dialect, + ); + await expectLater( + Migrator(db.sql).apply([first, enforce]), + throwsA(anything), + ); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, + true, + ); + expect(await db.entries.count(), 7); + await db.entries.byId(2).patch(owner: .set(1)); + await db.entries.byId(6).patch(tenant: .set(1)); + await Migrator(db.sql).apply([first, enforce]); + expect( + (await inspectTable(db.sql, 'entries')).foreignKeys.length, + 1, + ); + final release = Migration.diff( + '0003_unconstrain', + from: SchemaSnapshot(constrained), + to: SchemaSnapshot(appSchema), + previous: enforce.checksum, + dialect: db.dialect, + ); + await Migrator(db.sql).apply([first, enforce, release]); + expect( + (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, + true, + ); + await db.entries.create(id: 8, tenant: 9, owner: 99); + expect(await db.entries.count(), 8); + expect( + await db.entries + .byId(8) + .select((e) => e.ownerAccount.one()) + .single(), + isNull, + ); + }, + ); + + test('unique joins preserve root rows, NULL components and missing targets', () async { + final rows = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => ( + e.id, + e.ownerAccount.select((a) => (a.id, a.label).row).one(), + ).map((id, owner) => (id, owner)), + ) + .get(); + expect(rows, [ + (1, (1, 'dup')), + (2, null), + (3, null), + (4, null), + (5, (1, 'dup')), + (6, null), + (7, (3, null)), + ]); + expect(events.length, 1); + expect(events.single.sql, contains('LEFT JOIN')); + events.clear(); + final page = await db.entries + .orderBy((e) => [e.id.asc()]) + .skip(1) + .take(2) + .select( + (e) => ( + e.id, + e.ownerAccount.select((a) => a.id).one(), + ).map((id, owner) => (id, owner)), + ) + .get(); + expect(page, [(2, null), (3, null)]); + expect(events.length, 1); + }); + + test('required target and filter visibility remain explicit', () async { + await expectLater( + db.entries + .byId(2) + .select((e) => e.ownerAccount.required()) + .single(), + throwsA(code('RELATION.MISSING')), + ); + expect( + await db.entries + .byId(1) + .select((e) => e.ownerAccount.where((a) => a.id.eq(2)).one()) + .single(), + isNull, + ); + expect( + await db.entries + .byId(7) + .select((e) => e.ownerAccount.select((a) => a.label).required()) + .single(), + isNull, + ); + expect(await db.entries.count(), 7); + }); + + test( + 'nonunique composite collections batch by tuple and page per parent', + () async { + final rows = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.matchingAccounts + .orderBy((a) => [a.id.asc()]) + .select((a) => a.id) + .many(), + ) + .get(); + expect(rows, >[ + [1, 2], + [1, 2], + [], + [], + [1], + [], + [], + ]); + expect(events.length, 2); + events.clear(); + final pages = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => e.matchingAccounts + .orderBy((a) => [a.id.asc()]) + .skip(1) + .take(1) + .select((a) => a.id) + .many(), + ) + .get(); + expect(pages, >[ + [2], + [2], + [], + [], + [], + [], + [], + ]); + expect(events.length, 2); + expect(events.last.sql, contains('ROW_NUMBER()')); + }, + ); + + test( + 'to-one uses cardinality checks without assuming a unique lookup', + () async { + expect( + () => db.entries.select( + (e) => e.matchingAccounts.one(strategy: .join), + ), + throwsA(code('RELATION.JOIN_KEY')), + ); + expect(events, isEmpty); + await expectLater( + db.entries + .byId(1) + .select((e) => e.matchingAccounts.one()) + .single(), + throwsA(code('RELATION.CARDINALITY')), + ); + expect(events.length, 2); + expect( + await db.entries + .byId(1) + .select( + (e) => e.matchingAccounts + .where((a) => a.id.eq(2)) + .select((a) => a.id) + .one(), + ) + .single(), + 2, + ); + expect( + await db.entries + .byId(1) + .select( + (e) => e.matchingAccounts + .orderBy((a) => [a.id.desc()]) + .take(1) + .select((a) => a.id) + .one(), + ) + .single(), + 2, + ); + }, + ); + + test( + 'correlated filters and counts do not materialize related rows', + () async { + final rows = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => ( + e.matchingAccounts.count(), + e.matchingAccounts.any(), + e.matchingAccounts.none(), + e.matchingAccounts.every((a) => a.id.gte(2)), + ).row, + ) + .get(); + expect(rows, [ + (2, true, false, false), + (2, true, false, false), + (0, false, true, true), + (0, false, true, true), + (1, true, false, false), + (0, false, true, true), + (0, false, true, true), + ]); + expect(events.length, 1); + }, + ); + + test( + 'self relations and inverse collections share the existing planner', + () async { + final rows = await db.accounts + .orderBy((a) => [a.tenant.asc(), a.id.asc()]) + .select( + (a) => ( + a.manager.select((m) => m.id).one(), + a.reports + .orderBy((r) => [r.id.asc()]) + .select((r) => r.id) + .many(), + ).map((manager, reports) => (manager, reports)), + ) + .get(); + expect(rows.map((r) => r.$1), [null, 1, null, null]); + expect(rows.map((r) => r.$2), >[ + [2], + [], + [], + [], + ]); + expect(events.length, 2); + final entries = await db.accounts + .byId(tenant: 1, id: 1) + .select((a) => a.entries.select((e) => e.id).many()) + .single(); + expect(entries, [1]); + }, + ); + + test( + 'delete has no cascade and watches track the joined target', + () async { + final snapshots = StreamIterator( + db.entries + .byId(1) + .select((e) => e.ownerAccount.select((a) => a.id).one()) + .watch(), + ); + try { + expect( + await snapshots.moveNext().timeout(const Duration(seconds: 5)), + true, + ); + expect(snapshots.current, [1]); + await db.accounts.byId(tenant: 1, id: 1).delete().execute(); + expect( + await snapshots.moveNext().timeout(const Duration(seconds: 5)), + true, + ); + expect(snapshots.current, [null]); + expect(await db.entries.count(), 7); + expect((await db.entries.byId(1).single()).owner, 1); + expect( + (await db.accounts.byId(tenant: 1, id: 2).single()).managerId, + 1, + ); + } finally { + await snapshots.cancel(); + } + }, + ); + + test('transaction reads observe writes and outer rollback restores targets', () async { + await expectLater( + db.transaction((tx) async { + await tx.accounts.byId(tenant: 1, id: 1).delete().execute(); + expect( + await tx.entries + .byId(1) + .select((e) => e.ownerAccount.one()) + .single(), + isNull, + ); + throw StateError('rollback'); + }), + throwsStateError, + ); + expect( + await db.entries + .byId(1) + .select((e) => e.ownerAccount.select((a) => a.id).one()) + .single(), + 1, + ); + }); + + test( + 'streaming preserves optional and batched relation result shapes', + () async { + final rows = await db.entries + .orderBy((e) => [e.id.asc()]) + .select( + (e) => ( + e.ownerAccount.select((a) => a.id).one(), + e.matchingAccounts + .orderBy((a) => [a.id.asc()]) + .select((a) => a.id) + .many(), + ).map((owner, matches) => (owner, matches)), + ) + .stream(batchSize: 2) + .toList(); + expect(rows.map((r) => r.$1), [1, null, null, null, 1, null, 3]); + expect(rows.map((r) => r.$2), >[ + [1, 2], + [1, 2], + [], + [], + [1], + [], + [], + ]); + }, + ); + + test( + 'floating relation keys retain storage intent and key equality', + () async { + await db.readings.create(id: 1, value: 1e20); + await db.readings.create(id: 2, value: 1e20); + await db.readings.create(id: 3, value: 2e20); + final rows = await db.readings + .orderBy((r) => [r.id.asc()]) + .select( + (r) => r.peers + .orderBy((p) => [p.id.asc()]) + .select((p) => p.id) + .many(), + ) + .get(); + expect(rows, [ + [1, 2], + [1, 2], + [3], + ]); + }, + ); + }, + skip: + dialect == SqlDialect.postgres && + Platform.environment['ORM_TEST_POSTGRES'] == null + ? 'Set ORM_TEST_POSTGRES.' + : false, + ); + } +} diff --git a/test/union_test.dart b/test/union_test.dart new file mode 100644 index 00000000..ea7a201a --- /dev/null +++ b/test/union_test.dart @@ -0,0 +1,520 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import 'support/tables.dart'; + +Matcher code(String expected) => + isA().having((e) => e.code, 'code', expected); + +void main() { + runTests( + 'sqlite', + (observe) => sqlite(const SqliteOptions.memory(), onQuery: observe), + ); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + runTests('postgres', (observe) async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_union_tests', + maxConnections: 1, + ), + onQuery: observe, + ); + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_union_tests'), + ); + return db; + }); + } +} + +void runTests( + String name, + Future> Function(void Function(QueryEvent)) open, +) { + group('union $name', () { + late Database db; + final events = []; + setUp(() async { + db = await open(events.add); + await db.execute(SqlCommand('DROP TABLE IF EXISTS posts')); + await db.execute(SqlCommand('DROP TABLE IF EXISTS users')); + for (final command in createSchema([ + usersSchema, + postsSchema, + ], db.dialect)) { + await db.execute(command); + } + for (final email in ['one', 'two', 'three']) { + await db.table(users).createRow((u) => [u.email.set(email)]); + } + await db + .table(postsTable) + .createRow((p) => [p.authorId.set(1), p.title.set('one')]); + await db + .table(postsTable) + .createRow((p) => [p.authorId.set(1), p.title.set('post')]); + events.clear(); + }); + tearDown(() => db.close()); + + test('SQL duplicate and NULL semantics use one statement', () async { + final emails = db.table(users).select((u) => u.email); + final titles = db.table(postsTable).select((p) => p.title); + expect( + await emails.union(titles).get(), + unorderedEquals(['one', 'two', 'three', 'post']), + ); + expect(events.length, 1); + expect(await emails.unionAll(titles).count(), 5); + expect( + await db + .table(users) + .select((u) => u.nickname) + .union(db.table(postsTable).select((p) => p.tag)) + .get(), + [null], + ); + }); + + test( + 'heterogeneous records map only after SQL union and final pagination', + () async { + final result = db + .table(users) + .select((u) => (u.id, u.email).row) + .unionAll(db.table(postsTable).select((p) => (p.id, p.title).row)) + .orderBy( + (u) => [u.ref((u) => u.id).asc(), u.ref((u) => u.email).asc()], + ); + final List<(int, String)> rows = await result.get(); + expect(rows, [ + (1, 'one'), + (1, 'one'), + (2, 'post'), + (2, 'two'), + (3, 'three'), + ]); + var mapped = 0; + final selected = result + .skip(1) + .take(2) + .select( + (u) => + (u.ref((u) => u.id), u.ref((u) => u.email)).map((id, label) { + mapped++; + return (id: id, label: label.toUpperCase()); + }), + ); + selected.compile(); + expect(mapped, 0); + expect(await selected.get(), [ + (id: 1, label: 'ONE'), + (id: 2, label: 'POST'), + ]); + expect(mapped, 2); + final mappedRows = result + .take(1) + .map((row) => (id: row.$1, label: row.$2)); + expect(await mappedRows.single(), (id: 1, label: 'one')); + expect( + () => mappedRows.union(mappedRows), + throwsA(code('QUERY.UNION_SELECTION')), + ); + }, + ); + + test( + 'operand limits, order, parameters and final filter stay scoped', + () async { + const hostile = "x' OR 1=1 --"; + final left = db + .table(users) + .where((u) => u.email.ne(hostile)) + .orderBy((u) => [u.id.desc()]) + .take(1) + .select((u) => u.email); + final right = db + .table(postsTable) + .where((p) => p.title.ne('post')) + .orderBy((p) => [p.id.asc()]) + .take(1) + .select((p) => p.title); + final query = left + .unionAll(right) + .where((u) => u.ref((u) => u.email).ne('one')); + expect(query.compile().sql, isNot(contains(hostile))); + expect(query.compile().parameters, contains(hostile)); + expect(await query.get(), ['three']); + expect(await left.get(), ['three']); + expect(await right.get(), ['one']); + }, + ); + + test('repeated SQL expressions preserve positional columns', () async { + final left = db + .table(users) + .where((u) => u.id.eq(1)) + .select((u) => (u.id, u.id).row); + final right = db + .table(users) + .where((u) => u.id.eq(2)) + .select((u) => (u.id, u.score).row); + expect( + await left.unionAll(right).get(), + unorderedEquals([(1, 1), (2, 0)]), + ); + expect( + await left.asCte('repeated').query.unionAll(right).get(), + unorderedEquals([(1, 1), (2, 0)]), + ); + }); + + test( + 'nested sets preserve association, CTE exports and streaming', + () async { + final a = db + .table(users) + .where((u) => u.id.eq(1)) + .select((u) => u.email); + final b = db + .table(postsTable) + .where((p) => p.id.eq(1)) + .select((p) => p.title); + final c = db + .table(users) + .where((u) => u.id.eq(2)) + .select((u) => u.email); + expect( + await a.unionAll(b).union(c).get(), + unorderedEquals(['one', 'two']), + ); + expect( + await a.unionAll(b.union(c)).get(), + unorderedEquals(['one', 'one', 'two']), + ); + final cte = a.unionAll(b).asCte('names'); + final exported = cte.query.select( + (c) => c.ref((u) => u.ref((u) => u.email)), + ); + expect(await exported.stream(batchSize: 1).toList(), ['one', 'one']); + expect(await cte.query.unionAll(c).count(), 3); + }, + ); + + test( + 'same CTE names in distinct operands stay in separate scopes', + () async { + final a = db + .table(users) + .select((u) => (u.id, u.email).row) + .asCte('same'); + final b = db + .table(postsTable) + .select((p) => (p.id, p.title).row) + .asCte('same'); + expect(await a.query.union(b.query).count(), 4); + final joined = a.query.unionAll(b.query).asCte('combined').alias(); + final source = db + .table(users) + .join( + joined, + on: (u, c) => + u.id.equals(c.ref((u) => u.ref((c) => c.ref((u) => u.id)))), + ); + expect(await source.count(), 5); + }, + ); + + test( + 'SQL scalar, IN subqueries, aggregate and zero-row results compose', + () async { + final query = db + .table(users) + .where((u) => u.id.eq(1)) + .select((u) => u.id) + .union(db.table(postsTable).select((p) => p.authorId)); + expect( + await db.table(users).where((u) => u.id.isInQuery(query)).count(), + 1, + ); + expect( + await db + .table(users) + .take(1) + .select((u) => query.take(1).scalar()) + .single(), + 1, + ); + expect(await query.select((u) => u.ref((u) => u.id).sum()).single(), 1); + expect(await query.take(0).exists(), false); + expect(await query.take(0).get(), isEmpty); + }, + ); + + test('grouped branches and window branches remain SQL queries', () async { + final grouped = db.table(users).select((u) => u.id.count()); + final window = db + .table(postsTable) + .orderBy((p) => [p.id.asc()]) + .take(1) + .select((p) => rowNumber(orderBy: [p.id.asc()])); + expect(await grouped.unionAll(window).get(), unorderedEquals([3, 1])); + final groups = db + .table(users) + .groupBy((u) => [u.score]) + .having((u) => u.id.count().gt(1)) + .select((u) => (u.score, u.id.count()).row); + final postGroups = db + .table(postsTable) + .groupBy((p) => [p.authorId]) + .select((p) => (p.authorId, p.id.count()).row); + expect( + await groups.unionAll(postGroups).get(), + unorderedEquals([(0, 3), (1, 2)]), + ); + expect(await db.table(users).select((u) => u.id.average()).single(), 2.0); + }); + + test('bound constant projections retain Dart types', () async { + final one = db + .table(users) + .take(1) + .select((u) => value(7, Codecs.integer)); + final two = db.table(postsTable).take(1).select((p) => p.id); + expect(await one.unionAll(two).get(), unorderedEquals([7, 1])); + expect(await one.unionAll(one).get(), [7, 7]); + final nulls = db + .table(users) + .take(1) + .select((u) => value(null, Codecs.text.nullable())); + expect( + await nulls.union(db.table(postsTable).select((p) => p.tag)).get(), + [null], + ); + final values = db + .table(users) + .take(1) + .select( + (u) => ( + value(1.5, Codecs.real), + value(true, Codecs.boolean), + value(BigInt.parse('1234567890123456789012345'), Codecs.bigint), + value(DateTime.utc(2026), Codecs.dateTime), + value(Uint8List.fromList([1, 2, 3]), Codecs.bytes), + value(const SqlJson(null), Codecs.jsonDocument), + ).row, + ); + final row = (await values.union(values).single()); + expect( + (row.$1, row.$2, row.$3, row.$4), + ( + 1.5, + true, + BigInt.parse('1234567890123456789012345'), + DateTime.utc(2026), + ), + ); + expect(row.$5, [1, 2, 3]); + expect(row.$6.value, null); + expect( + await db + .table(users) + .take(1) + .select((u) => value(9, Codecs.integer)) + .single(), + 9, + ); + }); + + test('Dart maps and relation selections are rejected before execution', () { + final a = db + .table(users) + .select((u) => u.email.map((e) => e.toUpperCase())); + final b = db + .table(postsTable) + .select((p) => p.title.map((e) => e.toLowerCase())); + expect(() => a.union(b), throwsA(code('QUERY.UNION_SELECTION'))); + expect( + () => db.table(users).union(db.table(users)), + throwsA(code('QUERY.UNION_SELECTION')), + ); + final relation = db + .table(users) + .select((u) => u.posts.select((p) => p.title).many()); + expect( + () => relation.union(relation), + throwsA(code('QUERY.UNION_SELECTION')), + ); + expect( + () => a.asCte('mapped').query.union(b), + throwsA(code('QUERY.UNION_SELECTION')), + ); + expect(events, isEmpty); + }); + + test('codecs and nullability cannot silently change across operands', () { + final a = db.table(users).select((u) => u.email); + final b = db.table(users).select((u) => u.id); + expect(() => a.union(b), throwsA(code('QUERY.UNION_CODEC'))); + expect( + () => a.union(db.table(users).select((u) => u.nickname)), + throwsA(code('QUERY.UNION_CODEC')), + ); + final custom = Codecs.text.map((v) => v.toUpperCase(), (v) => v); + final encoded = db + .table(users) + .select((u) => sql(['', ''], [u.email], custom)); + expect(() => a.union(encoded), throwsA(code('QUERY.UNION_CODEC'))); + expect(events, isEmpty); + }); + + test('shared domain codecs work, and nullable wrappers retain decoding semantics', () async { + final codec = Codecs.text.map<_Label>(_Label.new, (v) => v.text); + final left = db + .table(users) + .select((u) => sql(['', ''], [u.email], codec)); + final right = db + .table(postsTable) + .select((p) => sql(['', ''], [p.title], codec)); + expect( + (await left.union(right).get()).map((v) => v.text), + unorderedEquals(['one', 'two', 'three', 'post']), + ); + final a = db + .table(users) + .select((u) => sql(['', ''], [u.nickname], codec.nullable())); + final b = db + .table(postsTable) + .select((p) => sql(['', ''], [p.tag], codec.nullable().nullable())); + expect(await a.union(b).get(), [null]); + final nullable = Codec( + 'text', + (v) => v == null ? 'missing' : v as String, + (v) => v, + ); + final direct = db + .table(users) + .select((u) => sql(['', ''], [u.nickname], nullable)); + final wrapped = db + .table(users) + .select((u) => sql(['', ''], [u.nickname], nullable.nullable())); + expect(() => direct.union(wrapped), throwsA(code('QUERY.UNION_CODEC'))); + expect(await direct.take(1).get(), ['missing']); + expect(await wrapped.take(1).get(), [null]); + }); + + test('export references and outer join nullability are guarded', () async { + final joined = users.alias(); + final left = db + .table(postsTable) + .leftJoin(joined, on: (p, u) => p.id.equals(u.id)) + .select((p) => joined.nullable(joined.fields.email)); + final right = db.table(users).select((u) => u.nickname); + final result = left.unionAll(right); + expect(await result.count(), 5); + expect( + () => result.select((u) => u.ref((_) => joined.fields.email)), + throwsA(code('QUERY.NULLABILITY')), + ); + expect( + () => result.select((u) => u.ref((p) => p.title)), + throwsA(code('QUERY.UNION_COLUMN')), + ); + final unsafe = db + .table(postsTable) + .leftJoin(joined, on: (p, u) => p.id.equals(u.id)) + .select((p) => joined.fields.email); + events.clear(); + expect( + () => unsafe.union(db.table(users).select((u) => u.email)).compile(), + throwsA(code('QUERY.NULLABILITY')), + ); + expect(events, isEmpty); + }); + + test('compound mutations and mixed sessions fail before SQL', () async { + final source = db.table(users).select((u) => u.email); + final query = source.union(source); + expect(() => query.delete().compile(), throwsA(code('MUTATION.QUERY'))); + expect( + () => query.seekAfter((u) => [u.ref((u) => u.email).cursor('one')]), + throwsA(code('QUERY.CURSOR')), + ); + await db.transaction((tx) async { + expect( + () => source.union(tx.table(users).select((u) => u.email)), + throwsA(code('QUERY.UNION_SESSION')), + ); + final t = tx.table(users).select((u) => u.email); + expect(await t.union(t).count(), 3); + }); + }); + + test('all record arities use typed SQL decoding', () async { + final base = db.table(users).where((u) => u.id.eq(1)); + final a = base.select((u) => (u.id, u.email, u.nickname).row); + final b = base.select((u) => (u.id, u.email, u.nickname, u.score).row); + final c = base.select( + (u) => (u.id, u.email, u.nickname, u.score, u.id).row, + ); + final d = base.select( + (u) => (u.id, u.email, u.nickname, u.score, u.id, u.email).row, + ); + expect(await a.union(a).single(), (1, 'one', null)); + expect(await b.union(b).single(), (1, 'one', null, 0)); + expect(await c.union(c).single(), (1, 'one', null, 0, 1)); + expect(await d.union(d).single(), (1, 'one', null, 0, 1, 'one')); + }); + + test('subscription observes committed changes in both operands', () async { + final query = db + .table(users) + .select((u) => u.email) + .union(db.table(postsTable).select((p) => p.title)); + final iterator = StreamIterator(query.watch()); + try { + expect( + await iterator.moveNext().timeout(const Duration(seconds: 5)), + true, + ); + expect(iterator.current.length, 4); + await db + .table(postsTable) + .where((p) => p.id.eq(2)) + .update((p) => [p.title.set('changed')]) + .execute(); + expect( + await iterator.moveNext().timeout(const Duration(seconds: 5)), + true, + ); + expect(iterator.current, contains('changed')); + await db.transaction((tx) async { + await tx + .table(users) + .where((u) => u.id.eq(2)) + .update((u) => [u.email.set('committed')]) + .execute(); + }); + expect( + await iterator.moveNext().timeout(const Duration(seconds: 5)), + true, + ); + expect(iterator.current, contains('committed')); + } finally { + await iterator.cancel(); + } + }); + }); +} + +final class _Label(final String text); diff --git a/test/watch_test.dart b/test/watch_test.dart new file mode 100644 index 00000000..e0ac969b --- /dev/null +++ b/test/watch_test.dart @@ -0,0 +1,785 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; +import 'package:test/test.dart'; + +import '../example/schema.orm.dart'; + +Matcher code(String code) => + isA().having((e) => e.code, 'code', code); + +void main() { + test( + 'independent native SQLite workers require explicit notification', + () async { + final directory = await Directory.systemTemp.createTemp( + 'orm-watch-external-', + ); + final db = await sqlite( + SqliteOptions.file('${directory.path}/data.sqlite'), + ); + final external = await sqlite( + SqliteOptions.file('${directory.path}/data.sqlite'), + ); + _Snapshots? result; + try { + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + final user = await db.users.create(email: 'before'); + result = _Snapshots(db.users.select((u) => u.email).watch()); + expect(await result.next(), ['before']); + await external.users.byId(user.id).patch(email: .set('external')); + await db.execute(SqlCommand('SELECT 1')); + await Future.delayed(Duration.zero); + expect(result.rows.length, 1); + db.invalidate([usersSchema]); + expect(await result.next(), ['external']); + } finally { + await result?.close(); + await external.close(); + await db.close(); + await directory.delete(recursive: true); + } + }, + ); + runTests( + 'sqlite', + (observe) => sqlite(const SqliteOptions.memory(), onQuery: observe), + ); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + runTests('postgres', (observe) async { + final db = postgres( + PostgresOptions( + url: Uri.parse(url), + tls: .disable, + schema: 'orm_watch_tests', + maxConnections: 1, + ), + onQuery: observe, + ); + await db.execute( + SqlCommand('CREATE SCHEMA IF NOT EXISTS orm_watch_tests'), + ); + return db; + }); + } +} + +void runTests( + String name, + Future> Function(void Function(QueryEvent)) open, +) { + group('watch $name', () { + late Database db; + final events = []; + void Function(QueryEvent)? hook; + final watches = <_Snapshots>[]; + _Snapshots watch(Stream> stream) { + final snapshots = _Snapshots(stream); + watches.add(snapshots); + return snapshots; + } + + setUp(() async { + hook = null; + db = await open((event) { + events.add(event); + hook?.call(event); + }); + for (final table in ['posts', 'users', '_orm_migrations']) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + await Migrator(db.sql).apply([ + Migration.create('0001_initial', appSchema, dialect: db.dialect), + ]); + events.clear(); + }); + tearDown(() async { + for (final watch in watches) { + await watch.close(); + } + watches.clear(); + await db.close(); + }); + Future barrier() async { + await db.execute(SqlCommand('SELECT 1')); + await Future.delayed(Duration.zero); + } + + Future post(int author, [String title = 'post']) => db.posts.create( + authorId: author, + title: title, + createdAt: DateTime.utc(2026), + ); + + test('lazy initial snapshot and relevant table invalidation', () async { + final stream = db.users.select((u) => u.email).watch(); + await barrier(); + expect(events.where((e) => e.sql.contains('FROM "users"')), isEmpty); + final result = watch(stream); + expect(await result.next(), isEmpty); + final user = await db.users.create(email: 'first'); + expect(await result.next(), ['first']); + events.clear(); + await post(user.id); + await barrier(); + expect(result.rows.length, 2); + expect( + events.where( + (e) => e.sql.startsWith('SELECT') && e.sql.contains('FROM "users"'), + ), + isEmpty, + ); + await db.users.byId(user.id).patch(email: .set('second')); + expect(await result.next(), ['second']); + await db.users.byId(user.id).delete().execute(); + expect(await result.next(), isEmpty); + }); + + test( + 'transactions publish one snapshot after commit and none on rollback', + () async { + final result = watch( + db.users.orderBy((u) => [u.id.asc()]).select((u) => u.email).watch(), + ); + expect(await result.next(), isEmpty); + await db.transaction((tx) async { + await tx.users.create(email: 'a'); + await tx.users.create(email: 'b'); + expect(result.rows.length, 1); + }); + expect(await result.next(), ['a', 'b']); + await expectLater( + db.transaction((tx) async { + await tx.users.create(email: 'rollback'); + throw StateError('rollback'); + }), + throwsStateError, + ); + await barrier(); + expect(result.rows.length, 2); + expect(await db.users.count(), 2); + }, + ); + + test( + 'savepoint changes merge on release and disappear on rollback', + () async { + final user = await db.users.create(email: 'initial'); + final result = watch(db.users.select((u) => u.email).watch()); + expect(await result.next(), ['initial']); + await db.transaction((tx) async { + await expectLater( + tx.savepoint((child) async { + await child.users.byId(user.id).patch(email: .set('rolled-back')); + throw StateError('recoverable'); + }), + throwsStateError, + ); + await tx.savepoint((child) => postIn(child, user.id)); + }); + await barrier(); + expect(result.rows.length, 1); + await db.transaction((tx) async { + await tx.savepoint((child) async { + await child.savepoint( + (nested) => + nested.users.byId(user.id).patch(email: .set('committed')), + ); + }); + expect(result.rows.length, 1); + }); + expect(await result.next(), ['committed']); + }, + ); + + test( + 'failed statements, zero-row writes and conflict no-ops do not refresh', + () async { + final user = await db.users.create(email: 'unique'); + final result = watch(db.users.select((u) => u.email).watch()); + expect(await result.next(), ['unique']); + await expectLater(db.users.create(email: 'unique'), throwsA(anything)); + await db.users.byId(999).patch(email: .set('absent')); + await db.users + .insert((u) => [u.email.set('unique')]) + .onConflictDoNothing() + .execute(); + await expectLater( + db.transaction((tx) async { + await tx.users.byId(user.id).patch(email: .set('temporary')); + try { + await tx.execute(SqlCommand('SELECT broken_column FROM users')); + } catch (_) {} + }), + throwsA(code('TRANSACTION.FAILED')), + ); + await barrier(); + expect(result.rows.length, 1); + }, + ); + + test( + 'batch writes and upsert returning publish committed changes', + () async { + final result = watch( + db.users.orderBy((u) => [u.id.asc()]).select((u) => u.email).watch(), + ); + expect(await result.next(), isEmpty); + final ids = await db.users + .insertMany(['a', 'b', 'c'], (u, email) => [u.email.set(email)]) + .returning((u) => u.id) + .get(); + expect(ids.length, 3); + expect(await result.next(), ['a', 'b', 'c']); + await db.users + .insert((u) => [u.email.set('b'), u.score.set(7)]) + .onConflictUpdate( + target: (u) => [u.email], + set: (old, incoming) => [old.email.set('changed')], + ) + .returning((u) => u.id) + .single(); + expect(await result.next(), ['a', 'changed', 'c']); + }, + ); + + test('joined and nested batch relations track child writes', () async { + final user = await db.users.create(email: 'author'); + final children = watch( + db.users + .select( + (u) => u.posts + .orderBy((p) => [p.id.asc()]) + .select( + (p) => ( + p.title, + p.author.select((a) => a.email).required(), + ).map((title, author) => (title, author)), + ) + .many(), + ) + .watch(), + ); + expect(await children.next(), [<(String, String)>[]]); + await post(user.id); + expect(await children.next(), [ + [('post', 'author')], + ]); + final authors = watch( + db.posts + .select((p) => p.author.select((u) => u.email).required()) + .watch(), + ); + expect(await authors.next(), ['author']); + await db.users.byId(user.id).patch(email: .set('updated')); + expect(await authors.next(), ['updated']); + expect(await children.next(), [ + [('post', 'updated')], + ]); + }); + + test( + 'relation predicates, SQL subqueries and CTE sources are dependencies', + () async { + final user = await db.users.create(email: 'a'); + final hasPosts = watch( + db.users.where((u) => u.posts.any()).select((u) => u.email).watch(), + ); + final total = db.posts.select((p) => p.id.count()).scalar(); + final subquery = watch(db.users.select((u) => total).watch()); + final cte = db.posts.select((p) => p.title).asCte('titles'); + final titles = watch(cte.query.watch()); + expect(await hasPosts.next(), isEmpty); + expect(await subquery.next(), [0]); + expect(await titles.next(), isEmpty); + await post(user.id, 'dependency'); + expect(await hasPosts.next(), ['a']); + expect(await subquery.next(), [1]); + expect(await titles.next(), ['dependency']); + }, + ); + + test('declared cascade deletion invalidates a child-only query', () async { + final user = await db.users.create(email: 'parent'); + await post(user.id); + final result = watch(db.posts.select((p) => p.title).watch()); + expect(await result.next(), ['post']); + await db.users.byId(user.id).patch(email: .set('no cascade')); + await barrier(); + expect(result.rows.length, 1); + await db.users.byId(user.id).delete().execute(); + expect(await result.next(), isEmpty); + }); + + for (final action in ['CASCADE', 'SET NULL']) { + test('$action tracks declared transitive deletion effects', () async { + final id = Column('id', Codecs.integer); + final root = TableSchema( + 'watch_root', + columns: [id], + primaryKey: ['id'], + ); + final branch = TableSchema( + 'watch_branch', + columns: [ + id, + Column('parent', Codecs.integer.nullable(), nullable: true), + ], + primaryKey: ['id'], + foreignKeys: [ + ForeignKey(['parent'], root.name, ['id'], onDelete: action), + ], + ); + final leaf = TableSchema( + 'watch_leaf', + columns: [id, Column('parent', Codecs.integer)], + primaryKey: ['id'], + foreignKeys: [ + ForeignKey(['parent'], branch.name, ['id'], onDelete: 'CASCADE'), + ], + ); + final schemas = [root, branch, leaf]; + db.registerSchema(schemas); + for (final command in createSchema(schemas, db.dialect)) { + await db.execute(command); + } + try { + await db.execute(SqlCommand('INSERT INTO watch_root VALUES (1)')); + await db.execute( + SqlCommand('INSERT INTO watch_branch VALUES (2, 1)'), + ); + await db.execute(SqlCommand('INSERT INTO watch_leaf VALUES (3, 2)')); + final branches = watch(db.table(_ids(branch)).watch()); + final leaves = watch(db.table(_ids(leaf)).watch()); + expect(await branches.next(), [2]); + expect(await leaves.next(), [3]); + await db.table(_ids(root)).delete().execute(); + expect(await branches.next(), action == 'CASCADE' ? isEmpty : [2]); + if (action == 'CASCADE') { + expect(await leaves.next(), isEmpty); + } else { + await barrier(); + expect(leaves.rows.length, 1); + expect( + (await db.execute(SqlCommand('SELECT parent FROM watch_branch'))) + .rows, + [ + [null], + ], + ); + } + await branches.close(); + await leaves.close(); + } finally { + for (final table in schemas.reversed) { + await db.execute(SqlCommand('DROP TABLE "${table.name}"')); + } + } + }); + } + + test( + 'explicit JOINs and joined CTE definitions track both physical sources', + () async { + final user = await db.users.create(email: 'a'); + await post(user.id); + final alias = postsTable.alias(); + final joined = watch( + db.users + .join(alias, on: (u, p) => u.id.equals(p.authorId)) + .select((_) => alias.fields.title) + .watch(), + ); + final cte = db.posts + .select( + (p) => (p.authorId, p.title).map((id, title) => (id, title)), + ) + .asCte('post_names'); + final cteAlias = cte.alias(); + final viaCte = watch( + db.users + .join( + cteAlias, + on: (u, c) => u.id.equals(c.ref((p) => p.authorId)), + ) + .select((_) => cteAlias.fields.ref((p) => p.title)) + .watch(), + ); + expect(await joined.next(), ['post']); + expect(await viaCte.next(), ['post']); + await db.posts.byId(1).patch(title: .set('joined')); + expect(await joined.next(), ['joined']); + expect(await viaCte.next(), ['joined']); + }, + ); + + test('raw SQL declares changed and read tables; explicit invalidation follows transactions', () async { + final user = await db.users.create(email: 'a'); + final count = sql( + ['(SELECT COUNT(*) FROM posts)'], + [], + Codecs.integer, + ); + final result = watch( + db.users.select((_) => count).watch(reads: [postsSchema]), + ); + expect(await result.next(), [0]); + await post(user.id); + expect(await result.next(), [1]); + await db.execute( + SqlCommand('DELETE FROM posts'), + changedTables: [postsSchema], + ); + expect(await result.next(), [0]); + await db.transaction((tx) async { + tx.invalidate([postsSchema]); + expect(result.rows.length, 3); + }); + expect(await result.next(), [0]); + await expectLater( + db.transaction((tx) async { + tx.invalidate([postsSchema]); + throw StateError('rollback invalidation'); + }), + throwsStateError, + ); + await barrier(); + expect(result.rows.length, 4); + }); + + test( + 'paused listeners coalesce writes and stop fetching until resumed', + () async { + final result = watch(db.users.select((u) => u.email).watch()); + expect(await result.next(), isEmpty); + result.subscription.pause(); + events.clear(); + for (var i = 0; i < 5; i++) { + await db.users.create(email: '$i'); + } + await barrier(); + expect(result.rows.length, 1); + expect( + events.where( + (e) => e.sql.startsWith('SELECT') && e.sql.contains('FROM "users"'), + ), + isEmpty, + ); + result.subscription.resume(); + expect((await result.next()).length, 5); + expect(result.rows.length, 2); + }, + ); + + test( + 'invalidation during a read suppresses the stale snapshot and refetches', + () async { + await db.users.create(email: 'before'); + final reading = Completer(), release = Completer(); + final intercepted = Database( + _AfterReadDriver(db.driver, reading, release), + ); + final result = watch(intercepted.users.select((u) => u.email).watch()); + await reading.future; + // The connection was released after producing the old rows, while the + // read's Future is deliberately held. A second view shares this driver. + final other = Database(intercepted.driver); + await other.users.byId(1).patch(email: .set('after')); + release.complete(); + expect(await result.next(), ['after']); + expect(result.rows.length, 1); + }, + ); + + test( + 'decoding errors are reported and a later write can recover', + () async { + final user = await db.users.create(email: 'bad'); + final result = watch( + db.users + .select( + (u) => u.email.map((value) { + if (value == 'bad') throw const FormatException('bad value'); + return value; + }), + ) + .watch(), + ); + await result.waitForError(); + expect(result.errors.single, isA()); + await db.users.byId(user.id).patch(email: .set('good')); + expect(await result.next(), ['good']); + }, + ); + + test( + 'separate subscriptions and views of one driver receive changes', + () async { + final other = Database(db.driver); + final stream = db.users.select((u) => u.email).watch(); + final one = watch(stream); + final two = watch(other.users.select((u) => u.email).watch()); + expect(await one.next(), isEmpty); + expect(await two.next(), isEmpty); + await other.users.create(email: 'shared'); + expect(await one.next(), ['shared']); + expect(await two.next(), ['shared']); + await one.close(); + await db.users.byId(1).patch(email: .set('still open')); + expect(await two.next(), ['still open']); + expect(one.rows.length, 2); + }, + ); + + test('watching leased sessions is rejected and cancelled tokens close the stream', () async { + await db.transaction((tx) async { + final result = watch(tx.users.watch()); + await result.waitForError(); + expect(result.errors.single, code('WATCH.SESSION')); + await result.done.future; + }); + final cancellation = CancellationToken(); + final result = watch( + db.users.watch(options: ExecutionOptions(cancellation: cancellation)), + ); + expect(await result.next(), isEmpty); + cancellation.cancel(); + await result.done.future; + expect(result.errors.single, code('OPERATION.CANCELLED')); + }); + + test( + 'database close finishes watchers even when a listener is paused', + () async { + final result = watch(db.users.watch()); + expect(await result.next(), isEmpty); + result.subscription.pause(); + await db.close().timeout(const Duration(seconds: 2)); + result.subscription.resume(); + await result.done.future; + final late = watch(db.users.watch()); + await late.waitForError(); + expect(late.errors.single, code('SESSION.CLOSED')); + }, + ); + + test('cancelling an active watched query interrupts SQL and releases its lease', () async { + if (!db.capabilities.cancellation) { + markTestSkipped( + 'This SQLite build does not expose native statement interruption.', + ); + return; + } + await db.users.create(email: 'a'); + final started = Completer(); + final expression = db.dialect == .postgres + ? sql(['(SELECT 1 FROM pg_sleep(20))'], [], Codecs.integer) + : sql( + [ + '(WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1000000000) SELECT sum(x) FROM n)', + ], + [], + Codecs.integer, + ); + // Query observation completes only after execution, so a short timer + // requests cancellation while the database is doing deliberately long work. + final result = watch(db.users.select((_) => expression).watch()); + Timer(const Duration(milliseconds: 40), () => started.complete()); + await started.future; + await result.close().timeout(const Duration(seconds: 3)); + expect(result.rows, isEmpty); + expect(result.errors, isEmpty); + expect(await db.users.count(), 1); + expect(events.any((e) => e.error != null), true); + }); + + test('unsupported watch deadlines fail before SQL and ordinary watches remain usable', () async { + if (db.capabilities.statementTimeout) { + markTestSkipped('This driver supports statement deadlines.'); + return; + } + final rejected = watch( + db.users.watch( + options: const ExecutionOptions(timeout: Duration(milliseconds: 40)), + ), + ); + await rejected.waitForError(); + expect(rejected.errors.single, code('CAPABILITY.CANCEL')); + expect(rejected.rows, isEmpty); + expect(events, isEmpty); + await rejected.close(); + + final ordinary = watch(db.users.select((u) => u.email).watch()); + expect(await ordinary.next(), isEmpty); + await db.users.create(email: 'still-watching'); + expect(await ordinary.next(), ['still-watching']); + expect(ordinary.errors, isEmpty); + }); + + if (name == 'postgres') { + test( + 'lost commit acknowledgement triggers a conservative re-read', + () async { + final uncertain = Database(_CommitAckDriver(db.driver)); + final result = watch(uncertain.users.select((u) => u.email).watch()); + expect(await result.next(), isEmpty); + await expectLater( + uncertain.transaction((tx) => tx.users.create(email: 'committed')), + throwsA(code('TRANSACTION.COMMIT')), + ); + expect(await result.next(), ['committed']); + expect((await db.users.single()).email, 'committed'); + }, + ); + + test('independent connections require an explicit external-write notification', () async { + final user = await db.users.create(email: 'before'); + final result = watch(db.users.select((u) => u.email).watch()); + expect(await result.next(), ['before']); + final external = await open((_) {}); + try { + await external.users.byId(user.id).patch(email: .set('external')); + await barrier(); + expect(result.rows.length, 1); + db.invalidate([usersSchema]); + expect(await result.next(), ['external']); + } finally { + await external.close(); + } + }); + } + }); +} + +Future postIn(Database db, int user) async { + await db.posts.create( + authorId: user, + title: 'unrelated', + createdAt: DateTime.utc(2026), + ); +} + +final class _Snapshots { + final List> rows = []; + final List errors = []; + final done = Completer(); + late final StreamSubscription> subscription; + Completer? _changed; + int _next = 0; + _Snapshots(Stream> stream) { + subscription = stream.listen( + (value) { + rows.add(value); + _changed?.complete(); + _changed = null; + }, + onError: (Object error) { + errors.add(error); + _changed?.complete(); + _changed = null; + }, + onDone: () { + done.complete(); + _changed?.complete(); + _changed = null; + }, + ); + } + Future> next() async { + while (rows.length <= _next) { + if (done.isCompleted) throw StateError('Watch ended: $errors'); + await (_changed ??= Completer()).future.timeout( + const Duration(seconds: 5), + ); + } + return rows[_next++]; + } + + Future waitForError() async { + while (errors.isEmpty) { + await (_changed ??= Completer()).future.timeout( + const Duration(seconds: 5), + ); + } + } + + Future close() => subscription.cancel(); +} + +/// Hold exactly one completed read after the underlying connection is released. +final class _AfterReadDriver( + final Driver inner, + final Completer reading, + final Completer release, +) implements Driver { + bool held = false; + @override + Capabilities get capabilities => inner.capabilities; + @override + Future run(Future Function(SqlConnection) action) async { + final value = await inner.run(action); + if (!held) { + held = true; + reading.complete(); + await release.future; + } + return value; + } + + @override + Future close() => inner.close(); +} + +Table _ids(TableSchema schema) => + Table(schema, _Ids.new, (f) => f.id); + +final class _Ids extends Fields { + _Ids(super.table); + late final id = column(Column('id', Codecs.integer)); +} + +final class _CommitAckDriver(final Driver inner) + implements Driver { + bool armed = true; + @override + Capabilities get capabilities => inner.capabilities; + @override + Future run(Future Function(SqlConnection) action) => + inner.run((connection) => action(_CommitAckConnection(connection, this))); + @override + Future close() => inner.close(); +} + +final class _CommitAckConnection( + final SqlConnection inner, + final _CommitAckDriver driver, +) implements SqlConnection { + @override + bool? get transactionActive => inner.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + final result = await inner.execute(command, options: options); + if (command.sql == 'COMMIT' && driver.armed) { + driver.armed = false; + throw StateError('Commit executed; acknowledgement lost'); + } + return result; + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => inner.openCursor(command, options: options); + @override + Future invalidate() => inner.invalidate(); +} diff --git a/tool/benchmark_decimal.dart b/tool/benchmark_decimal.dart new file mode 100644 index 00000000..d52a1b20 --- /dev/null +++ b/tool/benchmark_decimal.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; + +import '../test/support/decimals/schema.orm.dart'; + +Future main() async { + final results = >[]; + final schema = 'orm_decimal_cost_${DateTime.now().microsecondsSinceEpoch}'; + for (final backend in [ + 'sqlite', + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) 'postgres', + ]) { + final Database db = backend == 'sqlite' + ? await sqlite(const SqliteOptions.memory()) + : postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: schema, + ), + ); + var created = false; + try { + if (backend == 'postgres') { + await db.execute(SqlCommand('CREATE SCHEMA $schema')); + created = true; + } + await Migrator(db.sql).apply([ + Migration.create('0001_decimal', appSchema, dialect: db.dialect), + ]); + final version = (await db.execute( + SqlCommand( + backend == 'sqlite' + ? 'SELECT sqlite_version()' + : "SELECT current_setting('server_version')", + ), + )).rows.single.single; + await db.execute( + SqlCommand( + "INSERT INTO entries(amount, bucket) WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<10000) SELECT x, 'a' FROM n", + ), + ); + final cases = { + 'read': db.entries.select((e) => e.amount), + 'average_half_even_scale_2': db.entries.select( + (e) => e.amount.average(scale: 2, rounding: .halfEven), + ), + 'window_average_half_even_scale_2': db.entries.select( + (e) => e.amount + .average(scale: 2, rounding: .halfEven) + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent), + ), + 'divide_half_even_scale_2': db.entries.select( + (e) => e.amount.divide( + Decimal.parse('3'), + scale: 2, + rounding: .halfEven, + ), + ), + 'round_half_even_scale_minus_1': db.entries.select( + (e) => e.amount.rounded(-1, rounding: .halfEven), + ), + 'window_divide_half_even_scale_2': db.entries.select( + (e) => e.amount + .sum() + .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) + .divide(Decimal.parse('3'), scale: 2, rounding: .halfEven), + ), + }; + for (final entry in cases.entries) { + final expectedRows = entry.key == 'average_half_even_scale_2' + ? 1 + : 10000; + await entry.value.get(); + final samples = []; + for (var i = 0; i < 3; i++) { + final timer = Stopwatch()..start(); + final rows = await entry.value.get(); + timer.stop(); + if (rows.length != expectedRows) { + throw StateError('Incomplete sample'); + } + samples.add(timer.elapsedMicroseconds / 1000); + } + results.add({ + 'backend': backend, + 'version': version, + 'case': entry.key, + 'inputRows': 10000, + 'rows': expectedRows, + 'elapsedMs': samples, + 'sqlBytes': entry.value.compile().sql.length, + }); + } + } finally { + try { + if (created) { + await db.execute(SqlCommand('DROP SCHEMA $schema CASCADE')); + } + } finally { + await db.close(); + } + } + } + print( + const JsonEncoder.withIndent(' ').convert({ + 'recordedAt': DateTime.now().toUtc().toIso8601String(), + 'dart': Platform.version, + 'os': Platform.operatingSystemVersion, + 'warmupRuns': 1, + 'measuredRuns': 3, + 'scope': 'Single-client end-to-end query fetch and Decimal decoding; 10000 integer-valued rows; not a concurrency or percentile benchmark.', + 'results': results, + }), + ); +} diff --git a/tool/benchmark_editor.dart b/tool/benchmark_editor.dart new file mode 100644 index 00000000..0a520ba1 --- /dev/null +++ b/tool/benchmark_editor.dart @@ -0,0 +1,483 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:orm/generate.dart'; + +import 'src/build_fixture.dart'; +import 'src/editor_server.dart'; + +Future main(List args) async { + final smoke = args.contains('--smoke'); + final sameSession = args.contains('--same-session'); + if (args.any((a) => a != '--smoke' && a != '--same-session')) { + throw ArgumentError('Use --smoke, --same-session, or no arguments.'); + } + final samples = smoke ? 2 : 20; + final results = >[]; + for (final models in smoke ? [10] : [10, 100, 1000]) { + stdout.writeln('Editor protocol checks for $models models...'); + final fixture = await BuildFixture.create( + ormPath: Directory.current.path, + models: models, + ); + EditorServer? server; + try { + final schema = fixture.file('lib/schema.dart'); + await writeGeneratedSchema(schema.path); + final source = await schema.readAsString(); + final generatedBytes = await fixture.file('lib/schema.orm.dart').length(); + await fixture.write('lib/symbols.dart', _symbols); + final start = Stopwatch()..start(); + server = await EditorServer.start(fixture.directory.path); + final diagnosticServerPid = server.process.pid; + final initializeMicros = start.elapsedMicroseconds; + await server.ready(); + final projectReadyMicros = start.elapsedMicroseconds; + final completions = >[]; + for (final probe in [ + ( + name: 'table_entries', + file: 'lib/client.dart', + source: _client('db./*caret*/;'), + required: ['rows0', 'rows${models - 1}'], + forbidden: [], + ), + ( + name: 'schema_record_fields', + file: 'lib/schema.dart', + source: + '$source\nfinal editorProbe = rows0.key((r) => r./*caret*/);\n', + required: ['id', 'title', 'score', 'status'], + forbidden: [], + ), + ( + name: 'query_fields', + file: 'lib/client.dart', + source: _client('db.rows0.where((r) => r./*caret*/);'), + required: ['id', 'title', 'score', 'status'], + forbidden: [], + ), + ( + name: 'create_arguments', + file: 'lib/client.dart', + source: _client('await db.rows0.create(/*caret*/);'), + required: ['title:', 'status:'], + forbidden: [], + ), + ( + name: 'projected_record_fields', + file: 'lib/client.dart', + source: _client( + 'final rows = await db.rows0.select((r) => (r.id,r.title).map((id,title) => (id:id,title:title))).get(); final row = rows.first; row./*caret*/;', + ), + required: ['id', 'title'], + forbidden: ['score', 'status'], + ), + ]) { + final path = fixture.file(probe.file).path; + final caret = probe.source.indexOf('/*caret*/'); + final text = probe.source.replaceFirst('/*caret*/', ''); + // An editor opens a file in the consumer project, not an absent URI. + await fixture.write(probe.file, text); + final readiness = Stopwatch()..start(); + final version = server.open(path, text); + await server.waitDiagnostics(path, version, matches: (_) => true); + final analysisReadyMicros = readiness.elapsedMicroseconds; + Future<({int micros, Map result})> complete() async { + final active = server!; + final watch = Stopwatch()..start(); + final response = await active.request('textDocument/completion', { + ...editorLocation(path, text, caret), + 'context': {'triggerKind': 1}, + }); + final micros = watch.elapsedMicroseconds; + final result = response is List + ? {'items': response, 'isIncomplete': false} + : response as Map; + final items = (result['items'] as List).cast>(); + final labels = items.map((i) => i['label'] as String).toList(); + for (final wanted in probe.required) { + if (!labels.any((l) => l == wanted || l.startsWith('$wanted '))) { + throw StateError( + '${probe.name} is missing $wanted: $result; diagnostics=${active.diagnostics}; stderr=${active.stderr}', + ); + } + } + for (final unwanted in probe.forbidden) { + if (labels.contains(unwanted)) { + throw StateError( + 'Unselected $unwanted leaked into result completion.', + ); + } + } + return ( + micros: micros, + result: { + 'isIncomplete': result['isIncomplete'], + 'itemCount': items.length, + 'items': [ + for (final item in items.where( + (i) => probe.required.any( + (r) => (i['label'] as String).startsWith(r), + ), + )) + { + for (final k in [ + 'label', + 'kind', + 'detail', + 'labelDetails', + 'insertText', + 'textEdit', + ]) + if (item[k] != null) k: item[k], + }, + ], + }, + ); + } + + final first = await complete(); + for (var i = 0; i < 2; i++) { + await complete(); + } + final times = []; + for (var i = 0; i < samples; i++) { + times.add((await complete()).micros); + } + completions.add({ + 'probe': probe.name, + 'analysisReadyMicros': analysisReadyMicros, + 'firstRequestMicros': first.micros, + 'warm': _stats(times), + 'response': first.result, + 'requiredLabels': probe.required, + 'forbiddenLabels': probe.forbidden, + }); + stdout.writeln(' completion: ${probe.name}'); + if (probe.file == 'lib/schema.dart') { + await fixture.write('lib/schema.dart', source); + server.open(schema.path, source); + } + } + final client = fixture.file('lib/client.dart'); + final diagnosticResults = >[]; + for (final (name, body, code, token) in [ + ( + 'create_type', + "await db.rows0.create(title: 123, status: Status.pending);", + 'argument_type_not_assignable', + '123', + ), + ( + 'predicate_type', + "db.rows0.where((r) => r.score.eq('wrong'));", + 'argument_type_not_assignable', + "'wrong'", + ), + ( + 'selected_result', + "final rows = await db.rows0.select((r) => r.title).get(); final int wrong = rows.first; print(wrong);", + 'invalid_assignment', + 'rows.first', + ), + ( + 'missing_selected_field', + "final rows = await db.rows0.select((r) => (r.id,r.title).map((id,title) => (id:id,title:title))).get(); print(rows.first.score);", + 'undefined_getter', + 'score', + ), + ]) { + final text = _client(body), watch = Stopwatch()..start(); + final offset = text.lastIndexOf(token); + final expectedRange = { + 'start': editorPosition(text, offset), + 'end': editorPosition(text, offset + token.length), + }; + final version = server.open(client.path, text); + final event = await server.waitDiagnostics( + client.path, + version, + matches: (d) => d.any( + (e) => + e['code'] == code && + _sameRange(e['range'] as Map, expectedRange), + ), + ); + diagnosticResults.add({ + 'probe': name, + 'micros': watch.elapsedMicroseconds, + 'version': version, + 'expectedCode': code, + 'expectedRange': expectedRange, + 'diagnostics': event['diagnostics'], + }); + stdout.writeln(' diagnostic: $name'); + } + final clean = _client( + "await db.rows0.create(title: 'ok', status: Status.pending);", + ); + final repair = Stopwatch()..start(); + final cleanVersion = server.open(client.path, clean); + await server.waitDiagnostics( + client.path, + cleanVersion, + matches: _noErrors, + ); + final repairMicros = repair.elapsedMicroseconds; + await fixture.write('lib/client.dart', clean); + final renameStartup = Stopwatch()..start(); + if (!sameSession) { + await server.close(); + server = await EditorServer.start(fixture.directory.path); + await server.ready(); + } + final renameStartupMicros = sameSession + ? 0 + : renameStartup.elapsedMicroseconds; + final renameServerPid = server.process.pid; + final symbols = fixture.file('lib/symbols.dart'); + server.open(symbols.path, _symbols); + final renames = >[]; + for (final (name, marker, newName) in [ + ('record_field', 'String name', 'displayName'), + ('primary_constructor_field', 'final String name', 'displayName'), + ('table_field', 'late final name', 'displayName'), + ('record_alias', 'typedef RecordUser', 'RenamedUser'), + ]) { + final text = await symbols.readAsString(); + final offset = text.indexOf(marker) + marker.lastIndexOf(' ') + 1; + if (!text.contains(marker)) { + throw StateError('Missing rename marker.'); + } + final watch = Stopwatch()..start(); + final location = editorLocation(symbols.path, text, offset); + stdout.writeln(' requesting rename: $name'); + Object? prepare, response; + Map? error; + try { + prepare = await server.request( + 'textDocument/prepareRename', + location, + ); + response = await server.request('textDocument/rename', { + ...location, + 'newName': newName, + }); + } on EditorError catch (e) { + error = e.response; + } + final micros = watch.elapsedMicroseconds; + Map? applied; + if (response is Map) { + applied = await _applyRename(fixture, server, response); + } + final status = error != null + ? 'rejected' + : applied == null + ? 'unavailable' + : 'applied'; + if (name != 'record_field' && + (applied?['lib/symbols.dart'] as Map?)?['edits'] != + (name == 'record_alias' ? 3 : 2)) { + throw StateError('Unexpected $name rename: $response / $error'); + } + renames.add({ + 'probe': name, + 'status': status, + 'micros': micros, + 'prepare': prepare, + 'error': error, + 'workspaceEdit': _relative(response, fixture.directory), + 'applied': applied, + }); + stdout.writeln(' rename: $name ($status)'); + } + // A valid Dart selector may still violate the ORM's restricted selector AST. + final invalid = + '$source\nfinal invalid = rows0.index((r) => r.id + 1);\n'; + await fixture.write('lib/schema.dart', invalid); + server.open(schema.path, invalid); + final dartAnalysis = await fixture.run(['analyze', 'lib/schema.dart']); + String? generationError; + try { + await generateSchema(schema.path); + } on GenerationException catch (e) { + generationError = e.message; + } + if (generationError == null) { + throw StateError('The invalid selector passed generation.'); + } + await fixture.write('lib/schema.dart', source); + server.open(schema.path, source); + await server.close(); + server = null; + final analysis = await fixture.run(['analyze', 'lib']); + results.add({ + 'models': models, + 'sessionPids': { + 'diagnostics': diagnosticServerPid, + 'renames': renameServerPid, + }, + 'sourceBytes': utf8.encode(source).length, + 'generatedBytes': generatedBytes, + 'initializeMicros': initializeMicros, + 'projectReadyMicros': projectReadyMicros, + 'completions': completions, + 'diagnostics': diagnosticResults, + 'repairMicros': repairMicros, + 'renameServerStartupMicros': renameStartupMicros, + 'symbolRenames': renames, + 'ormRule': { + 'dartAnalysisMilliseconds': dartAnalysis.milliseconds, + 'dartAnalysisOutput': dartAnalysis.output, + 'generatorError': generationError, + }, + 'finalAnalyzeMilliseconds': analysis.milliseconds, + }); + stdout.writeln( + 'Verified completion, diagnostics and rename responses for $models models.', + ); + } finally { + await server?.close(); + await fixture.dispose(); + } + } + final source = await Process.run('git', ['rev-parse', 'HEAD']); + final cpu = await Process.run('sysctl', ['-n', 'machdep.cpu.brand_string']); + final report = { + 'recordedAt': DateTime.now().toUtc().toIso8601String(), + 'runtimeSourceCommit': source.stdout.toString().trim(), + 'dart': Platform.version, + 'os': Platform.operatingSystemVersion, + 'cpu': cpu.stdout.toString().trim(), + 'smoke': smoke, + 'sameSession': sameSession, + 'samplesPerWarmProbe': samples, + 'harnessSha256': { + for (final p in [ + 'tool/benchmark_editor.dart', + 'tool/src/editor_server.dart', + 'tool/src/build_fixture.dart', + ]) + p: sha256.convert(await File(p).readAsBytes()).toString(), + }, + 'scope': + 'Direct stdio LSP client against the installed Dart Analysis Server, isolated consumer packages and real generated APIs; no database. Includes client transport/JSON costs, excludes GUI/plugin rendering and dependency download. ${sameSession ? 'Each scale retains the same server across completion, deliberate errors, repair and symbol renames; PIDs are recorded.' : 'Each scale uses one fresh server for completion/diagnostics and another for symbol renames.'} Startup and initial project analysis are measured separately from completion. First completion follows a fresh diagnostic for the incomplete source and is not a cold OS/cache measurement. Warm probes use unchanged documents after two unrecorded warmups. Diagnostic probes serialize changes and await a fresh notification; this SDK omits document versions. Final CLI analysis verifies the edited sources. Symbol-form probes compare language rename behavior only, not three ORM authoring frontends or identical generated APIs from each form.', + 'limits': [ + if (!sameSession) 'This capture restarts the server for renames and does not establish mixed-session rename after diagnostic recovery.', + 'The named Record field remains unavailable for automatic rename in this SDK. A successful bounded sequence does not establish every IDE/plugin workflow.', + ], + 'results': results, + }; + final output = File( + smoke + ? '.dart_tool/editor${sameSession ? '-recovery' : ''}-smoke.json' + : 'research/benchmarks/editor${sameSession ? '-recovery' : ''}.json', + ); + await output.parent.create(recursive: true); + await output.writeAsString( + '${const JsonEncoder.withIndent(' ').convert(report)}\n', + ); + stdout.writeln('Saved ${output.path}'); +} + +String _client(String body) => + "import 'package:orm/orm.dart';\nimport 'schema.orm.dart';\nimport 'models.dart';\nFuture read(Database db) async { $body }\n"; +bool _noErrors(List> diagnostics) => + diagnostics.every((d) => d['severity'] != 1); +bool _sameRange(Map actual, Map expected) => + ['start', 'end'].every( + (key) => ['line', 'character'].every( + (axis) => (actual[key] as Map)[axis] == (expected[key] as Map)[axis], + ), + ); +Map _stats(List times) { + final sorted = [...times]..sort(); + return { + 'samplesMicros': times, + 'p50Micros': sorted[(times.length * .5).ceil() - 1], + 'p95Micros': sorted[(times.length * .95).ceil() - 1], + }; +} + +const _symbols = ''' +import 'package:orm/orm.dart'; +typedef RecordUser = ({int id, String name}); +typedef OtherRecord = ({int id, String name}); +final class ClassUser(final int id, final String name); +final class UserTable extends Fields { + UserTable(super.table); + late final id = column(Column('id', Codecs.integer)); + late final name = column(Column('name', Codecs.text)); +} +String recordLabel(RecordUser user) => user.name; +String otherLabel(OtherRecord user) => user.name; +RecordUser recordValue() => (id: 1, name: 'first'); +String classLabel(ClassUser user) => user.name; +Expr tableLabel(UserTable table) => table.name; +'''; + +Object? _relative(Object? value, Directory root) => switch (value) { + String() => value.replaceAll(root.uri.toString(), 'fixture/'), + List() => value.map((e) => _relative(e, root)).toList(), + Map() => { + for (final e in value.entries) + _relative(e.key, root).toString(): _relative(e.value, root), + }, + _ => value, +}; +Future> _applyRename( + BuildFixture fixture, + EditorServer server, + Map edit, +) async { + final changes = >>{}; + if (edit['changes'] case final Map changeMap) { + for (final e in changeMap.entries) { + changes[e.key] = (e.value as List).cast>(); + } + } + if (edit['documentChanges'] case final List documents) { + for (final d in documents.cast>()) { + if (d.containsKey('kind')) { + throw StateError('Unexpected resource operation.'); + } + changes[(d['textDocument'] as Map)['uri'] as String] = + (d['edits'] as List).cast>(); + } + } + final result = {}; + for (final entry in changes.entries) { + final file = File.fromUri(Uri.parse(entry.key)); + if (!file.absolute.path.startsWith('${fixture.directory.absolute.path}/')) { + throw StateError('Rename escaped the disposable fixture.'); + } + var text = await file.readAsString(); + int offset(Map p) { + final lines = text.split('\n'); + return lines.take(p['line'] as int).fold(0, (n, l) => n + l.length + 1) + + (p['character'] as int); + } + + final edits = [ + for (final e in entry.value) + ( + start: offset((e['range'] as Map)['start'] as Map), + end: offset((e['range'] as Map)['end'] as Map), + text: e['newText'] as String, + ), + ]..sort((a, b) => b.start.compareTo(a.start)); + for (final e in edits) { + text = text.replaceRange(e.start, e.end, e.text); + } + await file.writeAsString(text); + server.open(file.path, text); + result[file.path.substring(fixture.directory.path.length + 1)] = { + 'edits': edits.length, + 'result': text, + }; + } + return result; +} diff --git a/tool/benchmark_generation.dart b/tool/benchmark_generation.dart new file mode 100644 index 00000000..0354a1ee --- /dev/null +++ b/tool/benchmark_generation.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'src/build_fixture.dart'; + +Future main(List args) async { + final output = args.isEmpty + ? 'research/benchmarks/generation.json' + : args.single; + final versions = await packageVersions(); + final results = >[]; + for (final models in [10, 100, 1000]) { + print('Measuring $models models...'); + final fixture = await BuildFixture.create( + ormPath: Directory.current.path, + models: models, + ); + BuildWatch? watcher; + try { + final cold = await fixture.run(['run', 'build_runner', 'build']); + final unchanged = await fixture.run(['run', 'build_runner', 'build']); + if (!unchanged.output.contains('wrote 0 outputs')) { + throw StateError('Unchanged build rewrote outputs.'); + } + final start = Stopwatch()..start(); + watcher = await fixture.watch(); + await watcher.next(); + final watchStart = start.elapsedMilliseconds; + Future edit(String file, String contents) async { + final elapsed = Stopwatch()..start(); + await fixture.write(file, contents); + await watcher!.next(); + return elapsed.elapsedMilliseconds; + } + + final field = await edit( + 'lib/schema.dart', + BuildFixture.schema(models, extra: true), + ); + final imported = await edit( + 'lib/models.dart', + BuildFixture.domain(label: 'changed', defaultScore: 9), + ); + final client = fixture.file('lib/schema.orm.dart'); + final before = (await client.stat()).modified; + await fixture.write('lib/unrelated.dart', 'const unrelated = 2;\n'); + await watcher.quiet(); + if ((await client.stat()).modified != before) { + throw StateError('Unrelated edit rewrote client.'); + } + await watcher.close(); + watcher = null; + final analysis = await fixture.run(['analyze', 'lib']); + final snapshotSource = await fixture + .file('lib/schema.snapshot.dart') + .readAsString(); + if (RegExp(r'TableSchema\(').allMatches(snapshotSource).length != + models) { + throw StateError('Incorrect table count in generated Dart snapshot.'); + } + final value = { + 'models': models, + 'builder_mode': RegExp(r'Built with build_runner/(\w+)') + .firstMatch(cold.output) + ?.group(1), + 'schema_bytes': await fixture.file('lib/schema.dart').length(), + 'generated_dart_bytes': await client.length(), + 'snapshot_dart_bytes': await fixture + .file('lib/schema.snapshot.dart') + .length(), + 'cold_build_ms': cold.milliseconds, + 'no_change_build_ms': unchanged.milliseconds, + 'watch_start_ms': watchStart, + 'watch_field_edit_ms': field, + 'watch_import_edit_ms': imported, + 'unrelated_edit_triggered_build': false, + 'unrelated_observation_ms': 1000, + 'analyze_ms': analysis.milliseconds, + }; + results.add(value); + print(jsonEncode(value)); + } finally { + await watcher?.close(); + await fixture.dispose(); + } + } + final cpu = Platform.isMacOS + ? (await Process.run('sysctl', [ + '-n', + 'machdep.cpu.brand_string', + ])).stdout.toString().trim() + : null; + final report = { + 'recorded_at_utc': DateTime.now().toUtc().toIso8601String(), + 'dart': Platform.version, + 'os': Platform.operatingSystemVersion, + 'cpu': cpu, + 'logical_processors': Platform.numberOfProcessors, + 'packages': versions, + 'method': 'One sample per case in a fresh consumer package; dependency download excluded; shared SDK/pub/native caches warm. Timings include process startup or filesystem watch detection as applicable. No p50/p95 claim.', + 'results': results, + }; + final file = File(output); + await file.parent.create(recursive: true); + await file.writeAsString( + '${const JsonEncoder.withIndent(' ').convert(report)}\n', + ); + print('Saved $output'); +} + +Future> packageVersions() async { + final configFile = File('.dart_tool/package_config.json').absolute; + final config = + jsonDecode(await configFile.readAsString()) as Map; + final packages = (config['packages'] as List) + .cast>(); + final result = {}; + for (final name in ['build_runner', 'build', 'dart_style', 'analyzer']) { + final entry = packages.singleWhere((entry) => entry['name'] == name); + final root = Directory.fromUri( + configFile.uri.resolve(entry['rootUri'] as String), + ); + final file = File.fromUri(root.uri.resolve('pubspec.yaml')); + result[name] = RegExp( + r'^version: ([^\r\n]+)', + multiLine: true, + ).firstMatch(await file.readAsString())?.group(1); + } + return result; +} diff --git a/tool/benchmark_runtime.dart b/tool/benchmark_runtime.dart new file mode 100644 index 00000000..ae9cceef --- /dev/null +++ b/tool/benchmark_runtime.dart @@ -0,0 +1,554 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer' as developer; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:crypto/crypto.dart'; +import 'package:orm/migrate.dart'; +import 'package:orm/postgres.dart'; +import 'package:orm/sqlite.dart'; + +import '../example/schema.orm.dart'; +import 'src/runtime_cases.dart'; +import 'src/runtime_relay.dart'; +import 'src/runtime_vm.dart'; + +const _prefix = '@@orm-runtime '; + +Future main(List args) async { + if (args.firstOrNull == '--worker') { + await _worker(args[1]); + return; + } + var smoke = false; + String? outputPath; + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--smoke': + smoke = true; + case '--output': + if (++i == args.length) { + throw ArgumentError('--output requires a path.'); + } + outputPath = args[i]; + default: + throw ArgumentError('Use --smoke and/or --output .'); + } + } + final samples = smoke ? 5 : 200, warmup = smoke ? 2 : 20; + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url == null) { + throw StateError( + 'Set ORM_TEST_POSTGRES to a disposable local PostgreSQL database.', + ); + } + final endpoint = Uri.parse(url); + final results = >[]; + for (final scenario in [ + 'sqlite_wal', + 'postgres_loopback', + 'postgres_delay_20ms', + ]) { + RuntimeRelay? relay; + _Worker? worker; + try { + if (scenario.startsWith('postgres')) { + relay = await RuntimeRelay.open( + endpoint.host, + endpoint.hasPort ? endpoint.port : 5432, + Duration.zero, + ); + } + worker = await _Worker.start( + scenario, + relay == null + ? null + : endpoint + .replace(host: '127.0.0.1', port: relay.server.port) + .toString(), + ); + final ready = await worker.next(); + if (relay != null && scenario == 'postgres_delay_20ms') { + relay.oneWayDelay = const Duration(milliseconds: 10); + } + final echo = relay == null + ? null + : await relayRoundTrips(relay.oneWayDelay, smoke ? 2 : 10); + final ping = await worker.call({ + 'action': 'ping', + 'runs': smoke ? 2 : 10, + }); + final cases = >[]; + for (final name in runtimeCases) { + print('$scenario / $name: paired latency and throughput'); + final latency = await worker.call({ + 'action': 'latency', + 'case': name, + 'samples': samples, + 'warmup': warmup, + }); + final concurrency = await worker.call({ + 'action': 'concurrent', + 'case': name, + 'samples': samples, + 'clients': 8, + }); + final contention = await worker.call({ + 'action': 'contention', + 'case': name, + 'samples': smoke ? 8 : 64, + 'clients': 8, + }); + final probes = {}; + for (final lane in ['raw', 'orm']) { + final before = relay?.bytes; + final probe = await worker.call({ + 'action': 'probe', + 'case': name, + 'lane': lane, + }); + final after = relay?.bytes; + if (before != null && after != null) { + probe['postgresProtocolBytes'] = { + for (final key in before.keys) key: after[key]! - before[key]!, + }; + } + probes[lane] = probe; + } + final row = { + 'case': name, + 'latency': latency, + 'concurrent': concurrency, + 'observedContention': contention, + 'probes': probes, + }; + // Memory/trace runs follow all timing in a fresh process below. Enabling + // tracing can deoptimize code, so it must not precede later latency cases. + cases.add(row); + } + final info = Map.of(ready) + ..remove('serviceUri') + ..remove('isolateId'); + results.add({ + 'scenario': scenario, + 'environment': info, + 'oneWayDelayMs': relay?.oneWayDelay.inMilliseconds ?? 0, + if (echo != null) + 'echoRoundTripMicros': _stats(echo, echo.fold(0, (sum, n) => sum + n)) + ..remove('operationsPerSecond'), + 'selectOneMicros': ping, + 'cases': cases, + }); + } finally { + await worker?.close(); + await relay?.close(); + } + } + // Local profiling is sufficient to inspect query-layer object creation; + // repeating it through a latency relay would not isolate additional Dart work. + for (final backend in ['sqlite_wal', 'postgres_loopback']) { + print('$backend: separate heap and allocation trace process'); + final worker = await _Worker.start(backend, url, profile: true); + RuntimeVm? vm; + try { + final ready = await worker.next(); + vm = await RuntimeVm.connect( + ready['serviceUri'] as String, + ready['isolateId'] as String, + ); + for (final name in runtimeCases) { + await worker.call({ + 'action': 'latency', + 'case': name, + 'samples': 2, + 'warmup': warmup, + }); + await vm.trace(true); + final memory = {}; + for (final lane in ['raw', 'orm']) { + await worker.call({'action': 'clear'}); + final before = await vm.heap(); + final batch = await worker.call({ + 'action': 'profile', + 'case': name, + 'lane': lane, + 'runs': 3, + }); + final traces = await vm.allocations( + batch['startMicros'] as int, + batch['endMicros'] as int, + ); + final after = await vm.heap(); + memory[lane] = { + 'batch': batch, + 'heapBefore': before, + 'heapWithLastResult': after, + 'allocationTraces': traces, + }; + } + await vm.trace(false); + final target = results.singleWhere((r) => r['scenario'] == backend); + ((target['cases'] as List).cast>().singleWhere( + (r) => r['case'] == name, + ))['memory'] = memory; + } + } finally { + await vm?.close(); + await worker.close(); + } + } + final source = await Process.run('git', ['rev-parse', 'HEAD']); + if (source.exitCode != 0) { + throw StateError('Cannot identify measured source.'); + } + final cpu = await Process.run('sysctl', ['-n', 'machdep.cpu.brand_string']); + final files = [ + 'tool/benchmark_runtime.dart', + 'tool/src/runtime_cases.dart', + 'tool/src/runtime_relay.dart', + 'tool/src/runtime_vm.dart', + ]; + final report = { + 'recordedAt': DateTime.now().toUtc().toIso8601String(), + 'sourceCommit': source.stdout.toString().trim(), + 'harnessSha256': { + for (final file in files) + file: sha256.convert(await File(file).readAsBytes()).toString(), + }, + 'dart': Platform.version, + 'os': Platform.operatingSystemVersion, + 'cpu': cpu.stdout.toString().trim(), + 'samplesPerLane': samples, + 'warmupPairs': warmup, + 'smoke': smoke, + 'scope': 'Warm native JIT, reused query objects; raw uses the same public Driver and precompiled identical SQL with manual same-codec mapping. ORM includes compilation. Default hooks and allocation tracing are disabled in timing. Loopback relay has no loss or bandwidth limit; delayed scenario is controlled TCP latency, not a remote-server measurement. PostgreSQL protocol bytes exclude TCP/IP headers. SQLite byte counts are UTF-8 JSON of driver rows, not IPC wire bytes. Heap fields are live isolate-group memory; traces count selected classes only, not total allocated bytes.', + 'fixture': { + 'users': 100, + 'postsPerUser': 10, + 'selectedPostsPerUser': 3, + 'nullableNicknames': true, + 'sqlite': 'temporary file, WAL, warm cache', + 'postgresPool': 4, + 'concurrentClients': 8, + }, + 'results': results, + }; + final output = File( + outputPath ?? + (smoke + ? '.dart_tool/runtime-smoke.json' + : 'research/benchmarks/runtime.json'), + ); + await output.parent.create(recursive: true); + await output.writeAsString( + '${const JsonEncoder.withIndent(' ').convert(report)}\n', + ); + print('Verified and saved ${output.path}'); +} + +final class _Worker { + final Process process; + final StreamIterator lines; + final List errors = []; + late final StreamSubscription _stderr; + _Worker._(this.process) + : lines = StreamIterator( + process.stdout.transform(utf8.decoder).transform(const LineSplitter()), + ) { + _stderr = process.stderr.transform(utf8.decoder).listen(errors.add); + } + static Future<_Worker> start( + String backend, + String? url, { + bool profile = false, + }) async => _Worker._( + await Process.start( + Platform.resolvedExecutable, + [ + if (profile) '--profiler', + if (profile) '--sample-buffer-duration=60', + 'run', + if (profile) '--enable-vm-service=0', + if (profile) '--no-dds', + 'tool/benchmark_runtime.dart', + '--worker', + backend, + ], + environment: {'ORM_TEST_POSTGRES': ?url}, + ), + ); + Future> next() async { + while (await lines.moveNext().timeout(const Duration(minutes: 3))) { + final line = lines.current; + if (line.startsWith(_prefix)) { + return jsonDecode(line.substring(_prefix.length)) + as Map; + } + } + throw StateError('Benchmark worker exited: ${errors.join()}'); + } + + Future> call(Map request) { + process.stdin.writeln(jsonEncode(request)); + return next(); + } + + Future close() async { + await process.stdin.close(); + final code = await process.exitCode.timeout( + const Duration(seconds: 30), + onTimeout: () { + process.kill(); + return -1; + }, + ); + await lines.cancel(); + await _stderr.cancel(); + if (code != 0) { + throw StateError('Benchmark worker failed ($code): ${errors.join()}'); + } + } +} + +void _reply(Map result) => + print('$_prefix${jsonEncode(result)}'); +Map _stats(List samples, int wallMicros) { + final sorted = samples.toList()..sort(); + return { + 'samplesMicros': samples, + 'p50Micros': sorted[(sorted.length * .5).ceil() - 1], + 'p95Micros': sorted[(sorted.length * .95).ceil() - 1], + 'minMicros': sorted.first, + 'maxMicros': sorted.last, + 'wallMicros': wallMicros, + 'operationsPerSecond': samples.length * 1000000 / wallMicros, + }; +} + +Future _worker(String backend) async { + final temp = await Directory.systemTemp.createTemp('orm-runtime-'); + final schema = 'orm_runtime_${DateTime.now().microsecondsSinceEpoch}'; + final Database db = backend == 'sqlite_wal' + ? await sqlite(SqliteOptions.file('${temp.path}/data.sqlite')) + : postgres( + PostgresOptions( + url: Uri.parse(Platform.environment['ORM_TEST_POSTGRES']!), + tls: .disable, + schema: schema, + maxConnections: 4, + ), + ); + var created = false; + List? retained; + try { + if (backend != 'sqlite_wal') { + await db.execute(SqlCommand('CREATE SCHEMA "$schema"')); + created = true; + } + await Migrator(db.sql).apply([ + Migration.create('0001_benchmark', appSchema, dialect: db.dialect), + ]); + final nick = List.filled(16, 'long nickname 漢字').join(); + final title = List.filled(8, 'post title 漢字').join(); + await db.execute( + SqlCommand( + "WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<100) INSERT INTO users(id,email,nickname,score) SELECT 10000+x, 'user-' || x || '@example.invalid', CASE WHEN x%3=0 THEN NULL ELSE '$nick' END, x%10 FROM n", + ), + ); + await db.execute( + SqlCommand( + "WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1000) INSERT INTO posts(id,author_id,title,created_at) SELECT x, 10001+(x-1)/10, '$title' || x, '2026-01-01 00:00:00+00' FROM n", + ), + ); + final cases = { + for (final name in runtimeCases) + name: await RuntimeCase.prepare(db, name), + }; + // Open every PostgreSQL pool slot before concurrency measurements. + if (backend != 'sqlite_wal') { + final ready = Completer(); + var leases = 0; + await Future.wait([ + for (var i = 0; i < 4; i++) + db.driver.run((c) async { + await c.execute(SqlCommand('SELECT 1')); + if (++leases == 4) ready.complete(); + await ready.future; + }), + ]); + } + final version = (await db.execute( + SqlCommand( + backend == 'sqlite_wal' + ? 'SELECT sqlite_version()' + : "SELECT current_setting('server_version')", + ), + )).rows.single.single; + _reply({ + 'backend': backend, + 'databaseVersion': version, + 'serviceUri': '${(await developer.Service.getInfo()).serverUri}', + 'isolateId': developer.Service.getIsolateId(Isolate.current), + 'pid': pid, + }); + await for (final line + in stdin.transform(utf8.decoder).transform(const LineSplitter())) { + final request = jsonDecode(line) as Map; + final action = request['action']; + if (action == 'clear') { + retained = null; + _reply({}); + continue; + } + if (action == 'ping') { + final samples = []; + for (var i = 0; i < (request['runs'] as int); i++) { + final clock = Stopwatch()..start(); + await db.driver.run((c) => c.execute(SqlCommand('SELECT 1'))); + samples.add(clock.elapsedMicroseconds); + } + _reply(_stats(samples, samples.fold(0, (sum, n) => sum + n))); + continue; + } + final test = cases[request['case']]!; + final lane = request['lane'] as String?; + if (action == 'latency') { + for (var i = 0; i < (request['warmup'] as int); i++) { + await test.raw(); + await test.query.get(); + } + final samples = {'raw': [], 'orm': []}; + for (var i = 0; i < (request['samples'] as int); i++) { + for (final lane in i.isEven ? ['raw', 'orm'] : ['orm', 'raw']) { + final clock = Stopwatch()..start(); + final rows = await test.run(lane); + samples[lane]!.add(clock.elapsedMicroseconds); + if (rows.length != (test.name == 'aggregate' ? 10 : 100)) { + throw StateError('Incomplete sample.'); + } + } + } + _reply({ + for (final e in samples.entries) + e.key: _stats(e.value, e.value.fold(0, (sum, n) => sum + n)), + }); + } else if (action == 'concurrent') { + final result = {}; + for (final lane in ['raw', 'orm']) { + final samples = []; + var next = 0; + final wall = Stopwatch()..start(); + await Future.wait([ + for (var i = 0; i < (request['clients'] as int); i++) + () async { + while (next < (request['samples'] as int)) { + next++; + final clock = Stopwatch()..start(); + final rows = await test.run(lane); + samples.add(clock.elapsedMicroseconds); + if (rows.length != (test.name == 'aggregate' ? 10 : 100)) { + throw StateError('Incomplete concurrent sample.'); + } + } + }(), + ]); + result[lane] = _stats(samples, wall.elapsedMicroseconds); + } + _reply(result); + } else if (action == 'contention') { + final result = {}; + for (final lane in ['raw', 'orm']) { + final capture = CaptureDriver(db.driver, retainResults: false); + final query = runtimeQuery(Database(capture), test.name); + var next = 0; + await Future.wait([ + for (var i = 0; i < (request['clients'] as int); i++) + () async { + while (next < (request['samples'] as int)) { + next++; + if (lane == 'raw') { + await test.raw(driver: capture); + } else { + await query.get(); + } + } + }(), + ]); + result[lane] = { + 'acquisition': _stats( + capture.acquireMicros, + capture.acquireMicros.fold(0, (sum, n) => sum + n), + )..remove('operationsPerSecond'), + 'sqlMicros': capture.sqlMicros, + 'scope': 'Separate instrumented eight-client run; acquisition includes driver lease/setup, not only native pool queue time.', + }; + } + _reply(result); + } else if (action == 'probe') { + final capture = CaptureDriver(db.driver), decode = []; + final rows = lane == 'raw' + ? await test.raw(driver: capture) + : await runtimeQuery( + Database(capture, onDecode: decode.add), + test.name, + ).get(); + if (capture.commands.length != test.commands.length) { + throw StateError('Raw and ORM statement counts differ.'); + } + for (final (i, command) in capture.commands.indexed) { + if (command.sql != test.commands[i].sql || + jsonEncode(command.parameters) != + jsonEncode(test.commands[i].parameters)) { + throw StateError('Raw and ORM SQL/parameters differ.'); + } + } + _reply({ + 'sql': [ + for (final c in capture.commands) + {'sql': c.sql, 'parameters': c.parameters}, + ], + 'returnedRows': capture.results.map((r) => r.rows.length).toList(), + 'driverRowsUtf8JsonBytes': capture.results + .map((r) => utf8.encode(jsonEncode(r.rows)).length) + .toList(), + 'outputUtf8JsonBytes': utf8 + .encode(jsonEncode(canonicalRows(rows))) + .length, + 'acquireMicros': capture.acquireMicros, + 'sqlMicros': capture.sqlMicros, + 'ormDecodeMicros': decode + .map((e) => e.elapsed.inMicroseconds) + .toList(), + }); + } else if (action == 'profile') { + final beforeRss = ProcessInfo.currentRss, + start = developer.Timeline.now; + for (var i = 0; i < (request['runs'] as int); i++) { + retained = await test.run(lane!); + } + final end = developer.Timeline.now; + _reply({ + 'runs': request['runs'], + 'startMicros': start, + 'endMicros': end, + 'retainedRootRows': retained!.length, + 'rssBefore': beforeRss, + 'rssAfter': ProcessInfo.currentRss, + 'processLifetimeMaxRss': ProcessInfo.maxRss, + }); + } else { + throw StateError('Unknown benchmark action.'); + } + } + } finally { + try { + if (created) { + await db.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + } + } finally { + await db.close(); + await temp.delete(recursive: true); + } + } +} diff --git a/tool/build_sqlite_web.dart b/tool/build_sqlite_web.dart new file mode 100644 index 00000000..7fbb3a1e --- /dev/null +++ b/tool/build_sqlite_web.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:dart_style/dart_style.dart'; + +const _engineVersion = '3.6.0'; +const _engineHash = + '13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4'; + +/// Maintainer command. Consumers use the published artifacts, without compiling. +Future main(List args) async { + if (args.any((a) => a != '--check') || args.length > 1) { + throw ArgumentError('Use --check to verify packaged SQLite web assets.'); + } + final config = await Isolate.resolvePackageUri( + Uri.parse('package:sqlite3/wasm.dart'), + ); + final enginePubspec = await File.fromUri(config!.resolve('../pubspec.yaml')) + .readAsString(); + if (!RegExp( + '^version: $_engineVersion\$', + multiLine: true, + ).hasMatch(enginePubspec)) { + throw StateError( + 'Review the SQLite WASM build when updating package:sqlite3.', + ); + } + final sources = [ + for (final file in Directory('lib').listSync(recursive: true)) + if (file is File && + file.path.endsWith('.dart') && + !file.path.endsWith('/web_build.dart')) + file, + File('pubspec.yaml'), + File('pubspec.lock'), + File('tool/build_sqlite_web.dart'), + ]..sort((a, b) => a.path.compareTo(b.path)); + final fingerprint = BytesBuilder(); + fingerprint.add(utf8.encode(Platform.version.split(' ').first)); + fingerprint.addByte(0); + for (final file in sources) { + fingerprint.add(utf8.encode(file.path)); + fingerprint.addByte(0); + fingerprint.add(await file.readAsBytes()); + fingerprint.addByte(0); + } + final build = sha256.convert(fingerprint.takeBytes()).toString(); + const wasmName = 'sqlite3-$_engineHash.wasm'; + final assets = Directory('assets/sqlite'); + final engine = File('${assets.path}/$wasmName'); + final manifest = File('lib/src/sqlite/web_build.dart'); + String source(String workerHash) => + DartFormatter(languageVersion: DartFormatter.latestLanguageVersion) + .format('''// Generated by tool/build_sqlite_web.dart. Do not edit. +const sqliteWebProtocol = 1; +const sqliteWorkerBuild = '$build'; +const sqliteWorkerFile = 'worker-$workerHash.js'; +const sqliteWorkerSha256 = '$workerHash'; +const sqliteWasmFile = '$wasmName'; +const sqliteWasmSha256 = '$_engineHash'; +const sqliteWasmIntegrity = 'sha256-${base64Encode([for (var i = 0; i < _engineHash.length; i += 2) int.parse(_engineHash.substring(i, i + 2), radix: 16)])}'; +'''); + Future digest(File file) async => + (await sha256.bind(file.openRead()).first).toString(); + if (args.contains('--check')) { + final current = await manifest.readAsString(); + final hash = RegExp(r"sqliteWorkerSha256\s*=\s*'([0-9a-f]{64})'") + .firstMatch(current) + ?.group(1); + final worker = File('${assets.path}/worker-$hash.js'); + if (!await worker.exists() || + !await engine.exists() || + await digest(engine) != _engineHash || + current != source(await digest(worker))) { + throw StateError( + 'SQLite web assets are stale. Run dart run tool/build_sqlite_web.dart.', + ); + } + stdout.writeln('SQLite web source, worker and WASM fingerprints match.'); + return; + } + await assets.create(recursive: true); + if (!await engine.exists()) { + final cache = File('.dart_tool/browser/sqlite3.wasm'); + if (await cache.exists() && await digest(cache) == _engineHash) { + await cache.copy(engine.path); + } else { + final client = HttpClient(); + try { + final response = await (await client.getUrl( + Uri.parse( + 'https://github.com/simolus3/sqlite3.dart/releases/download/sqlite3-$_engineVersion/sqlite3.wasm', + ), + )).close(); + if (response.statusCode != 200) { + throw StateError('WASM download: ${response.statusCode}'); + } + final bytes = await response.fold>( + [], + (all, chunk) => all..addAll(chunk), + ); + if (sha256.convert(bytes).toString() != _engineHash) { + throw StateError('WASM hash differs.'); + } + await engine.writeAsBytes(bytes); + } finally { + client.close(force: true); + } + } + } + if (await digest(engine) != _engineHash) { + throw StateError('Packaged WASM hash differs.'); + } + await manifest.writeAsString(source('')); + final temporary = Directory('.dart_tool/sqlite_web') + ..createSync(recursive: true); + final entry = File('${temporary.path}/worker.dart'); + await entry.writeAsString( + "import 'package:orm/sqlite_web_worker.dart';\nvoid main() => runSqliteWebWorker();\n", + ); + final output = File('${temporary.path}/worker.js'); + final result = await Process.run(Platform.resolvedExecutable, [ + 'compile', + 'js', + '-O2', + '--no-source-maps', + '--packages=.dart_tool/package_config.json', + entry.path, + '-o', + output.path, + ]); + if (result.exitCode != 0) { + throw StateError('${result.stdout}\n${result.stderr}'); + } + final workerHash = await digest(output); + final workerName = 'worker-$workerHash.js'; + final worker = File('${assets.path}/$workerName'); + await output.copy(worker.path); + await manifest.writeAsString(source(workerHash)); + for (final file in assets.listSync()) { + if (file is File && file.path.endsWith('.js') && file.path != worker.path) { + await file.delete(); + } + } + stdout.writeln('Packaged $workerName and $wasmName.'); +} diff --git a/tool/compare_authoring.dart b/tool/compare_authoring.dart new file mode 100644 index 00000000..7378f45d --- /dev/null +++ b/tool/compare_authoring.dart @@ -0,0 +1,396 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:orm/generate.dart'; + +import 'src/authoring.dart'; +import 'src/authoring_fixture.dart'; +import 'src/build_fixture.dart'; +import 'src/editor_server.dart'; + +Future main(List args) async { + if (args.any((a) => a != '--smoke')) { + throw ArgumentError('Use --smoke or no arguments.'); + } + final smoke = args.contains('--smoke'); + final results = >[]; + final expected = {}; + for (final form in smoke ? ['record'] : authoringSources.keys) { + stdout.writeln('Checking $form authoring...'); + final fixture = await BuildFixture.create(ormPath: Directory.current.path); + EditorServer? editor; + try { + final author = fixture.file('lib/author.dart'); + final original = DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ).format('// @dart=3.13\n${authoringSources[form]!}'); + Future<({Map report, GeneratedSchema generated})> emit( + String variant, + String source, + ) async { + await fixture.write('lib/author.dart', source); + final watch = Stopwatch()..start(); + final normalized = await normalizeAuthoring(author.path, form); + final normalizeMicros = watch.elapsedMicroseconds; + await fixture.write('lib/schema.dart', normalized.text); + final generated = await generateSchema( + fixture.file('lib/schema.dart').path, + ); + final generationMicros = watch.elapsedMicroseconds - normalizeMicros; + final snapshot = jsonEncode(generated.snapshot.toJson()); + final baseline = expected[variant]; + if (baseline == null) { + expected[variant] = ( + dart: generated.dart, + snapshot: snapshot, + schema: normalized.text, + ); + } else if (baseline.dart != generated.dart || + baseline.snapshot != snapshot || + baseline.schema != normalized.text) { + throw StateError( + '$form/$variant differs from the Record API or snapshot.', + ); + } + await fixture.write('lib/schema.orm.dart', generated.dart); + await fixture.write('lib/schema.snapshot.dart', generated.snapshotDart); + return ( + generated: generated, + report: { + 'sourceBytes': utf8.encode(source).length, + 'sourceLines': source.trimRight().split('\n').length, + 'modelDeclarationBytes': utf8 + .encode(source.substring(0, source.indexOf('final users ='))) + .length, + 'modelDeclarationLines': source + .substring(0, source.indexOf('final users =')) + .trimRight() + .split('\n') + .length, + 'normalizeMicros': normalizeMicros, + 'generationMicros': generationMicros, + 'schemaSha256': _hash(normalized.text), + 'clientSha256': _hash(generated.dart), + 'snapshotSha256': _hash(snapshot), + }, + ); + } + + final base = await emit('base', original); + _checkSnapshot(base.generated.snapshot.toJson()); + await fixture.write('lib/client.dart', authoringConsumer); + final initialAnalysis = await fixture.run(['analyze', 'lib']); + + final changed = form == 'table' + ? original.replaceFirst( + "Column('bio', Codecs.text.nullable()", + "Column('bio', Codecs.integer.nullable()", + ) + : original.replaceFirst('String? bio', 'int? bio'); + if (changed == original) { + throw StateError('Field edit did not change $form source.'); + } + final fieldEdit = await emit('field_type', changed); + final fieldErrors = await _consumerErrors(fixture, 'invalid_assignment'); + await fixture.write( + 'lib/client.dart', + authoringConsumer.replaceFirst('String? bio', 'int? bio'), + ); + await fixture.run(['analyze', 'lib']); + + await emit('base', original); + await fixture.write('lib/client.dart', authoringConsumer); + editor = await EditorServer.start(fixture.directory.path); + await editor.ready(); + editor.open(author.path, original); + final marker = form == 'table' ? 'late final email' : 'String email'; + final offset = original.indexOf(marker) + marker.lastIndexOf(' ') + 1; + if (!original.contains(marker)) { + throw StateError('Missing rename marker.'); + } + final location = editorLocation(author.path, original, offset); + final timer = Stopwatch()..start(); + final prepared = await editor.request( + 'textDocument/prepareRename', + location, + ); + final edits = await editor.request('textDocument/rename', { + ...location, + 'newName': 'contactEmail', + }); + final renameMicros = timer.elapsedMicroseconds; + String renamed; + if (form == 'record') { + if (prepared != null || edits != null) { + throw StateError( + 'Record rename behavior changed; review the experiment.', + ); + } + renamed = original + .replaceFirst('String email', 'String contactEmail') + .replaceAll('u.email', 'u.contactEmail'); + } else { + renamed = _renameOneFile( + author.path, + original, + edits as Map, + ); + if (!renamed.contains('u.contactEmail') || + !renamed.contains("'email'")) { + throw StateError( + 'Rename missed constraints or changed physical mapping.', + ); + } + } + await editor.close(); + editor = null; + final rename = await emit('rename', renamed); + if (rename.report['snapshotSha256'] != base.report['snapshotSha256']) { + throw StateError('A Dart field rename changed the physical schema.'); + } + final renameErrors = await _consumerErrors(fixture, 'undefined_getter'); + await fixture.write( + 'lib/client.dart', + authoringConsumer + .replaceFirst( + "email: 'a@example.com'", + "contactEmail: 'a@example.com'", + ) + .replaceAll('user.email', 'user.contactEmail') + .replaceAll('u.email', 'u.contactEmail'), + ); + await fixture.run(['analyze', 'lib']); + + final failures = >[]; + for (final (name, source, phase, fragment) in [ + ( + 'unknown_field', + original.replaceAll('u.email', 'u.absent'), + 'dart', + 'absent', + ), + ( + 'computed_selector', + '$original\nfinal invalid = users.index((u) => 0);\n', + 'generator', + '0', + ), + ( + 'duplicate_key_field', + '$original\nfinal invalid = users.index((u) => (u.id, u.id));\n', + 'generator', + 'u.id', + ), + ( + 'non_unique_target', + original.replaceFirst(RegExp(r'final userIdentity = [^;]+;\n'), ''), + 'generator', + 'posts', + ), + if (form == 'primary') + ( + 'constructor_logic', + original.replaceRange( + original.indexOf(');'), + original.indexOf(');') + 2, + ") { this { throw StateError('must not execute'); } }", + ), + 'authoring', + 'final class User', + ), + if (form == 'table') + ( + 'custom_codec', + original.replaceFirst( + 'Codecs.text.nullable()', + 'Codecs.text.map((v) => v, (v) => v).nullable()', + ), + 'authoring', + 'Codecs.text', + ), + ]) { + await fixture.write('lib/author.dart', source); + AuthoringFailure? failure; + try { + final normalized = await normalizeAuthoring(author.path, form); + await fixture.write('lib/schema.dart', normalized.text); + try { + await generateSchema(fixture.file('lib/schema.dart').path); + } on GenerationException catch (e) { + final match = RegExp(r'\(offset (\d+)\)').firstMatch(e.message); + if (match == null) rethrow; + failure = AuthoringFailure( + 'generator', + name, + e.message, + normalized.originalOffset(int.parse(match[1]!)), + fragment.length, + ); + } + } on AuthoringFailure catch (e) { + failure = e; + } + if (failure == null || + failure.phase != phase || + !source.substring(failure.offset).startsWith(fragment)) { + throw StateError( + '$form/$name has the wrong error location: $failure', + ); + } + failures.add({'case': name, ...failure.toJson(source)}); + } + await emit('base', original); + await fixture.write('lib/client.dart', authoringConsumer); + final finalAnalysis = await fixture.run(['analyze', 'lib']); + results.add({ + 'form': form, + 'source': original, + 'base': base.report, + 'initialAnalyzeMs': initialAnalysis.milliseconds, + 'fieldTypeEdit': { + ...fieldEdit.report, + 'source': changed, + 'staleConsumerDiagnostics': fieldErrors, + }, + 'fieldRename': { + ...rename.report, + 'source': renamed, + 'method': form == 'record' ? 'manual' : 'lsp', + 'lspMicros': renameMicros, + 'prepare': prepared, + 'edit': _relative(edits, fixture.directory), + 'staleConsumerDiagnostics': renameErrors, + 'physicalSnapshotUnchanged': true, + }, + 'errors': failures, + 'finalAnalyzeMs': finalAnalysis.milliseconds, + }); + stdout.writeln( + 'Verified $form: identical APIs, typed consumers, rename and source errors.', + ); + } finally { + await editor?.close(); + await fixture.dispose(); + } + } + final commit = await Process.run('git', ['rev-parse', 'HEAD']); + final report = { + 'recordedAt': DateTime.now().toUtc().toIso8601String(), + 'runtimeSourceCommit': commit.stdout.toString().trim(), + 'dart': Platform.version, + 'os': Platform.operatingSystemVersion, + 'smoke': smoke, + 'scope': 'Controlled User/Post/Profile/Follow declaration-frontends experiment. All forms are resolved, normalized to the same Record schema, and sent through the unchanged production generator. Class/table inputs describe schema only; arbitrary constructors/methods and nominal runtime row objects are not implemented. No database execution or runtime performance claim. One timing observation per stage, warm SDK/pub/OS caches, offline dependency resolution excluded. Physical snapshots and generated Dart must be byte-identical for each variant. Record field rename is manual; class/table schema references use a clean LSP server. Generated-client references are independently checked and repaired after regeneration.', + 'harnessSha256': { + for (final p in [ + 'tool/compare_authoring.dart', + 'tool/src/authoring.dart', + 'tool/src/authoring_fixture.dart', + 'tool/src/build_fixture.dart', + 'tool/src/editor_server.dart', + ]) + p: _hash(await File(p).readAsString()), + }, + 'outputs': { + for (final entry in expected.entries) + entry.key: { + 'schema': entry.value.schema, + 'clientSha256': _hash(entry.value.dart), + 'snapshot': jsonDecode(entry.value.snapshot), + }, + }, + 'results': results, + }; + final path = smoke + ? '.dart_tool/authoring-smoke.json' + : 'research/benchmarks/authoring.json'; + await File(path) + .writeAsString('${const JsonEncoder.withIndent(' ').convert(report)}\n'); + stdout.writeln('Saved $path'); +} + +String _hash(String text) => sha256.convert(utf8.encode(text)).toString(); + +Future> _consumerErrors( + BuildFixture fixture, + String requiredCode, +) async { + final result = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '--format=machine', + 'lib/client.dart', + ], workingDirectory: fixture.directory.path); + final output = '${result.stdout}\n${result.stderr}'; + final lines = output + .split('\n') + .where((l) => l.startsWith('ERROR|')) + .map((l) => l.replaceAll(fixture.directory.path, 'fixture')) + .toList(); + if (result.exitCode == 0 || + !lines.any((l) => l.toLowerCase().contains('|$requiredCode|'))) { + throw StateError('Missing stale-client diagnostic $requiredCode: $output'); + } + return lines; +} + +void _checkSnapshot(Map snapshot) { + final tables = (snapshot['tables'] as List).cast>(); + if (tables.length != 4 || + tables.fold(0, (n, t) => n + (t['foreignKeys'] as List).length) != 4 || + (tables.singleWhere((t) => t['name'] == 'follows')['primaryKey'] as List) + .length != + 3 || + (tables.singleWhere((t) => t['name'] == 'profiles')['primaryKey'] as List) + .length != + 2) { + throw StateError( + 'The fixture lost required relation/key metadata: $snapshot', + ); + } +} + +String _renameOneFile(String path, String source, Map edit) { + final documents = (edit['documentChanges'] as List) + .cast>(); + if (documents.length != 1 || + (documents.single['textDocument'] as Map)['uri'] != + File(path).absolute.uri.toString()) { + throw StateError('Rename escaped its authoring source: $edit'); + } + int offset(Map p) => + source + .split('\n') + .take(p['line'] as int) + .fold(0, (n, l) => n + l.length + 1) + + (p['character'] as int); + final edits = [ + for (final e + in (documents.single['edits'] as List).cast>()) + ( + start: offset((e['range'] as Map)['start'] as Map), + end: offset((e['range'] as Map)['end'] as Map), + text: e['newText'] as String, + ), + ]..sort((a, b) => b.start.compareTo(a.start)); + if (edits.length != 2) { + throw StateError( + 'Expected declaration and unique-key reference rename: $edit', + ); + } + for (final e in edits) { + source = source.replaceRange(e.start, e.end, e.text); + } + return source; +} + +Object? _relative(Object? value, Directory root) => switch (value) { + String() => value.replaceAll(root.uri.toString(), 'fixture/'), + List() => value.map((e) => _relative(e, root)).toList(), + Map() => { + for (final e in value.entries) + _relative(e.key, root).toString(): _relative(e.value, root), + }, + _ => value, +}; diff --git a/tool/generate_fixtures.dart b/tool/generate_fixtures.dart new file mode 100644 index 00000000..000c62f1 --- /dev/null +++ b/tool/generate_fixtures.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:orm/generate.dart'; + +/// Refresh checked-in clients after a generator change; historical migrations +/// remain immutable and are never regenerated by this command. +Future main() async { + final paths = {'test/support/nominal/schema.dart'}; + final queries = {}; + for (final directory in ['test/support', 'example']) { + for (final file in Directory(directory).listSync(recursive: true)) { + if (file is! File || + file.path.contains('/build/') || + file.path.contains('/.dart_tool/')) { + continue; + } + if (file.path.endsWith('.orm.dart')) { + paths.add(file.path.replaceFirst(RegExp(r'\.orm\.dart$'), '.dart')); + } else if (file.path.endsWith('.queries.dart')) { + queries.add( + file.path.replaceFirst(RegExp(r'\.queries\.dart$'), '.dart'), + ); + } + } + } + for (final path in paths.toList()..sort()) { + if (await File(path).exists()) { + await writeGeneratedSchema(path); + stdout.writeln('Generated $path'); + } + } + for (final path in queries.toList()..sort()) { + if (await File(path).exists()) { + await writeGeneratedQueries(path); + stdout.writeln('Generated $path'); + } + } +} diff --git a/tool/src/authoring.dart b/tool/src/authoring.dart new file mode 100644 index 00000000..dac3d5d3 --- /dev/null +++ b/tool/src/authoring.dart @@ -0,0 +1,330 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/type.dart'; + +/// Experiment-only declaration normalization. Does not run constructors or +/// selectors, add production authoring APIs, or preserve class runtime behavior. +Future normalizeAuthoring(String path, String form) async { + path = File(path).absolute.path; + final contexts = AnalysisContextCollection(includedPaths: [path]); + try { + final result = await contexts + .contextFor(path) + .currentSession + .getResolvedUnit(path); + if (result is! ResolvedUnitResult) throw StateError('Cannot resolve $path'); + final errors = result.diagnostics.where( + (e) => e.severity.name.toLowerCase() == 'error', + ); + if (errors.isNotEmpty) { + final e = errors.first; + throw AuthoringFailure( + 'dart', + e.diagnosticCode.lowerCaseName, + e.message, + e.offset, + e.length, + ); + } + final output = AuthoringSource(result.content); + output.add("import 'package:orm/schema.dart';\n", 0); + for (final declaration in result.unit.declarations) { + if (declaration is TopLevelVariableDeclaration) continue; + if (form == 'record' && declaration is GenericTypeAlias) { + final type = declaration.type as RecordTypeAnnotation; + output.add( + 'typedef ${declaration.name.lexeme} = ({\n', + declaration.offset, + ); + for (final field in type.namedFields!.fields) { + output.field( + field.name.lexeme, + field.type.type!, + field.metadata, + field.name.offset, + ); + } + output.add('});\n', declaration.end - 1); + } else if (form == 'primary' && declaration is ClassDeclaration) { + final primary = declaration.namePart; + if (primary is! PrimaryConstructorDeclaration || + declaration.body.members.isNotEmpty || + declaration.extendsClause != null || + declaration.implementsClause != null || + declaration.withClause != null || + primary.typeParameters != null || + primary.constructorName != null) { + throw AuthoringFailure( + 'authoring', + 'unsupported_class', + 'Use an unmodified primary constructor with declaring fields only.', + declaration.offset, + declaration.length, + ); + } + output.add( + 'typedef ${primary.typeName.lexeme} = ({\n', + declaration.offset, + ); + for (final parameter in primary.formalParameters.parameters) { + if (!parameter.isFinal || + parameter.defaultClause != null || + parameter.type == null || + parameter.name == null) { + throw AuthoringFailure( + 'authoring', + 'unsupported_parameter', + 'Use typed final declaring parameters without constructor defaults.', + parameter.offset, + parameter.length, + ); + } + output.field( + parameter.name!.lexeme, + parameter.type!.type!, + parameter.metadata, + parameter.name!.offset, + ); + } + output.add('});\n', declaration.end - 1); + } else if (form == 'table' && declaration is ClassDeclaration) { + if (declaration.extendsClause?.superclass.name.lexeme != 'Fields' || + declaration.implementsClause != null || + declaration.withClause != null || + declaration.namePart.typeParameters != null) { + throw AuthoringFailure( + 'authoring', + 'unsupported_table', + 'Use a Fields class with column members only.', + declaration.offset, + declaration.length, + ); + } + output.add( + 'typedef ${declaration.namePart.typeName.lexeme} = ({\n', + declaration.offset, + ); + for (final member in declaration.body.members) { + if (member is ConstructorDeclaration && + member.toSource() == + '${declaration.namePart.typeName.lexeme}(super.table);') { + continue; + } + if (member is! FieldDeclaration || + !member.fields.isFinal || + member.fields.variables.length != 1) { + throw AuthoringFailure( + 'authoring', + 'unsupported_member', + 'Use one final column field per declaration.', + member.offset, + member.length, + ); + } + final variable = member.fields.variables.single; + final initializer = variable.initializer; + final type = variable.declaredFragment!.element.type; + if (initializer is! MethodInvocation || + initializer.methodName.name != 'column' || + initializer.argumentList.arguments.length != 1 || + type is! InterfaceType || + type.element.name != 'Field') { + throw AuthoringFailure( + 'authoring', + 'unsupported_column', + 'Use column(Column(...)) with a statically resolved Field type.', + variable.offset, + variable.length, + ); + } + final column = + initializer.argumentList.arguments.single.argumentExpression; + if (column is! InstanceCreationExpression || + column.constructorName.type.name.lexeme != 'Column') { + throw AuthoringFailure( + 'authoring', + 'unsupported_column', + 'Use a Column constructor.', + initializer.offset, + initializer.length, + ); + } + final args = column.argumentList.arguments; + if (args.length < 2 || + args.first is! SimpleStringLiteral || + args + .skip(2) + .any( + (a) => a is! NamedArgument || a.name.lexeme != 'nullable', + )) { + throw AuthoringFailure( + 'authoring', + 'unsupported_column_options', + 'Use literal physical names and annotations for schema metadata.', + column.offset, + column.length, + ); + } + final fieldType = type.typeArguments.single; + final baseType = fieldType.getDisplayString().replaceAll('?', ''); + final codec = switch (baseType) { + 'int' => 'Codecs.integer', + 'String' => 'Codecs.text', + 'bool' => 'Codecs.boolean', + 'DateTime' => 'Codecs.dateTime', + _ => '', + }; + final nullable = fieldType.getDisplayString().endsWith('?'); + final codecSource = '$codec${nullable ? '.nullable()' : ''}'; + final nullableSource = + args + .whereType() + .firstOrNull + ?.argumentExpression + .toSource() ?? + 'false'; + if (codec.isEmpty || + args[1].toSource() != codecSource || + nullableSource != '$nullable') { + throw AuthoringFailure( + 'authoring', + 'unsupported_codec', + 'Use the matching built-in codec and explicit SQL nullability; custom conversions cannot be discarded.', + args[1].offset, + args[1].length, + ); + } + output.field( + variable.name.lexeme, + fieldType, + member.metadata, + variable.name.offset, + physical: (args.first as SimpleStringLiteral).value, + ); + } + output.add('});\n', declaration.end - 1); + } else { + throw AuthoringFailure( + 'authoring', + 'unsupported_declaration', + 'Only the selected model declaration form is accepted by this experiment.', + declaration.offset, + declaration.length, + ); + } + } + for (final declaration + in result.unit.declarations.whereType()) { + output.copy(declaration); + output.add('\n', declaration.end); + } + return output; + } finally { + await contexts.dispose(); + } +} + +final class AuthoringSource(final String original) { + final _text = StringBuffer(); + final _spans = <({int start, int end, int source, bool exact})>[]; + String get text => _text.toString(); + + void add(String value, int offset, {bool exact = false}) { + _spans.add(( + start: _text.length, + end: _text.length + value.length, + source: offset, + exact: exact, + )); + _text.write(value); + } + + void copy(AstNode node) => + add(original.substring(node.offset, node.end), node.offset, exact: true); + + void field( + String name, + DartType type, + Iterable annotations, + int offset, { + String? physical, + }) { + final display = type.getDisplayString(); + if (!{ + 'int', + 'String', + 'bool', + 'DateTime', + 'int?', + 'String?', + 'bool?', + 'DateTime?', + }.contains(display)) { + throw AuthoringFailure( + 'authoring', + 'unsupported_type', + 'The controlled experiment supports int, String, bool and DateTime only.', + offset, + name.length, + ); + } + final metadata = annotations.toList(); + for (final annotation in metadata) { + if (annotation.name.name == 'ColumnName') { + final arg = annotation.arguments!.arguments.single; + if (arg is! SimpleStringLiteral || physical != null) { + throw AuthoringFailure( + 'authoring', + 'physical_name', + 'Use one literal physical column name.', + annotation.offset, + annotation.length, + ); + } + physical = arg.value; + } else { + copy(annotation); + add(' ', annotation.end); + } + } + physical ??= name.replaceAllMapped( + RegExp('[A-Z]'), + (m) => '_${m[0]!.toLowerCase()}', + ); + add('@ColumnName(${jsonEncode(physical)}) $display $name,\n', offset); + } + + int originalOffset(int offset) { + final span = _spans.lastWhere((s) => offset >= s.start && offset < s.end); + return span.source + (span.exact ? offset - span.start : 0); + } +} + +final class AuthoringFailure( + final String phase, + final String code, + final String message, + final int offset, + final int length, +) implements Exception { + Map toJson(String source) { + final before = source.substring(0, offset); + return { + 'phase': phase, + 'code': code, + 'message': message, + 'offset': offset, + 'length': length, + 'line': '\n'.allMatches(before).length + 1, + 'column': offset - before.lastIndexOf('\n'), + 'sourceLine': source.split('\n')['\n'.allMatches(before).length], + }; + } + + @override + String toString() => '$phase/$code at $offset: $message'; +} diff --git a/tool/src/authoring_fixture.dart b/tool/src/authoring_fixture.dart new file mode 100644 index 00000000..085bf719 --- /dev/null +++ b/tool/src/authoring_fixture.dart @@ -0,0 +1,92 @@ +/// Controlled authoring inputs, not additional supported ORM frontends. +const authoringSources = { + 'record': + ''' +import 'package:orm/schema.dart'; +typedef User = ({@Id.generated() int id, int tenantId, @ColumnName('email') String email, String? nickname}); +typedef Post = ({@Id.generated() int id, int tenantId, int authorId, String title, @Default.sql('false') bool published, DateTime createdAt}); +typedef Profile = ({int tenantId, int userId, String? bio}); +typedef Follow = ({int tenantId, int followerId, int followedId, DateTime createdAt}); +$_constraints +''', + 'primary': + ''' +import 'package:orm/schema.dart'; +final class User(@Id.generated() final int id, final int tenantId, @ColumnName('email') final String email, final String? nickname); +final class Post(@Id.generated() final int id, final int tenantId, final int authorId, final String title, @Default.sql('false') final bool published, final DateTime createdAt); +final class Profile(final int tenantId, final int userId, final String? bio); +final class Follow(final int tenantId, final int followerId, final int followedId, final DateTime createdAt); +$_constraints +''', + 'table': + ''' +import 'package:orm/schema.dart'; +import 'package:orm/orm.dart'; +final class User extends Fields { + User(super.table); + @Id.generated() late final id = column(Column('id', Codecs.integer)); + late final tenantId = column(Column('tenant_id', Codecs.integer)); + late final email = column(Column('email', Codecs.text)); + late final nickname = column(Column('nickname', Codecs.text.nullable(), nullable: true)); +} +final class Post extends Fields { + Post(super.table); + @Id.generated() late final id = column(Column('id', Codecs.integer)); + late final tenantId = column(Column('tenant_id', Codecs.integer)); + late final authorId = column(Column('author_id', Codecs.integer)); + late final title = column(Column('title', Codecs.text)); + @Default.sql('false') late final published = column(Column('published', Codecs.boolean)); + late final createdAt = column(Column('created_at', Codecs.dateTime)); +} +final class Profile extends Fields { + Profile(super.table); + late final tenantId = column(Column('tenant_id', Codecs.integer)); + late final userId = column(Column('user_id', Codecs.integer)); + late final bio = column(Column('bio', Codecs.text.nullable(), nullable: true)); +} +final class Follow extends Fields { + Follow(super.table); + late final tenantId = column(Column('tenant_id', Codecs.integer)); + late final followerId = column(Column('follower_id', Codecs.integer)); + late final followedId = column(Column('followed_id', Codecs.integer)); + late final createdAt = column(Column('created_at', Codecs.dateTime)); +} +$_constraints +''', +}; + +const _constraints = ''' +final users = entity(table: 'users'); +final posts = entity(table: 'posts'); +final profiles = entity(table: 'profiles'); +final follows = entity(table: 'follows'); +final userIdentity = users.unique((u) => (u.tenantId, u.id)); +final userEmail = users.unique((u) => (u.tenantId, u.email)); +final profileIdentity = profiles.primaryKey((p) => (p.tenantId, p.userId)); +final followIdentity = follows.primaryKey((f) => (f.tenantId, f.followerId, f.followedId)); +final author = posts.key((p) => (p.tenantId, p.authorId)).references(users.key((u) => (u.tenantId, u.id)), inverse: 'posts'); +final profileOwner = profiles.key((p) => (p.tenantId, p.userId)).references(users.key((u) => (u.tenantId, u.id)), inverse: 'profile', onDelete: .cascade); +final follower = follows.key((f) => (f.tenantId, f.followerId)).references(users.key((u) => (u.tenantId, u.id)), inverse: 'following', onDelete: .cascade); +final followed = follows.key((f) => (f.tenantId, f.followedId)).references(users.key((u) => (u.tenantId, u.id)), inverse: 'followers', onDelete: .cascade); +final authorTimeline = posts.index((p) => (p.tenantId, p.authorId, p.createdAt, p.id)); +final distinctUsers = follows.check('follower_id <> followed_id'); +'''; + +const authoringConsumer = ''' +import 'package:orm/orm.dart'; +import 'schema.orm.dart'; + +Future consume(Database db) async { + final user = await db.users.create(tenantId: 1, email: 'a@example.com'); + final String email = user.email; + await db.users.byId(user.id).patch(nickname: .set('A')); + final cards = await db.users.select((u) => ( + u.email, + u.posts.take(3).select((p) => p.title).many(), + u.profile.select((p) => p.bio).one(), + u.following.select((f) => f.followed.select((u) => u.email).required()).many(), + ).map((email, posts, bio, following) => (email: email, posts: posts, bio: bio, following: following))).get(); + final List<({String email, List posts, String? bio, List following})> typed = cards; + print((email, typed)); +} +'''; diff --git a/tool/src/browser_profile.dart b/tool/src/browser_profile.dart new file mode 100644 index 00000000..cf76aef6 --- /dev/null +++ b/tool/src/browser_profile.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +/// Chrome children can still finish profile writes after the parent exits. +Future deleteBrowserProfile(Directory profile) async { + const attempts = 20; + final notEmpty = Platform.isWindows + ? 145 + : Platform.isMacOS + ? 66 + : 39; + for (var attempt = 1; attempt <= attempts; attempt++) { + try { + await profile.delete(recursive: true); + return; + } on FileSystemException catch (error) { + final code = error.osError?.errorCode; + final missing = code == 2 || Platform.isWindows && code == 3; + if (missing && !await profile.exists()) return; + if ((!missing && code != notEmpty) || attempt == attempts) rethrow; + await Future.delayed(const Duration(milliseconds: 100)); + } + } +} diff --git a/tool/src/build_fixture.dart b/tool/src/build_fixture.dart new file mode 100644 index 00000000..7782f452 --- /dev/null +++ b/tool/src/build_fixture.dart @@ -0,0 +1,200 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// A disposable consumer package used by process tests and generation benchmarks. +final class BuildFixture { + final Directory directory; + BuildFixture._(this.directory); + + static Future create({ + required String ormPath, + int models = 1, + }) async { + final result = BuildFixture._( + await Directory.systemTemp.createTemp('orm-build-'), + ); + await result.write('pubspec.yaml', ''' +name: orm_build_fixture +publish_to: none +environment: + sdk: '>=3.13.0 <4.0.0' +dependencies: + orm: + path: ${jsonEncode(ormPath)} +dev_dependencies: + build_runner: ^2.16.1 +'''); + await result.write('build.yaml', ''' +targets: + \$default: + builders: + orm:orm: + enabled: true + generate_for: + - lib/schema.dart +'''); + await result.write('lib/models.dart', domain()); + await result.write('lib/schema.dart', schema(models)); + await result.write('lib/unrelated.dart', 'const unrelated = 1;\n'); + try { + await result.run(['pub', 'get', '--offline']); + await result._copySqliteDownloads(ormPath); + return result; + } catch (_) { + await result.dispose(); + rethrow; + } + } + + // Keep temporary consumers on the normal native-asset hook, while avoiding a + // new network download per process fixture. sqlite3 rechecks each cached hash. + Future _copySqliteDownloads(String ormPath) async { + const cachePath = '.dart_tool/hooks_runner/shared/sqlite3/build'; + final cache = Directory('$ormPath/$cachePath'); + if (!await cache.exists()) return; + await for (final entry in cache.list(followLinks: false)) { + if (entry is! Directory || + !entry.path + .split(Platform.pathSeparator) + .last + .startsWith('download-')) { + continue; + } + await for (final file in entry.list(followLinks: false)) { + if (file is! File) continue; + final relative = file.path.substring(cache.path.length); + final target = File('${directory.path}/$cachePath$relative'); + await target.parent.create(recursive: true); + await file.copy(target.path); + } + } + } + + static String domain({String label = 'waiting', int defaultScore = 0}) => + ''' +import 'package:orm/schema.dart'; +const defaultScore = '$defaultScore'; +enum Status { @EnumValue('$label') pending, ready } +'''; + + static String schema( + int models, { + bool extra = false, + bool invalid = false, + }) => + ''' +import 'package:orm/schema.dart'; +import 'models.dart'; +${[for (var i = 0; i < models; i++) ''' +typedef Row$i = ({@Id.generated() int id, String title, @Default.sql(defaultScore) int score, Status status${extra && i == 0 ? ', bool enabled' : ''}}); +final rows$i = entity(table: 'rows_$i'); +'''].join()} +${invalid ? 'int invalid = "not an integer";' : ''} +'''; + + File file(String path) => File('${directory.path}/$path'); + Future write(String path, String contents) async { + final output = file(path); + await output.parent.create(recursive: true); + await output.writeAsString(contents, flush: true); + } + + Future<({int milliseconds, String output})> run(List args) async { + final watch = Stopwatch()..start(); + final result = await Process.run( + Platform.resolvedExecutable, + args, + workingDirectory: directory.path, + ); + final output = '${result.stdout}\n${result.stderr}'; + if (result.exitCode != 0) { + throw StateError( + 'dart ${args.join(' ')} failed (${result.exitCode}):\n$output', + ); + } + return (milliseconds: watch.elapsedMilliseconds, output: output); + } + + Future watch() => BuildWatch.start(directory.path); + Future dispose() => directory.delete(recursive: true); +} + +final class BuildWatch { + final Process process; + final List _log = []; + final List _builds = []; + final List> _subscriptions = []; + Completer? _changed; + int _next = 0; + int? _exitCode; + BuildWatch._(this.process) { + for (final stream in [process.stdout, process.stderr]) { + _subscriptions.add( + stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + line, + ) { + _log.add(line); + if (_log.length > 100) _log.removeAt(0); + if (line.contains('Built with build_runner') || + line.contains('Failed to build with build_runner')) { + _builds.add(line); + _notify(); + } + }), + ); + } + unawaited( + process.exitCode.then((code) { + _exitCode = code; + _notify(); + }), + ); + } + + static Future start(String directory) async => BuildWatch._( + await Process.start(Platform.resolvedExecutable, [ + 'run', + 'build_runner', + 'watch', + ], workingDirectory: directory), + ); + void _notify() { + _changed?.complete(); + _changed = null; + } + + Future next({bool success = true}) async { + while (_builds.length <= _next) { + if (_exitCode != null) { + throw StateError('Watcher exited ($_exitCode):\n${_log.join('\n')}'); + } + await (_changed ??= Completer()).future.timeout( + const Duration(minutes: 2), + onTimeout: () => + throw StateError('Build did not finish:\n${_log.join('\n')}'), + ); + } + final result = _builds[_next++]; + if (result.contains('Failed to build') == success) { + throw StateError('Unexpected build outcome:\n${_log.join('\n')}'); + } + return result; + } + + Future close() async { + process.kill(ProcessSignal.sigint); + await process.exitCode.timeout(const Duration(seconds: 15)); + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + } + + Future quiet({Duration duration = const Duration(seconds: 1)}) async { + final count = _builds.length; + await Future.delayed(duration); + if (_builds.length != count || _exitCode != null) { + throw StateError('Watcher did not remain idle:\n${_log.join('\n')}'); + } + } +} diff --git a/tool/src/editor_server.dart b/tool/src/editor_server.dart new file mode 100644 index 00000000..9db9f497 --- /dev/null +++ b/tool/src/editor_server.dart @@ -0,0 +1,291 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// A small stdio LSP client for measuring the SDK used by Dart editors. +final class EditorServer { + final Process process; + final _pending = >{}; + final _bytes = []; + final _versions = {}; + final _editRevisions = {}; + final diagnostics = >{}; + final stderr = StringBuffer(); + final notifications = >[]; + final _initialAnalysis = Completer(); + Completer? _changed; + int _id = 0, revision = 0; + int? _exitCode; + late final StreamSubscription> _out; + late final StreamSubscription _err; + EditorServer._(this.process) { + _out = process.stdout.listen(_receive); + _err = process.stderr.transform(utf8.decoder).listen(stderr.write); + unawaited( + process.exitCode.then((code) { + _exitCode = code; + for (final pending in _pending.values) { + pending.completeError(StateError('LSP exited $code: $stderr')); + } + _pending.clear(); + _signal(); + }), + ); + } + + static Future start(String directory) async { + final server = EditorServer._( + await Process.start(Platform.resolvedExecutable, [ + 'language-server', + '--protocol=lsp', + '--client-id=orm-editor-benchmark', + ], workingDirectory: directory), + ); + try { + final root = Directory(directory).uri.toString(); + await server.request('initialize', { + 'processId': pid, + 'rootUri': root, + 'workspaceFolders': [ + {'uri': root, 'name': 'orm-editor-fixture'}, + ], + 'capabilities': { + 'window': {'workDoneProgress': true}, + 'general': { + 'positionEncodings': ['utf-16'], + }, + 'workspace': { + 'configuration': true, + 'workspaceEdit': {'documentChanges': true}, + }, + 'textDocument': { + 'completion': { + 'completionItem': { + 'snippetSupport': true, + 'labelDetailsSupport': true, + }, + }, + 'publishDiagnostics': {'versionSupport': true}, + 'rename': {'prepareSupport': true}, + }, + }, + 'initializationOptions': {'suggestFromUnimportedLibraries': false}, + }); + server.notify('initialized', {}); + return server; + } catch (_) { + await server.close(); + rethrow; + } + } + + void _send(Map message) { + final body = utf8.encode(jsonEncode({'jsonrpc': '2.0', ...message})); + process.stdin.add(ascii.encode('Content-Length: ${body.length}\r\n\r\n')); + process.stdin.add(body); + } + + void notify(String method, Object? params) => + _send({'method': method, 'params': params}); + Future request(String method, Object? params) async { + if (_exitCode != null) throw StateError('LSP already exited $_exitCode.'); + final id = ++_id, result = Completer(); + _pending[id] = result; + _send({'id': id, 'method': method, 'params': params}); + try { + return await result.future.timeout( + const Duration(seconds: 60), + onTimeout: () { + throw StateError( + 'Timed out: $method $params; notifications=$notifications; stderr=$stderr', + ); + }, + ); + } finally { + _pending.remove(id); + } + } + + void _receive(List bytes) { + _bytes.addAll(bytes); + while (true) { + var headerEnd = -1; + for (var i = 0; i + 3 < _bytes.length; i++) { + if (_bytes[i] == 13 && + _bytes[i + 1] == 10 && + _bytes[i + 2] == 13 && + _bytes[i + 3] == 10) { + headerEnd = i; + break; + } + } + if (headerEnd < 0) return; + final header = ascii.decode(_bytes.sublist(0, headerEnd)); + final length = int.parse( + RegExp( + r'Content-Length: (\d+)', + caseSensitive: false, + ).firstMatch(header)![1]!, + ); + final end = headerEnd + 4 + length; + if (_bytes.length < end) return; + final message = jsonDecode( + utf8.decode(_bytes.sublist(headerEnd + 4, end)), + ) as Map; + _bytes.removeRange(0, end); + _handle(message); + } + } + + void _handle(Map message) { + if (message['method'] case final String method) { + if (message.containsKey('id')) { + notifications.add(message); + if (notifications.length > 30) notifications.removeAt(0); + final params = message['params'] as Map?; + final result = method == 'workspace/configuration' + ? [ + for (final _ in params!['items'] as List) + {'completeFunctionCalls': true, 'documentation': 'full'}, + ] + : null; + _send({'id': message['id'], 'result': result}); + } else if (method == 'textDocument/publishDiagnostics') { + final params = message['params'] as Map; + diagnostics[params['uri'] as String] = { + ...params, + 'revision': ++revision, + }; + _signal(); + } else { + if (method == r'$/progress' && + (message['params'] as Map)['token'] == 'ANALYZING' && + ((message['params'] as Map)['value'] as Map)['kind'] == 'end' && + !_initialAnalysis.isCompleted) { + _initialAnalysis.complete(); + } + notifications.add(message); + if (notifications.length > 30) notifications.removeAt(0); + if (Platform.environment['ORM_EDITOR_DEBUG'] == '1') { + stdout.writeln('LSP: $message'); + } + } + } else { + final pending = _pending.remove(message['id']); + if (pending == null) return; + if (message['error'] case final Map error) { + pending.completeError(EditorError(error)); + } else { + pending.complete(message['result']); + } + } + } + + void _signal() { + _changed?.complete(); + _changed = null; + } + + Future ready() => + _initialAnalysis.future.timeout(const Duration(seconds: 60)); + + int open(String path, String text) { + final uri = File(path).absolute.uri.toString(); + final prior = _versions[uri], version = (prior ?? 0) + 1; + _versions[uri] = version; + _editRevisions[uri] = revision; + if (prior == null) { + notify('textDocument/didOpen', { + 'textDocument': { + 'uri': uri, + 'languageId': 'dart', + 'version': version, + 'text': text, + }, + }); + } else { + notify('textDocument/didChange', { + 'textDocument': {'uri': uri, 'version': version}, + 'contentChanges': [ + {'text': text}, + ], + }); + } + return version; + } + + Future> waitDiagnostics( + String path, + int version, { + required bool Function(List>) matches, + }) async { + final uri = File(path).absolute.uri.toString(); + Future> wait() async { + while (true) { + if (_exitCode != null) { + throw StateError('LSP exited $_exitCode: $stderr'); + } + // Dart omits versions and suppresses already-empty diagnostics. Call + // this for deliberate errors and their repair, with serialized edits. + final current = diagnostics[uri]; + if (current != null && + _versions[uri] == version && + (current['revision'] as int) > _editRevisions[uri]! && + matches( + (current['diagnostics'] as List).cast>(), + )) { + return current; + } + await (_changed ??= Completer()).future; + } + } + + return wait().timeout( + const Duration(seconds: 60), + onTimeout: () { + throw StateError( + 'No matching diagnostics for $uri version $version: $diagnostics; notifications=$notifications; stderr=$stderr', + ); + }, + ); + } + + Future close() async { + if (_exitCode == null) { + try { + await request('shutdown', null); + notify('exit', null); + } catch (_) { + process.kill(); + } + await process.stdin.close(); + try { + await process.exitCode.timeout(const Duration(seconds: 10)); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await process.exitCode; + } + } + await _out.cancel(); + await _err.cancel(); + } +} + +final class EditorError(final Map response) + implements Exception { + @override + String toString() => jsonEncode(response); +} + +Map editorPosition(String source, int offset) { + final before = source.substring(0, offset), last = before.lastIndexOf('\n'); + return { + 'line': '\n'.allMatches(before).length, + 'character': offset - last - 1, + }; +} + +Map editorLocation(String path, String source, int offset) => { + 'textDocument': {'uri': File(path).absolute.uri.toString()}, + 'position': editorPosition(source, offset), +}; diff --git a/tool/src/runtime_cases.dart b/tool/src/runtime_cases.dart new file mode 100644 index 00000000..8f9be921 --- /dev/null +++ b/tool/src/runtime_cases.dart @@ -0,0 +1,252 @@ +import 'package:orm/orm.dart'; + +import '../../example/schema.orm.dart'; + +typedef UserCard = ({int id, String email}); +typedef PostCard = ({int id, String title}); +typedef UserPosts = ({int id, String email, List posts}); +typedef ScoreCount = ({int score, int users}); + +const runtimeCases = ['full_rows', 'two_columns', 'three_posts', 'aggregate']; +const firstUser = 10001; +const userCount = 100; +final _nullableText = Codecs.text.nullable(); + +Query runtimeQuery(Database db, String name) { + final users = db.users.where((u) => u.id.gte(firstUser)); + return switch (name) { + 'full_rows' => users.orderBy((u) => [u.id.asc()]), + 'two_columns' => + users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => (u.id, u.email).map((id, email) => (id: id, email: email)), + ), + 'three_posts' => + users + .orderBy((u) => [u.id.asc()]) + .select( + (u) => ( + u.id, + u.email, + u.posts + .orderBy((p) => [p.id.desc()]) + .take(3) + .select( + (p) => ( + p.id, + p.title, + ).map((id, title) => (id: id, title: title)), + ) + .many(), + ).map((id, email, posts) => (id: id, email: email, posts: posts)), + ), + 'aggregate' => + users + .groupBy((u) => [u.score]) + .orderBy((u) => [u.score.asc()]) + .select( + (u) => ( + u.score, + u.id.count(), + ).map((score, users) => (score: score, users: users)), + ), + _ => throw ArgumentError.value(name, 'case'), + }; +} + +/// Precompiled SQL captured once from the corresponding ORM query. Both paths +/// share the same Driver; this baseline measures the query layer above it. +final class RuntimeCase { + final String name; + final Database db; + final Query query; + final List commands; + final List keyParameters; + RuntimeCase._( + this.name, + this.db, + this.query, + this.commands, + this.keyParameters, + ); + + static Future prepare(Database db, String name) async { + final capture = CaptureDriver(db.driver); + final expected = await runtimeQuery(Database(capture), name).get(); + final query = runtimeQuery(db, name); + final commands = capture.commands; + final keys = name == 'three_posts' + ? [ + for (final (i, p) in commands.last.parameters.indexed) + if (p is int && p >= firstUser && p < firstUser + userCount) i, + ] + : []; + if (commands.length != (name == 'three_posts' ? 2 : 1) || + name == 'three_posts' && keys.length != userCount) { + throw StateError('Unexpected SQL/key shape for $name.'); + } + final result = RuntimeCase._(name, db, query, commands, keys); + final actual = await result.raw(); + if (canonicalRows(expected).toString() != + canonicalRows(actual).toString()) { + throw StateError('Raw and ORM results differ for $name.'); + } + // Independently check the seeded business shape, not just mutual agreement. + if (expected.length != (name == 'aggregate' ? 10 : userCount)) { + throw StateError('Unexpected root count for $name.'); + } + if (name == 'three_posts') { + for (final row in expected.cast()) { + final last = (row.id - firstUser + 1) * 10; + if (row.posts.map((p) => p.id).join(',') != + '$last,${last - 1},${last - 2}') { + throw StateError('Incorrect per-parent ordering/limit.'); + } + } + } + if (name == 'aggregate' && + expected.cast().any((r) => r.users != 10)) { + throw StateError('Incorrect grouped counts.'); + } + return result; + } + + Future> run(String lane) => lane == 'orm' ? query.get() : raw(); + + Future> raw({Driver? driver}) => + (driver ?? db.driver).run((connection) async { + final rows = (await connection.execute(commands.first)).rows; + switch (name) { + case 'full_rows': + return [ + for (final row in rows) + ( + id: Codecs.integer.decode(row[0]), + email: Codecs.text.decode(row[1]), + nickname: _nullableText.decode(row[2]), + score: Codecs.integer.decode(row[3]), + ), + ]; + case 'two_columns': + return [ + for (final row in rows) + ( + id: Codecs.integer.decode(row[0]), + email: Codecs.text.decode(row[1]), + ), + ]; + case 'aggregate': + return [ + for (final row in rows) + ( + score: Codecs.integer.decode(row[0]), + users: Codecs.integer.decode(row[1]), + ), + ]; + case 'three_posts': + final keys = [ + for (final row in rows) Codecs.integer.decode(row[0]), + ]; + if (keys.length != keyParameters.length) { + throw StateError('Fixture keys changed.'); + } + final parameters = commands.last.parameters.toList(); + for (final (i, key) in keys.indexed) { + parameters[keyParameters[i]] = Codecs.integer.encode(key); + } + final children = (await connection.execute( + SqlCommand(commands.last.sql, parameters), + )).rows; + final grouped = >{}; + for (final row in children) { + final parent = Codecs.integer.decode(row[2]); + (grouped[parent] ??= []).add(( + id: Codecs.integer.decode(row[0]), + title: Codecs.text.decode(row[1]), + )); + } + return [ + for (final row in rows) + ( + id: Codecs.integer.decode(row[0]), + email: Codecs.text.decode(row[1]), + posts: List.unmodifiable( + grouped[Codecs.integer.decode(row[0])] ?? const [], + ), + ), + ]; + default: + throw StateError(name); + } + }); +} + +List canonicalRows(List rows) => [ + for (final row in rows) + switch (row) { + User() => [row.id, row.email, row.nickname, row.score], + UserCard() => [row.id, row.email], + UserPosts() => [ + row.id, + row.email, + [ + for (final p in row.posts) [p.id, p.title], + ], + ], + ScoreCount() => [row.score, row.users], + _ => throw StateError('Unknown result shape: $row'), + }, +]; + +/// Used only for SQL/volume verification, never in the latency timing path. +final class CaptureDriver( + final Driver inner, { + final bool retainResults = true, +}) implements Driver { + final commands = []; + final results = []; + final acquireMicros = []; + final sqlMicros = []; + @override + Capabilities get capabilities => inner.capabilities; + @override + Future run(Future Function(SqlConnection) action) { + final clock = Stopwatch()..start(); + return inner.run((connection) { + acquireMicros.add(clock.elapsedMicroseconds); + return action(_CaptureConnection(connection, this)); + }); + } + + @override + Future close() async {} // Borrowed exclusively for the benchmark. +} + +final class _CaptureConnection( + final SqlConnection inner, + final CaptureDriver owner, +) implements SqlConnection { + @override + bool? get transactionActive => inner.transactionActive; + @override + Future execute( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) async { + if (owner.retainResults) owner.commands.add(command); + final clock = Stopwatch()..start(); + final result = await inner.execute(command, options: options); + owner.sqlMicros.add(clock.elapsedMicroseconds); + if (owner.retainResults) owner.results.add(result); + return result; + } + + @override + Future openCursor( + SqlCommand command, { + ExecutionOptions options = const ExecutionOptions(), + }) => inner.openCursor(command, options: options); + @override + Future invalidate() => inner.invalidate(); +} diff --git a/tool/src/runtime_relay.dart b/tool/src/runtime_relay.dart new file mode 100644 index 00000000..fcbc3f26 --- /dev/null +++ b/tool/src/runtime_relay.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:io'; + +/// Calibrate this relay's transport delay with a byte echo, independently of +/// PostgreSQL's multi-round-trip prepare/execute/dispose protocol. +Future> relayRoundTrips(Duration delay, int samples) async { + final echo = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final accepted = []; + echo.listen((socket) { + accepted.add(socket); + socket.setOption(SocketOption.tcpNoDelay, true); + socket.listen(socket.add, onError: (Object _) => socket.destroy()); + }); + final relay = await RuntimeRelay.open('127.0.0.1', echo.port, delay); + final socket = await Socket.connect('127.0.0.1', relay.server.port); + socket.setOption(SocketOption.tcpNoDelay, true); + final replies = StreamIterator(socket); + try { + final result = []; + for (var i = -1; i < samples; i++) { + final byte = (i + 1) % 256, clock = Stopwatch()..start(); + socket.add([byte]); + if (!await replies.moveNext().timeout(const Duration(seconds: 10)) || + replies.current.length != 1 || + replies.current.single != byte) { + throw StateError('Relay changed or lost data.'); + } + if (i >= 0) result.add(clock.elapsedMicroseconds); + } + if (relay.sentBytes != samples + 1 || relay.receivedBytes != samples + 1) { + throw StateError('Relay byte accounting differs.'); + } + return result; + } finally { + await replies.cancel(); + socket.destroy(); + await relay.close(); + for (final client in accepted) { + client.destroy(); + } + await echo.close(); + } +} + +/// Loopback TCP relay. Every received chunk is scheduled once after a fixed +/// one-way delay; chunks are not serially delayed or bandwidth-throttled. +/// Runs outside the benchmark process, including its timers and byte counters. +final class RuntimeRelay { + final ServerSocket server; + final String host; + final int port; + Duration oneWayDelay; + final _sockets = {}; + var sentBytes = 0, receivedBytes = 0; + RuntimeRelay._(this.server, this.host, this.port, this.oneWayDelay); + + static Future open( + String host, + int port, + Duration delay, + ) async { + final result = RuntimeRelay._( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0), + host, + port, + delay, + ); + result.server.listen(result._connect); + return result; + } + + Future _connect(Socket client) async { + _sockets.add(client); + try { + final upstream = await Socket.connect(host, port); + _sockets.add(upstream); + client.setOption(SocketOption.tcpNoDelay, true); + upstream.setOption(SocketOption.tcpNoDelay, true); + _pipe(client, upstream, true); + _pipe(upstream, client, false); + } catch (_) { + client.destroy(); + _sockets.remove(client); + } + } + + void _pipe(Socket source, Socket target, bool outgoing) { + final pending = {}; + var queuedBytes = 0, ended = false, broken = false; + late StreamSubscription> subscription; + void fail() { + broken = true; + for (final timer in pending) { + timer.cancel(); + } + pending.clear(); + source.destroy(); + target.destroy(); + _sockets.remove(source); + _sockets.remove(target); + } + + void finish() { + if (ended && pending.isEmpty && !broken) { + unawaited( + target.close().catchError((Object _) { + fail(); + return target; + }), + ); + } + } + + subscription = source.listen( + (data) { + if (outgoing) { + sentBytes += data.length; + } else { + receivedBytes += data.length; + } + if (oneWayDelay == Duration.zero) { + target.add(data); + return; + } + queuedBytes += data.length; + if (queuedBytes >= 1024 * 1024 && !subscription.isPaused) { + subscription.pause(); + } + late Timer timer; + timer = Timer(oneWayDelay, () { + pending.remove(timer); + if (broken) return; + target.add(data); + queuedBytes -= data.length; + if (subscription.isPaused && queuedBytes < 512 * 1024) { + subscription.resume(); + } + finish(); + }); + pending.add(timer); + }, + onError: (Object _) => fail(), + onDone: () { + ended = true; + finish(); + }, + ); + target.done.catchError((Object _) { + fail(); + return target; + }); + } + + Map get bytes => {'sent': sentBytes, 'received': receivedBytes}; + Future close() async { + await server.close(); + for (final socket in _sockets.toList()) { + socket.destroy(); + } + _sockets.clear(); + } +} diff --git a/tool/src/runtime_vm.dart b/tool/src/runtime_vm.dart new file mode 100644 index 00000000..c7552131 --- /dev/null +++ b/tool/src/runtime_vm.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// A small JSON-RPC client for the SDK service protocol. Runs in the controller +/// process so JSON decoding and service responses are outside the measured heap. +final class RuntimeVm { + final WebSocket socket; + final String isolate; + final _pending = >>{}; + final classes = {}; + late final StreamSubscription _subscription; + var _next = 0; + RuntimeVm._(this.socket, this.isolate) { + _subscription = socket.listen((message) { + final json = jsonDecode(message as String) as Map; + final pending = _pending.remove(json['id']); + if (pending == null) return; + if (json['error'] case final error?) { + pending.completeError(StateError('$error')); + } else { + pending.complete(json['result'] as Map); + } + }); + } + + static Future connect(String uri, String isolate) async { + final url = Uri.parse(uri).resolve('ws').replace(scheme: 'ws'); + return RuntimeVm._(await WebSocket.connect('$url'), isolate); + } + + Future> call( + String method, [ + Map params = const {}, + ]) { + final id = ++_next, result = Completer>(); + _pending[id] = result; + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'method': method, + 'params': {'isolateId': isolate, ...params}, + }), + ); + return result.future.timeout(const Duration(seconds: 30)); + } + + Future> heap() async { + final profile = await call('getAllocationProfile', {'gc': true}); + // Dart 3.13.3 emits current counts for the accumulated fields as well. + // Only use fields whose live-heap meaning matches the implementation. + return { + 'memoryUsage': profile['memoryUsage'], + 'gcTimestamp': profile['dateLastServiceGC'], + 'classes': [ + for (final row + in (profile['members'] as List).cast>()) + if ({ + '_Record', + '_List', + '_GrowableList', + '_Map', + '_SelectionPlan', + }.contains((row['class'] as Map)['name'])) + { + 'name': (row['class'] as Map)['name'], + 'instancesCurrent': row['instancesCurrent'], + 'bytesCurrent': row['bytesCurrent'], + }, + ], + }; + } + + Future trace(bool enable) async { + if (classes.isEmpty) { + final response = await call('getClassList'); + for (final row + in (response['classes'] as List).cast>()) { + if ({ + '_Record', + '_List', + '_GrowableList', + '_Map', + '_SelectionPlan', + '_Combined', + }.contains(row['name'])) { + classes[int.parse((row['id'] as String).split('/').last)] = + row['name'] as String; + } + } + if (!classes.containsValue('_Record')) { + throw StateError('Record allocation class not found.'); + } + } + for (final id in classes.keys) { + await call('setTraceClassAllocation', { + 'classId': 'classes/$id', + 'enable': enable, + }); + } + } + + Future> allocations(int start, int end) async { + final profile = await call('getAllocationTraces', { + 'timeOriginMicros': start, + 'timeExtentMicros': end - start, + }); + final samples = (profile['samples'] as List).cast>(); + final counts = {}, topFrames = {}; + final functions = profile['functions'] as List; + var truncated = 0; + for (final sample in samples) { + final name = classes[sample['classId']] ?? 'class/${sample['classId']}'; + counts.update(name, (n) => n + 1, ifAbsent: () => 1); + if (sample['truncated'] == true) truncated++; + final stack = sample['stack'] as List; + if (stack.isNotEmpty) { + final function = + (functions[stack.first as int] as Map)['function'] as Map; + final frame = function['name'] as String? ?? 'unknown'; + topFrames.update(frame, (n) => n + 1, ifAbsent: () => 1); + } + } + if (samples.isEmpty) { + throw StateError('Allocation tracing returned no samples.'); + } + final sortedFrames = topFrames.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + return { + 'tracedClasses': classes.values.toList(), + 'sampleCount': samples.length, + 'counts': counts, + 'truncatedStacks': truncated, + 'topAllocationFrames': [ + for (final e in sortedFrames.take(12)) + {'function': e.key, 'samples': e.value}, + ], + 'scope': 'Selected allocation traces in the main query isolate; not all object allocations or allocated bytes. Timing is measured separately with tracing disabled.', + }; + } + + Future close() async { + await _subscription.cancel(); + await socket.close(); + } +} diff --git a/tool/test_browser.dart b/tool/test_browser.dart new file mode 100644 index 00000000..23249bac --- /dev/null +++ b/tool/test_browser.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/src/sqlite/assets_io.dart'; +import 'package:orm/src/sqlite/web_build.dart'; +import 'package:orm/generate.dart'; + +import 'src/browser_profile.dart'; + +const engineVersion = '3.6.0'; +const engineDigest = + '13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4'; + +/// Real Chrome smoke/acceptance runner. Assets remain in .dart_tool/browser. +Future main(List arguments) async { + if (arguments.any((a) => a != '--wasm')) { + throw ArgumentError('Use --wasm for a Dart WASM client.'); + } + final wasm = arguments.contains('--wasm'); + final assets = Directory('.dart_tool/browser').absolute; + await assets.create(recursive: true); + final checked = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'tool/build_sqlite_web.dart', + '--check', + ]); + if (checked.exitCode != 0) { + throw StateError('${checked.stdout}${checked.stderr}'); + } + await copySqliteWebAssets(Directory('${assets.path}/orm')); + final generated = await generateSchema('test/support/browser/schema.dart'); + if (generated.dart != + await File('test/support/browser/schema.orm.dart').readAsString() || + generated.snapshotDart != + await File('test/support/browser/schema.snapshot.dart') + .readAsString()) { + throw StateError('Regenerate the browser schema fixture.'); + } + Future compile(List args) async { + final result = await Process.run(Platform.resolvedExecutable, [ + 'compile', + ...args, + ]); + if (result.exitCode != 0) { + throw StateError('${result.stdout}\n${result.stderr}'); + } + } + + await Future.wait([ + compile([ + wasm ? 'wasm' : 'js', + if (!wasm) '-O2', + 'test/support/browser/main.dart', + '-o', + '${assets.path}/main.${wasm ? 'wasm' : 'js'}', + ]), + ]); + final chrome = + Platform.environment['CHROME_EXECUTABLE'] ?? + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + final profile = await Directory.systemTemp.createTemp('orm-browser-'); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final result = Completer>(); + server.listen((request) async { + try { + if (request.uri.path == '/report' && request.method == 'POST') { + final body = await utf8.decoder.bind(request).join(); + if (!result.isCompleted) { + result.complete(jsonDecode(body) as Map); + } + request.response.write('ok'); + } else if (request.uri.path == '/') { + request.response.headers.contentType = ContentType.html; + request.response.write( + 'Running ORM browser checks${wasm ? '''''' : ''}', + ); + } else if (request.uri.path == '/mismatched-worker.js') { + final protocol = request.uri.queryParameters['protocol'] == 'old' + ? sqliteWebProtocol - 1 + : sqliteWebProtocol; + request.response.headers.contentType = ContentType.parse( + 'text/javascript', + ); + request.response.write( + 'onmessage=e=>postMessage([e.data[0],true,false,[$protocol,"old-build"]]);', + ); + } else if ({ + '/main.js', + '/main.wasm', + '/main.mjs', + '/orm/$sqliteWorkerFile', + '/orm/$sqliteWasmFile', + }.contains(request.uri.path)) { + request.response.headers.contentType = ContentType.parse( + request.uri.path.endsWith('.wasm') + ? 'application/wasm' + : 'text/javascript', + ); + await request.response.addStream( + File('${assets.path}${request.uri.path}').openRead(), + ); + } else { + request.response.statusCode = 404; + } + } catch (error) { + if (!result.isCompleted) result.completeError(error); + } finally { + await request.response.close(); + } + }); + Process? browser; + final errors = StringBuffer(); + try { + browser = await Process.start(chrome, [ + '--headless=new', + '--no-first-run', + '--disable-background-networking', + '--disable-component-update', + '--user-data-dir=${profile.path}', + 'http://127.0.0.1:${server.port}/', + ]); + browser.stdout.drain(); + browser.stderr.transform(utf8.decoder).listen(errors.write); + final report = await result.future.timeout(const Duration(minutes: 2)); + report['dartCompilation'] = wasm ? 'wasm' : 'js'; + report['dartSdk'] = Platform.version; + report['sqlitePackage'] = engineVersion; + report['sqliteWasmSha256'] = engineDigest; + stdout.writeln(const JsonEncoder.withIndent(' ').convert(report)); + await File( + '${assets.path}/report.${wasm ? 'wasm' : 'js'}.json', + ).writeAsString('${const JsonEncoder.withIndent(' ').convert(report)}\n'); + if (report['passed'] != true) exitCode = 1; + } catch (error) { + stderr.writeln('$error\n$errors'); + exitCode = 1; + } finally { + browser?.kill(); + if (browser != null) await browser.exitCode; + await server.close(force: true); + await deleteBrowserProfile(profile); + } +} diff --git a/tool/test_flutter.dart b/tool/test_flutter.dart new file mode 100644 index 00000000..030c751a --- /dev/null +++ b/tool/test_flutter.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +const applicationId = 'dev.orm.orm_flutter'; + +/// Install two built APKs on a dedicated emulator, then restart the upgraded app. +/// Refuses physical devices and an already-installed acceptance application. +Future main(List args) async { + if (args.length != 4 || !RegExp(r'^emulator-\d+$').hasMatch(args[1])) { + throw ArgumentError( + 'Usage: dart run tool/test_flutter.dart ' + ' ', + ); + } + final [adb, device, legacy, current] = args; + Future command( + List arguments, { + bool checked = true, + }) async { + final result = await Process.run(adb, ['-s', device, ...arguments]); + if (checked && result.exitCode != 0) { + throw StateError( + 'adb ${arguments.join(' ')}: ${result.stdout}${result.stderr}', + ); + } + return result; + } + + final installed = await command([ + 'shell', + 'pm', + 'path', + applicationId, + ], checked: false); + if (installed.exitCode != 0 && installed.exitCode != 1) { + throw StateError('Cannot inspect installation: ${installed.stderr}'); + } + if ('${installed.stdout}'.contains('package:')) { + throw StateError( + 'Use a fresh dedicated emulator or explicitly uninstall ' + 'only $applicationId from your test emulator before rerunning.', + ); + } + if ('${(await command(['shell', 'getprop', 'ro.kernel.qemu'])).stdout}' + .trim() != + '1') { + throw StateError('The selected device is not an emulator.'); + } + final artifacts = Directory('.dart_tool/flutter') + ..createSync(recursive: true); + await command(['shell', 'input', 'keyevent', 'KEYCODE_WAKEUP']); + await command(['shell', 'wm', 'dismiss-keyguard']); + final phases = >[]; + for (final (phase, apk) in [ + ('legacy', legacy), + ('upgrade', current), + ('reopen', null), + ]) { + await command(['shell', 'am', 'force-stop', applicationId]); + if (apk != null) { + await command(['install', if (phase == 'upgrade') '-r', apk]); + } + // This runner exclusively owns the selected test emulator. + await command(['logcat', '-c']); + await command([ + 'shell', + 'am', + 'start', + '-W', + '-n', + '$applicationId/.MainActivity', + '--es', + 'orm_phase', + phase, + ]); + final deadline = DateTime.now().add(const Duration(minutes: 2)); + Map? report; + while (report == null) { + final output = + '${(await command(['logcat', '-d', '-v', 'raw', '-s', 'flutter:I'])).stdout}'; + await File('${artifacts.path}/$phase.log').writeAsString(output); + final chunks = {}; + var expected = 0; + for (final match in RegExp( + 'ORM_ACCEPTANCE $phase (\\d+)/(\\d+) ([A-Za-z0-9+/=]+)', + ).allMatches(output)) { + final index = int.parse(match[1]!); + final count = int.parse(match[2]!); + if (expected != 0 && expected != count) { + throw StateError('Conflicting report chunks'); + } + expected = count; + if (chunks[index] != null && chunks[index] != match[3]) { + throw StateError('Conflicting report content'); + } + chunks[index] = match[3]!; + } + if (expected > 0 && + chunks.length == expected && + List.generate(expected, (i) => i + 1).every(chunks.containsKey)) { + report = jsonDecode( + utf8.decode( + base64Decode( + [for (var i = 1; i <= expected; i++) chunks[i]!].join(), + ), + ), + ) as Map; + } else { + if (DateTime.now().isAfter(deadline)) { + throw StateError( + '$phase report timed out; see ${artifacts.path}/$phase.log', + ); + } + await Future.delayed(const Duration(seconds: 1)); + } + } + await File('${artifacts.path}/$phase.json') + .writeAsString(const JsonEncoder.withIndent(' ').convert(report)); + if (report['status'] != 'passed' || + report['phase'] != phase || + report['release'] != (phase != 'legacy')) { + throw StateError('$phase failed: ${jsonEncode(report)}'); + } + final pid = '${(await command(['shell', 'pidof', applicationId])).stdout}' + .trim(); + if (pid != '${report['pid']}') { + throw StateError('Report process does not match running app'); + } + final packageInfo = + '${(await command(['shell', 'dumpsys', 'package', applicationId])).stdout}'; + final versionCode = RegExp(r'versionCode=(\d+)') + .firstMatch(packageInfo)?[1]; + if (versionCode != (phase == 'legacy' ? '1' : '2')) { + throw StateError('Unexpected installed APK version: $versionCode'); + } + report['apkVersionCode'] = int.parse(versionCode!); + phases.add(report); + stdout.writeln( + '$phase: ${(report['checks'] as List).length} checks passed (PID $pid)', + ); + } + if (phases.map((p) => p['pid']).toSet().length != 3 || + phases.map((p) => p['path']).toSet().length != 1 || + jsonEncode(phases[1]['rows']) != jsonEncode(phases[2]['rows']) || + jsonEncode(phases[1]['history']) != jsonEncode(phases[2]['history'])) { + throw StateError( + 'Process, database path, row or history continuity failed', + ); + } + // The report precedes setState in the app; let Android's activity fade finish. + await Future.delayed(const Duration(seconds: 1)); + final screenshot = await Process.run(adb, [ + '-s', + device, + 'exec-out', + 'screencap', + '-p', + ], stdoutEncoding: null); + if (screenshot.exitCode != 0) throw StateError('${screenshot.stderr}'); + await Directory('research/validation').create(recursive: true); + final screenshotFile = File('research/validation/flutter-android.png'); + await screenshotFile.writeAsBytes(screenshot.stdout as List); + final sources = {}; + for (final directory in [ + 'lib', + 'example/flutter/lib', + 'example/flutter/assets', + 'example/flutter/android/app/src/main', + ]) { + for (final entry in Directory( + directory, + ).listSync(recursive: true).whereType()) { + if (entry.path.endsWith('/GeneratedPluginRegistrant.java')) continue; + sources[entry.path] = (await sha256.bind(entry.openRead()).first) + .toString(); + } + } + for (final path in [ + 'tool/test_flutter.dart', + 'example/flutter/pubspec.yaml', + 'example/flutter/pubspec.lock', + 'example/flutter/.metadata', + 'example/flutter/android/app/build.gradle.kts', + 'example/flutter/android/settings.gradle.kts', + 'example/flutter/android/gradle/wrapper/gradle-wrapper.properties', + ]) { + sources[path] = (await sha256.bind(File(path).openRead()).first).toString(); + } + final git = await Process.run('git', ['rev-parse', 'HEAD']); + if (git.exitCode != 0) throw StateError('${git.stderr}'); + final properties = await File('example/flutter/android/local.properties') + .readAsLines(); + final sdk = properties + .singleWhere((line) => line.startsWith('flutter.sdk=')) + .substring('flutter.sdk='.length); + final flutter = jsonDecode( + await File('$sdk/bin/cache/flutter.version.json').readAsString(), + ) as Map; + final result = { + 'capturedAt': DateTime.now().toUtc().toIso8601String(), + 'runtimeSourceCommit': '${git.stdout}'.trim(), + 'flutter': { + for (final key in [ + 'frameworkVersion', + 'channel', + 'frameworkRevision', + 'engineRevision', + 'dartSdkVersion', + ]) + key: flutter[key], + }, + 'device': device, + 'abi': + '${(await command(['shell', 'getprop', 'ro.product.cpu.abi'])).stdout}' + .trim(), + 'buildFingerprint': + '${(await command(['shell', 'getprop', 'ro.build.fingerprint'])).stdout}' + .trim(), + 'apkSha256': { + for (final (name, path) in [('legacy', legacy), ('current', current)]) + name: (await sha256.bind(File(path).openRead()).first).toString(), + }, + 'sourceSha256': sources, + 'screenshotSha256': (await sha256.bind(screenshotFile.openRead()).first) + .toString(), + 'phases': phases, + 'limits': [ + 'Android arm64 API ${phases.first['androidApi']} emulator; not physical hardware, iOS or macOS Flutter.', + 'Legacy APK debug; upgraded/restarted APK release AOT.', + 'Frame and timer progress demonstrate worker separation, not an FPS benchmark.', + 'Force-stop/relaunch is process persistence evidence, not power-loss testing.', + ], + }; + await Directory('research/validation').create(recursive: true); + await File('research/validation/flutter.json') + .writeAsString('${const JsonEncoder.withIndent(' ').convert(result)}\n'); + stdout.writeln( + 'Report: research/validation/flutter.json; screenshot: ${screenshotFile.path}', + ); +} diff --git a/tool/test_flutter_web.dart b/tool/test_flutter_web.dart new file mode 100644 index 00000000..ee2e5e89 --- /dev/null +++ b/tool/test_flutter_web.dart @@ -0,0 +1,293 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/src/sqlite/web_build.dart'; + +import 'src/browser_profile.dart'; + +/// Release Flutter acceptance against an unchanged copy of the product package. +Future main(List arguments) async { + if (arguments.length != 1) { + throw ArgumentError('Pass the absolute path to the Flutter SDK.'); + } + final root = Directory.current.absolute; + final sdk = Directory(arguments.single).absolute; + final flutter = '${sdk.path}/bin/flutter'; + final work = Directory('${root.path}/.dart_tool/flutter_web_test'); + if (await work.exists()) await work.delete(recursive: true); + final package = Directory('${work.path}/orm'); + final app = Directory('${work.path}/app'); + await app.create(recursive: true); + await package.create(recursive: true); + + Future copyTree(String source, String destination) async { + final from = Directory(source); + for (final entry in from.listSync(recursive: true)) { + if (entry is! File) continue; + final target = File( + '$destination/${entry.path.substring(source.length + 1)}', + ); + await target.parent.create(recursive: true); + await entry.copy(target.path); + } + } + + Future write(String path, String content) async { + final file = File(path); + await file.parent.create(recursive: true); + await file.writeAsString(content); + } + + Future run(String executable, List args, String cwd) async { + stdout.writeln('Running: ${[executable, ...args].join(' ')}'); + final result = await Process.run(executable, args, workingDirectory: cwd); + stdout.write(result.stdout); + stderr.write(result.stderr); + if (result.exitCode != 0) throw StateError('Command failed: $args'); + } + + await copyTree('${root.path}/lib', '${package.path}/lib'); + await File('${root.path}/pubspec.yaml').copy('${package.path}/pubspec.yaml'); + await copyTree('${root.path}/assets', '${package.path}/assets'); + await run('${sdk.path}/bin/cache/dart-sdk/bin/dart', [ + 'run', + 'tool/build_sqlite_web.dart', + '--check', + ], root.path); + await write('${app.path}/pubspec.yaml', ''' +name: orm_flutter_web_test +publish_to: none +environment: + sdk: '>=3.13.0 <4.0.0' +dependencies: + flutter: + sdk: flutter + orm: + path: ../orm + sqlite3: 3.6.0 + web: ^1.1.1 +flutter: + uses-material-design: true +'''); + for (final relative in [ + 'test/support/browser', + 'test/support/temporal_precision', + 'example/teams', + ]) { + await copyTree('${root.path}/$relative', '${app.path}/lib/$relative'); + } + final checksFile = File('${app.path}/lib/test/support/browser/main.dart'); + var checks = await checksFile.readAsString(); + String replace(String source, String from, String to) { + if (!source.contains(from)) { + throw StateError('Browser fixture changed: $from'); + } + return source.replaceFirst(from, to); + } + + checks += '\nint Function() flutterFrames = () => 0;\n'; + checks = replace( + checks, + 'var ticks = 0;', + 'final beforeFrames = flutterFrames();\n var ticks = 0;', + ); + checks = replace( + checks, + "expect(ticks > 0, 'Main event loop was blocked');", + "expect(ticks > 0, 'Main event loop was blocked');\n" + " expect(flutterFrames() > beforeFrames, 'Flutter animation stopped during SQL');", + ); + checks = replace( + checks, + "location.replace('/?phase=recover')", + "location.replace('/nested/detail/42?phase=recover')", + ); + checks = replace( + checks, + "result['browser'] = web.window.navigator.userAgent;", + "result['browser'] = web.window.navigator.userAgent;\n" + " result['actualWasm'] = const bool.fromEnvironment('dart.tool.dart2wasm');\n" + " result['flutterFrames'] = flutterFrames();\n" + " result['baseUri'] = web.document.baseURI;\n" + " result['dartUriBase'] = Uri.base.toString();\n" + " result['isolated'] = web.window.crossOriginIsolated;", + ); + checks = replace( + checks, + 'web.document.body!.textContent = body;', + '// Keep the Flutter widget tree mounted.', + ); + await checksFile.writeAsString(checks); + await write('${app.path}/lib/main.dart', _flutterMain); + await write('${app.path}/web/index.html', ''' + +ORM Flutter Web probe + +'''); + await run(flutter, ['pub', 'get', '--offline'], app.path); + final reports = >[]; + for (final mode in ['js', 'wasm']) { + await run(flutter, [ + 'build', + 'web', + '--release', + '--no-pub', + '--base-href', + '/nested/', + '--no-web-resources-cdn', + if (mode == 'wasm') '--wasm', + ], app.path); + final output = Directory('${app.path}/build/web'); + for (final isolated in mode == 'js' ? [false] : [false, true]) { + final report = await _serve(output, isolated); + report['build'] = mode; + report['expectedIsolation'] = isolated; + report['flutterSdk'] = jsonDecode( + await File('${sdk.path}/bin/cache/flutter.version.json').readAsString(), + ); + report['sqliteWasmSha256'] = sqliteWasmSha256; + reports.add(report); + stdout.writeln(jsonEncode(report)); + await write( + '${work.path}/report.json', + '${const JsonEncoder.withIndent(' ').convert(reports)}\n', + ); + if (report['passed'] != true || + report['actualWasm'] != (mode == 'wasm') || + report['isolated'] != isolated) { + throw StateError('Flutter Web probe failed.'); + } + } + } +} + +Future> _serve(Directory output, bool isolated) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final complete = Completer>(); + final requests = []; + server.listen((request) async { + try { + final path = request.uri.path; + requests.add(path); + if (isolated) { + request.response.headers.set( + 'Cross-Origin-Opener-Policy', + 'same-origin', + ); + request.response.headers.set( + 'Cross-Origin-Embedder-Policy', + 'require-corp', + ); + } + if (path == '/report' && request.method == 'POST') { + final body = await utf8.decoder.bind(request).join(); + if (!complete.isCompleted) { + complete.complete(jsonDecode(body) as Map); + } + request.response.write('ok'); + } else if (path == '/mismatched-worker.js') { + final protocol = request.uri.queryParameters['protocol'] == 'old' + ? sqliteWebProtocol - 1 + : sqliteWebProtocol; + request.response.headers.contentType = ContentType.parse( + 'text/javascript', + ); + request.response.write( + 'onmessage=e=>postMessage([e.data[0],true,false,[$protocol,"old-build"]]);', + ); + } else { + final relative = path == '/nested/detail/42' + ? 'index.html' + : path.startsWith('/nested/') + ? path.substring(8) + : ''; + final file = File('${output.path}/$relative'); + if (relative.isEmpty || + relative.contains('..') || + !await file.exists()) { + request.response.statusCode = 404; + } else { + request.response.headers.contentType = ContentType.parse( + relative.endsWith('.wasm') + ? 'application/wasm' + : relative.endsWith('.js') || relative.endsWith('.mjs') + ? 'text/javascript' + : relative.endsWith('.html') + ? 'text/html' + : 'application/octet-stream', + ); + await request.response.addStream(file.openRead()); + } + } + } finally { + await request.response.close(); + } + }); + final profile = await Directory.systemTemp.createTemp('orm-flutter-web-'); + Process? browser; + final errors = StringBuffer(); + try { + browser = await Process.start( + Platform.environment['CHROME_EXECUTABLE'] ?? + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + [ + '--headless=new', + '--no-first-run', + '--disable-background-networking', + '--disable-component-update', + '--user-data-dir=${profile.path}', + 'http://127.0.0.1:${server.port}/nested/detail/42', + ], + ); + browser.stdout.drain(); + browser.stderr.transform(utf8.decoder).listen(errors.write); + final report = await complete.future.timeout(const Duration(minutes: 3)); + report['requests'] = requests; + return report; + } catch (e) { + stderr.writeln('$errors\nRequests: $requests'); + rethrow; + } finally { + browser?.kill(); + if (browser != null) await browser.exitCode; + await server.close(force: true); + await deleteBrowserProfile(profile); + } +} + +const _flutterMain = r''' +import 'package:flutter/material.dart'; +import 'test/support/browser/main.dart' as acceptance; + +void main() => runApp(const MaterialApp(home: Probe())); +class Probe extends StatefulWidget { + const Probe({super.key}); + @override + State createState() => _ProbeState(); +} +class _ProbeState extends State with SingleTickerProviderStateMixin { + late final AnimationController ticker; + int frames = 0; + String status = 'Running ORM checks'; + @override + void initState() { + super.initState(); + ticker = AnimationController(vsync: this, duration: const Duration(seconds: 1)) + ..addListener(() => frames++)..repeat(); + acceptance.flutterFrames = () => frames; + WidgetsBinding.instance.addPostFrameCallback((_) async { + await acceptance.main(); + if (mounted) setState(() => status = 'Completed'); + }); + } + @override + void dispose() { ticker.dispose(); super.dispose(); } + @override + Widget build(BuildContext context) => Scaffold(body: Center(child: Column( + mainAxisSize: MainAxisSize.min, + children: [RotationTransition(turns: ticker, child: const Text('ORM')), + Text(status)], + ))); +} +''';