diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e12b2e67..a9fb6fa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: persist-credentials: false - uses: dart-lang/setup-dart@v1 with: - sdk: 3.13.3 + sdk: 3.13.4 - name: Install dependencies run: dart pub get --enforce-lockfile - name: Check formatting @@ -82,7 +82,7 @@ jobs: - 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 + for target in bin lib test tool example/*.dart example/migrations example/teams example/company; do dart analyze "$target" done - name: Check committed SQLite web assets @@ -102,7 +102,7 @@ jobs: persist-credentials: false - uses: dart-lang/setup-dart@v1 with: - sdk: 3.13.3 + sdk: 3.13.4 - name: Install dependencies run: dart pub get --enforce-lockfile - name: Locate Chrome diff --git a/AGENTS.md b/AGENTS.md index 60fef8ef..e491bd5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,9 @@ Old code and upstream architecture are retired. - 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 `doc/progress.md` accurate, including unfinished work and validation limits. +- Keep `doc/` focused on public usage, examples and compatibility limits. + Keep research notes and development/test-run records out of public documentation; + put reusable contributor workflows in `CONTRIBUTING.md`. - 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fcc7f46..82ddab5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 6.0.0-beta.3 + +Breaking schema authoring change: replace annotated entities and hand-written row +types with `model(...)`, then regenerate clients. Table and column names still +identify the physical schema; keep existing migration history unchanged. + +- Add `model(...)` with named Record column declarations, generating nominal + rows and typed queries from one definition without annotations. +- Support model-local keys, indexes, checks and relationships, including self, + composite and alternate-key references; follow exported/cross-file models. +- Name relations with Record fields. Declare reverse navigation on its own model, + reject ambiguous references, and select target keys with named column mappings. +- Report Record schema errors with source locations and diagnostic codes. Keep + snapshots independent of application types and preserve migration histories. +- Replace entity declarations and storage annotations with `model(...)`; remove + the previous schema reader. Catalog import and project initialization emit the + same Record schema syntax. +- Declare named SQL result and parameter columns with the same helpers and + generate their result classes alongside query methods. + ## 6.0.0-beta.2 - Replace shared `part` libraries with independent modules and explicit public diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..6334cc0d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,312 @@ +# Contributing + +Use Dart 3.13 or newer. From a repository checkout: + +```sh +dart pub get +dart analyze +dart test +dart run example/main.dart +dart run example/company/main.dart +dart run example/queries.dart +``` + +The Flutter example is a separate package; run its analysis with the Flutter SDK. +For browser checks, set `CHROME_EXECUTABLE` and run +`dart run tool/test_browser.dart`, then add `--wasm` for the WASM build. + +## Real database checks + +Set `ORM_TEST_POSTGRES`, `ORM_TEST_MYSQL` and `ORM_TEST_MARIADB` to disposable +database URLs to include those integration suites. Tests create and drop their +own tables; MySQL/MariaDB migration tests also create isolated databases. +Use dedicated test servers and accounts with those privileges. + +Server TLS defaults to `verifyFull`. Self-signed local fixtures can explicitly +set `ORM_TEST_MYSQL_TLS=require` and `ORM_TEST_MARIADB_TLS=require`. +Run `dart test --concurrency=1` when testing the complete native matrix. + +## Documentation and public APIs + +Keep public entrypoints explicit and add `///` comments at the declarations they +export. Explain result shape, ownership, transaction boundaries and errors where +those affect callers. Internal modules use ordinary imports and exports; do not +introduce `part` directives to share private state. + +Handwritten user guides live in `doc/`. Explain supported behavior, practical +examples and compatibility limits. Keep research notes, task progress and test-run +logs out of public guides; report validation in the relevant pull request. +Dartdoc categories connect those guides to API navigation. After changing comments, +exports or category configuration, run: + +```sh +dart doc --validate-links +``` + +After moving or renaming APIs, remove the previous generated `doc/api/` directory +before regenerating so stale pages cannot survive. Broken links, ambiguous +canonical exports and unresolved symbol references fail documentation generation. + +Inspect the generated `doc/api/` pages through a local HTTP server, including +library/category navigation, symbol links and public signatures. Do not commit +or publish that generated directory. Run package analysis and the relevant +examples as well; a formatted code block alone does not verify an example. + +## Release checks + +Check formatting and run `dart pub publish --dry-run`. The published package +includes its Dart sources, documentation, examples and SQLite Web assets; +development caches and repository test tools stay out of the archive. + +`dart run tool/build_sqlite_web.dart --check` verifies committed worker/WASM +resources. Regenerate them only when their source fingerprint changes. +Report the tested revision, platforms and meaningful skips in the pull request. +Do not use a public guide as a development status log. + +## Choose regression suites by behavior + +All names below refer to files under `test/` with the `_test.dart` suffix. + +| Changed behavior | Representative suites | +| --- | --- | +| Model declarations and generated types | `record_generation`, `record_relations`, `record_database`, `generator`, `generator_dialects`, `types`, `generated_database`, `nominal_database` | +| Build/watch and project commands | `builder`, `cli`, `cli_workflow`, `named_sql_generation`, `import_cli`, `migration_plan_cli` | +| Projections, mapping and query scope | `database`, `selection`, `query_boundary`, `union`, `plan` | +| Keys, relationships and batched loading | `relation`, `many_to_many`, `unconstrained_relation` | +| Values and custom codecs | `integer`, `custom_codec`, `temporal`, `temporal_precision`, `decimal`, `decimal_division`, `decimal_average` | +| Defaults, generated values and constraints | `client_default`, `computed`, `check` | +| Sessions, transactions and failure recovery | `transaction`, `retry`, `acquisition`, `stream`, `session_connection`, `runtime_lifecycle_review` | +| Subscriptions and execution observations | `watch`, `observation` | +| Migration history, catalog and recovery | `migration`, `migration_target`, `migration_recovery`, `backfill`, `schema_version`, `import` | +| MySQL and MariaDB behavior | `mysql_driver`, `mysql_database`, `mysql_import`, `mysql_transaction_boundary`, `mysql_migration` | + +Use real database URLs to enable the server suites. Tests create their own tables +or schemas; MySQL/MariaDB recovery tests require permission to create isolated +databases. See [database setup](#real-database-checks) for environment variables and the +complete native command. Capability-dependent skips must be reported separately +from passes, especially when the SQLite build cannot interrupt running SQL. + +## Verify browser and Flutter behavior + +From the repository root: + +```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 +``` + +Browser checks exercise packaged resource identity, generated queries, memory and +OPFS storage, reopen/upgrade, constraints, transactions, cursors, subscriptions, +value transport and explicit rejection of unsupported interruption. They use real +Chrome and record JS/WASM mode. The Flutter Web runner also verifies release +packaging, nested routes and configurations with and without isolation headers. + +For Android, follow the [APK upgrade and restart workflow](https://github.com/medz/dart-orm/blob/main/example/flutter/README.md). Browser, +Android and native server validation are separate: one passing target does not +establish another. Keep SDK, database, browser/device, source revision and report +paths with the validation result. Reports are local outputs under `.dart_tool/`. + + +## Measure generation and editor cost + + +From an ORM repository checkout: + +```sh +dart run tool/benchmark_generation.dart +dart run tool/benchmark_editor.dart --smoke +dart run tool/benchmark_editor.dart +``` + +The generation benchmark creates disposable projects with 10, 100 and 1000 +four-field Record models. It measures first and unchanged builds, watcher startup, +field and imported-metadata edits, and analysis. It also checks that an unrelated +edit leaves output timestamps unchanged. The report is written to +`.dart_tool/benchmarks/generation.json`; a positional argument chooses another +report path. + +The editor benchmark drives the installed Dart Analysis Server over stdio LSP. +It checks completion labels, deliberate type errors with exact diagnostic ranges, +source edits and a final consumer analysis. Full results go to +`.dart_tool/benchmarks/editor.json`. `--smoke` uses ten models and two warm samples, +writing `.dart_tool/benchmarks/editor-smoke.json`; it checks the harness rather +than establishing performance. `--same-session` retains the server across error +recovery and rename probes; its separate report includes `-recovery` in the name. + +Generation timing includes process startup where applicable, excludes dependency +downloads, and uses warm shared SDK/pub caches. Editor measurements include protocol +transport and JSON processing, but exclude GUI rendering and editor extensions. +Keep startup, analysis readiness, first completion and warm completion separate. +A successful rename probe does not guarantee every IDE workflow; Record field +renaming depends on the Dart SDK. Always regenerate and analyze after refactoring. + +Run these tools independently of other builds with source and SDK versions fixed. +Read sample counts, conditions and errors in the produced report before comparing +results. The build/watch regression suites also exercise imported metadata, +prior-builder output, error recovery and generated-file cleanup. + +## Decimal benchmarks + +The repository benchmark fetches and decodes 10000 integer-valued Decimal rows, +with one warmup and three measured runs per case. It includes ordinary reads, +division, rounding, average and running aggregates. Build before measuring: + +```sh +dart build cli --target=tool/benchmark_decimal.dart --output=/tmp/orm-decimal-benchmark +mkdir -p .dart_tool/benchmarks +/tmp/orm-decimal-benchmark/bundle/bin/benchmark_decimal > .dart_tool/benchmarks/decimal.json +``` + +Set `ORM_TEST_POSTGRES` to a disposable local PostgreSQL URL to include that +backend. The script disables TLS for this local benchmark, creates a uniquely +named schema and removes it afterward; the account needs schema-creation +permission. Without the variable it measures SQLite only. + +These are single-client end-to-end samples, including compilation of each query, +database work, transport and decoding. They are not latency percentiles, +concurrency measurements or estimates for large coefficients and high scales. +Run separately from tests and builds. Retain database/SDK versions and each sample +from the JSON report when comparing revisions. + +## Runtime benchmarks + +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/benchmarks/runtime-smoke.json`; it validates the harness and is not +a performance result. The normal run writes `.dart_tool/benchmarks/runtime.json`. +Use `--output ` to retain separate runs, for example +`--output .dart_tool/benchmarks/runtime-after.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](https://github.com/medz/dart-orm/blob/main/example/schema.dart). + +| Case | Result | Expected SQL result volume | +| --- | --- | --- | +| `full_rows` | 100 complete user results; 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. + +### Interpret a comparison + +Compare exact SQL, bound parameters, statement counts and complete business +results before comparing durations. A faster run with fewer returned rows or a +different transaction boundary is a different workload. + +Use absolute overhead, selected bytes, actual statement counts and measured lease +waits together. A short local query can have a large percentage overhead while +remaining short in absolute time. With network delay, protocol round trips can +dominate; selecting fewer columns does not eliminate those round trips. Allocation +traces can locate intermediate collections, but do not establish retained memory +or application throughput. + +Retain the report's runtime revision, SDK, OS/CPU, database versions, fixture, +sample counts and harness hashes. Repeat runs to assess load, scheduling and JIT +variation. If raw-driver timings or calibrated relay latency also change, do not +attribute the entire difference to the ORM. A native JIT result says nothing about +AOT, browser or Flutter performance without a corresponding workload there. + +Typed selection decodes values into the requested scalar, Record, DTO or dynamic +Map. Relationship expansion preserves driver-row ownership. Any optimization must +retain mapping order, decoder failures, transaction boundaries, cursor cleanup +and observation events. Statement reuse must also preserve protocol disposal and +connection ownership; omitting cleanup is not a valid cache. diff --git a/README.md b/README.md index ee9a9a5f..f2666f1e 100644 --- a/README.md +++ b/README.md @@ -7,25 +7,45 @@ schema and migrations in Dart. SQLite, PostgreSQL, MySQL and MariaDB share a typ query API, with explicit database capabilities and transaction boundaries. [Get started](#get-started) · [Guides](https://github.com/medz/dart-orm/blob/main/doc/README.md) · -[API reference](https://pub.dev/documentation/orm/6.0.0-beta.2/) · -[Examples](https://github.com/medz/dart-orm/tree/main/example) · [pub.dev](https://pub.dev/packages/orm/versions/6.0.0-beta.2) +[API reference](https://pub.dev/documentation/orm/6.0.0-beta.3/) · +[Examples](https://github.com/medz/dart-orm/tree/main/example) · [pub.dev](https://pub.dev/packages/orm/versions/6.0.0-beta.3) > **6.0 beta:** a new implementation requiring Dart 3.13+. This is a breaking > replacement for the Prisma-based 5.x client. Read the [release notes](https://github.com/medz/dart-orm/blob/main/CHANGELOG.md) > before upgrading an existing application. -Upgrading from beta.1: `first()` now requires a row. Use `firstOrNull()` when an -empty result is expected. See the [beta.2 changes](https://github.com/medz/dart-orm/blob/main/CHANGELOG.md#600-beta2) -for export changes and the new `singleOrNull()` API. +Upgrading from beta.2: replace annotated entities and hand-written row types with +`model(...)` and regenerate your clients. Keep existing migration history; table +and column names continue to identify the physical schema. See the +[beta.3 changes](https://github.com/medz/dart-orm/blob/main/CHANGELOG.md#600-beta3). + +## Record schemas + +Record schemas define each model and table together, without annotations: + +```dart +final task = model('tasks', ( + id: identity(), + title: text(), + done: boolean(defaultValue: false), +)); +``` + +Import `package:orm/schema.dart` in the definition. Generation produces the `Task` +row, `db.task` and a standalone migration snapshot. See [authoring](https://github.com/medz/dart-orm/blob/main/doc/authoring.md) +and the [complete company example](https://github.com/medz/dart-orm/blob/main/example/company/README.md). ## Get started -Create a Dart application and initialize SQLite: +Create a Dart application and add the package: ```sh dart create -t console my_app cd my_app -dart pub add orm:^6.0.0-beta.2 +dart pub add orm:6.0.0-beta.3 +``` + +```sh dart run orm init --database sqlite ``` @@ -35,13 +55,11 @@ registry. The starter model in `lib/schema.dart` is ordinary Dart: ```dart 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(); +final task = model('tasks', ( + id: identity(), + title: text(), + done: boolean(defaultValue: false), +)); ``` Create and review the first migration, then apply it: @@ -61,9 +79,9 @@ import 'package:orm/sqlite.dart'; Future main() async { final db = await sqlite(const SqliteOptions.file('app.sqlite')); try { - final Task task = await db.tasks.create(title: 'Ship something useful'); + final Task task = await db.task.create(title: 'Ship something useful'); - final List<(int, String)> pending = await db.tasks + final List<(int, String)> pending = await db.task .where((t) => t.done.eq(false)) .orderBy((t) => [t.id.asc()]) .select((t) => (t.id, t.title).row) @@ -71,7 +89,7 @@ Future main() async { print(pending); await db.transaction((tx) async { - await tx.tasks.byId(task.id).patch(done: .set(true)); + await tx.task.byId(task.id).patch(done: .set(true)); }); } finally { await db.close(); @@ -157,6 +175,6 @@ migrations without importing today's application models. See [API boundaries](ht - [Transactions and execution](https://github.com/medz/dart-orm/blob/main/doc/execution.md) · [Subscriptions](https://github.com/medz/dart-orm/blob/main/doc/watch.md) - [CLI](https://github.com/medz/dart-orm/blob/main/doc/cli.md) · [build_runner](https://github.com/medz/dart-orm/blob/main/doc/generation.md) · [Existing databases](https://github.com/medz/dart-orm/blob/main/doc/importing.md) - [SQL inspection](https://github.com/medz/dart-orm/blob/main/doc/observability.md) · [Named SQL](https://github.com/medz/dart-orm/blob/main/doc/named-sql.md) -- [Contributing and validation](https://github.com/medz/dart-orm/blob/main/doc/contributing.md) +- [Contributing and validation](https://github.com/medz/dart-orm/blob/main/CONTRIBUTING.md) Licensed under the [BSD 3-Clause License](https://github.com/medz/dart-orm/blob/main/LICENSE). diff --git a/assets/sqlite/worker-0c84ece1c73ab998fbc2c37fe03c3ea9138bf2a717039553dcabf80481929eb1.js b/assets/sqlite/worker-4360a5ed33bec6997af5514e04d393eb8fae00e331f2532a696ce34b0b9e44a1.js similarity index 99% rename from assets/sqlite/worker-0c84ece1c73ab998fbc2c37fe03c3ea9138bf2a717039553dcabf80481929eb1.js rename to assets/sqlite/worker-4360a5ed33bec6997af5514e04d393eb8fae00e331f2532a696ce34b0b9e44a1.js index ca281e29..84b9d683 100644 --- a/assets/sqlite/worker-0c84ece1c73ab998fbc2c37fe03c3ea9138bf2a717039553dcabf80481929eb1.js +++ b/assets/sqlite/worker-4360a5ed33bec6997af5514e04d393eb8fae00e331f2532a696ce34b0b9e44a1.js @@ -4889,7 +4889,7 @@ $S:21} A.fO.prototype={ W(a6,a7){var s=0,r=A.aM(t.X),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1,a2,a3,a4,a5 var $async$W=A.aN(function(a8,a9){if(a8===1){o.push(a9) -s=p}for(;;)A:switch(s){case 0:if(a6==="hello"){q=A.x([1,"811c9351ca7a8a06979b43b9b68aaf97fac2c0c721c506e574d7897eed0693d6"],t.f) +s=p}for(;;)A:switch(s){case 0:if(a6==="hello"){q=A.x([1,"1ffcc1c93a300242a8c4ca7badc270240d0550d23254799eda18232f8bd3312d"],t.f) s=1 break}s=a6==="open"?3:4 break diff --git a/doc/README.md b/doc/README.md index 10da94ed..8b9f719f 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,32 +1,43 @@ # Dart ORM guides -Start with the [quickstart](https://github.com/medz/dart-orm/blob/main/README.md#get-started), then choose the guide for -the part of your application you are building. +Define a schema, generate typed models and queries, and evolve your database with +reviewed Dart migrations. Start with [schema declarations](https://github.com/medz/dart-orm/blob/main/doc/authoring.md) +and the [company example](https://github.com/medz/dart-orm/blob/main/example/company/README.md). -The [API reference](https://pub.dev/documentation/orm/6.0.0-beta.2/) documents the -beta.2 package. See the [release notes](https://github.com/medz/dart-orm/blob/main/CHANGELOG.md#600-beta2) -when upgrading from beta.1. -Run `dart doc --validate-links` in a checkout to generate the reference from the -current public library and member comments into `doc/api/`. +> **Version:** These guides describe `6.0.0-beta.3` and its Record schema API. +> When upgrading, replace annotated entities with `model(...)` and regenerate +> clients. Keep existing migration history unchanged. + +## Define and query | Task | Guide | | --- | --- | -| Declare models, keys and relations | [Authoring](https://github.com/medz/dart-orm/blob/main/doc/authoring.md) | -| Understand imports and execution | [API and mental model](https://github.com/medz/dart-orm/blob/main/doc/api.md) | +| Define models, columns, keys and relationships | [Schema declarations](https://github.com/medz/dart-orm/blob/main/doc/authoring.md) | +| Generate a client and use build_runner | [Generation](https://github.com/medz/dart-orm/blob/main/doc/generation.md) | +| Choose imports and open a database | [Entrypoints](https://github.com/medz/dart-orm/blob/main/doc/api.md) | | Filter, select, join and paginate | [Queries](https://github.com/medz/dart-orm/blob/main/doc/queries.md) | | Load related data | [Relationships](https://github.com/medz/dart-orm/blob/main/doc/relations.md) | -| Work with domain values | [Types and codecs](https://github.com/medz/dart-orm/blob/main/doc/types.md), [Decimals](https://github.com/medz/dart-orm/blob/main/doc/decimals.md) | -| Define database behavior | [Defaults](https://github.com/medz/dart-orm/blob/main/doc/defaults.md), [Computed columns](https://github.com/medz/dart-orm/blob/main/doc/computed.md), [Checks](https://github.com/medz/dart-orm/blob/main/doc/checks.md) | +| Use domain values, enums and JSON | [Types and codecs](https://github.com/medz/dart-orm/blob/main/doc/types.md), [Decimals](https://github.com/medz/dart-orm/blob/main/doc/decimals.md) | +| Add defaults and database constraints | [Defaults](https://github.com/medz/dart-orm/blob/main/doc/defaults.md), [Computed columns](https://github.com/medz/dart-orm/blob/main/doc/computed.md), [Checks](https://github.com/medz/dart-orm/blob/main/doc/checks.md) | + +## Run and maintain + +| Task | Guide | +| --- | --- | +| Configure project commands | [CLI](https://github.com/medz/dart-orm/blob/main/doc/cli.md) | | Evolve or adopt a database | [Migrations](https://github.com/medz/dart-orm/blob/main/doc/migrations.md), [Backfills](https://github.com/medz/dart-orm/blob/main/doc/backfills.md), [Importing](https://github.com/medz/dart-orm/blob/main/doc/importing.md) | | Use transactions, streaming and timeouts | [Execution](https://github.com/medz/dart-orm/blob/main/doc/execution.md) | | Subscribe to changes | [Query subscriptions](https://github.com/medz/dart-orm/blob/main/doc/watch.md) | -| Inspect SQL and execution costs | [Observability](https://github.com/medz/dart-orm/blob/main/doc/observability.md), [Performance](https://github.com/medz/dart-orm/blob/main/doc/performance.md) | +| Inspect SQL and query costs | [Observability](https://github.com/medz/dart-orm/blob/main/doc/observability.md), [Performance](https://github.com/medz/dart-orm/blob/main/doc/performance.md) | | Use hand-written SQL | [Named SQL](https://github.com/medz/dart-orm/blob/main/doc/named-sql.md) | -| Configure generation and project commands | [CLI](https://github.com/medz/dart-orm/blob/main/doc/cli.md), [Generation](https://github.com/medz/dart-orm/blob/main/doc/generation.md) | + +## Choose a platform + +| Task | Guide | +| --- | --- | +| Check engine and platform limits | [Capabilities](https://github.com/medz/dart-orm/blob/main/doc/capabilities.md) | | Build Flutter or browser applications | [Flutter](https://github.com/medz/dart-orm/blob/main/doc/flutter.md), [SQLite Web](https://github.com/medz/dart-orm/blob/main/doc/sqlite-web.md) | | Use MySQL or MariaDB | [Drivers](https://github.com/medz/dart-orm/blob/main/doc/mysql.md), [Migration recovery](https://github.com/medz/dart-orm/blob/main/doc/mysql-migrations.md) | -| Check supported behavior | [Capabilities](https://github.com/medz/dart-orm/blob/main/doc/capabilities.md), [Acceptance](https://github.com/medz/dart-orm/blob/main/doc/acceptance.md) | -| Contribute or reproduce validation | [Contributing](https://github.com/medz/dart-orm/blob/main/doc/contributing.md), [Progress](https://github.com/medz/dart-orm/blob/main/doc/progress.md) | -The beta uses Dart declarations and engine-specific Dart migration histories. -The retired Prisma-based 5.x documentation does not describe this API. +For repository setup, tests and documentation changes, see +[Contributing](https://github.com/medz/dart-orm/blob/main/CONTRIBUTING.md). diff --git a/doc/acceptance.md b/doc/acceptance.md deleted file mode 100644 index cdd602de..00000000 --- a/doc/acceptance.md +++ /dev/null @@ -1,85 +0,0 @@ -# Validation guide - -Use the repository checks to verify the API and database behavior after a change. -A test name below identifies a scenario to exercise, not a claim that the current -checkout has passed it. [Progress](https://github.com/medz/dart-orm/blob/main/doc/progress.md) records completed validation; -[capabilities](https://github.com/medz/dart-orm/blob/main/doc/capabilities.md) states database and platform limits. - -## Start with the public workflow - -From a disposable consumer project, initialize a database, generate its client, -create and review the first migration, then apply and verify it. Run a typed -projection and a transaction through generated table getters. Change a model, -regenerate, and ensure the analyzer catches stale result or input types. The -[CLI guide](https://github.com/medz/dart-orm/blob/main/doc/cli.md) and [quickstart](https://github.com/medz/dart-orm/blob/main/README.md#get-started) provide the commands. - -Verify independent imports as well: values and schema metadata need no connection; -typed SQL can compile offline; a raw driver and runtime work without generated -models; saved migrations do not import current application models. The -`layer_boundaries_test`, `runtime_test` and `sql_builder_test` suites exercise -these contracts. - -## Choose regression suites by behavior - -All names below refer to files under `test/` with the `_test.dart` suffix. - -| Changed behavior | Representative suites | -| --- | --- | -| Model declarations and generated types | `generator`, `generator_dialects`, `nominal_generation`, `types`, `generated_database`, `nominal_database` | -| Build/watch and project commands | `builder`, `cli`, `cli_workflow`, `named_sql_generation`, `import_cli`, `migration_plan_cli` | -| Projections, mapping and query scope | `database`, `selection`, `query_boundary`, `union`, `plan` | -| Keys, relationships and batched loading | `relation`, `many_to_many`, `unconstrained_relation` | -| Values and custom codecs | `integer`, `custom_codec`, `temporal`, `temporal_precision`, `decimal`, `decimal_division`, `decimal_average` | -| Defaults, generated values and constraints | `client_default`, `computed`, `check` | -| Sessions, transactions and failure recovery | `transaction`, `retry`, `acquisition`, `stream`, `session_connection`, `runtime_lifecycle_review` | -| Subscriptions and execution observations | `watch`, `observation` | -| Migration history, catalog and recovery | `migration`, `migration_target`, `migration_recovery`, `backfill`, `schema_version`, `import` | -| MySQL and MariaDB behavior | `mysql_driver`, `mysql_database`, `mysql_import`, `mysql_transaction_boundary`, `mysql_migration` | - -Use real database URLs to enable the server suites. Tests create their own tables -or schemas; MySQL/MariaDB recovery tests require permission to create isolated -databases. See [contributing](https://github.com/medz/dart-orm/blob/main/doc/contributing.md) for environment variables and the -complete native command. Capability-dependent skips must be reported separately -from passes, especially when the SQLite build cannot interrupt running SQL. - -## Verify browser and Flutter behavior - -From the repository root: - -```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 -``` - -Browser checks exercise packaged resource identity, generated queries, memory and -OPFS storage, reopen/upgrade, constraints, transactions, cursors, subscriptions, -value transport and explicit rejection of unsupported interruption. They use real -Chrome and record JS/WASM mode. The Flutter Web runner also verifies release -packaging, nested routes and configurations with and without isolation headers. - -For Android, follow the [APK upgrade and restart workflow](https://github.com/medz/dart-orm/blob/main/doc/flutter.md). Browser, -Android and native server validation are separate: one passing target does not -establish another. Keep SDK, database, browser/device, source revision and report -paths with the validation result. Reports are local outputs under `.dart_tool/`. - -## Check documentation and packaging - -After static analysis, run `dart doc --validate-links` to generate the actual API -site when changing categories, exports or documentation links. Inspect library -navigation, public type signatures and links in the generated pages. Documentation -must expose usable public APIs without presenting internal helpers as supported -entrypoints. - -Run `dart pub publish --dry-run` to inspect the package archive. Handwritten -`doc/`, examples and runtime resources belong in the package; generated `doc/api/`, -local validation output and repository-only tools do not. Verify the published -version separately when making an actual release. - -## Measure performance separately - -Use [runtime benchmarks](https://github.com/medz/dart-orm/blob/main/doc/performance.md), [generation/editor measurements](https://github.com/medz/dart-orm/blob/main/doc/generation.md) -and the [decimal cost probe](https://github.com/medz/dart-orm/blob/main/doc/decimals.md#verify-precision-and-measure-cost) for -performance work. Preserve result semantics, transaction boundaries and exact -query volume when comparing revisions. Correctness tests, allocation traces and -latency measurements answer different questions and should have separate results. diff --git a/doc/api.md b/doc/api.md index 9d3cead4..8a445599 100644 --- a/doc/api.md +++ b/doc/api.md @@ -1,6 +1,6 @@ # Choose an entrypoint -Start with an immutable Dart class, generate its client, choose a database entrypoint, +Define a Record schema, 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. @@ -12,7 +12,7 @@ import 'package:orm/sqlite.dart'; final db = await sqlite(const SqliteOptions.file('app.sqlite')); try { - final tasks = await db.tasks.where((t) => t.done.eq(false)).get(); + final tasks = await db.task.where((t) => t.done.eq(false)).get(); print(tasks); } finally { await db.close(); @@ -40,7 +40,7 @@ browser database. Flutter bundles its browser resources automatically; see | 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 | +| Schema declaration | `package:orm/schema.dart` | Record model definitions, columns, keys and relationships | | 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 | @@ -48,8 +48,8 @@ browser database. Flutter bundles its browser resources automatically; see | 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 +Generated clients define immutable row classes and typed table getters. Model +handles and unrelated application helpers remain in the source schema library. 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. @@ -74,7 +74,7 @@ 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')) + .user.where((u) => u.email.eq('seven@example.com')) .select((u) => u.id) .compile(); // command.sql and separately bound command.parameters; no connection opened. @@ -100,10 +100,10 @@ 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 +Each generated model class is its 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 +structural. Two models can declare identical field shapes while keeping distinct row types +and SQL table identities. `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 @@ -163,8 +163,8 @@ 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( + final user = await tx.user.create(email: 'seven@example.com'); + await tx.post.create( authorId: user.id, title: 'First post', createdAt: DateTime.now(), diff --git a/doc/authoring.md b/doc/authoring.md index 79fc9a62..8e43067f 100644 --- a/doc/authoring.md +++ b/doc/authoring.md @@ -1,160 +1,250 @@ -# Models and typed queries +# Define your schema -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. +Declare each model and table together with `model(...)`. Name columns in a +Record, then add keys, indexes and relationships in the same definition. +Generation produces immutable row classes and typed queries; no annotations or +separate hand-written row classes are needed. ```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 Model user = model( + 'users', + ( + id: identity(), + email: text(unique: true), + nickname: text().nullable(), + active: boolean(defaultValue: true), + ), + relations: (u) => (posts: referencedBy(() => post),), ); -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); +final post = model( + 'posts', + ( + id: identity(), + authorId: integer(), + title: text(), + createdAt: dateTime(clientDefault: DateTime.now), + ), + relations: (p) => ( + author: references(p.authorId, () => user, onDelete: .cascade), + ), + indexes: (p) => [ + index((p.authorId, p.createdAt, p.id), name: 'posts_author_timeline'), + ], +); ``` -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). +Run `dart run orm generate lib/schema.dart`, or use +[build_runner watch](https://github.com/medz/dart-orm/blob/main/doc/generation.md). This produces `schema.orm.dart` with the +immutable `User`/`Post` row classes and typed query API, plus an independent +`schema.snapshot.dart` for migrations. The [company example](https://github.com/medz/dart-orm/blob/main/example/company/schema.dart) +includes self references and a many-to-many association with business fields; +`dart run example/company/main.dart` runs it against SQLite in memory. + +Use `final name = model(...)` by default. `Model` is a non-generic declaration +type with no public constructor. For self references, write +`final Model name = model(...)`. For mutually related models, annotate enough +declarations to break every type inference cycle; the example annotates `user`. +These type annotations preserve field types in all selector callbacks. + +The table name is explicit. The Dart declaration `user` supplies `User` and +`db.user`; the generator does not guess English singular/plural forms. Two models +with identical Record shapes still have separate table and nominal row identities. -`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. +The second argument is a named Record of `ColumnDefinition` values. Dart +preserves each field's type and provides completion in local relation/index/key callbacks. +Generation also checks that every entry is a supported column declaration. -Generate with `dart run orm generate lib/schema.dart`, then import the generated -client and the chosen driver. [Generation](https://github.com/medz/dart-orm/blob/main/doc/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. +## Rows, inserts and updates ```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(); +final User created = await db.user.create(email: 'seven@example.com'); +await db.user.byId(created.id).patch(nickname: .set('Seven')); +await db.user.byId(created.id).patch(nickname: .set(null)); + +final List titles = await db.user.byId(created.id) + .select((u) => u.posts.select((p) => p.title).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. +A generated row contains every stored field. Its constructor requires all values, +including nullable columns and identities. Inserts have their own contract: + +- Non-null columns without defaults are required. +- Nullable columns without defaults can be omitted and are inserted as SQL NULL. +- Identity and defaulted columns use `Change`: omission keeps the default; + `.set(value)` explicitly supplies a value. +- Computed columns are absent from generated inserts and patches. + +All patch parameters use `Change`. Omission leaves a column unchanged; +`.set(null)` clears a nullable column. Projections remain independent of full rows. +Relations load only through explicit selections; rows have no implicit lazy queries, +equality generation, serialization, `copyWith` or global identity map. + +## Column types and defaults + +| Declaration | Dart value | +| --- | --- | +| `identity()`, `integer()` | `int` | +| `text()`, `boolean()`, `real()` | `String`, `bool`, `double` | +| `bigInteger()`, `decimal()` | `BigInt`, `Decimal` | +| `dateTime()` | `DateTime` UTC instant | +| `date()`, `time()`, `localDateTime()` | `LocalDate`, `LocalTime`, `LocalDateTime` | +| `bytes()`, `json()` | `Uint8List`, `SqlJson` | +| `enumeration(Status.values)` | `Status` | +| `custom(emailCodec)` | The public const codec's domain type | + +Append `.nullable()` for SQL NULL. `json()` retains the distinction between a JSON +null document and SQL NULL. An integer-storage domain ID can use +`custom(PersonId.codec).identity()`; it must remain a single non-null primary key. + +`name: 'existing_column'` fixes a physical column name; otherwise the generator +uses snake_case. `integer(bits: 32)`, `decimal(precision: 10, scale: 2)` and temporal +`precision: 3` declare storage limits; each migration engine checks its capabilities. + +`defaultValue` on integer, text, boolean, real and enum columns declares a typed +SQL constant. Text is escaped as a literal. Enum constants are encoded using the +same labels as their column codec. For stable labels independent of Dart renames: -## Full rows, writes and projections +```dart +status: enumeration( + Status.values, + labels: {Status.pending: 'waiting', Status.done: 'complete'}, + defaultValue: Status.pending, +), +``` -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?`. +Every enum constant needs a distinct label. Use the full enum constant name in the +generic default argument; dot shorthand has no suitable context there. +Enum labels are application codecs over text storage. Changing those labels needs +an explicit data migration; schema diffing does not infer a label rename. -The generated `create` method has its own insert contract: +`defaultSql: 'CURRENT_TIMESTAMP'` is trusted database SQL. `clientDefault: +DateTime.now` is a public function or constructor tear-off, invoked only when an +insert omits the value. It may coexist with a database default: omission uses +the client factory, while `.defaultValue()` requests the database default. +Choose either `defaultValue` or `defaultSql` for the database default. Generation +never executes a factory or codec. Use `.computed('price * quantity')` for a read-only +computed column; it accepts explicit dialect overrides and `storage:`. Add trusted +checks inside the model with `checks: [check('price >= 0', name: 'positive_price')]`. -- 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. +## Keys and relationships -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. +`identity()` declares a database-generated primary key. Other primary keys use +`primaryKey: (m) => m.code` or an ordered composite tuple: -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. +```dart +primaryKey: (m) => (m.projectId, m.employeeId), +uniqueKeys: (m) => [(m.departmentId, m.email)], +``` -Selected shapes remain independent of complete models. Project a scalar, a typed -Record, or a separate application DTO: +Relationships are named members of the Record returned by `relations`. A forward +reference declares a database foreign key and a query member on the current model. +A reverse member is declared on the model that exposes it: ```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})> +final Model employee = model( + 'employees', + (id: identity(), name: text(), managerId: integer().nullable()), + relations: (e) => ( + manager: references(e.managerId, () => employee, onDelete: .setNull), + reports: referencedBy(() => employee), + ), +); ``` -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. +`manager` and `reports` are generated query members, not string options or stored +row fields. `references` defaults to the target's primary key and deletion action +`restrict`; `cascade` and `setNull` are explicit. Reverse navigation reuses a +forward reference and creates neither another foreign key nor an implicit index. +Renaming either relation changes the query API without changing the physical +schema. A unique foreign key expresses a one-to-one constraint. Many-to-many +associations use an explicit model, which can also hold business fields. -## Supported declarations and errors +`referencedBy(() => model)` requires exactly one forward reference from that model +to the current one. If several exist, choose the foreign-key mapping explicitly: -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. +```dart +// On user, when post has both authorId and reviewerId references to user: +relations: (u) => ( + authoredPosts: referencedBy(() => post, on: (authorId: u.id)), + reviewedPosts: referencedBy(() => post, on: (reviewerId: u.id)), +), +``` + +No match or ambiguity is a generation error. The generator never guesses from a +relation name or chooses the first candidate. Reverse declarations work across +files, regardless of declaration order. Their mapping references fields, so a +forward relation's query name can change independently. + +For composite primary keys, a positional Record retains target primary-key order: -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 +relations: (m) => ( + team: references((m.tenantId, m.teamCode), () => team), +), +``` + +A named Record instead maps **target Dart field names to local columns**. This +selects an alternate unique key or spells out a composite mapping without strings: + +```dart +relations: (m) => ( + team: references((tenantId: m.tenantId, code: m.teamCode), () => team), + owner: references((email: m.ownerEmail), () => user), +), +``` -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. +Named mappings are normalized to the declared target key order; reordering their +entries does not change a foreign key. Target fields must form a primary or unique +key. `constraint: false` on `references` allows read-only navigation without a +database foreign key, including to non-unique fields; its reverse is read-only too. -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. +**Typing boundary:** `m.teamCode` and `u.id` have native Dart types and completion. +Target mapping names (`code`, `authorId`) are checked against the referenced model +by generation, not Dart member completion. No `dynamic` selector or generated input +library is used. Invalid names, incompatible value types/codecs, repeated columns, +invalid target uniqueness and unmatched inverse mappings fail before emission. +The `relations` callback must return a literal named Record containing direct +`references`/`referencedBy` calls; it is read statically and never executed. -## Records and schema history +## Split schemas and static boundaries -Named Record typedefs remain an explicit structural data form: +Use ordinary independent Dart libraries. A schema root can export selected models: ```dart -typedef Coordinate = ({@Id() int id, double x, double y}); -final coordinates = entity(); +export 'employees.dart' show employee; +export 'projects.dart' show project, projectMember; ``` -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. +Generation includes the root's local models and exported models, then follows +model references transitively. Local models retain declaration order; external +models are ordered by physical table name so renaming exported Dart variables +does not change the snapshot fingerprint. Unrelated imports and other unexported +models are not additional roots. Imported enum/domain types retain their defining library; +unambiguous public types are reexported by the generated client. For colliding +domain names, import the original libraries with prefixes. + +Declarations are static source, not executable configuration scripts. Model and +column helper calls must be direct. Key callbacks are arrow expressions selecting +local fields; relation members are a named Record and other constraint collections +are literal lists, without control flow or +spreads. String/bool/numeric configuration accepts literals and const references. +Factories, mutation-based registration and dynamic schema assembly are not run. +Unsupported discovered declarations fail with a source diagnostic rather than +silently disappearing. Schema-specific checks run during generation. Keep +build_runner watch active to catch those errors while editing. + +Keep physical names fixed during Dart refactoring. Regenerate and analyze consumers +after changing declarations. Generated model/field renames do not promise automatic +IDE edits across every generated API. Migration snapshots contain physical metadata, +without application imports, and destructive renames are never inferred. + +## Updating an existing schema + +Use `model(...)` for every table and move column settings into the column helpers. +Generate the new clients and update application imports to use their row classes. +Keep physical table and column names fixed, then compare the generated snapshots +before creating a migration. Saved migration definitions remain independent of +current schema declarations and must not be regenerated. diff --git a/doc/backfills.md b/doc/backfills.md index 73dfe4d5..c45cd166 100644 --- a/doc/backfills.md +++ b/doc/backfills.md @@ -129,10 +129,3 @@ 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/doc/capabilities.md b/doc/capabilities.md index a60b1a02..b9097ff9 100644 --- a/doc/capabilities.md +++ b/doc/capabilities.md @@ -2,8 +2,7 @@ Choose an engine and platform explicitly. The shared query API does not make backend behavior identical: connections expose their actual capabilities and -reject unsupported operations before execution where possible. Refer to -[progress](https://github.com/medz/dart-orm/blob/main/doc/progress.md) for the revision and platforms most recently verified. +reject unsupported operations before execution where possible. ## Values and physical storage @@ -13,8 +12,8 @@ reject unsupported operations before execution where possible. Refer to | Decimal | Exact storage, comparison, keys and schema metadata on all four engines | SQLite/PostgreSQL support exact arithmetic, aggregates, windows and rounding. MySQL/MariaDB reject operations that can silently lose precision; see [engine limits](https://github.com/medz/dart-orm/blob/main/doc/mysql.md). | | Time | UTC `DateTime`, `LocalDate`, `LocalTime`, `LocalDateTime` and explicit temporal precision | An IANA zone name is a separate application value. Calendar SQL arithmetic and timezone-rule conversion have no typed API. | | JSON | `SqlJson` distinguishes JSON null from SQL NULL | A Dart Map is not an entity mapping. Backend-specific JSON path operations require explicit SQL. | -| Enum | Checked enum codecs and stable `EnumValue` text labels | Native PostgreSQL enums need separate metadata and migration support. | -| Binary and custom IDs | `Uint8List` and public const `UseCodec` declarations | Codec changes do not automatically produce DDL. Domain equality and ordering must match the chosen storage. | +| Enum | Checked enum codecs and explicit text labels | Native PostgreSQL enums need separate metadata and migration support. | +| Binary and custom IDs | `Uint8List` and public const codecs | Codec changes do not automatically produce DDL. Domain equality and ordering must match the chosen storage. | See [types and codecs](https://github.com/medz/dart-orm/blob/main/doc/types.md) and [decimals](https://github.com/medz/dart-orm/blob/main/doc/decimals.md) for exact conversion, precision and nullability rules. Catalog import reads physical metadata without @@ -88,14 +87,21 @@ an application-owned path; browsers choose a named OPFS database. Flutter Web bundles matching worker/WASM resources. [SQLite Web](https://github.com/medz/dart-orm/blob/main/doc/sqlite-web.md) documents resource overrides, secure-context requirements and exclusive ownership. -The release baseline covers native SQLite/PostgreSQL, real MySQL 8.4/MariaDB 11.8, -Chrome JS/WASM, Flutter Web release builds and an Android emulator's debug-to-AOT -upgrade and process restart. This does not certify every server version, browser, -physical device, iOS or macOS Flutter. Remote/serverless transports, replica -routing and alternative pools need their own adapters and validation. - -Immutable model classes and Record declarations share the generator; classes -retain nominal result types. Editor symbol refactoring depends on the Dart SDK. -Regenerate and analyze after schema edits. [Generation](https://github.com/medz/dart-orm/blob/main/doc/generation.md) describes -build/watch behavior and editor probes; [performance](https://github.com/medz/dart-orm/blob/main/doc/performance.md) describes -reproducible cost measurements without implying universal latency guarantees. +| Platform | Scope | +| --- | --- | +| Native Dart | SQLite, PostgreSQL, MySQL and MariaDB adapters; engine restrictions above apply | +| Browser | SQLite in Chrome JS/WASM; secure context and worker/OPFS requirements apply | +| Flutter | SQLite on Android and Flutter Web; persistence and resource ownership follow the platform driver | +| Other browsers and devices | Safari, Firefox, physical-device and iOS/macOS Flutter behavior are not verified | + +Record schema generation has been exercised with SQLite and PostgreSQL. Its +integration with MySQL/MariaDB, browser and Flutter clients is not yet verified. +Remote/serverless transports, replica routing and alternative pools require +separate adapters. + +Record schemas generate immutable model classes and typed queries. +Editor completion checks local fields; cross-model mapping names and schema +semantics are checked during generation. Regenerate and analyze after edits. +See [generation](https://github.com/medz/dart-orm/blob/main/doc/generation.md) for +build/watch behavior and [performance](https://github.com/medz/dart-orm/blob/main/doc/performance.md) +for workload-specific cost measurements. diff --git a/doc/checks.md b/doc/checks.md index b99b399f..51baf339 100644 --- a/doc/checks.md +++ b/doc/checks.md @@ -1,34 +1,27 @@ # 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. +Declare row constraints in the model's `checks` list. Expressions use physical +SQL column names: ```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', +final product = model( + 'products', + (id: identity(), price: real(), discount: real().nullable(), label: text()), + checks: [ + check('price >= 0', name: 'nonnegative_price'), + check('discount >= 0 AND discount <= price', name: 'valid_discount'), + check('length(label) <= 20', + postgres: 'char_length(label) <= 20', name: 'short_label'), + ], ); ``` -`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. +Constraint names are explicit, stable and unique per table. Renaming a Dart model +or field does not rename them. SQLite compares ASCII identifier case without +distinction; PostgreSQL preserves quoted case. An engine override replaces the +common expression for that backend; the selected expression must be nonempty. -These are trusted schema SQL fragments, like `@Default.sql`; do not construct +These are trusted schema SQL fragments, like `defaultSql`; 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. @@ -42,7 +35,7 @@ and [SQLite CHECK semantics](https://www.sqlite.org/lang_createtable.html#check_ ## Snapshots, verification and import -The current generated `TableSchema.checks` retains overrides in `CheckSchema`. +The 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. @@ -57,8 +50,8 @@ 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. +The exact unnamed range/precision expressions emitted for integer widths and +decimal precision 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 diff --git a/doc/cli.md b/doc/cli.md index 7b1d556d..9047837c 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -14,8 +14,8 @@ dart run orm migrate verify | File | Purpose | | --- | --- | -| `lib/schema.dart` | Editable nominal Dart model and table declaration | -| `lib/schema.orm.dart` | Generated typed query client | +| `lib/schema.dart` | Editable Record schema | +| `lib/schema.orm.dart` | Generated models and typed queries | | `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 | @@ -23,8 +23,7 @@ dart run orm migrate verify 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. +valid immediately. build_runner is optional 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 diff --git a/doc/computed.md b/doc/computed.md index c11d96ed..c4f8162d 100644 --- a/doc/computed.md +++ b/doc/computed.md @@ -1,32 +1,34 @@ # Database computed columns -Declare SQL over physical column names. The generated Record includes the result; +Declare SQL over physical column names. The generated row 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'); +final line = model('lines', ( + id: identity(), + price: integer(), + quantity: integer(), + label: text(), + total: integer().computed('price * quantity'), + labelSize: integer().computed( + 'length(label)', + postgres: 'char_length(label)', + storage: .virtual, + ), +)); + +final row = await db.line.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(); +await db.line.byId(row.id).patch(quantity: .set(5)); // total becomes 20 +final totals = await db.line.select((r) => r.total).get(); +print(totals); // [20] ``` `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. +columns. Use stored columns when the same indexed schema must work on both engines. Generated computed fields are `ReadField`: they support expressions, ordering, selection and relationship keys, but have no `set`, `increment`, `change` or @@ -78,8 +80,7 @@ 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 +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). @@ -94,7 +95,7 @@ This plans SQL without selecting rows; immutable constant functions may run duri 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 +`db import` emits `.computed(...)` 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/doc/contributing.md b/doc/contributing.md deleted file mode 100644 index af169124..00000000 --- a/doc/contributing.md +++ /dev/null @@ -1,59 +0,0 @@ -# Contributing - -Use Dart 3.13 or newer. From a repository checkout: - -```sh -dart pub get -dart analyze -dart test -dart run example/main.dart -dart run example/queries.dart -``` - -The Flutter example is a separate package; run its analysis with the Flutter SDK. -For browser checks, set `CHROME_EXECUTABLE` and run -`dart run tool/test_browser.dart`, then add `--wasm` for the WASM build. - -## Real database checks - -Set `ORM_TEST_POSTGRES`, `ORM_TEST_MYSQL` and `ORM_TEST_MARIADB` to disposable -database URLs to include those integration suites. Tests create and drop their -own tables; MySQL/MariaDB migration tests also create isolated databases. -Use dedicated test servers and accounts with those privileges. - -Server TLS defaults to `verifyFull`. Self-signed local fixtures can explicitly -set `ORM_TEST_MYSQL_TLS=require` and `ORM_TEST_MARIADB_TLS=require`. -Run `dart test --concurrency=1` when testing the complete native matrix. - -## Documentation and public APIs - -Keep public entrypoints explicit and add `///` comments at the declarations they -export. Explain result shape, ownership, transaction boundaries and errors where -those affect callers. Internal modules use ordinary imports and exports; do not -introduce `part` directives to share private state. - -Handwritten guides live in `doc/`. Dartdoc categories connect those guides to API -navigation. After changing comments, exports or category configuration, run: - -```sh -dart doc --validate-links -``` - -After moving or renaming APIs, remove the previous generated `doc/api/` directory -before regenerating so stale pages cannot survive. Broken links, ambiguous -canonical exports and unresolved symbol references fail documentation generation. - -Inspect the generated `doc/api/` pages through a local HTTP server, including -library/category navigation, symbol links and public signatures. Do not commit -or publish that generated directory. Run package analysis and the relevant -examples as well; a formatted code block alone does not verify an example. - -## Release checks - -Check formatting and run `dart pub publish --dry-run`. The published package -includes its Dart sources, documentation, examples and SQLite Web assets; -development caches and repository test tools stay out of the archive. - -`dart run tool/build_sqlite_web.dart --check` verifies committed worker/WASM -resources. Regenerate them only when their source fingerprint changes. -Keep validation records tied to the actual revision and platform tested. diff --git a/doc/decimals.md b/doc/decimals.md index 8dbd4a84..38028fad 100644 --- a/doc/decimals.md +++ b/doc/decimals.md @@ -14,16 +14,16 @@ silently lose precision. See [engine-specific boundaries](https://github.com/med ```dart import 'package:orm/schema.dart'; -typedef Invoice = ({ - @Id.generated() int id, - Decimal total, - Decimal? discount, -}); -final invoices = entity(); +final invoice = model('invoices', ( + id: identity(), + total: decimal(), + discount: decimal().nullable(), +)); // After generation: -await db.invoices.create(total: Decimal.parse('9007199254740993.01')); -final total = await db.invoices.select((i) => i.total.sum()).single(); +await db.invoice.create(total: Decimal.parse('9007199254740993.01')); +final total = await db.invoice.select((i) => i.total.sum()).single(); +print(total); // 9007199254740993.01 ``` Use `Decimal.parse(String)` or `Decimal.fromBigInt(value, scale: 2)`. @@ -68,14 +68,14 @@ SQL expressions also provide `divide`, `divideExpression` and `rounded`, with th same explicit scale and six rounding modes as values: ```dart -final installments = await db.invoices.select( +final installments = await db.invoice.select( (i) => i.total.divide( Decimal.parse('3'), scale: 2, rounding: .halfEven, ), ).get(); -final roundedTotal = await db.invoices.select( +final roundedTotal = await db.invoice.select( (i) => i.total.sum().rounded(2, rounding: .halfEven), ).single(); ``` @@ -110,10 +110,10 @@ 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( +final average = await db.invoice.select( (i) => i.total.average(scale: 2, rounding: .halfEven), ).single(); -final running = await db.invoices.orderBy((i) => [i.id.asc()]).select( +final running = await db.invoice.orderBy((i) => [i.id.asc()]).select( (i) => i.total.average(scale: 2, rounding: .halfEven).over( orderBy: [i.id.asc()], frame: .rowsToCurrent, @@ -142,7 +142,7 @@ 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 unique = db.invoice.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(); @@ -162,19 +162,19 @@ an out-of-range Decimal just to divide it back into range. 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, -}); +final balance = model('balances', ( + id: identity(), + amount: decimal(precision: 12, scale: 2), + initial: decimal(precision: 12, scale: 2, defaultSql: "'1.235'"), +)); ``` 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. +`decimal(precision: 3, scale: -2)` stores multiples of 100 through 99900 in magnitude; +`decimal(precision: 3, scale: 5)` stores up to 0.00999 in magnitude. Negative/excess scales +require PostgreSQL 15 or newer. 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. @@ -229,7 +229,7 @@ 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'")`. +should be quoted decimal strings, for example `decimal(defaultSql: "'0.10'")`. Ordinary numeric literals can be evaluated approximately by SQLite first. Unconstrained decimal columns have no generated finite-value CHECK. PostgreSQL @@ -253,32 +253,8 @@ text keys `2` and `2.00` become duplicate numeric keys, so the migration fails a rolls back data, structure and history together. Repair the data before retrying. Historical backfills retain exact decimal keys in their resumable checkpoints. -## Verify precision and measure cost - -Use the repository's decimal, division/window and average suites to verify -storage, arithmetic, rounding, catalog drift and rollback with real SQLite and -PostgreSQL connections. The Chrome checks cover exact Decimal transport; they do -not rerun the complete native precision suite. Android acceptance currently has -no Decimal-specific scenario. MySQL/MariaDB arithmetic limits are described in -[the engine guide](https://github.com/medz/dart-orm/blob/main/doc/mysql.md). - -The repository benchmark fetches and decodes 10000 integer-valued Decimal rows, -with one warmup and three measured runs per case. It includes ordinary reads, -division, rounding, average and running aggregates. Build before measuring: - -```sh -dart build cli --target=tool/benchmark_decimal.dart --output=/tmp/orm-decimal-benchmark -mkdir -p .dart_tool/benchmarks -/tmp/orm-decimal-benchmark/bundle/bin/benchmark_decimal > .dart_tool/benchmarks/decimal.json -``` - -Set `ORM_TEST_POSTGRES` to a disposable local PostgreSQL URL to include that -backend. The script disables TLS for this local benchmark, creates a uniquely -named schema and removes it afterward; the account needs schema-creation -permission. Without the variable it measures SQLite only. - -These are single-client end-to-end samples, including compilation of each query, -database work, transport and decoding. They are not latency percentiles, -concurrency measurements or estimates for large coefficients and high scales. -Run separately from tests and builds. Retain database/SDK versions and each sample -from the JSON report when comparing revisions. +Decimal storage and arithmetic can cost more than integer or floating-point +operations. Measure with representative coefficient sizes, scales and result +counts; small integer-valued Decimals do not model every workload. See +[performance](https://github.com/medz/dart-orm/blob/main/doc/performance.md) for +measurement guidance. diff --git a/doc/defaults.md b/doc/defaults.md index 4f43b14c..58ddf617 100644 --- a/doc/defaults.md +++ b/doc/defaults.md @@ -1,28 +1,29 @@ # 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. +Use `clientDefault` when Dart should generate an omitted insert value. Use +`defaultValue` for typed database constants, or `defaultSql` for a SQL expression. ```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". +final entry = model('entries', ( + id: identity(), + createdAt: dateTime(clientDefault: DateTime.now), + label: text(clientDefault: initialLabel), + state: text(defaultValue: 'server'), +)); + +final created = await db.entry.create(); // Dart timestamp/label; database id. +print(created.label); // draft +await db.entry.create(label: .set('manual')); +await db.entry.create(state: .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 +functions explicitly, for example `text().nullable(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. @@ -38,11 +39,13 @@ fields with SQL defaults: | `.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. +A column can combine `clientDefault` with a database default. Omission uses the +client factory; `.defaultValue()` uses the database default. Choose either +`defaultValue` or `defaultSql` for that database default, not both. +`.defaultValue()` requires a database default or identity; it does not rerun a +client factory. A nullable +factory returning null stores SQL NULL. Patches and conflict updates keep omitted +fields unchanged. SQLite does not support UPDATE SET DEFAULT. `create`, `createRow`, `insert` and `insertMany` share this behavior. Factories run once per omitted column per input row while constructing an insert. Batch values @@ -57,8 +60,7 @@ 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. +browser worker. ## Physical schema diff --git a/doc/execution.md b/doc/execution.md index e4f676e6..07277fa3 100644 --- a/doc/execution.md +++ b/doc/execution.md @@ -4,7 +4,7 @@ and fetches the next batch when the consumer requests more rows: ```dart -await for (final user in db.users +await for (final user in db.user .orderBy((u) => [u.id.asc()]) .stream(batchSize: 128)) { await exportUser(user); @@ -33,7 +33,7 @@ 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 for (final row in tx.user.stream()) { await consume(row); } }, options: const PostgresTransaction( @@ -57,10 +57,10 @@ final options = ExecutionOptions( timeout: const Duration(seconds: 5), cancellation: token, ); -final rows = await db.users.select((u) => u.email).get(options: options); +final rows = await db.user.select((u) => u.email).get(options: options); await db.transaction((tx) async { - await tx.users.create(email: 'within-a-lease@example.com'); + await tx.user.create(email: 'within-a-lease@example.com'); }, acquire: const AcquisitionOptions(timeout: Duration(seconds: 2))); await db.session((session) async { @@ -118,7 +118,7 @@ final options = ExecutionOptions( cancellation: token, timeout: const Duration(seconds: 5), ); -final pending = db.users.where((u) => u.email.eq(email)).get(options: options); +final pending = db.user.where((u) => u.email.eq(email)).get(options: options); // Another event can call token.cancel(). final users = await pending; ``` @@ -154,13 +154,11 @@ 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: +remain available. When distributing a native application, build with its native +assets: ```sh -dart build cli --target=test/support/native_execution.dart --output=/tmp/orm-native-check -/tmp/orm-native-check/bundle/bin/native_execution +dart build cli --target=bin/app.dart --output=build/app ``` Distribute the complete `bundle` directory, including its native libraries. @@ -204,9 +202,9 @@ for their scopes, inheritance and limits, and non-executing `query.inspect()`. ```dart final cancel = CancellationToken(); await db.transaction((tx) async { - await tx.users.create(email: 'one@example.com'); + await tx.user.create(email: 'one@example.com'); await tx.savepoint((child) async { - await child.users.create(email: 'two@example.com'); + await child.user.create(email: 'two@example.com'); }); }, acquire: const AcquisitionOptions(timeout: Duration(seconds: 1)), @@ -293,8 +291,8 @@ describe why statement errors alone cannot establish SQLite transaction state. ```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)) + final user = await tx.user.byId(userId).single(); + await tx.user.where((u) => u.id.eq(userId)) .update((u) => [u.nickname.set(user.email)]).execute(); return user.email; }, @@ -347,13 +345,3 @@ 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/doc/flutter.md b/doc/flutter.md index a78e2108..5f7e349e 100644 --- a/doc/flutter.md +++ b/doc/flutter.md @@ -64,27 +64,19 @@ and an exclusive owner; unsupported storage fails explicitly. Deployment base paths, content security policy, advanced resource overrides and browser limits are covered in [SQLite Web](https://github.com/medz/dart-orm/blob/main/doc/sqlite-web.md). -Hot reload retains application state. Release tests do not establish that Flutter -Web hot restart always releases abandoned JS resources. Close a live OPFS session +Hot reload retains application state. Flutter Web hot restart can abandon live +JavaScript resources without releasing the database. Close a live OPFS session or refresh the page when restarting. Query subscriptions cover known committed writes; another tab or independent connection needs [explicit invalidation](https://github.com/medz/dart-orm/blob/main/doc/watch.md). -## Verify upgrades and lifecycle - -The repository [Flutter example](https://github.com/medz/dart-orm/blob/main/example/flutter/README.md) includes native and -browser runners. Its Android workflow installs a legacy debug APK, upgrades it to -a release AOT APK with retained application data, then force-stops and reopens it -in another process. Assertions cover fixed migration history, preserved rows, -new defaults and relationships, generated writes, rollback, subscriptions, -read-only reopening, worker progress and supported cancellation. - -Run `tool/test_flutter.dart` with a dedicated emulator and the two built APKs as -shown in the example instructions. It refuses an existing acceptance installation -and writes reports, logs and a screenshot under `.dart_tool/flutter/`. The -Web runner is `dart run tool/test_flutter_web.dart /absolute/path/to/flutter`. - -Animation and timer progress while SQL is running checks worker separation. It -is not a frame-rate measurement. Emulator process restart does not simulate -power loss, and Android checks do not certify iOS, macOS Flutter or physical -devices. [Progress](https://github.com/medz/dart-orm/blob/main/doc/progress.md) records the tested revision and current validation -status; [capabilities](https://github.com/medz/dart-orm/blob/main/doc/capabilities.md) describes driver-specific limits. +## Upgrades and lifecycle + +Apply saved migrations when opening the application database. Test upgrades using +an existing database with representative data, including a process restart after +migration. Close the database when its application owner shuts down; do not open +another instance for each widget rebuild. + +The [Flutter example](https://github.com/medz/dart-orm/blob/main/example/flutter/README.md) +includes upgrade and restart instructions. See +[capabilities](https://github.com/medz/dart-orm/blob/main/doc/capabilities.md) for +platform support and driver limits. diff --git a/doc/generation.md b/doc/generation.md index 29debd22..a8d88a1e 100644 --- a/doc/generation.md +++ b/doc/generation.md @@ -6,16 +6,15 @@ 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 +Generated clients contain nominal row classes and an immutable `appSchema`. +Declaration values remain in their source libraries. 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](https://github.com/medz/dart-orm/blob/main/doc/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 +member collisions such as a model called `close` fail here; keep the physical +name with the first `model` argument when renaming its Dart declaration. Database-specific computed and identifier restrictions are checked when selecting the migration engine. ## Standalone command @@ -75,8 +74,10 @@ 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 +libraries containing or exporting `model` declarations. Referenced models +are included transitively; unrelated imports are not additional schema roots. +Do not select 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. @@ -95,8 +96,8 @@ 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 +changes also regenerate them. Unrelated source file edits do not trigger a build +or modify 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. @@ -112,9 +113,16 @@ 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. +Application encoders, decoders and declaration callbacks are not executed. The +schema reader reads column helper calls, named fields and local selectors. +Relations are a named Record of `references` and `referencedBy` calls. All forward +references are resolved before inverse matching, including across libraries. +Ambiguous or missing inverse mappings fail with source locations. Named target +mappings are validated and canonicalized without executing callbacks. +Source static types preserve domain IDs even when constant evaluation erases an +extension type's representation. Record declaration errors include a diagnostic +code, source URI and line/column; schema-only checks currently run at generation +time, not through a dedicated editor plugin. Generated source stays reviewable and can be committed with the application. Schema snapshot changes are migration inputs, not permission to apply DDL @@ -125,7 +133,7 @@ database changes. Run `dart analyze` after generation so changes to a model, selector or result type are checked in application code. A renamed Dart field can retain its physical -column with `@ColumnName('old_name')`; changing a database name requires an explicit +column with `name: 'old_name'`; changing a database name requires an explicit [reviewed migration](https://github.com/medz/dart-orm/blob/main/doc/migrations.md). The generator reports ORM-specific errors such as a computed expression in a key @@ -133,40 +141,3 @@ selector. These can be valid Dart expressions, so editor diagnostics alone do no replace a generation run. Keep `build_runner watch` active while editing schemas. Treat generated files as outputs: make edits in the declaration, regenerate, then repair affected application references with the type checker. - -## Measure generation and editor cost - -From an ORM repository checkout: - -```sh -dart run tool/benchmark_generation.dart -dart run tool/benchmark_editor.dart --smoke -dart run tool/benchmark_editor.dart -``` - -The generation benchmark creates disposable projects with 10, 100 and 1000 -four-field Record models. It measures first and unchanged builds, watcher startup, -field and imported-metadata edits, and analysis. It also checks that an unrelated -edit leaves output timestamps unchanged. The report is written to -`.dart_tool/benchmarks/generation.json`; a positional argument chooses another -report path. - -The editor benchmark drives the installed Dart Analysis Server over stdio LSP. -It checks completion labels, deliberate type errors with exact diagnostic ranges, -source edits and a final consumer analysis. Full results go to -`.dart_tool/benchmarks/editor.json`. `--smoke` uses ten models and two warm samples, -writing `.dart_tool/benchmarks/editor-smoke.json`; it checks the harness rather -than establishing performance. `--same-session` retains the server across error -recovery and rename probes; its separate report includes `-recovery` in the name. - -Generation timing includes process startup where applicable, excludes dependency -downloads, and uses warm shared SDK/pub caches. Editor measurements include protocol -transport and JSON processing, but exclude GUI rendering and editor extensions. -Keep startup, analysis readiness, first completion and warm completion separate. -A successful rename probe does not guarantee every IDE workflow; Record field -renaming depends on the Dart SDK. Always regenerate and analyze after refactoring. - -Run these tools independently of other builds with source and SDK versions fixed. -Read sample counts, conditions and errors in the produced report before comparing -results. The build/watch regression suites also exercise imported metadata, -prior-builder output, error recovery and generated-file cleanup. diff --git a/doc/importing.md b/doc/importing.md index dfc08ae9..86e538e4 100644 --- a/doc/importing.md +++ b/doc/importing.md @@ -43,16 +43,17 @@ missing targets and temporary objects shadowing selected tables produce issues. ## Declarations and reports +Catalog import emits `model(...)` declarations using the same column helpers, +keys and relationships as a handwritten schema: + ```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); +final accounts = model('accounts', ( + id: integer(name: 'id').identity(), + email: text(name: 'email'), + displayName: text(name: 'display_name').nullable(), +), uniqueKeys: (a) => [a.email]); ``` Every table and field retains an explicit physical name. Dart keywords, punctuation, @@ -65,7 +66,7 @@ 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](https://github.com/medz/dart-orm/blob/main/doc/mysql.md). -Ordinary enforced [CHECK constraints](https://github.com/medz/dart-orm/blob/main/doc/checks.md) become `.check(...)` declarations +Ordinary enforced [CHECK constraints](https://github.com/medz/dart-orm/blob/main/doc/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. @@ -92,7 +93,7 @@ rows or a column name. |---|---|---| | 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 | +| TEXT COLLATE orm_decimal_v1, optional precision CHECK | NUMERIC, NUMERIC(p,s) | Decimal, optional precision and scale | | REAL | DOUBLE PRECISION | double | | BLOB | BYTEA | Uint8List | | — | BOOLEAN | bool | @@ -106,7 +107,7 @@ 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; +`decimal(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 @@ -114,8 +115,8 @@ need explicit support. See [decimal boundaries](https://github.com/medz/dart-orm/blob/main/doc/decimals.md) before reviewing a draft. SQLite rowid integer primary keys and PostgreSQL BY DEFAULT integer primary-key -identities get `@Id.generated()`. [Computed expressions](https://github.com/medz/dart-orm/blob/main/doc/computed.md) become -`@Computed.sql` with their stored/virtual mode and an `IMPORT.COMPUTED_SQL` +identities get `.identity()`. [Computed expressions](https://github.com/medz/dart-orm/blob/main/doc/computed.md) become +`.computed(...)` 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 @@ -128,7 +129,7 @@ restrictions do not block them. Imported names that collide with Database member 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. +ORM range checks retain their `integer(bits: 16)` or `integer(bits: 32)` declaration. See [integer widths](https://github.com/medz/dart-orm/blob/main/doc/types.md#signed-integer-column-widths) for range enforcement, aggregation and migration behavior. @@ -153,8 +154,8 @@ 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. +settings. Trigger definitions likewise remain unmanaged and are not modified by +import or verification. ## Tooling API diff --git a/doc/migrations.md b/doc/migrations.md index 460d90c9..7ef23ea8 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -89,8 +89,8 @@ 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 +Use the application's database engine when checking migrations. 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. @@ -353,12 +353,8 @@ nontransactional work and includes progress. A `MIGRATION.STEP` exception retain 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 +## Existing databases ```dart final result = await Migrator(db).baseline( diff --git a/doc/mysql-migrations.md b/doc/mysql-migrations.md index acec4861..f55798b9 100644 --- a/doc/mysql-migrations.md +++ b/doc/mysql-migrations.md @@ -121,14 +121,3 @@ 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/doc/mysql.md b/doc/mysql.md index 54329670..29b6a372 100644 --- a/doc/mysql.md +++ b/doc/mysql.md @@ -186,24 +186,3 @@ parameters instead of depending on string-literal escaping. `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 `doc/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/doc/named-sql.md b/doc/named-sql.md index becee610..a1ecb2d4 100644 --- a/doc/named-sql.md +++ b/doc/named-sql.md @@ -1,14 +1,15 @@ # Named SQL queries -Declare parameters and results as Dart records, keep complex SQL in files, and +Declare parameters and results with named Records of column helpers, keep 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( +final authorStats = sqlQuery( + result: (author: text(), postCount: integer(), points: integer()), + parameters: (minimum: integer(), author: text().nullable()), sqlite: 'stats.sqlite.sql', postgres: 'stats.postgres.sql', ); @@ -60,17 +61,19 @@ await db.transaction((tx) async { ``` 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. +default to SQL NULL. Omit `parameters` or use `()` when no parameters are needed. +The result Record generates a nominal class named after the declaration: +`authorStats` produces `AuthorStats`. Import that class from the generated file. + +Use `enumeration(...)` for enum labels and `custom(codec)` for domain values. +Result fields use snake_case SQL aliases by default; `text(name: 'display_name')` +sets an explicit alias. Parameters keep their Dart names. Query columns do not +accept identities, defaults, computed expressions or storage-width constraints. + +Each supplied SQL path enables that database engine. Bindings target `QueryContext`, +so the same API works with a database, transaction or offline `SqlBuilder`. +Binding a query for an engine without a SQL variant fails with `QUERY.DIALECT` +before execution. Database checks validate only the selected engine. ## What the checks establish @@ -79,8 +82,8 @@ 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 +codecs using the `pg_prepared_statements.result_types` catalog column. The checker +requires this catalog metadata. 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 @@ -92,8 +95,8 @@ 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. +MariaDB planning can evaluate stored functions, so ordinary `EXPLAIN` does not +provide the same no-execution guarantee. These checks do not infer expression nullability, integer range, decimal scale, enum labels or custom codec correctness. Integer declarations can accept native @@ -162,10 +165,4 @@ Run `dart run build_runner watch`. Both the Dart library and referenced SQL asse 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. +contain both model and named-query declarations when both builders target it. diff --git a/doc/observability.md b/doc/observability.md index 636550ab..0560582b 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -5,7 +5,7 @@ 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) => ( +final query = db.user.select((u) => ( u.name, u.memberships .orderBy((m) => [m.joinedAt.desc(), m.teamId.desc()]) @@ -113,6 +113,5 @@ Neither a successful SQL event nor a decode error alone establishes a transactio 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](https://github.com/medz/dart-orm/blob/main/doc/acceptance.md). +Use [performance measurements](https://github.com/medz/dart-orm/blob/main/doc/performance.md) +to compare end-to-end latency, query volume and allocation costs in your workload. diff --git a/doc/performance.md b/doc/performance.md index c8b5eebc..178bbf95 100644 --- a/doc/performance.md +++ b/doc/performance.md @@ -1,144 +1,84 @@ -# Runtime cost measurements +# Query performance -Run the same native SQL and result shapes through the public driver and ORM: +Start with the amount of data and work a query requests: selected columns, returned +rows, SQL statements and indexes. Measure on the database and platform your +application uses; the same Dart expression can have different backend costs. -```sh -ORM_TEST_POSTGRES='postgresql://localhost/orm_bench' dart run tool/benchmark_runtime.dart +## Select only what you need + +A projection selects and decodes only its requested values: + +```dart +final people = await db.employee + .orderBy((e) => [e.id.asc()]) + .take(50) + .select((e) => (e.id, e.name).row) + .get(); +``` + +Loading a complete row also transfers and decodes columns your view may never use. +This matters especially for large text, JSON and binary values. `get()` materializes +the result; use pagination or streaming when callers can process bounded chunks. +Keyset pagination needs a deterministic unique tie breaker. See +[queries](https://github.com/medz/dart-orm/blob/main/doc/queries.md) and +[streaming](https://github.com/medz/dart-orm/blob/main/doc/execution.md). + +## Count relationship statements + +Load related values in the selection rather than making one request per row. +A to-one relation whose keys prove uniqueness can join the root statement. +Collections batch parent keys into child queries; nested collections add loading +stages, and driver parameter limits can split batches. + +```dart +final query = db.employee.select((e) => ( + e.name, + e.manager.select((m) => m.name).one(), +).row); +print(query.inspect().toJson()); ``` -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/benchmarks/runtime-smoke.json`; it validates the harness and is not -a performance result. The normal run writes `.dart_tool/benchmarks/runtime.json`. -Use `--output ` to retain separate runs, for example -`--output .dart_tool/benchmarks/runtime-after.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](https://github.com/medz/dart-orm/blob/main/example/schema.dart). - -| Case | Result | Expected SQL result volume | -| --- | --- | --- | -| `full_rows` | 100 complete user results; 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. - -## Interpret a comparison - -Compare exact SQL, bound parameters, statement counts and complete business -results before comparing durations. A faster run with fewer returned rows or a -different transaction boundary is a different workload. - -Use absolute overhead, selected bytes, actual statement counts and measured lease -waits together. A short local query can have a large percentage overhead while -remaining short in absolute time. With network delay, protocol round trips can -dominate; selecting fewer columns does not eliminate those round trips. Allocation -traces can locate intermediate collections, but do not establish retained memory -or application throughput. - -Retain the report's runtime revision, SDK, OS/CPU, database versions, fixture, -sample counts and harness hashes. Repeat runs to assess load, scheduling and JIT -variation. If raw-driver timings or calibrated relay latency also change, do not -attribute the entire difference to the ORM. A native JIT result says nothing about -AOT, browser or Flutter performance without a corresponding workload there. - -Typed selection decodes values into the requested scalar, Record, DTO or dynamic -Map. Relationship expansion preserves driver-row ownership. Any optimization must -retain mapping order, decoder failures, transaction boundaries, cursor cleanup -and observation events. Statement reuse must also preserve protocol disposal and -connection ownership; omitting cleanup is not a valid cache. - -See [observability](https://github.com/medz/dart-orm/blob/main/doc/observability.md) for the event timing scopes and -[acceptance](https://github.com/medz/dart-orm/blob/main/doc/acceptance.md) for correctness checks that accompany performance work. +`inspect()` describes planned SQL without executing it. Use `onQuery` to count +statements actually executed, including child batches. `onAcquire` and `onDecode` +help separate connection waiting and decoding costs. Their scopes do not add up +to every part of application latency; see +[observability](https://github.com/medz/dart-orm/blob/main/doc/observability.md). + +## Index for access patterns + +Declare indexes for frequently combined filters, joins and ordering: + +```dart +indexes: (e) => [ + index((e.departmentId, e.id), name: 'employees_department_id'), +], +``` + +Primary and unique keys already create database access paths. Relationships do +not automatically add indexes for foreign-key columns. Inspect the database query +plan before adding an index; indexes cost storage and work on every affected write. +Use reviewed migrations to add or change them. + +## Keep work bounded + +Keep transactions short and avoid waiting for unrelated network requests while +holding a database connection. Batch compatible writes while preserving the +required transaction boundary. Bound result sizes, relation pagination and retry +budgets. A timeout does not prove a submitted write rolled back. + +Custom decoders, result mappers and observation callbacks run in Dart. Keep them +small; background database execution does not move application callbacks off the +caller's isolate. Exact decimal operations should be measured with representative +coefficient sizes and scales. + +## Compare equivalent workloads + +Compare complete results, SQL, bound parameters, statement counts and transaction +boundaries before latency. Record absolute time as well as percentage differences. +A local single-client benchmark does not predict remote, concurrent, browser or +Flutter performance. + +Measure on representative data with the same SDK, engine configuration and query +shape. Separate startup from steady-state work, run enough samples to see variation, +and avoid profiling allocations during ordinary latency measurements. Repository +contributors can use the [benchmark tools](https://github.com/medz/dart-orm/blob/main/CONTRIBUTING.md#runtime-benchmarks). diff --git a/doc/progress.md b/doc/progress.md deleted file mode 100644 index de6255cb..00000000 --- a/doc/progress.md +++ /dev/null @@ -1,78 +0,0 @@ -# Implementation status - -## Completed refactor - -The entry APIs, explicit exports, internal module structure, public comments and -guides now form independent Dart libraries. There are no `part` or `part of` -directives in the package, examples, tools or tests. Typed declarations, -engine-specific Dart migration histories and independently usable layers remain -the foundation. - -- Implementation files are grouped under values, schema, query, runtime, ORM, - drivers, generation, migration and CLI responsibilities. Public entrypoints - explicitly select their exports; internal compiler and runtime bridges stay - outside the public namespace and generated API reference. -- `first()` requires a row. `firstOrNull()` and `singleOrNull()` explicitly permit - an empty result, including write-returning queries. A selected SQL NULL remains - a valid result. Cardinality checks on writes happen after execution; use a - transaction when an error must roll back the write. -- Schema declarations and physical metadata have separate exports. MySQL and - MariaDB have separate driver entrypoints. Existing connection factories remain - the direct way to open each engine. -- Public API comments explain ownership, errors and engine boundaries. Dartdoc - categories connect API pages to guides, with canonical library ownership and - CI checks for broken links and ambiguous exports. -- Archived exploration artifacts are removed. Benchmark and Flutter report - defaults use ignored `.dart_tool/benchmarks/` and `.dart_tool/flutter/` paths. - -## Published baseline - -`6.0.0-beta.1` is the released baseline at `5e4854df`, merged through -[PR #486](https://github.com/medz/dart-orm/pull/486). Its release CI passed 1141 native -tests with 41 capability skips and Chrome JS/WASM checks. SQLite, PostgreSQL, -MySQL 8.4 and MariaDB 11.8 formed the real-database scope. Flutter Web release -configurations and Android APK upgrade/restart were checked separately. - -The [package release](https://pub.dev/packages/orm/versions/6.0.0-beta.1) remains -immutable. Unpublished refactoring does not change that archive. Platform and -engine restrictions remain documented in [capabilities](https://github.com/medz/dart-orm/blob/main/doc/capabilities.md). - -## Refactor validation - -Validated locally with Dart 3.13.3, Flutter 3.47.4 and Chrome 153: - -- Package analysis and formatting passed; the Flutter example also passed - `flutter analyze`. AST checks reject `part` directives and enforce layer - boundaries. Public namespace checks verify explicit exports. -- The complete native test run used real SQLite, PostgreSQL 18, MySQL 8.4 and - MariaDB 11.8: 1209 passed, 8 capability skips and 2 failures. Both failures were - the SQLite/PostgreSQL executions of an old upsert fixture expecting nullable - `first()`. After migrating it to `firstOrNull()`, the complete database, - cardinality, migration, export and layer suites passed again: 114 tests, zero - failures. These are separate runs, not a single all-green full-suite run. -- The native run includes generation, CLI, migration recovery, transactions and - database integration suites. The dedicated cardinality suite covers empty - results, selected NULLs, limits, write-returning behavior and rollback. -- Chrome JS and WASM each passed 21 browser scenarios. Flutter Web passed the - same 21 scenarios in JS, WASM without isolation and WASM with isolation. These - include OPFS persistence, reload recovery, nested-route asset resolution, - transactions, worker responsiveness and real Flutter frame progress. -- The main example ran successfully. The query cookbook passed all 9 checks on - both SQLite and PostgreSQL. Committed SQLite worker/WASM fingerprints match - the refactored source. -- `dart doc --validate-links` generated 21 public libraries with zero warnings - and zero errors. The generated index was checked for canonical API ownership - and absence of internal compiler/session helpers. -- Publication dry-run inspected the package archive, excluding generated docs, - local caches, repository test tools and removed artifacts. After committing, - `dart pub publish --dry-run` passed with zero warnings. - -Android, iOS, native macOS/Windows/Linux Flutter applications and non-Chrome -browsers were not rerun for this refactor. Earlier Android release evidence -belongs to the published baseline. Local logs and browser reports live under -ignored `.dart_tool/` directories; they are not published package contents. - -The refactor is prepared for `6.0.0-beta.2`. Publication follows Codex review and -CI approval of the release PR. The beta.1 archive above is unchanged. - -See [validation](https://github.com/medz/dart-orm/blob/main/doc/acceptance.md) and [contributing](https://github.com/medz/dart-orm/blob/main/doc/contributing.md) for commands. diff --git a/doc/queries.md b/doc/queries.md index f032df44..624125a7 100644 --- a/doc/queries.md +++ b/doc/queries.md @@ -20,7 +20,7 @@ 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')); +var query = db.user.where((u) => u.email.like('%@example.com')); if (minimumScore != null) { query = query.where((u) => u.score.gte(minimumScore)); } @@ -57,15 +57,15 @@ Choose distinct root-table columns with a declared unique tie breaker. Nullable columns require explicit NULL ordering: ```dart -final first = await db.users +final first = await db.user .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) => [ + final token = db.user.cursorToken((u) => [ u.nickname.cursor(last.nickname, nulls: .last), u.id.cursor(last.id), ]); - final next = await db.users.seekToken(token, + final next = await db.user.seekToken(token, orderBy: (u) => [u.nickname.asc(nulls: .last), u.id.asc()], ).take(20).get(); } @@ -85,7 +85,7 @@ projection. This example uses the [generated example schema](https://github.com/ ```dart final author = usersTable.alias(); -final rows = await db.posts +final rows = await db.post .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) @@ -99,21 +99,21 @@ 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 +`db.user.orderBy((u) => [u.posts.count().desc(), u.id.asc()])`. This compiles a correlated count without loading posts. Normal nested results still use [relationship selections](https://github.com/medz/dart-orm/blob/main/doc/relations.md) and their explicit loading strategies. ## Grouping, subqueries, CTEs and windows ```dart -final totals = db.posts.groupBy((p) => [p.authorId]) +final totals = db.post.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()]) +final ranks = await db.post.orderBy((p) => [p.id.asc()]) .select((p) => (p.id, rowNumber( partitionBy: [p.authorId], orderBy: [p.createdAt.desc(), p.id.desc()], )).row).get(); @@ -141,12 +141,12 @@ application; do not interpolate user input into them. 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 names = db.user.select((u) => u.email) + .union(db.post.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)) +final rows = db.user.select((u) => (u.id, u.email).row) + .unionAll(db.post.select((p) => (p.id, p.title).row)) .orderBy((row) => [ row.ref((u) => u.id).asc(), row.ref((u) => u.email).asc(), @@ -181,8 +181,8 @@ 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())); +final upper = db.user.select((u) => u.email.map((v) => v.toUpperCase())); +final lower = db.post.select((p) => p.title.map((v) => v.toLowerCase())); // upper.union(lower) throws QUERY.UNION_SELECTION. ``` @@ -204,9 +204,9 @@ 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) +final recent = db.user.orderBy((u) => [u.id.desc()]).take(10) .select((u) => u.email) - .unionAll(db.posts.orderBy((p) => [p.id.desc()]).take(10) + .unionAll(db.post.orderBy((p) => [p.id.desc()]).take(10) .select((p) => p.title)); final filtered = recent.where((row) => row.ref((u) => u.email).like('a%')); diff --git a/doc/relations.md b/doc/relations.md index 30b25521..9e17d8fc 100644 --- a/doc/relations.md +++ b/doc/relations.md @@ -1,10 +1,24 @@ # Relationship queries +Declare relation members in your model's `relations` Record. Forward references +own the foreign key; reverse references belong to the model exposing them: + +```dart +final Model user = model('users', (id: identity(), email: text()), + relations: (u) => (posts: referencedBy(() => post),)); + +final post = model('posts', (id: identity(), title: text(), authorId: integer()), + relations: (p) => (author: references(p.authorId, () => user),)); +``` + +See [schema declarations](https://github.com/medz/dart-orm/blob/main/doc/authoring.md#keys-and-relationships) +for self references, composite keys and multiple references between the same models. + 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) => ( +final cards = await db.post.select((p) => ( p.title, p.author.select((a) => (a.id, a.email) .map((id, email) => (id: id, email: email))).required(), @@ -13,76 +27,76 @@ final cards = await db.posts.select((p) => ( ## 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()`: +Use `references(..., constraint: false)` for read-only navigation when the +database does not enforce a foreign key: ```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'); +final Model account = model('accounts', ( + tenant: integer(), id: integer(), label: text().nullable(), +), primaryKey: (a) => (a.tenant, a.id), + relations: (a) => (entries: referencedBy(() => entry),)); + +final entry = model('entries', ( + id: identity(), tenant: integer().nullable(), owner: integer().nullable(), +), relations: (e) => ( + ownerAccount: references((e.tenant, e.owner), () => account, constraint: false), +)); ``` -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. +`e.ownerAccount.one()` returns an optional `Account`; `a.entries.many()` returns +`Entry` rows. These members support the same projections, filters, pagination, +transactions, streams and subscriptions as constrained references. + +Matching rows may be missing, and lookup keys may be nonunique. No unique +constraint or index is inferred. A declared primary/unique key permits automatic +to-one JOIN loading; otherwise `one()` uses a batch and checks cardinality. +`required()` reports missing matches. SQL equality does not match a composite key +containing NULL. Source and target keys must have matching types and codecs. + +Write stored key values through the ordinary table API. A read-only relationship +provides no cascade, automatic connect/disconnect or existence check. Deleting a +target can leave dangling references. `watch()` tracks the tables read by the +selection without introducing foreign-key write effects. + +Adding or renaming read-only navigation does not alter the physical snapshot. +Changing a constrained reference to `constraint: false` removes its foreign key +and requires a reviewed migration. Adding the constraint back can fail on dangling +data. Catalog import cannot discover these navigation rules; declare them yourself. +Relationships support same-database key equality, including self references. ## Many-to-many with business fields -An explicit association table gives each membership its own role and joining time. -The [teams example](https://github.com/medz/dart-orm/blob/main/example/teams/schema.dart) uses two foreign keys and a -composite primary key, with no artificial membership ID: +An association model can carry a role, joining time or other data belonging to +the relationship: ```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); + +final team = model('teams', (id: identity(), name: text()), + relations: (t) => (memberships: referencedBy(() => membership),)); +final user = model('users', (id: identity(), name: text()), + relations: (u) => (memberships: referencedBy(() => membership),)); +final Model membership = model( + 'memberships', + ( + teamId: integer(), userId: integer(), + role: enumeration(MembershipRole.values, defaultValue: MembershipRole.member), + joinedAt: dateTime(), + ), + primaryKey: (m) => (m.teamId, m.userId), + relations: (m) => ( + team: references(m.teamId, () => team, onDelete: .cascade), + user: references(m.userId, () => user, onDelete: .cascade), + ), + indexes: (m) => [index((m.userId, m.teamId), name: 'memberships_user_team')], +); ``` -`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: +The 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) => ( +final cards = await db.user.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) @@ -99,12 +113,12 @@ Create and update memberships through their ordinary generated table API: ```dart await db.transaction((tx) async { - await tx.memberships.create(teamId: 10, userId: 1, + await tx.membership.create(teamId: 10, userId: 1, role: .set(MembershipRole.owner), joinedAt: DateTime.now()); - await tx.memberships.byId(teamId: 20, userId: 1) + await tx.membership.byId(teamId: 20, userId: 1) .patch(role: .set(MembershipRole.member)); }); -await db.memberships.byId(teamId: 10, userId: 1).delete().execute(); +await db.membership.byId(teamId: 10, userId: 1).delete().execute(); ``` The composite key rejects duplicate memberships. Both endpoints must already @@ -118,33 +132,15 @@ 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 -``` +Collection loading batches parent keys instead of issuing a query for each parent. +Within one parameter batch, the membership-card example uses one root statement +and one child statement; the child joins the team name. An empty root result needs +no child statement. A nested collection adds another loading stage, and parameter +limits can split a stage into multiple statements. -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`. +Use [query inspection and observations](https://github.com/medz/dart-orm/blob/main/doc/observability.md) +to see the actual statements and row counts for your selection. For a complete +association model, see the [company example](https://github.com/medz/dart-orm/blob/main/example/company/README.md). ## Single relationships @@ -187,7 +183,7 @@ 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) => ( +final query = db.post.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))); @@ -203,7 +199,7 @@ fresh getters or select a batch strategy for the separate view. 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) +final users = await db.user.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()) diff --git a/doc/sqlite-web.md b/doc/sqlite-web.md index b144f47e..dcc58059 100644 --- a/doc/sqlite-web.md +++ b/doc/sqlite-web.md @@ -11,7 +11,7 @@ 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'); +final user = await db.user.create(email: 'seven@example.com'); await db.close(); ``` @@ -111,14 +111,13 @@ 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. +the worker. Schema migrations run through the same owned connection. Close the +database before replacing its owner or restarting an active OPFS session. 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 +stability of VFS storage formats. Check storage compatibility when upgrading the +SQLite dependency. The driver uses the package's [dedicated-worker OPFS API](https://pub.dev/documentation/sqlite3/latest/wasm/SimpleOpfsFileSystem-class.html). ## Numeric and temporal boundaries @@ -144,7 +143,7 @@ Blobs use structured-clone typed arrays. BigInt uses tagged decimal text. Decima 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 +decoding avoids a lossy total-microseconds JS number. The codec preserves 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. @@ -169,42 +168,14 @@ observe another origin, tab or independent database connection. Named SQL querie retain their explicit `reads:` requirement. Same-origin persistence, browser storage permission and database ownership are separate from reactive invalidation. -## Reproducible acceptance +## Browser support and restarts -```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. Consult [progress](https://github.com/medz/dart-orm/blob/main/doc/progress.md) for the revision and configurations -most recently verified. - -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. +Chrome JavaScript and WebAssembly are supported configurations. Safari and Firefox +are not verified. There is no IndexedDB fallback or multi-tab sharing service. +For Flutter resource setup, see the [Flutter guide](https://github.com/medz/dart-orm/blob/main/doc/flutter.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; +live JS resources without closing them, is not a supported OPFS lifecycle transition; 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/doc/types.md b/doc/types.md index 252d82b5..151ca00e 100644 --- a/doc/types.md +++ b/doc/types.md @@ -18,15 +18,14 @@ extension type const UserId(int value) { static int _encode(UserId value) => value.value; } -typedef User = ({ - @Id.generated() @UseCodec(UserId.codec) UserId id, - String name, -}); -final users = entity(); +final user = model('users', ( + id: custom(UserId.codec).identity(), + name: text(), +)); ``` -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 +After generation, `db.user.byId(UserId(1))` accepts the domain ID; +`db.user.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 @@ -34,39 +33,38 @@ the encoder's return type. The general `Codec(storage, decode, encode)` accep 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**. +`custom(codec)` 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. +Private codec references and inline codec constructors are rejected. Use +`custom(codec).nullable()` for optional values: SQL `NULL` bypasses a non-nullable +domain decoder. A codec whose Dart value type is nullable also produces a nullable +column. 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](https://github.com/medz/dart-orm/blob/main/test/support/codecs/schema.dart) and its -[types](https://github.com/medz/dart-orm/blob/main/test/support/codecs/types.dart). `Codec.map` remains available when -constructing table definitions directly at runtime. +`Codec.map` remains available when constructing table definitions directly. ## Enums ```dart -enum Membership { - @EnumValue('pending-payment') pending, - active, - @EnumValue('closed') cancelled, -} - -typedef Account = ({ - @Id() int id, - Membership membership, - Membership? previousMembership, -}); +enum Membership { pending, active, cancelled } + +final account = model('accounts', ( + id: identity(), + membership: enumeration(Membership.values, labels: { + Membership.pending: 'pending-payment', + Membership.active: 'active', + Membership.cancelled: 'closed', + }), + previousMembership: enumeration(Membership.values).nullable(), +)); ``` -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 +Enums use text labels. Without a `labels` map, the label is the constant's Dart +name. When supplied, the map must cover every enum constant. 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. @@ -95,10 +93,10 @@ Location decodeLocation(Object? raw) { String encodeLocation(Location value) => jsonEncode({'city': value.city, 'zone': value.zone}); -typedef Place = ({ - @Id() int id, - @UseCodec(locationCodec) Location? location, -}); +final place = model('places', ( + id: identity(), + location: custom(locationCodec).nullable(), +)); ``` Use `Codecs.json.decode(raw)` inside a custom JSON decoder. SQLite supplies JSON @@ -109,10 +107,10 @@ 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, -}); +final event = model('events', ( + id: identity(), + payload: json().nullable(), +)); // In generated create/patch arguments: // null -> SQL NULL @@ -129,8 +127,7 @@ 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. +are not supported as relational keys; use scalar IDs for relationships. ## Physical storage and migrations @@ -157,7 +154,7 @@ 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 +Native enum types remain a backend extension. Browser numeric limits and transport are described in [SQLite web](https://github.com/medz/dart-orm/blob/main/doc/sqlite-web.md). Use `Decimal` for exact decimal data. @@ -187,8 +184,8 @@ UTC. The upper bound follows [Dart DateTime's range](https://api.dart.dev/dart-c 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. +instant decoding rejects them. For browser representation limits, see +[SQLite Web](https://github.com/medz/dart-orm/blob/main/doc/sqlite-web.md#numeric-and-temporal-boundaries). Native drivers advertise `Capabilities.temporal`, covering the local types below and UTC instants. Borrowed PostgreSQL pools must use `postgresTypeRegistry()` and @@ -228,24 +225,21 @@ instants, so the shown conversion preserves values; existing infinities or value 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 [migration fixture](https://github.com/medz/dart-orm/blob/main/test/support/instants/m0001_legacy.dart) -illustrates a fixed timestamp history for upgrade and rollback checks. Plain -unmanaged SQLite TEXT imports as String; the managed instant collation lets the -importer infer DateTime without sampling data. +Unmanaged SQLite TEXT 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(); +final appointment = model('appointments', ( + id: identity(), + day: date(), + time: time(defaultSql: "'12:30'"), + starts: localDateTime().nullable(), +)); // After generation: -await db.appointments.create( +await db.appointment.create( day: LocalDate(2024, 2, 29), time: Change.set(LocalTime(12, 30)), starts: LocalDateTime.parse('2024-02-29 12:30:00.000001'), @@ -318,19 +312,18 @@ 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(); +final event = model('events', ( + id: identity(), + clock: time(precision: 3), + appointment: localDateTime(precision: 3), + occurredAt: dateTime(precision: 0), +)); ``` -`@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. +`time`, `localDateTime` and `dateTime` accept `precision` from 0 through 6, +including on nullable columns. Manual `Column` declarations use +`temporalPrecision: 3`, including for domain codecs with a temporal storage tag. +Date-only and unrelated storage types do not accept temporal precision. Omitting precision retains microseconds. Explicit six and the default have the same canonical snapshot and DDL, preserving existing migration checksums. @@ -346,7 +339,7 @@ SQLite defaults, computed expressions and migration copies use the same register 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. +Default/six-digit columns preserve microseconds without precision rounding. 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): @@ -360,7 +353,7 @@ 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 +final selected = await db.event .select((e) => e.appointment.withPrecision(0)) .get(); ``` @@ -380,28 +373,20 @@ conversion expressions for the selected database. Rounding can merge unique keys 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(); +final counter = model('counters', ( + id: integer(bits: 32).identity(), + small: integer(bits: 16), + optional: integer(bits: 32).nullable(), + total: integer(), +)); ``` -`@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 +`integer(bits: ...)` accepts 16, 32 or 64. Omitting it means 64. For manual +tables, pass `integerBits: 16` to `Column`, including columns with integer-backed +domain codecs. 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 @@ -428,7 +413,7 @@ 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. +`integer(bits: ...)` 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 @@ -438,24 +423,14 @@ retain the width. Changing an identity column's width preserves generation and, 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](https://github.com/medz/dart-orm/blob/main/doc/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. +Native `int` supports signed 64-bit values. Browser `int` values use the +safe-number range; use `BigInt` for wider exact transport. See +[SQLite Web](https://github.com/medz/dart-orm/blob/main/doc/sqlite-web.md) for details. ## Exact decimals -Use `Decimal` for finite base-ten values, including money. Generated fields use -`Codecs.decimal` without an annotation. See [exact decimals](https://github.com/medz/dart-orm/blob/main/doc/decimals.md) for +Use `decimal()` for finite base-ten values, including money. Generated fields +use the `Decimal` Dart type and `Codecs.decimal`. See [exact decimals](https://github.com/medz/dart-orm/blob/main/doc/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/doc/watch.md b/doc/watch.md index 7ee8eec7..6a103b86 100644 --- a/doc/watch.md +++ b/doc/watch.md @@ -4,7 +4,7 @@ committed writes: ```dart -final subscription = db.users +final subscription = db.user .orderBy((u) => [u.id.asc()]) .select((u) => u.email) .watch() @@ -13,7 +13,7 @@ final subscription = db.users onError: (Object error) => print(error), ); -await db.users.create(email: 'seven@example.com'); +await db.user.create(email: 'seven@example.com'); await subscription.cancel(); ``` @@ -32,8 +32,8 @@ 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'); + await tx.user.create(email: 'a@example.com'); + await tx.user.create(email: 'b@example.com'); }); // One combined invalidation after COMMIT succeeds. ``` @@ -45,7 +45,7 @@ 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. +safe. 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 @@ -83,7 +83,7 @@ Add those dependencies explicitly: final postCount = sql( ['(SELECT COUNT(*) FROM posts)'], [], Codecs.integer, ); -final counts = db.users.select((_) => postCount).watch(reads: [postsSchema]); +final counts = db.user.select((_) => postCount).watch(reads: [postsSchema]); ``` Raw commands can declare tables they affect: @@ -136,16 +136,3 @@ 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](https://github.com/medz/dart-orm/blob/main/doc/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/company/README.md b/example/company/README.md new file mode 100644 index 00000000..5bb340ca --- /dev/null +++ b/example/company/README.md @@ -0,0 +1,38 @@ +# Company schema + +This example defines departments, employees, projects and project memberships in +one [Record schema](schema.dart). It generates immutable model classes, typed +queries and an independent migration snapshot. + +| Model | Relationships | +| --- | --- | +| Department | Has employees | +| Employee | Belongs to a department; optionally reports to another employee | +| Project | Has an employee owner and project memberships | +| ProjectMember | Connects one employee to one project, with a role and joining time | + +Each model declares its own query members in `relations`. `references` owns a +foreign key; `referencedBy` exposes its reverse. The membership's composite primary +key prevents an employee from joining the same project twice. + +Deleting a manager clears their reports' `managerId`. Deleting a project removes +its memberships. Employees who still own projects cannot be deleted; departments +with employees cannot be deleted either. + +From the repository root, generate the client and run the example: + +```sh +dart pub get +dart run bin/orm.dart generate example/company/schema.dart +dart run example/company/main.dart +``` + +The program creates an in-memory SQLite database, applies a migration, creates +Alice and Bob in one transaction, and selects Bob's manager: + +```text +Bob reports to Alice +``` + +See [schema declarations](../../doc/authoring.md) for columns and constraints, and +[relationship queries](../../doc/relations.md) for selecting related values. diff --git a/example/company/main.dart b/example/company/main.dart new file mode 100644 index 00000000..6d679f9e --- /dev/null +++ b/example/company/main.dart @@ -0,0 +1,34 @@ +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_company', appSchema, dialect: db.dialect)]); + final worker = await db.transaction((tx) async { + final team = await tx.department.create(name: 'Engineering'); + final manager = await tx.employee.create( + name: 'Alice', + email: 'alice@example.com', + departmentId: team.id, + ); + return tx.employee.create( + name: 'Bob', + email: 'bob@example.com', + departmentId: team.id, + managerId: manager.id, + ); + }); + final String? managerName = await db.employee + .byId(worker.id) + .select((e) => e.manager.select((m) => m.name).one()) + .single(); + print('${worker.name} reports to $managerName'); + } finally { + await db.close(); + } +} diff --git a/example/company/schema.dart b/example/company/schema.dart new file mode 100644 index 00000000..73bec97e --- /dev/null +++ b/example/company/schema.dart @@ -0,0 +1,78 @@ +import 'package:orm/schema.dart'; + +enum ProjectStatus { draft, active, archived } + +enum MemberRole { maintainer, member } + +// 部门 +final department = model('departments', ( + id: identity(), + name: text(unique: true), +), relations: (d) => (employees: referencedBy(() => employee))); + +// 员工:所属部门与直属上级 +final Model employee = model( + 'employees', + ( + id: identity(), + name: text(), + email: text(unique: true), + active: boolean(defaultValue: true), + createdAt: dateTime(clientDefault: DateTime.now), + departmentId: integer(), + managerId: integer().nullable(), + ), + relations: (e) => ( + department: references(e.departmentId, () => department), + manager: references(e.managerId, () => employee, onDelete: .setNull), + directReports: referencedBy(() => employee), + ownedProjects: referencedBy(() => project), + memberships: referencedBy(() => projectMember), + ), + indexes: (e) => [ + index((e.departmentId, e.id), name: 'employees_department_id'), + index((e.managerId, e.id), name: 'employees_manager_id'), + ], +); + +// 项目:负责人及项目状态 +final Model project = model( + 'projects', + ( + id: identity(), + name: text(), + status: enumeration( + ProjectStatus.values, + defaultValue: ProjectStatus.draft, + ), + createdAt: dateTime(clientDefault: DateTime.now), + ownerId: integer(), + ), + relations: (p) => ( + owner: references(p.ownerId, () => employee), + members: referencedBy(() => projectMember), + ), + indexes: (p) => [index((p.ownerId, p.id), name: 'projects_owner_id')], +); + +// 项目成员:员工与项目的多对多关系,包含角色和加入时间 +final projectMember = model( + 'project_members', + ( + projectId: integer(), + employeeId: integer(), + role: enumeration(MemberRole.values, defaultValue: MemberRole.member), + joinedAt: dateTime(clientDefault: DateTime.now), + ), + primaryKey: (m) => (m.projectId, m.employeeId), + relations: (m) => ( + project: references(m.projectId, () => project, onDelete: .cascade), + employee: references(m.employeeId, () => employee, onDelete: .cascade), + ), + indexes: (m) => [ + index(( + m.employeeId, + m.projectId, + ), name: 'project_members_employee_project'), + ], +); diff --git a/example/company/schema.orm.dart b/example/company/schema.orm.dart new file mode 100644 index 00000000..a45a32bc --- /dev/null +++ b/example/company/schema.orm.dart @@ -0,0 +1,509 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. + +import 'package:orm/sql.dart'; + +import "schema.dart" as models; +export "schema.dart" show ProjectStatus, MemberRole; + +/// A complete immutable row from "departments". +final class Department({required final int id, required final String name}); +final _departmentId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _departmentName = Column( + "name", + Codecs.text, + nullable: false, + generated: false, +); +final departmentSchema = TableSchema( + "departments", + columns: [_departmentId, _departmentName], + primaryKey: ["id"], + uniqueKeys: [ + ["name"], + ], + indexes: [], + foreignKeys: [], +); + +final class DepartmentFields extends Fields { + DepartmentFields(super.table); + late final id = column(_departmentId); + late final name = column(_departmentName); + Relation get employees => + Relation(employeeTable, parent: [id], child: (row) => [row.departmentId]); +} + +final departmentTable = Table( + departmentSchema, + DepartmentFields.new, + (row) => (row.id, row.name).map((v0, v1) => Department(id: v0, name: v1)), +); + +final class DepartmentTableSet extends TableSet { + DepartmentTableSet(QueryContext db) : super(db, departmentTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required String name, + }) => createRow((row) => [...row.id.change(id), row.name.set(name)]); + Query byId(int id) => + where((row) => row.id.eq(id)); +} + +extension DepartmentUpdates on Query { + Future patch({Change name = const Change.keep()}) => + update((row) => [...row.name.change(name)]).execute(); +} + +/// A complete immutable row from "employees". +final class Employee({ + required final int id, + required final String name, + required final String email, + required final bool active, + required final DateTime createdAt, + required final int departmentId, + required final int? managerId, +}); +final _employeeId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _employeeName = Column( + "name", + Codecs.text, + nullable: false, + generated: false, +); +final _employeeEmail = Column( + "email", + Codecs.text, + nullable: false, + generated: false, +); +final _employeeActive = Column( + "active", + Codecs.boolean, + nullable: false, + generated: false, + defaultSql: "true", +); +final _employeeCreatedAt = Column( + "created_at", + Codecs.dateTime, + nullable: false, + generated: false, + clientDefault: DateTime.now, +); +final _employeeDepartmentId = Column( + "department_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _employeeManagerId = Column( + "manager_id", + Codecs.integer.nullable(), + nullable: true, + generated: false, +); +final employeeSchema = TableSchema( + "employees", + columns: [ + _employeeId, + _employeeName, + _employeeEmail, + _employeeActive, + _employeeCreatedAt, + _employeeDepartmentId, + _employeeManagerId, + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + indexes: [ + IndexSchema("employees_department_id", [ + "department_id", + "id", + ], unique: false), + IndexSchema("employees_manager_id", ["manager_id", "id"], unique: false), + ], + foreignKeys: [ + ForeignKey(["department_id"], "departments", ["id"], onDelete: "RESTRICT"), + ForeignKey(["manager_id"], "employees", ["id"], onDelete: "SET NULL"), + ], +); + +final class EmployeeFields extends Fields { + EmployeeFields(super.table); + late final id = column(_employeeId); + late final name = column(_employeeName); + late final email = column(_employeeEmail); + late final active = column(_employeeActive); + late final createdAt = column(_employeeCreatedAt); + late final departmentId = column(_employeeDepartmentId); + late final managerId = column(_employeeManagerId); + Relation get department => Relation( + departmentTable, + parent: [departmentId], + child: (row) => [row.id], + ); + Relation get manager => + Relation(employeeTable, parent: [managerId], child: (row) => [row.id]); + Relation get directReports => + Relation(employeeTable, parent: [id], child: (row) => [row.managerId]); + Relation get ownedProjects => + Relation(projectTable, parent: [id], child: (row) => [row.ownerId]); + Relation get memberships => Relation( + projectMemberTable, + parent: [id], + child: (row) => [row.employeeId], + ); +} + +final employeeTable = Table( + employeeSchema, + EmployeeFields.new, + (row) => + ( + (row.id, row.name, row.email, row.active, row.createdAt).map( + (id, name, email, active, createdAt) => ( + id: id, + name: name, + email: email, + active: active, + createdAt: createdAt, + ), + ), + (row.departmentId, row.managerId).map( + (departmentId, managerId) => + (departmentId: departmentId, managerId: managerId), + ), + ).map( + (left, right) => Employee( + id: left.id, + name: left.name, + email: left.email, + active: left.active, + createdAt: left.createdAt, + departmentId: right.departmentId, + managerId: right.managerId, + ), + ), +); + +final class EmployeeTableSet extends TableSet { + EmployeeTableSet(QueryContext db) : super(db, employeeTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required String name, + required String email, + Change active = const Change.keep(), + Change createdAt = const Change.keep(), + required int departmentId, + int? managerId, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.name.set(name), + row.email.set(email), + ...row.active.change(active), + ...row.createdAt.change(createdAt), + row.departmentId.set(departmentId), + row.managerId.set(managerId), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension EmployeeUpdates on Query { + Future patch({ + Change name = const Change.keep(), + Change email = const Change.keep(), + Change active = const Change.keep(), + Change createdAt = const Change.keep(), + Change departmentId = const Change.keep(), + Change managerId = const Change.keep(), + }) => update( + (row) => [ + ...row.name.change(name), + ...row.email.change(email), + ...row.active.change(active), + ...row.createdAt.change(createdAt), + ...row.departmentId.change(departmentId), + ...row.managerId.change(managerId), + ], + ).execute(); +} + +/// A complete immutable row from "projects". +final class Project({ + required final int id, + required final String name, + required final models.ProjectStatus status, + required final DateTime createdAt, + required final int ownerId, +}); +final _projectId = Column( + "id", + Codecs.integer, + nullable: false, + generated: true, +); +final _projectName = Column( + "name", + Codecs.text, + nullable: false, + generated: false, +); +final _projectStatus = Column( + "status", + Codecs.enumeration({ + models.ProjectStatus.draft: "draft", + models.ProjectStatus.active: "active", + models.ProjectStatus.archived: "archived", + }), + nullable: false, + generated: false, + defaultSql: "'draft'", +); +final _projectCreatedAt = Column( + "created_at", + Codecs.dateTime, + nullable: false, + generated: false, + clientDefault: DateTime.now, +); +final _projectOwnerId = Column( + "owner_id", + Codecs.integer, + nullable: false, + generated: false, +); +final projectSchema = TableSchema( + "projects", + columns: [ + _projectId, + _projectName, + _projectStatus, + _projectCreatedAt, + _projectOwnerId, + ], + primaryKey: ["id"], + uniqueKeys: [], + indexes: [ + IndexSchema("projects_owner_id", ["owner_id", "id"], unique: false), + ], + foreignKeys: [ + ForeignKey(["owner_id"], "employees", ["id"], onDelete: "RESTRICT"), + ], +); + +final class ProjectFields extends Fields { + ProjectFields(super.table); + late final id = column(_projectId); + late final name = column(_projectName); + late final status = column(_projectStatus); + late final createdAt = column(_projectCreatedAt); + late final ownerId = column(_projectOwnerId); + Relation get owner => + Relation(employeeTable, parent: [ownerId], child: (row) => [row.id]); + Relation get members => Relation( + projectMemberTable, + parent: [id], + child: (row) => [row.projectId], + ); +} + +final projectTable = Table( + projectSchema, + ProjectFields.new, + (row) => (row.id, row.name, row.status, row.createdAt, row.ownerId).map( + (v0, v1, v2, v3, v4) => + Project(id: v0, name: v1, status: v2, createdAt: v3, ownerId: v4), + ), +); + +final class ProjectTableSet extends TableSet { + ProjectTableSet(QueryContext db) : super(db, projectTable) { + db.registerSchema(appSchema); + } + Future create({ + Change id = const Change.keep(), + required String name, + Change status = const Change.keep(), + Change createdAt = const Change.keep(), + required int ownerId, + }) => createRow( + (row) => [ + ...row.id.change(id), + row.name.set(name), + ...row.status.change(status), + ...row.createdAt.change(createdAt), + row.ownerId.set(ownerId), + ], + ); + Query byId(int id) => where((row) => row.id.eq(id)); +} + +extension ProjectUpdates on Query { + Future patch({ + Change name = const Change.keep(), + Change status = const Change.keep(), + Change createdAt = const Change.keep(), + Change ownerId = const Change.keep(), + }) => update( + (row) => [ + ...row.name.change(name), + ...row.status.change(status), + ...row.createdAt.change(createdAt), + ...row.ownerId.change(ownerId), + ], + ).execute(); +} + +/// A complete immutable row from "project_members". +final class ProjectMember({ + required final int projectId, + required final int employeeId, + required final models.MemberRole role, + required final DateTime joinedAt, +}); +final _projectMemberProjectId = Column( + "project_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _projectMemberEmployeeId = Column( + "employee_id", + Codecs.integer, + nullable: false, + generated: false, +); +final _projectMemberRole = Column( + "role", + Codecs.enumeration({ + models.MemberRole.maintainer: "maintainer", + models.MemberRole.member: "member", + }), + nullable: false, + generated: false, + defaultSql: "'member'", +); +final _projectMemberJoinedAt = Column( + "joined_at", + Codecs.dateTime, + nullable: false, + generated: false, + clientDefault: DateTime.now, +); +final projectMemberSchema = TableSchema( + "project_members", + columns: [ + _projectMemberProjectId, + _projectMemberEmployeeId, + _projectMemberRole, + _projectMemberJoinedAt, + ], + primaryKey: ["project_id", "employee_id"], + uniqueKeys: [], + indexes: [ + IndexSchema("project_members_employee_project", [ + "employee_id", + "project_id", + ], unique: false), + ], + foreignKeys: [ + ForeignKey(["project_id"], "projects", ["id"], onDelete: "CASCADE"), + ForeignKey(["employee_id"], "employees", ["id"], onDelete: "CASCADE"), + ], +); + +final class ProjectMemberFields extends Fields { + ProjectMemberFields(super.table); + late final projectId = column(_projectMemberProjectId); + late final employeeId = column(_projectMemberEmployeeId); + late final role = column(_projectMemberRole); + late final joinedAt = column(_projectMemberJoinedAt); + Relation get project => + Relation(projectTable, parent: [projectId], child: (row) => [row.id]); + Relation get employee => + Relation(employeeTable, parent: [employeeId], child: (row) => [row.id]); +} + +final projectMemberTable = Table( + projectMemberSchema, + ProjectMemberFields.new, + (row) => (row.projectId, row.employeeId, row.role, row.joinedAt).map( + (v0, v1, v2, v3) => + ProjectMember(projectId: v0, employeeId: v1, role: v2, joinedAt: v3), + ), +); + +final class ProjectMemberTableSet + extends TableSet { + ProjectMemberTableSet(QueryContext db) : super(db, projectMemberTable) { + db.registerSchema(appSchema); + } + Future create({ + required int projectId, + required int employeeId, + Change role = const Change.keep(), + Change joinedAt = const Change.keep(), + }) => createRow( + (row) => [ + row.projectId.set(projectId), + row.employeeId.set(employeeId), + ...row.role.change(role), + ...row.joinedAt.change(joinedAt), + ], + ); + Query byId({ + required int projectId, + required int employeeId, + }) => where( + (row) => row.projectId.eq(projectId).and(row.employeeId.eq(employeeId)), + ); +} + +extension ProjectMemberUpdates on Query { + Future patch({ + Change projectId = const Change.keep(), + Change employeeId = const Change.keep(), + Change role = const Change.keep(), + Change joinedAt = const Change.keep(), + }) => update( + (row) => [ + ...row.projectId.change(projectId), + ...row.employeeId.change(employeeId), + ...row.role.change(role), + ...row.joinedAt.change(joinedAt), + ], + ).execute(); +} + +final appSchema = List.unmodifiable([ + departmentSchema, + employeeSchema, + projectSchema, + projectMemberSchema, +]); + +extension AppTables on QueryContext { + DepartmentTableSet get department => DepartmentTableSet(this); + EmployeeTableSet get employee => EmployeeTableSet(this); + ProjectTableSet get project => ProjectTableSet(this); + ProjectMemberTableSet get projectMember => ProjectMemberTableSet(this); +} diff --git a/example/company/schema.snapshot.dart b/example/company/schema.snapshot.dart new file mode 100644 index 00000000..4e679c60 --- /dev/null +++ b/example/company/schema.snapshot.dart @@ -0,0 +1,87 @@ +// Generated physical schema. Keep historical copies with their migration. +import 'package:orm/migrate.dart'; + +final schema = SchemaSnapshot([ + TableSchema( + "departments", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("name", Codecs.text), + ], + primaryKey: ["id"], + uniqueKeys: [ + ["name"], + ], + ), + TableSchema( + "employees", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("name", Codecs.text), + Column("email", Codecs.text), + Column("active", Codecs.boolean, defaultSql: "true"), + Column("created_at", Codecs.dateTime), + Column("department_id", Codecs.integer), + Column("manager_id", Codecs.integer.nullable(), nullable: true), + ], + primaryKey: ["id"], + uniqueKeys: [ + ["email"], + ], + indexes: [ + IndexSchema("employees_department_id", [ + "department_id", + "id", + ], unique: false), + IndexSchema("employees_manager_id", ["manager_id", "id"], unique: false), + ], + foreignKeys: [ + ForeignKey( + ["department_id"], + "departments", + ["id"], + onDelete: "RESTRICT", + ), + ForeignKey(["manager_id"], "employees", ["id"], onDelete: "SET NULL"), + ], + ), + TableSchema( + "projects", + columns: [ + Column("id", Codecs.integer, generated: true), + Column("name", Codecs.text), + Column("status", Codecs.text, defaultSql: "'draft'"), + Column("created_at", Codecs.dateTime), + Column("owner_id", Codecs.integer), + ], + primaryKey: ["id"], + + indexes: [ + IndexSchema("projects_owner_id", ["owner_id", "id"], unique: false), + ], + foreignKeys: [ + ForeignKey(["owner_id"], "employees", ["id"], onDelete: "RESTRICT"), + ], + ), + TableSchema( + "project_members", + columns: [ + Column("project_id", Codecs.integer), + Column("employee_id", Codecs.integer), + Column("role", Codecs.text, defaultSql: "'member'"), + Column("joined_at", Codecs.dateTime), + ], + primaryKey: ["project_id", "employee_id"], + + indexes: [ + IndexSchema("project_members_employee_project", [ + "employee_id", + "project_id", + ], unique: false), + ], + foreignKeys: [ + ForeignKey(["project_id"], "projects", ["id"], onDelete: "CASCADE"), + ForeignKey(["employee_id"], "employees", ["id"], onDelete: "CASCADE"), + ], + ), +]); diff --git a/example/flutter/lib/acceptance.dart b/example/flutter/lib/acceptance.dart index f0327f1d..b7b5c1ae 100644 --- a/example/flutter/lib/acceptance.dart +++ b/example/flutter/lib/acceptance.dart @@ -85,7 +85,7 @@ Future> runAcceptance({ 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(); + final original = await db.note.byId(1).single(); check( original.text == 'Written by version 1' && original.createdAt == DateTime.utc(2026, 9, 15, 1, 2, 3, 123, 456), @@ -95,7 +95,7 @@ Future> runAcceptance({ original.done == (phase == 'reopen'), 'New field default / persisted update', ); - final query = db.notes + final query = db.note .orderBy((n) => [n.id.asc()]) .select( (n) => @@ -133,11 +133,11 @@ Future> runAcceptance({ check(true, 'Initial watch snapshot delivered to Flutter'); if (phase == 'upgrade') { await db.transaction((tx) async { - final note = await tx.notes.create( + final note = await tx.note.create( text: 'Written by version 2', createdAt: DateTime.utc(2026, 9, 15, 2), ); - await tx.comments.create( + await tx.comment.create( noteId: note.id, text: 'Related in one transaction', ); @@ -150,7 +150,7 @@ Future> runAcceptance({ final rollback = StateError('Expected rollback'); try { await db.transaction((tx) async { - await tx.notes.create( + await tx.note.create( text: 'Must roll back', createdAt: DateTime.utc(2026), ); @@ -164,7 +164,7 @@ Future> runAcceptance({ snapshots.length == count && (await query.get()).length == 3, 'Rollback neither persisted nor notified', ); - await db.notes.byId(1).patch(done: .set(true)); + await db.note.byId(1).patch(done: .set(true)); await waitFor((rows) => rows.first.done); check(true, 'Typed patch refreshed watch'); var ticks = 0; diff --git a/example/flutter/lib/acceptance_web.dart b/example/flutter/lib/acceptance_web.dart index 2798587f..66fa7315 100644 --- a/example/flutter/lib/acceptance_web.dart +++ b/example/flutter/lib/acceptance_web.dart @@ -39,19 +39,19 @@ Future> runAcceptance({ )).matches, 'Bundled Dart migrations match the browser database', ); - if (await db.notes.count() == 0) { + if (await db.note.count() == 0) { await db.transaction((tx) async { - final note = await tx.notes.create( + final note = await tx.note.create( text: 'Stored in the browser', createdAt: DateTime.now().toUtc(), ); - await tx.comments.create( + await tx.comment.create( noteId: note.id, text: 'Written in the same transaction', ); }); } - final rows = await db.notes + final rows = await db.note .orderBy((n) => [n.id.asc()]) .select( (n) => diff --git a/example/flutter/lib/legacy/schema.dart b/example/flutter/lib/legacy/schema.dart index ba0d8ba5..6add474f 100644 --- a/example/flutter/lib/legacy/schema.dart +++ b/example/flutter/lib/legacy/schema.dart @@ -1,4 +1,7 @@ import 'package:orm/schema.dart'; -typedef Note = ({@Id.generated() int id, String body, DateTime createdAt}); -final notes = entity(table: 'notes'); +final note = model('notes', ( + id: identity(), + body: text(), + createdAt: dateTime(), +)); diff --git a/example/flutter/lib/legacy/schema.orm.dart b/example/flutter/lib/legacy/schema.orm.dart index 6d7ad8bc..9a8e1d73 100644 --- a/example/flutter/lib/legacy/schema.orm.dart +++ b/example/flutter/lib/legacy/schema.orm.dart @@ -2,58 +2,61 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Note; - -final _notesId = Column( +/// A complete immutable row from "notes". +final class Note({ + required final int id, + required final String body, + required final DateTime createdAt, +}); +final _noteId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _notesBody = Column( +final _noteBody = Column( "body", Codecs.text, nullable: false, generated: false, ); -final _notesCreatedAt = Column( +final _noteCreatedAt = Column( "created_at", Codecs.dateTime, nullable: false, generated: false, ); -final notesSchema = TableSchema( +final noteSchema = TableSchema( "notes", - columns: [_notesId, _notesBody, _notesCreatedAt], + columns: [_noteId, _noteBody, _noteCreatedAt], 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 class NoteFields extends Fields { + NoteFields(super.table); + late final id = column(_noteId); + late final body = column(_noteBody); + late final createdAt = column(_noteCreatedAt); } -final notesTable = Table( - notesSchema, - NotesFields.new, +final noteTable = Table( + noteSchema, + NoteFields.new, (row) => ( row.id, row.body, row.createdAt, - ).map((id, body, createdAt) => (id: id, body: body, createdAt: createdAt)), + ).map((v0, v1, v2) => Note(id: v0, body: v1, createdAt: v2)), ); -final class NotesTableSet extends TableSet { - NotesTableSet(QueryContext db) : super(db, notesTable) { +final class NoteTableSet extends TableSet { + NoteTableSet(QueryContext db) : super(db, noteTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required String body, required DateTime createdAt, @@ -64,10 +67,10 @@ final class NotesTableSet extends TableSet { row.createdAt.set(createdAt), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension NotesUpdates on Query { +extension NoteUpdates on Query { Future patch({ Change body = const Change.keep(), Change createdAt = const Change.keep(), @@ -76,8 +79,8 @@ extension NotesUpdates on Query { ).execute(); } -final appSchema = List.unmodifiable([notesSchema]); +final appSchema = List.unmodifiable([noteSchema]); extension AppTables on QueryContext { - NotesTableSet get notes => NotesTableSet(this); + NoteTableSet get note => NoteTableSet(this); } diff --git a/example/flutter/lib/legacy/seed.dart b/example/flutter/lib/legacy/seed.dart index efa04e57..cd289e31 100644 --- a/example/flutter/lib/legacy/seed.dart +++ b/example/flutter/lib/legacy/seed.dart @@ -6,11 +6,11 @@ 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( + await tx.note.create( body: 'Written by version 1', createdAt: DateTime.utc(2026, 9, 15, 1, 2, 3, 123, 456), ); - await tx.notes.create( + await tx.note.create( body: 'Keep this across the upgrade', createdAt: DateTime.utc(2026, 9, 15), ); diff --git a/example/flutter/lib/schema.dart b/example/flutter/lib/schema.dart index f76261ba..88333976 100644 --- a/example/flutter/lib/schema.dart +++ b/example/flutter/lib/schema.dart @@ -1,19 +1,15 @@ 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 Model note = model('notes', ( + id: identity(), + text: text(name: 'body'), + done: boolean(defaultValue: false), + createdAt: dateTime(), +), relations: (n) => (comments: referencedBy(() => comment))); -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, - ); +final comment = model( + 'comments', + (id: identity(), noteId: integer(), text: text()), + relations: (c) => + (note: references(c.noteId, () => note, onDelete: .cascade)), +); diff --git a/example/flutter/lib/schema.orm.dart b/example/flutter/lib/schema.orm.dart index 6efbed11..c9bdac28 100644 --- a/example/flutter/lib/schema.orm.dart +++ b/example/flutter/lib/schema.orm.dart @@ -2,67 +2,73 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Note, Comment; - -final _notesId = Column( +/// A complete immutable row from "notes". +final class Note({ + required final int id, + required final String text, + required final bool done, + required final DateTime createdAt, +}); +final _noteId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _notesText = Column( +final _noteText = Column( "body", Codecs.text, nullable: false, generated: false, ); -final _notesDone = Column( +final _noteDone = Column( "done", Codecs.boolean, nullable: false, generated: false, defaultSql: "false", ); -final _notesCreatedAt = Column( +final _noteCreatedAt = Column( "created_at", Codecs.dateTime, nullable: false, generated: false, ); -final notesSchema = TableSchema( +final noteSchema = TableSchema( "notes", - columns: [_notesId, _notesText, _notesDone, _notesCreatedAt], + columns: [_noteId, _noteText, _noteDone, _noteCreatedAt], 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 class NoteFields extends Fields { + NoteFields(super.table); + late final id = column(_noteId); + late final text = column(_noteText); + late final done = column(_noteDone); + late final createdAt = column(_noteCreatedAt); + Relation get comments => + Relation(commentTable, 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 noteTable = Table( + noteSchema, + NoteFields.new, + (row) => ( + row.id, + row.text, + row.done, + row.createdAt, + ).map((v0, v1, v2, v3) => Note(id: v0, text: v1, done: v2, createdAt: v3)), ); -final class NotesTableSet extends TableSet { - NotesTableSet(QueryContext db) : super(db, notesTable) { +final class NoteTableSet extends TableSet { + NoteTableSet(QueryContext db) : super(db, noteTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required String text, Change done = const Change.keep(), @@ -75,10 +81,10 @@ final class NotesTableSet extends TableSet { row.createdAt.set(createdAt), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension NotesUpdates on Query { +extension NoteUpdates on Query { Future patch({ Change text = const Change.keep(), Change done = const Change.keep(), @@ -92,27 +98,33 @@ extension NotesUpdates on Query { ).execute(); } -final _commentsId = Column( +/// A complete immutable row from "comments". +final class Comment({ + required final int id, + required final int noteId, + required final String text, +}); +final _commentId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _commentsNoteId = Column( +final _commentNoteId = Column( "note_id", Codecs.integer, nullable: false, generated: false, ); -final _commentsText = Column( +final _commentText = Column( "text", Codecs.text, nullable: false, generated: false, ); -final commentsSchema = TableSchema( +final commentSchema = TableSchema( "comments", - columns: [_commentsId, _commentsNoteId, _commentsText], + columns: [_commentId, _commentNoteId, _commentText], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -121,41 +133,40 @@ final commentsSchema = TableSchema( ], ); -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 class CommentFields extends Fields { + CommentFields(super.table); + late final id = column(_commentId); + late final noteId = column(_commentNoteId); + late final text = column(_commentText); + Relation get note => + Relation(noteTable, parent: [noteId], child: (row) => [row.id]); } -final commentsTable = Table( - commentsSchema, - CommentsFields.new, +final commentTable = Table( + commentSchema, + CommentFields.new, (row) => ( row.id, row.noteId, row.text, - ).map((id, noteId, text) => (id: id, noteId: noteId, text: text)), + ).map((v0, v1, v2) => Comment(id: v0, noteId: v1, text: v2)), ); -final class CommentsTableSet extends TableSet { - CommentsTableSet(QueryContext db) : super(db, commentsTable) { +final class CommentTableSet extends TableSet { + CommentTableSet(QueryContext db) : super(db, commentTable) { db.registerSchema(appSchema); } - Future create({ + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension CommentsUpdates on Query { +extension CommentUpdates on Query { Future patch({ Change noteId = const Change.keep(), Change text = const Change.keep(), @@ -164,9 +175,9 @@ extension CommentsUpdates on Query { .execute(); } -final appSchema = List.unmodifiable([notesSchema, commentsSchema]); +final appSchema = List.unmodifiable([noteSchema, commentSchema]); extension AppTables on QueryContext { - NotesTableSet get notes => NotesTableSet(this); - CommentsTableSet get comments => CommentsTableSet(this); + NoteTableSet get note => NoteTableSet(this); + CommentTableSet get comment => CommentTableSet(this); } diff --git a/example/main.dart b/example/main.dart index cbd913e3..3b6093d8 100644 --- a/example/main.dart +++ b/example/main.dart @@ -10,16 +10,16 @@ Future main() async { 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( + final User user = await tx.user.create(email: 'seven@example.com'); + await tx.post.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 + await db.user.byId(user.id).patch(nickname: .set('Seven')); + final cards = await db.user .select( (u) => ( u.email, diff --git a/example/queries.dart b/example/queries.dart index d6fd4fb2..f881b02a 100644 --- a/example/queries.dart +++ b/example/queries.dart @@ -22,23 +22,23 @@ Future> queryCookbook( 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( + final z = await tx.user.create( email: 'z@example.com', nickname: 'Z', score: .set(2), ); - final a = await tx.users.create(email: 'a@example.com'); - await tx.posts.create( + final a = await tx.user.create(email: 'a@example.com'); + await tx.post.create( authorId: z.id, title: 'Z1', createdAt: DateTime.utc(2026, 1, 1), ); - await tx.posts.create( + await tx.post.create( authorId: z.id, title: 'Z2', createdAt: DateTime.utc(2026, 1, 2), ); - await tx.posts.create( + await tx.post.create( authorId: a.id, title: 'A1', createdAt: DateTime.utc(2026, 1, 1), @@ -46,7 +46,7 @@ Future> queryCookbook( return (z, a); }); - var query = db.users.where((u) => u.email.like('%@example.com')); + var query = db.user.where((u) => u.email.like('%@example.com')); if (minimumScore != null) { query = query.where((u) => u.score.gte(minimumScore)); } @@ -64,8 +64,8 @@ Future> queryCookbook( 'Dynamic typed filter and scalar selection', ); - final author = usersTable.alias(); - final joined = await db.posts + final author = userTable.alias(); + final joined = await db.post .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) @@ -76,7 +76,7 @@ Future> queryCookbook( joined.indexed.every((e) => e.$2 == expectedJoined[e.$1]), 'Joined field orders root rows', ); - final relationOrder = await db.users + final relationOrder = await db.user .orderBy((u) => [u.posts.count().desc(), u.id.asc()]) .select((u) => u.email) .get(); @@ -85,7 +85,7 @@ Future> queryCookbook( 'Correlated relation count orders root rows', ); - final totals = db.posts + final totals = db.post .groupBy((p) => [p.authorId]) .having((p) => p.id.count().gt(1)) .select((p) => (p.authorId, p.id.count()).row) @@ -97,7 +97,7 @@ Future> queryCookbook( active.length == 1 && active.single == (z.id, 2), 'Grouping, HAVING and CTE exported columns', ); - final ranks = await db.posts + final ranks = await db.post .orderBy((p) => [p.id.asc()]) .select( (p) => ( @@ -113,15 +113,15 @@ Future> queryCookbook( 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); + final selectedAuthors = db.post.select((p) => p.authorId); check( - await db.users.where((u) => u.id.isInQuery(selectedAuthors)).count() == 2, + await db.user.where((u) => u.id.isInQuery(selectedAuthors)).count() == 2, 'Typed IN subquery', ); - final counts = await db.users + final counts = await db.user .orderBy((u) => [u.id.asc()]) .select( - (u) => db.posts + (u) => db.post .where((p) => p.authorId.equals(u.id)) .select((p) => p.id.count()) .scalar(), @@ -129,18 +129,18 @@ Future> queryCookbook( .get(); check(jsonEncode(counts) == '[2,1]', 'Correlated scalar aggregate'); - final first = await db.users + final first = await db.user .orderBy((u) => [u.nickname.asc(nulls: .last), u.id.asc()]) .take(1) .get(); final last = first.single; - final token = db.users.cursorToken( + final token = db.user.cursorToken( (u) => [ u.nickname.cursor(last.nickname, nulls: .last), u.id.cursor(last.id), ], ); - final next = await db.users + final next = await db.user .seekToken( token, orderBy: (u) => [u.nickname.asc(nulls: .last), u.id.asc()], diff --git a/example/schema.dart b/example/schema.dart index 048c208a..c68ce9a9 100644 --- a/example/schema.dart +++ b/example/schema.dart @@ -1,22 +1,18 @@ 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 Model user = model('users', ( + id: identity(), + email: text(unique: true), + nickname: text().nullable(), + score: integer(defaultValue: 0), +), relations: (u) => (posts: referencedBy(() => post))); -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)); +final post = model( + 'posts', + (id: identity(), authorId: integer(), title: text(), createdAt: dateTime()), + indexes: (p) => [ + index((p.authorId, p.createdAt, p.id), name: 'author_timeline'), + ], + relations: (p) => + (author: references(p.authorId, () => user, onDelete: .cascade)), +); diff --git a/example/schema.orm.dart b/example/schema.orm.dart index c5b7f17d..606059e7 100644 --- a/example/schema.orm.dart +++ b/example/schema.orm.dart @@ -2,37 +2,41 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show User, Post; - -final _usersId = Column( +/// A complete immutable row from "users". +final class User({ + required final int id, + required final String email, + required final String? nickname, + required final int score, +}); +final _userId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _usersEmail = Column( +final _userEmail = Column( "email", Codecs.text, nullable: false, generated: false, ); -final _usersNickname = Column( +final _userNickname = Column( "nickname", Codecs.text.nullable(), nullable: true, generated: false, ); -final _usersScore = Column( +final _userScore = Column( "score", Codecs.integer, nullable: false, generated: false, defaultSql: "0", ); -final usersSchema = TableSchema( +final userSchema = TableSchema( "users", - columns: [_usersId, _usersEmail, _usersNickname, _usersScore], + columns: [_userId, _userEmail, _userNickname, _userScore], primaryKey: ["id"], uniqueKeys: [ ["email"], @@ -41,29 +45,32 @@ final usersSchema = TableSchema( 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 class UserFields extends Fields { + UserFields(super.table); + late final id = column(_userId); + late final email = column(_userEmail); + late final nickname = column(_userNickname); + late final score = column(_userScore); + Relation get posts => + Relation(postTable, 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 userTable = Table( + userSchema, + UserFields.new, + (row) => ( + row.id, + row.email, + row.nickname, + row.score, + ).map((v0, v1, v2, v3) => User(id: v0, email: v1, nickname: v2, score: v3)), ); -final class UsersTableSet extends TableSet { - UsersTableSet(QueryContext db) : super(db, usersTable) { +final class UserTableSet extends TableSet { + UserTableSet(QueryContext db) : super(db, userTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required String email, String? nickname, @@ -76,10 +83,10 @@ final class UsersTableSet extends TableSet { ...row.score.change(score), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension UsersUpdates on Query { +extension UserUpdates on Query { Future patch({ Change email = const Change.keep(), Change nickname = const Change.keep(), @@ -93,33 +100,40 @@ extension UsersUpdates on Query { ).execute(); } -final _postsId = Column( +/// A complete immutable row from "posts". +final class Post({ + required final int id, + required final int authorId, + required final String title, + required final DateTime createdAt, +}); +final _postId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _postsAuthorId = Column( +final _postAuthorId = Column( "author_id", Codecs.integer, nullable: false, generated: false, ); -final _postsTitle = Column( +final _postTitle = Column( "title", Codecs.text, nullable: false, generated: false, ); -final _postsCreatedAt = Column( +final _postCreatedAt = Column( "created_at", Codecs.dateTime, nullable: false, generated: false, ); -final postsSchema = TableSchema( +final postSchema = TableSchema( "posts", - columns: [_postsId, _postsAuthorId, _postsTitle, _postsCreatedAt], + columns: [_postId, _postAuthorId, _postTitle, _postCreatedAt], primaryKey: ["id"], uniqueKeys: [], indexes: [ @@ -134,30 +148,29 @@ final postsSchema = TableSchema( ], ); -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 class PostFields extends Fields { + PostFields(super.table); + late final id = column(_postId); + late final authorId = column(_postAuthorId); + late final title = column(_postTitle); + late final createdAt = column(_postCreatedAt); + Relation get author => + Relation(userTable, parent: [authorId], child: (row) => [row.id]); } -final postsTable = Table( - postsSchema, - PostsFields.new, +final postTable = Table( + postSchema, + PostFields.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), + (v0, v1, v2, v3) => Post(id: v0, authorId: v1, title: v2, createdAt: v3), ), ); -final class PostsTableSet extends TableSet { - PostsTableSet(QueryContext db) : super(db, postsTable) { +final class PostTableSet extends TableSet { + PostTableSet(QueryContext db) : super(db, postTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required int authorId, required String title, @@ -170,10 +183,10 @@ final class PostsTableSet extends TableSet { row.createdAt.set(createdAt), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension PostsUpdates on Query { +extension PostUpdates on Query { Future patch({ Change authorId = const Change.keep(), Change title = const Change.keep(), @@ -187,9 +200,9 @@ extension PostsUpdates on Query { ).execute(); } -final appSchema = List.unmodifiable([usersSchema, postsSchema]); +final appSchema = List.unmodifiable([userSchema, postSchema]); extension AppTables on QueryContext { - UsersTableSet get users => UsersTableSet(this); - PostsTableSet get posts => PostsTableSet(this); + UserTableSet get user => UserTableSet(this); + PostTableSet get post => PostTableSet(this); } diff --git a/example/teams/main.dart b/example/teams/main.dart index f1cbca33..c26bb15f 100644 --- a/example/teams/main.dart +++ b/example/teams/main.dart @@ -1,7 +1,6 @@ import 'package:orm/migrate.dart'; import 'package:orm/sqlite.dart'; -import 'schema.dart'; import 'schema.orm.dart'; Future main() async { @@ -19,16 +18,16 @@ Future main() async { 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( + await tx.user.create(id: 1, name: 'Ada'); + await tx.team.create(id: 10, name: 'Core'); + await tx.team.create(id: 20, name: 'Docs'); + await tx.membership.create( teamId: 10, userId: 1, role: .set(MembershipRole.owner), joinedAt: DateTime.utc(2026, 1, 1), ); - await tx.memberships.create( + await tx.membership.create( teamId: 20, userId: 1, joinedAt: DateTime.utc(2026, 1, 2), @@ -37,7 +36,7 @@ Future main() async { events.clear(); acquisitions.clear(); decodes.clear(); - final query = db.users.select( + final query = db.user.select( (u) => ( u.name, u.memberships diff --git a/example/teams/schema.dart b/example/teams/schema.dart index f728fa12..9610e830 100644 --- a/example/teams/schema.dart +++ b/example/teams/schema.dart @@ -1,35 +1,38 @@ 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 Model user = model( + 'users', + (id: integer(), name: text()), + primaryKey: (u) => u.id, + relations: (u) => (memberships: referencedBy(() => membership)), +); + +final Model team = model( + 'teams', + (id: integer(), name: text()), + primaryKey: (t) => t.id, + relations: (t) => (memberships: referencedBy(() => membership)), +); -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), +final membership = model( + 'memberships', + ( + teamId: integer(), + userId: integer(), + role: enumeration( + MembershipRole.values, + defaultValue: MembershipRole.member, + ), + joinedAt: dateTime(), + ), + primaryKey: (m) => (m.teamId, m.userId), + indexes: (m) => [ + index((m.userId, m.joinedAt, m.teamId), name: 'user_memberships'), + ], + relations: (m) => ( + team: references(m.teamId, () => team, onDelete: .cascade), + user: references(m.userId, () => user, onDelete: .cascade), + ), ); diff --git a/example/teams/schema.orm.dart b/example/teams/schema.orm.dart index 7ce78dc5..32d95f71 100644 --- a/example/teams/schema.orm.dart +++ b/example/teams/schema.orm.dart @@ -3,53 +3,55 @@ import 'package:orm/sql.dart'; import "schema.dart" as models; -export "schema.dart" show User, Team, Membership; +export "schema.dart" show MembershipRole; -final _usersId = Column( +/// A complete immutable row from "users". +final class User({required final int id, required final String name}); +final _userId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _usersName = Column( +final _userName = Column( "name", Codecs.text, nullable: false, generated: false, ); -final usersSchema = TableSchema( +final userSchema = TableSchema( "users", - columns: [_usersId, _usersName], + columns: [_userId, _userName], 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 class UserFields extends Fields { + UserFields(super.table); + late final id = column(_userId); + late final name = column(_userName); + Relation get memberships => + Relation(membershipTable, 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 userTable = Table( + userSchema, + UserFields.new, + (row) => (row.id, row.name).map((v0, v1) => User(id: v0, name: v1)), ); -final class UsersTableSet extends TableSet { - UsersTableSet(QueryContext db) : super(db, usersTable) { +final class UserTableSet extends TableSet { + UserTableSet(QueryContext db) : super(db, userTable) { db.registerSchema(appSchema); } - Future create({required int id, required String name}) => + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension UsersUpdates on Query { +extension UserUpdates on Query { Future patch({ Change id = const Change.keep(), Change name = const Change.keep(), @@ -58,51 +60,53 @@ extension UsersUpdates on Query { .execute(); } -final _teamsId = Column( +/// A complete immutable row from "teams". +final class Team({required final int id, required final String name}); +final _teamId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _teamsName = Column( +final _teamName = Column( "name", Codecs.text, nullable: false, generated: false, ); -final teamsSchema = TableSchema( +final teamSchema = TableSchema( "teams", - columns: [_teamsId, _teamsName], + columns: [_teamId, _teamName], 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 class TeamFields extends Fields { + TeamFields(super.table); + late final id = column(_teamId); + late final name = column(_teamName); + Relation get memberships => + Relation(membershipTable, 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 teamTable = Table( + teamSchema, + TeamFields.new, + (row) => (row.id, row.name).map((v0, v1) => Team(id: v0, name: v1)), ); -final class TeamsTableSet extends TableSet { - TeamsTableSet(QueryContext db) : super(db, teamsTable) { +final class TeamTableSet extends TableSet { + TeamTableSet(QueryContext db) : super(db, teamTable) { db.registerSchema(appSchema); } - Future create({required int id, required String name}) => + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension TeamsUpdates on Query { +extension TeamUpdates on Query { Future patch({ Change id = const Change.keep(), Change name = const Change.keep(), @@ -111,19 +115,26 @@ extension TeamsUpdates on Query { .execute(); } -final _membershipsTeamId = Column( +/// A complete immutable row from "memberships". +final class Membership({ + required final int teamId, + required final int userId, + required final models.MembershipRole role, + required final DateTime joinedAt, +}); +final _membershipTeamId = Column( "team_id", Codecs.integer, nullable: false, generated: false, ); -final _membershipsUserId = Column( +final _membershipUserId = Column( "user_id", Codecs.integer, nullable: false, generated: false, ); -final _membershipsRole = Column( +final _membershipRole = Column( "role", Codecs.enumeration({ models.MembershipRole.owner: "owner", @@ -133,19 +144,19 @@ final _membershipsRole = Column( generated: false, defaultSql: "'member'", ); -final _membershipsJoinedAt = Column( +final _membershipJoinedAt = Column( "joined_at", Codecs.dateTime, nullable: false, generated: false, ); -final membershipsSchema = TableSchema( +final membershipSchema = TableSchema( "memberships", columns: [ - _membershipsTeamId, - _membershipsUserId, - _membershipsRole, - _membershipsJoinedAt, + _membershipTeamId, + _membershipUserId, + _membershipRole, + _membershipJoinedAt, ], primaryKey: ["team_id", "user_id"], uniqueKeys: [], @@ -162,33 +173,32 @@ final membershipsSchema = TableSchema( ], ); -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 class MembershipFields extends Fields { + MembershipFields(super.table); + late final teamId = column(_membershipTeamId); + late final userId = column(_membershipUserId); + late final role = column(_membershipRole); + late final joinedAt = column(_membershipJoinedAt); + Relation get team => + Relation(teamTable, parent: [teamId], child: (row) => [row.id]); + Relation get user => + Relation(userTable, parent: [userId], child: (row) => [row.id]); } -final membershipsTable = Table( - membershipsSchema, - MembershipsFields.new, +final membershipTable = Table( + membershipSchema, + MembershipFields.new, (row) => (row.teamId, row.userId, row.role, row.joinedAt).map( - (teamId, userId, role, joinedAt) => - (teamId: teamId, userId: userId, role: role, joinedAt: joinedAt), + (v0, v1, v2, v3) => + Membership(teamId: v0, userId: v1, role: v2, joinedAt: v3), ), ); -final class MembershipsTableSet - extends TableSet { - MembershipsTableSet(QueryContext db) : super(db, membershipsTable) { +final class MembershipTableSet extends TableSet { + MembershipTableSet(QueryContext db) : super(db, membershipTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ required int teamId, required int userId, Change role = const Change.keep(), @@ -201,13 +211,13 @@ final class MembershipsTableSet row.joinedAt.set(joinedAt), ], ); - Query byId({ + Query byId({ required int teamId, required int userId, }) => where((row) => row.teamId.eq(teamId).and(row.userId.eq(userId))); } -extension MembershipsUpdates on Query { +extension MembershipUpdates on Query { Future patch({ Change teamId = const Change.keep(), Change userId = const Change.keep(), @@ -224,13 +234,13 @@ extension MembershipsUpdates on Query { } final appSchema = List.unmodifiable([ - usersSchema, - teamsSchema, - membershipsSchema, + userSchema, + teamSchema, + membershipSchema, ]); extension AppTables on QueryContext { - UsersTableSet get users => UsersTableSet(this); - TeamsTableSet get teams => TeamsTableSet(this); - MembershipsTableSet get memberships => MembershipsTableSet(this); + UserTableSet get user => UserTableSet(this); + TeamTableSet get team => TeamTableSet(this); + MembershipTableSet get membership => MembershipTableSet(this); } diff --git a/lib/schema.dart b/lib/schema.dart index e23dde65..344ebcb2 100644 --- a/lib/schema.dart +++ b/lib/schema.dart @@ -1,62 +1,81 @@ -/// Declares typed Dart models, storage annotations, and relationships. +/// Declares models and tables together using typed Record column declarations. /// -/// Import this library in a schema source file. [entity] associates a Dart model -/// with a physical table; annotations such as [Id], [Default], and [UseCodec] -/// define stored values. Top-level key and relationship declarations generate -/// query access without executing application constructors or opening a database. +/// Import this library in a schema source; generate its immutable rows, typed +/// queries and physical snapshot with `dart run orm generate lib/schema.dart`. +/// Declaration callbacks and application factories are not executed by generation. /// /// ```dart /// import 'package:orm/schema.dart'; /// -/// final class User(@Id.generated() final int id, final String email); -/// final users = entity(); -/// final uniqueEmail = users.unique((user) => user.email); +/// final user = model('users', ( +/// id: identity(), +/// email: text(unique: true), +/// name: text().nullable(), +/// )); /// ``` /// -/// Run `dart run orm generate path/to/schema.dart` to generate the typed client. -/// Use `package:orm/schema_model.dart` for physical metadata and -/// `package:orm/migrate.dart` to apply reviewed schema changes. +/// Use the generated client for queries, `schema_model.dart` for physical metadata, +/// and `migrate.dart` for reviewed database changes. /// /// {@category Declarations} -/// {@canonicalFor declaration.ClientDefault} -/// {@canonicalFor declaration.ColumnName} -/// {@canonicalFor declaration.Computed} -/// {@canonicalFor declaration.DecimalDigits} -/// {@canonicalFor declaration.Default} -/// {@canonicalFor declaration.Entity} -/// {@canonicalFor declaration.EntityKey} -/// {@canonicalFor declaration.EnumValue} -/// {@canonicalFor declaration.Id} -/// {@canonicalFor declaration.IntegerBits} +/// {@canonicalFor declaration.Model} +/// {@canonicalFor declaration.model} +/// {@canonicalFor declaration.ColumnDefinition} +/// {@canonicalFor declaration.IndexDefinition} +/// {@canonicalFor declaration.ReferenceDefinition} +/// {@canonicalFor declaration.CheckDefinition} +/// {@canonicalFor declaration.identity} +/// {@canonicalFor declaration.integer} +/// {@canonicalFor declaration.text} +/// {@canonicalFor declaration.boolean} +/// {@canonicalFor declaration.real} +/// {@canonicalFor declaration.bigInteger} +/// {@canonicalFor declaration.decimal} +/// {@canonicalFor declaration.dateTime} +/// {@canonicalFor declaration.date} +/// {@canonicalFor declaration.time} +/// {@canonicalFor declaration.localDateTime} +/// {@canonicalFor declaration.bytes} +/// {@canonicalFor declaration.json} +/// {@canonicalFor declaration.enumeration} +/// {@canonicalFor declaration.custom} +/// {@canonicalFor declaration.index} +/// {@canonicalFor declaration.references} +/// {@canonicalFor declaration.referencedBy} +/// {@canonicalFor declaration.check} /// {@canonicalFor declaration.ReferentialAction} -/// {@canonicalFor declaration.SchemaConstraint} -/// {@canonicalFor declaration.SqlDeclaration} -/// {@canonicalFor declaration.TemporalPrecision} -/// {@canonicalFor declaration.Unique} -/// {@canonicalFor declaration.UseCodec} -/// {@canonicalFor declaration.entity} -/// {@canonicalFor declaration.sqlQuery} +/// {@canonicalFor queries.SqlDeclaration} +/// {@canonicalFor queries.sqlQuery} library; export 'values.dart'; -export 'schema_model.dart' show ComputedStorage; export 'src/schema/declaration.dart' show - ClientDefault, - ColumnName, - Computed, - DecimalDigits, - Default, - Entity, - EntityKey, - EnumValue, - Id, - IntegerBits, ReferentialAction, - SchemaConstraint, - SqlDeclaration, - TemporalPrecision, - Unique, - UseCodec, - entity, - sqlQuery; + Model, + model, + ColumnDefinition, + IndexDefinition, + ReferenceDefinition, + CheckDefinition, + identity, + integer, + text, + boolean, + real, + bigInteger, + decimal, + dateTime, + date, + time, + localDateTime, + bytes, + json, + enumeration, + custom, + index, + references, + referencedBy, + check; +export 'schema_model.dart' show ComputedStorage; +export 'src/schema/queries.dart' show SqlDeclaration, sqlQuery; diff --git a/lib/schema_model.dart b/lib/schema_model.dart index a3ec61b5..ef7f8419 100644 --- a/lib/schema_model.dart +++ b/lib/schema_model.dart @@ -8,7 +8,7 @@ /// metadata does not apply DDL: migration tooling validates the target engine and /// executes reviewed schema changes separately. /// -/// Use `package:orm/schema.dart` for application model annotations. +/// Use `package:orm/schema.dart` for application model declarations. /// /// {@category Schema} /// {@canonicalFor model.CheckSchema} diff --git a/lib/src/cli/init.dart b/lib/src/cli/init.dart index a8b09b9e..ff139ad6 100644 --- a/lib/src/cli/init.dart +++ b/lib/src/cli/init.dart @@ -83,13 +83,11 @@ Future initializeProject(List args, CliOutput output) async { 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(); +final task = model('tasks', ( + id: identity(), + title: text(), + done: boolean(defaultValue: false), +)); '''; String _initialConfig(String engine) { diff --git a/lib/src/generate/build.dart b/lib/src/generate/build.dart index fc2466ed..29f1b929 100644 --- a/lib/src/generate/build.dart +++ b/lib/src/generate/build.dart @@ -80,7 +80,7 @@ final class _OrmBuilder implements builder.Builder { } final output = input.changeExtension('.orm.dart'); final (unit, library) = await _resolveSchema(step); - final result = generateResolvedSchema( + final result = await generateResolvedSchema( unit, library, p.url.relative(input.path, from: p.url.dirname(output.path)), @@ -94,6 +94,26 @@ final class _OrmBuilder implements builder.Builder { } return p.url.relative(asset.path, from: p.url.dirname(output.path)); }, + resolve: (library) async { + final node = await step.resolver.astNodeFor( + library.firstFragment, + resolve: true, + ); + if (node is! CompilationUnit) { + throw GenerationException('Cannot resolve schema ${library.uri}.'); + } + final errors = await node.declaredFragment!.element.session.getErrors( + library.firstFragment.source.fullName, + ); + if (errors is! ErrorsResult) { + throw GenerationException('Cannot validate schema ${library.uri}.'); + } + final failures = errors.diagnostics.where( + (e) => e.severity.name.toLowerCase() == 'error', + ); + if (failures.isNotEmpty) throw GenerationException(failures.join('\n')); + return node; + }, ); // Both outputs are fully constructed before touching the build writer. final snapshot = result.snapshotDart; diff --git a/lib/src/generate/emitter.dart b/lib/src/generate/emitter.dart index ef64ee82..5e50335a 100644 --- a/lib/src/generate/emitter.dart +++ b/lib/src/generate/emitter.dart @@ -5,11 +5,13 @@ import 'types.dart'; String emitSchema(List 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 ${dartLiteral(import)} as models;") - ..writeln( - "export ${dartLiteral(import)} show ${schema.map((e) => e.row).toSet().join(', ')};", - ); + ..writeln("import 'package:orm/sql.dart';"); + if (names.usesSource) { + b.writeln("import ${dartLiteral(import)} as models;"); + } + for (final (uri, symbols) in names.exports) { + b.writeln('export ${dartLiteral(uri)} show ${symbols.join(', ')};'); + } if (names.typedData) { b.writeln("import 'dart:typed_data';"); } @@ -18,6 +20,10 @@ String emitSchema(List schema, String import, DartNames names) { } b.writeln(''); for (final entity in schema) { + b.writeln( + '/// A complete immutable row from ${dartLiteral(entity.table)}.', + ); + b.writeln(rowDeclaration(entity)); for (final f in entity.fields) { b.writeln( 'final ${columnSymbol(entity, f)} = Column<${f.type}>(${dartLiteral(f.column)}, ${f.codec}, ' @@ -54,7 +60,7 @@ String emitSchema(List schema, String import, DartNames names) { ); } b.writeln('}'); - final selection = _modelSelection(entity, 'row'); + final selection = modelSelection(entity, 'row'); b.writeln( 'final ${entity.name}Table = Table<${entity.rowType}, ${entity.fieldsType}>(' '${entity.name}Schema, ${entity.fieldsType}.new, (row) => $selection);', @@ -63,23 +69,38 @@ String emitSchema(List schema, String import, DartNames names) { 'final class ${entity.setType} extends TableSet<${entity.rowType}, ${entity.fieldsType}> {' '${entity.setType}(QueryContext db) : super(db, ${entity.name}Table) { db.registerSchema(appSchema); }', ); + var input = 'row'; + final create = + entity.fields.any((f) => f.computed == null && f.name == 'createRow') + ? 'this.createRow' + : 'createRow'; + final where = entity.primaryKey.contains('where') ? 'this.where' : 'where'; + final update = + entity.fields.any( + (f) => !f.generated && f.computed == null && f.name == 'update', + ) + ? 'this.update' + : 'update'; + for (var suffix = 2; entity.fields.any((f) => f.name == input); suffix++) { + input = 'row$suffix'; + } 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})'); + assignments.add('...$input.${f.name}.change(${f.name})'); } else if (f.nullable) { parameters.add('${f.type} ${f.name}'); - assignments.add('row.${f.name}.set(${f.name})'); + assignments.add('$input.${f.name}.set(${f.name})'); } else { parameters.add('required ${f.type} ${f.name}'); - assignments.add('row.${f.name}.set(${f.name})'); + assignments.add('$input.${f.name}.set(${f.name})'); } } b.writeln( - 'Future<${entity.rowType}> create({${parameters.join(', ')}}) => createRow((row) => [${assignments.join(', ')}]);', + 'Future<${entity.rowType}> create({${parameters.join(', ')}}) => $create(($input) => [${assignments.join(', ')}]);', ); if (entity.primaryKey.isNotEmpty) { final positional = entity.primaryKey.length == 1; @@ -90,7 +111,7 @@ String emitSchema(List schema, String import, DartNames names) { .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)});', + '$where(($input) => ${entity.primaryKey.map((k) => '$input.$k.eq($k)').join('.and(')}${')' * (entity.primaryKey.length - 1)});', ); } b.writeln('}'); @@ -98,7 +119,7 @@ String emitSchema(List schema, String import, DartNames names) { 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(); }', + '$update(($input) => [${entity.fields.where((f) => !f.generated && f.computed == null).map((f) => '...$input.${f.name}.change(${f.name})').join(', ')}]).execute(); }', ); } } @@ -113,11 +134,9 @@ String emitSchema(List schema, String import, DartNames names) { return b.toString(); } -String _modelSelection(ModelEntity entity, String row) { - final named = entity.constructorNamedFields; - if (named == null) return recordSelection(entity.fields, row); +String modelSelection(ModelEntity entity, String row) { String construct(String Function(ModelField) value) => - '${entity.rowType}(${entity.fields.map((f) => '${named.contains(f.name) ? '${f.name}: ' : ''}${value(f)}').join(', ')})'; + '${entity.rowType}(${entity.fields.map((f) => '${f.name}: ${value(f)}').join(', ')})'; final fields = entity.fields; if (fields.length == 1) { return '$row.${fields.single.name}.map((value) => ${construct((_) => 'value')})'; @@ -152,3 +171,6 @@ String recordSelection(List fields, String row) { String _computedLiteral(ComputedColumn value) => 'ComputedColumn.forDialects(sqlite: ${dartLiteral(value.sqlite)}, postgres: ${dartLiteral(value.postgres)}, mysql: ${value.mysql == null ? 'null' : dartLiteral(value.mysql!)}, mariadb: ${value.mariadb == null ? 'null' : dartLiteral(value.mariadb!)}, storage: ComputedStorage.${value.storage.name})'; + +String rowDeclaration(ModelEntity entity) => + 'final class ${entity.row}({${entity.fields.map((f) => 'required final ${f.type} ${f.name}').join(', ')}});'; diff --git a/lib/src/generate/exception.dart b/lib/src/generate/exception.dart index c8021ae0..41907991 100644 --- a/lib/src/generate/exception.dart +++ b/lib/src/generate/exception.dart @@ -3,9 +3,28 @@ final class GenerationException implements Exception { /// The validation failure, including source location when available. final String message; + /// Stable diagnostic category for source-level schema errors. + final String? code; + + /// Source library, when the failure has a declaration. + final Uri? source; + + /// One-based source line, or null for a failure without a source location. + final int? line; + + /// One-based source column, or null for a failure without a source location. + final int? column; + /// Creates a source-generation failure with an actionable [message]. - const GenerationException(this.message); + const GenerationException( + this.message, { + this.code, + this.source, + this.line, + this.column, + }); @override - String toString() => 'GenerationException: $message'; + String toString() => + '${source == null ? 'GenerationException' : '$source:$line:$column'}: ${code == null ? '' : '[$code] '}$message'; } diff --git a/lib/src/generate/import.dart b/lib/src/generate/import.dart index 1e25b7c5..41ccbb93 100644 --- a/lib/src/generate/import.dart +++ b/lib/src/generate/import.dart @@ -53,7 +53,7 @@ final class ImportedSchema { /// Editable Dart model declarations for supported catalog objects. final String dart; - /// Physical table names mapped to their generated Dart entity names. + /// Physical table names mapped to their Dart model declaration names. final Map entities; /// Physical table and column names mapped to Dart field names. @@ -307,7 +307,7 @@ Future _importCatalog( } for (final column in info.columns) { final path = '$name.${column.name}'; - if (_importType(column, db.dialect) == null) { + if (_importColumn(column, db.dialect) == null) { issues.add( SchemaImportIssue( 'IMPORT.TYPE', @@ -404,8 +404,8 @@ Future _importCatalog( // 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 (isMysqlDialect(dialect)) return _importMysqlType(column); +String? _importColumn(ColumnInfo column, SqlDialect dialect) { + if (isMysqlDialect(dialect)) return _importMysqlColumn(column); return switch (( dialect, column.temporalPrecision == null @@ -414,66 +414,59 @@ Future _importCatalog( )) { (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, - ), + 'decimal', + (SqlDialect.postgres, 'NUMERIC') => 'decimal', + (SqlDialect.postgres, _) when column.decimalPrecision != null => 'decimal', (SqlDialect.sqlite, 'INTEGER') || - (SqlDialect.postgres, 'SMALLINT' || 'INTEGER' || 'BIGINT') => ('int', null), + (SqlDialect.postgres, 'SMALLINT' || 'INTEGER' || 'BIGINT') => 'integer', (SqlDialect.sqlite, 'TEXT') when column.collation?.toLowerCase() == 'orm_date_v1' => - ('LocalDate', null), + 'date', (SqlDialect.sqlite, 'TEXT') when column.collation?.toLowerCase() == 'orm_time_v1' => - ('LocalTime', null), + 'time', (SqlDialect.sqlite, 'TEXT') when column.collation?.toLowerCase() == 'orm_local_datetime_v1' => - ('LocalDateTime', null), + 'localDateTime', (SqlDialect.sqlite, 'TEXT') when column.collation?.toLowerCase() == 'orm_instant_v1' => - ('DateTime', null), - (_, 'TEXT') => ('String', null), + 'dateTime', + (_, 'TEXT') => 'text', (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'), + (SqlDialect.postgres, 'DOUBLE PRECISION') => 'real', + (SqlDialect.sqlite, 'BLOB') || (SqlDialect.postgres, 'BYTEA') => 'bytes', + (SqlDialect.postgres, 'BOOLEAN') => 'boolean', + (SqlDialect.postgres, 'TIMESTAMPTZ') => 'dateTime', + (SqlDialect.postgres, 'DATE') => 'date', + (SqlDialect.postgres, 'TIME WITHOUT TIME ZONE') => 'time', + (SqlDialect.postgres, 'TIMESTAMP WITHOUT TIME ZONE') => 'localDateTime', + (SqlDialect.postgres, 'JSONB') => 'json', _ => null, }; } -(String, String?)? _importMysqlType(ColumnInfo column) { +String? _importMysqlColumn(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); + return 'decimal'; } 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'), + 'SMALLINT' || 'INT' || 'BIGINT' => 'integer', + 'TINYINT(1)' => 'boolean', + 'VARCHAR(255)' => 'text', + 'DOUBLE' => 'real', + 'LONGBLOB' => 'bytes', + 'DATE' => 'date', + 'TIME' => 'time', + 'DATETIME' => 'localDateTime', + 'JSON' => 'json', _ => null, }; } @@ -485,6 +478,7 @@ final class _ImportNames { final used = { ...Keyword.keywords.keys, ...databaseMembers, + ...generatedTypeNames, 'column', 'readColumn', 'appSchema', @@ -505,13 +499,27 @@ final class _ImportNames { 'LocalDate', 'LocalTime', 'LocalDateTime', - 'DecimalDigits', - 'entity', - 'Id', - 'Unique', - 'ColumnName', - 'Default', - 'UseCodec', + 'Model', + 'model', + 'identity', + 'integer', + 'text', + 'boolean', + 'real', + 'bigInteger', + 'decimal', + 'dateTime', + 'date', + 'time', + 'localDateTime', + 'bytes', + 'json', + 'enumeration', + 'custom', + 'index', + 'check', + 'references', + 'referencedBy', 'Codecs', }; String take( @@ -538,7 +546,7 @@ final class _ImportNames { Set symbols(String v) => { v, if (entity) ...[ - '${_importCap(v)}Row', + _importCap(v), '${v}Schema', '${v}Table', '${_importCap(v)}Fields', @@ -582,112 +590,21 @@ ImportedSchema _importDeclarations( 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(${dartLiteral(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(${dartLiteral(c.declarationDefaultSql!)})'); - } - if (c.computed != null) { - final computed = c.declarationComputed!; - b.writeln( - '@Computed.sql(${dartLiteral(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: ${dartLiteral(info.name)});', - ); - } - String selector(String table, List columns) { + final relations = {for (final info in infos.values) info.name: []}; + String selection(String table, List columns) { final values = columns.map((c) => 'row.${fields[table]![c]}').toList(); - return '(row) => ${values.length == 1 ? values.single : '(${values.join(', ')})'}'; + return values.length == 1 ? values.single : '(${values.join(', ')})'; } + String mapping( + String local, + List columns, + String target, + List targetColumns, + ) => + '(${List.generate(columns.length, (i) => '${fields[target]![targetColumns[i]]}: row.${fields[local]![columns[i]]}').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: ${dartLiteral(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(${dartLiteral(check.expression)}, ' - 'name: ${check.name == null ? 'null' : dartLiteral(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]) @@ -706,16 +623,16 @@ ImportedSchema _importDeclarations( ].any((k) => sameStrings(k, key.targetColumns)) || key.columns.asMap().entries.any( (entry) => - _importType( + _importColumn( info.columns.singleWhere((c) => c.name == entry.value), dialect, - )?.$1 != - _importType( + ) != + _importColumn( target.columns.singleWhere( (c) => c.name == key.targetColumns[entry.key], ), dialect, - )?.$1, + ), ) || key.onDelete == 'SET NULL' && key.columns.any( @@ -743,10 +660,99 @@ ImportedSchema _importDeclarations( } fieldNames[info.name]!.used.add(relation); final inverse = fieldNames[key.target]!.take('${entity}Rows'); + relations[info.name]!.add( + '$relation: references(${mapping(info.name, key.columns, key.target, key.targetColumns)}, () => ${entities[key.target]}, onDelete: .$action)', + ); + relations[key.target]!.add( + '$inverse: referencedBy(() => $entity, on: ${mapping(key.target, key.targetColumns, info.name, key.columns)})', + ); + } + } + final b = StringBuffer( + '// Imported catalog draft. Review the import report before baselining.\n\n', + )..writeln("import 'package:orm/schema.dart';"); + for (final info in infos.values) { + final entity = entities[info.name]!; + b.writeln( + 'final ${relations[info.name]!.isEmpty ? '' : 'Model '}$entity = model(${dartLiteral(info.name)}, (', + ); + for (final c in info.columns) { + final helper = _importColumn(c, dialect)!; + final options = ['name: ${dartLiteral(c.name)}']; + if (c.integerBits != null && c.integerBits != 64) { + options.add('bits: ${c.integerBits}'); + } + if (c.temporalPrecision != null && c.temporalPrecision != 6) { + options.add('precision: ${c.temporalPrecision}'); + } + if (c.decimalPrecision != null) { + options.add( + 'precision: ${c.decimalPrecision}, scale: ${c.decimalScale ?? 0}', + ); + } + if (c.declarationDefaultSql != null) { + options.add('defaultSql: ${dartLiteral(c.declarationDefaultSql!)}'); + } + var column = '$helper(${options.join(', ')})'; + if (c.nullable) column += '.nullable()'; + if (generated[info.name]!.contains(c.name)) column += '.identity()'; + if (c.computed case final computed?) { + column += + '.computed(${dartLiteral(c.declarationComputed!.expression(dialect))}, storage: .${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, + ), + ); + } + b.writeln('${fields[info.name]![c.name]}: $column,'); + } + b.writeln('),'); + if (info.primaryKey.isNotEmpty && generated[info.name]!.isEmpty) { + b.writeln( + 'primaryKey: (row) => ${selection(info.name, info.primaryKey)},', + ); + } + final unique = info.uniqueKeys.toList() + ..sort((a, b) => jsonEncode(a).compareTo(jsonEncode(b))); + if (unique.isNotEmpty) { + b.writeln( + 'uniqueKeys: (row) => [${unique.map((k) => selection(info.name, k)).join(', ')}],', + ); + } + final indexes = info.indexes.toList() + ..sort((a, b) => a.name.compareTo(b.name)); + if (indexes.isNotEmpty) { + b.writeln( + 'indexes: (row) => [${indexes.map((i) => 'index(${selection(info.name, i.columns)}, name: ${dartLiteral(i.name)}, unique: ${i.unique})').join(', ')}],', + ); + } + final checks = info.checks.toList() + ..sort( + (a, b) => + jsonEncode([a.name, a.expression]) + .compareTo(jsonEncode([b.name, b.expression])), + ); + if (checks.isNotEmpty) { b.writeln( - 'final $relation = $entity.key(${selector(info.name, key.columns)}).references(${entities[key.target]}.key(${selector(key.target, key.targetColumns)}), inverse: ${dartLiteral(inverse)}, onDelete: .$action);', + 'checks: [${checks.map((c) => 'check(${dartLiteral(c.expression)}, name: ${c.name == null ? 'null' : dartLiteral(c.name!)})').join(', ')}],', + ); + 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, + ), ); } + if (relations[info.name]!.isNotEmpty) { + b.writeln('relations: (row) => (${relations[info.name]!.join(', ')},),'); + } + b.writeln(');'); } if (infos.isEmpty) { b.writeln('// No supported tables were imported. See the import report.'); diff --git a/lib/src/generate/model.dart b/lib/src/generate/model.dart index 79981903..83d36728 100644 --- a/lib/src/generate/model.dart +++ b/lib/src/generate/model.dart @@ -38,6 +38,48 @@ const databaseMembers = { 'toString', }; +// Names referenced without a prefix in generated clients. +const generatedTypeNames = { + 'Column', + 'Codecs', + 'TableSchema', + 'Fields', + 'Table', + 'Query', + 'QueryContext', + 'TableSet', + 'Change', + 'Relation', + 'IndexSchema', + 'ForeignKey', + 'ComputedColumn', + 'ComputedStorage', + 'CheckSchema', + 'String', + 'int', + 'double', + 'bool', + 'DateTime', + 'List', + 'Map', + 'Set', + 'Object', + 'Record', + 'Future', + 'BigInt', + 'Decimal', + 'LocalDate', + 'LocalTime', + 'LocalDateTime', + 'SqlJson', + 'Uint8List', + 'Expr', + 'Codec', + 'SqlQueryDefinition', + 'SqlDialect', + 'SqlTemplate', +}; + final class ModelField { final String name; final String column; @@ -110,32 +152,24 @@ final class ModelEntity { final String row; final List 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 indexes = []; final List edges = []; final List checks = []; - ModelEntity( - 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], - ]; + ModelEntity(this.name, this.table, this.row, this.fields) + : 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'; + String get rowType => row; ModelField field(String name) => fields.firstWhere( (f) => f.name == name, orElse: () => throw GenerationException('$this has no field $name.'), @@ -174,6 +208,8 @@ final class ModelIndex( final bool unique, ); +/// Keys follow query navigation: [parentKeys] belong to the declaring model and +/// [childKeys] to [target], independently of the physical foreign-key direction. final class ModelRelation( final String name, final ModelEntity target, diff --git a/lib/src/generate/queries.dart b/lib/src/generate/queries.dart index 4b48f88c..96aa52f9 100644 --- a/lib/src/generate/queries.dart +++ b/lib/src/generate/queries.dart @@ -9,10 +9,11 @@ import 'package:path/path.dart' as p; import '../../runtime.dart'; import '../../sql.dart'; -import 'emitter.dart' show recordSelection; +import 'emitter.dart' show modelSelection, rowDeclaration; import 'exception.dart'; import 'model.dart'; -import 'reader.dart'; +import 'schema/columns.dart'; +import 'schema/syntax.dart'; import 'source.dart'; import 'types.dart'; @@ -123,38 +124,19 @@ Future generateResolvedQueries( 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) { + final symbols = {...generatedTypeNames}; + List fields(Expression? expression, {required bool parameters}) { + if (expression == null && parameters) return []; + if (expression is! RecordLiteral || + expression.fields.any((f) => f is! RecordLiteralNamedField)) { throw const GenerationException( - 'Named SQL requires non-generic record typedefs in the source library.', - ); - } - return alias.type as RecordTypeAnnotation; - } - - List fields( - RecordTypeAnnotation record, { - required bool parameters, - }) { - if (record.positionalFields.isNotEmpty) { - throw const GenerationException( - 'Named SQL records require named fields.', + 'Named SQL requires a named Record of column declarations.', ); } final fields = [ - for (final f - in record.namedFields?.fields ?? []) - reader.readField(f, f.name.lexeme, f.type.type, f.metadata), + for (final f in expression.fields.cast()) + readColumn(f, library.typeSystem, names).$1, ]; for (final f in fields) { if (f.id || @@ -167,12 +149,12 @@ Future generateResolvedQueries( f.integerBits != null || f.decimalPrecision != null) { throw const GenerationException( - 'Query fields accept codec and column-name annotations only.', + 'Query columns declare value types and SQL names, without table constraints or defaults.', ); } if (parameters && f.column != snakeCase(f.name)) { throw const GenerationException( - 'SQL parameters use Dart field names, without ColumnName.', + 'SQL parameters use Dart field names, without a name override.', ); } } @@ -193,34 +175,35 @@ Future generateResolvedQueries( if (call is! MethodInvocation || call.methodName.name != 'sqlQuery' || call.methodName.element?.library?.uri.toString() != - schemaDeclarationUri) { + 'package:orm/src/schema/queries.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('_')) { + if (!declaration.variables.isFinal || 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 symbol = name[0].toUpperCase() + name.substring(1); + for (final generated in [symbol, '${symbol}Fields', '${symbol}Sql']) { + if (!symbols.add(generated)) { + throw GenerationException( + 'Generated symbol $generated is ambiguous. Rename the Dart query.', + ); + } } - final resultFields = fields(record(types.first), parameters: false); - final parameterFields = fields(record(types.last), parameters: true); + final resultFields = fields( + namedArgument(call, 'result'), + parameters: false, + ); + final parameterFields = fields( + namedArgument(call, 'parameters'), + parameters: true, + ); final sql = {}; for (final dialect in SqlDialect.values) { - final path = reader.namedString(call, dialect.name); + final path = namedString(call, dialect.name); if (path == null) continue; if (path.isEmpty || p.url.isAbsolute(path) || @@ -253,7 +236,7 @@ Future generateResolvedQueries( ModelEntity( name, '_orm_sql_${snakeCase(name)}', - types.first.toSource(), + symbol, resultFields, ), parameterFields, @@ -263,9 +246,7 @@ Future generateResolvedQueries( } } if (queries.isEmpty) { - throw const GenerationException( - 'No sqlQuery() declarations found.', - ); + throw const GenerationException('No sqlQuery(...) declarations found.'); } return GeneratedQueries( DartFormatter(languageVersion: library.languageVersion.effective) @@ -301,11 +282,13 @@ String _emitQueries( DartNames names, ) { final b = StringBuffer('// GENERATED CODE - DO NOT MODIFY BY HAND.\n\n') - ..writeln("import 'package:orm/sql.dart';") - ..writeln('import ${dartLiteral(sourceImport)} as models;') - ..writeln( - 'export ${dartLiteral(sourceImport)} show ${queries.map((q) => q.result.row).toSet().join(', ')};', - ); + ..writeln("import 'package:orm/sql.dart';"); + if (names.usesSource) { + b.writeln('import ${dartLiteral(sourceImport)} as models;'); + } + for (final (uri, symbols) in names.exports) { + b.writeln('export ${dartLiteral(uri)} show ${symbols.join(', ')};'); + } if (names.typedData) b.writeln("import 'dart:typed_data';"); for (final (uri, prefix) in names.imports) { b.writeln('import ${dartLiteral(uri)} as $prefix;'); @@ -317,6 +300,7 @@ String _emitQueries( } for (final query in queries) { final entity = query.result; + b.writeln(rowDeclaration(entity)); for (final f in entity.fields) { b.writeln( 'final ${_queryColumn(entity, f)} = Column<${f.type}>(${dartLiteral(f.column)}, ${f.codec}, nullable: ${f.nullable});', @@ -332,7 +316,7 @@ String _emitQueries( b.writeln( 'final _sqlDefinition${entity.symbol} = SqlQueryDefinition<${entity.rowType}, ${entity.fieldsType}>(' 'Table(TableSchema(${dartLiteral(entity.table)}, columns: [${entity.fields.map((f) => _queryColumn(entity, f)).join(', ')}]), ' - '${entity.fieldsType}.new, (row) => ${recordSelection(entity.fields, 'row')}), {' + '${entity.fieldsType}.new, (row) => ${modelSelection(entity, 'row')}), {' '${query.sql.entries.map((entry) => 'SqlDialect.${entry.key.name}: SqlTemplate(${dartLiteral(entry.value)}, dialect: SqlDialect.${entry.key.name})').join(', ')}' '});', ); diff --git a/lib/src/generate/reader.dart b/lib/src/generate/reader.dart deleted file mode 100644 index 4de93d47..00000000 --- a/lib/src/generate/reader.dart +++ /dev/null @@ -1,650 +0,0 @@ -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:analyzer/dart/element/type_system.dart'; - -import '../../schema_model.dart'; -import 'exception.dart'; -import 'model.dart'; -import 'source.dart'; -import 'types.dart'; - -final class SchemaReader( - final CompilationUnit unit, - final TypeSystem typeSystem, - final DartNames names, -) { - final Map entities = {}; - - List 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() != - schemaDeclarationUri) { - 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 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') ?? snakeCase(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 = ModelEntity( - 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') ?? snakeCase(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( - ModelIndex( - namedString(call, 'name') ?? snakeCase(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() != - schemaDeclarationUri) { - 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) => sameStrings(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, - ModelRelation(variable.name.lexeme, target, local, remote, onDelete), - call, - ); - final inverse = namedString(call, 'inverse'); - if (inverse != null) { - _addEdge( - target, - ModelRelation( - 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' || - !sameStrings(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, 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 = []; - 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); - } - - ModelField 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() != - schemaDeclarationUri) { - 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() != codecLibraryUri) { - _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', - ...valueLibraryUris, - }.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 ModelField( - name: name, - column: column ?? snakeCase(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, - ); - } - - (ModelEntity, 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, ModelEntity 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(ModelEntity source, ModelRelation 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/schema.dart b/lib/src/generate/schema.dart index f8146eb2..d3fbc460 100644 --- a/lib/src/generate/schema.dart +++ b/lib/src/generate/schema.dart @@ -10,7 +10,8 @@ import 'package:path/path.dart' as p; import '../../migrate.dart'; import 'emitter.dart'; import 'exception.dart'; -import 'reader.dart'; +import 'schema/reader.dart'; +import 'schema/sources.dart'; import 'source.dart'; import 'types.dart'; @@ -70,7 +71,7 @@ Future generateSchema( final import = p .relative(source, from: p.dirname(output)) .replaceAll(r'\', '/'); - return generateResolvedSchema( + return await generateResolvedSchema( resolved.unit, resolved.libraryElement, import, @@ -79,20 +80,39 @@ Future generateSchema( .relative(uri.toFilePath(), from: p.dirname(output)) .replaceAll(r'\', '/') : uri.toString(), + resolve: (library) async { + final result = await library.session.getResolvedUnit( + library.firstFragment.source.fullName, + ); + if (result is! ResolvedUnitResult) { + throw GenerationException('Cannot analyze ${library.uri}.'); + } + final errors = result.diagnostics.where( + (e) => e.severity.name.toLowerCase() == 'error', + ); + if (errors.isNotEmpty) throw GenerationException(errors.join('\n')); + return result.unit; + }, ); } finally { await contexts.dispose(); } } -GeneratedSchema generateResolvedSchema( +Future generateResolvedSchema( CompilationUnit unit, LibraryElement library, String sourceImport, - String Function(Uri) importUri, -) { + String Function(Uri) importUri, { + required Future Function(LibraryElement) resolve, +}) async { final names = DartNames(library.uri, importUri); - final schema = SchemaReader(unit, library.typeSystem, names).read(); + final schema = SchemaReader( + unit, + library.typeSystem, + names, + await schemaSources(unit, library, resolve), + ).read(); return GeneratedSchema( DartFormatter(languageVersion: library.languageVersion.effective) .format(emitSchema(schema, sourceImport, names)), diff --git a/lib/src/generate/schema/columns.dart b/lib/src/generate/schema/columns.dart new file mode 100644 index 00000000..2ec8ace0 --- /dev/null +++ b/lib/src/generate/schema/columns.dart @@ -0,0 +1,330 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/dart/element/type_system.dart'; + +import '../../../schema_model.dart'; +import '../model.dart'; +import '../source.dart'; +import '../types.dart'; +import 'syntax.dart'; + +(ModelField, DartType, Object) readColumn( + RecordLiteralNamedField field, + TypeSystem typeSystem, + DartNames names, +) { + final type = field.fieldExpression.staticType; + if (type is! InterfaceType || + type.element.name != 'ColumnDefinition' || + type.element.library.uri.toString() != schemaDeclarationUri) { + failAt( + field, + 'COLUMN', + 'Every field must be a column declaration, such as text() or integer().', + ); + } + final name = field.name.lexeme; + if ({ + 'table', + 'column', + 'readColumn', + 'hashCode', + 'runtimeType', + 'toString', + 'noSuchMethod', + }.contains(name)) { + failAt( + field, + 'NAME', + 'Field $name conflicts with the generated fields API. Use another Dart name and name: for SQL.', + ); + } + final valueType = type.typeArguments.single; + if (valueType is DynamicType) { + failAt( + field, + 'COLUMN', + 'Stored columns require a concrete value type, not dynamic.', + ); + } + final nullable = typeSystem.isNullable(valueType); + var expression = field.fieldExpression; + ComputedColumn? computed; + Expression? nullableFactory; + final modifiers = {}; + while (expression is MethodInvocation && + expression.target != null && + callElement(expression)?.enclosingElement?.name == 'ColumnDefinition') { + final method = callName(expression); + if (!modifiers.add(method)) { + failAt(expression, 'DUPLICATE', '$method is declared twice on $name.'); + } + switch (method) { + case 'nullable': + nullableFactory = namedArgument(expression, 'clientDefault'); + break; + case 'identity': + break; + case 'computed': + final sql = stringValue(positionalArguments(expression).single); + computed = ComputedColumn.forDialects( + sqlite: namedString(expression, 'sqlite') ?? sql, + postgres: namedString(expression, 'postgres') ?? sql, + mysql: namedString(expression, 'mysql') ?? sql, + mariadb: namedString(expression, 'mariadb') ?? sql, + storage: enumName(namedArgument(expression, 'storage')) == 'virtual' + ? ComputedStorage.virtual + : ComputedStorage.stored, + ); + if (SqlDialect.values.every( + (d) => computed!.expression(d).trim().isEmpty, + )) { + failAt( + expression, + 'COLUMN', + 'Computed SQL must be non-empty for at least one database.', + ); + } + default: + failAt(expression, 'COLUMN', 'Unsupported column modifier $method.'); + } + expression = expression.target!; + } + if (expression is! MethodInvocation || + callElement(expression)?.library?.uri.toString() != + schemaDeclarationUri || + callElement(expression) is! TopLevelFunctionElement) { + failAt( + expression, + 'COLUMN', + 'Use a column helper directly. Arbitrary factories and aliases are not executed.', + ); + } + final call = expression; + final helper = callName(call); + if (helper == 'identity' && modifiers.contains('identity')) { + failAt(call, 'DUPLICATE', 'Identity is declared twice.'); + } + final generated = helper == 'identity' || modifiers.contains('identity'); + String storage, codec; + Object? codecIdentity; + var codecNullable = false; + Map? enumLabels; + if (helper == 'enumeration') { + final enumType = typeSystem.promoteToNonNull(valueType); + if (enumType is! InterfaceType || enumType.element is! EnumElement) { + failAt(call, 'ENUM', 'Use a concrete enum type and its .values list.'); + } + final enumeration = enumType.element as EnumElement; + final values = resolvedElement(positionalArguments(call).single); + if (values is! FieldElement || + values.name != 'values' || + values.enclosingElement != enumeration) { + failAt(call, 'ENUM', 'Pass ${enumeration.name}.values to enumeration().'); + } + enumLabels = { + for (final f in enumeration.fields.where((f) => f.isEnumConstant)) + f.name!: f.name!, + }; + final labels = namedArgument(call, 'labels'); + if (labels != null) { + if (labels is! SetOrMapLiteral || !labels.isMap) { + failAt( + labels, + 'ENUM', + 'Use a literal map of enum constants to storage labels.', + ); + } + final mapped = {}; + for (final entry in labels.elements) { + if (entry is! MapLiteralEntry) { + failAt( + entry, + 'ENUM', + 'Enum labels do not support collection control flow or spreads.', + ); + } + final constant = resolvedElement(entry.key); + if (constant is! FieldElement || + !constant.isEnumConstant || + constant.enclosingElement != enumeration || + mapped.containsKey(constant.name)) { + failAt(entry.key, 'ENUM', 'Use each enum constant exactly once.'); + } + mapped[constant.name!] = stringValue(entry.value); + } + if (mapped.length != enumLabels.length) { + failAt(labels, 'ENUM', 'Provide a label for every enum constant.'); + } + enumLabels = {for (final key in enumLabels.keys) key: mapped[key]!}; + } + if (enumLabels.isEmpty || + enumLabels.values.toSet().length != enumLabels.length) { + failAt( + call, + 'ENUM', + 'Enum storage labels must be non-empty in number and distinct.', + ); + } + final symbol = names.type(enumType); + codec = + 'Codecs.enumeration<$symbol>({${enumLabels.entries.map((e) => '$symbol.${e.key}: ${dartLiteral(e.value)}').join(', ')}})'; + storage = 'text'; + } else if (helper == 'custom') { + final source = positionalArguments(call).single; + final constant = constantValue(source); + storage = constant?.getField('sqlType')?.toStringValue() ?? ''; + if (!{ + 'integer', + 'bigint', + 'decimal', + 'real', + 'text', + 'boolean', + 'instant', + 'date', + 'time', + 'local_datetime', + 'blob', + 'json', + }.contains(storage)) { + failAt( + source, + 'CODEC', + 'Use a public const Codec with a supported storage type.', + ); + } + codec = names.codecReference(source); + codecIdentity = constant; + final domain = (source.staticType as InterfaceType).typeArguments.single; + codecNullable = typeSystem.isNullable(domain); + } else { + final mapping = switch (helper) { + 'identity' || 'integer' => ('integer', 'integer'), + 'text' => ('text', 'text'), + 'boolean' => ('boolean', 'boolean'), + 'real' => ('real', 'real'), + 'bigInteger' => ('bigint', 'bigint'), + 'decimal' => ('decimal', 'decimal'), + 'dateTime' => ('instant', 'dateTime'), + 'date' => ('date', 'date'), + 'time' => ('time', 'time'), + 'localDateTime' => ('local_datetime', 'localDateTime'), + 'bytes' => ('blob', 'bytes'), + 'json' => ('json', 'jsonDocument'), + _ => failAt(call, 'COLUMN', 'Unsupported column helper $helper.'), + }; + (storage, codec) = (mapping.$1, 'Codecs.${mapping.$2}'); + } + final comparisonCodec = codecIdentity ?? codec; + if (nullable && !codecNullable) codec += '.nullable()'; + var defaultSql = namedString(call, 'defaultSql'); + final literalDefault = namedArgument(call, 'defaultValue'); + final factory = nullableFactory ?? namedArgument(call, 'clientDefault'); + if (nullableFactory != null && namedArgument(call, 'clientDefault') != null) { + failAt(field, 'DEFAULT', 'Declare clientDefault only once.'); + } + if (defaultSql != null && literalDefault != null || + computed != null && + (generated || + defaultSql != null || + literalDefault != null || + factory != null)) { + failAt( + field, + 'DEFAULT', + 'Use defaultValue or defaultSql; computed columns cannot have identity or defaults.', + ); + } + if (literalDefault != null) { + if (literalDefault.staticType is DynamicType) { + failAt( + literalDefault, + 'DEFAULT', + 'Defaults require a statically typed value.', + ); + } + final value = enumLabels == null + ? scalarValue(literalDefault) + : enumLabels[enumName(literalDefault)]; + defaultSql = switch (value) { + String() => "'${value.replaceAll("'", "''")}'", + bool() => value ? 'true' : 'false', + int() => '$value', + double() when value.isFinite => '$value', + _ => failAt( + literalDefault, + 'DEFAULT', + 'Use a non-null literal/const default or an explicit SQL/client default.', + ), + }; + } + String? clientDefault; + if (factory != null) { + final signature = factory.staticType; + var function = resolvedElement(factory); + if (function is VariableElement) { + function = function.computeConstantValue()?.toFunctionValue(); + } + if (signature is! FunctionType || + signature.typeParameters.isNotEmpty || + signature.formalParameters.any((p) => p.isRequired) || + function is! ExecutableElement || + signature.returnType is DynamicType || + !typeSystem.isSubtypeOf(signature.returnType, valueType)) { + failAt( + factory, + 'DEFAULT', + 'Use a public synchronous function or constructor tear-off returning $valueType without required arguments.', + ); + } + clientDefault = names.factoryReference(factory, function, signature); + } + final bits = namedInteger(call, 'bits'); + final precision = namedInteger(call, 'precision'); + final scale = namedInteger(call, 'scale'); + if (bits != null && (storage != 'integer' || !{16, 32, 64}.contains(bits))) { + failAt(call, 'STORAGE', 'Integer bits must be 16, 32 or 64.'); + } + if (storage == 'decimal') { + if (scale != null && precision == null || + precision != null && + (precision < 1 || + precision > 1000 || + (scale ?? 0) < -1000 || + (scale ?? 0) > 1000)) { + failAt( + call, + 'STORAGE', + 'Decimal precision must be 1..1000; scale -1000..1000 requires precision.', + ); + } + } else if (scale != null || + precision != null && + (!{'instant', 'time', 'local_datetime'}.contains(storage) || + precision < 0 || + precision > 6)) { + failAt(call, 'STORAGE', 'Temporal precision must be 0..6.'); + } + names.exportType(valueType); + final result = ModelField( + name: name, + column: namedString(call, 'name') ?? snakeCase(name), + type: names.type(valueType), + codec: codec, + storage: storage, + nullable: nullable, + id: generated, + generated: generated, + unique: namedBoolean(call, 'unique') ?? false, + defaultSql: defaultSql, + clientDefault: clientDefault, + computed: computed, + integerBits: bits, + decimalPrecision: storage == 'decimal' ? precision : null, + decimalScale: scale, + temporalPrecision: storage == 'decimal' ? null : precision, + ); + return (result, valueType, comparisonCodec); +} diff --git a/lib/src/generate/schema/reader.dart b/lib/src/generate/schema/reader.dart new file mode 100644 index 00000000..48b7aa33 --- /dev/null +++ b/lib/src/generate/schema/reader.dart @@ -0,0 +1,592 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/dart/element/type_system.dart'; + +import '../../../schema_model.dart'; +import '../model.dart'; +import '../source.dart'; +import '../types.dart'; +import 'columns.dart'; +import 'syntax.dart'; + +/// Reads source declarations, never application objects or callback results. +final class SchemaReader( + final CompilationUnit unit, + final TypeSystem typeSystem, + final DartNames names, + final List declarations, +) { + final _models = {}; + final _definitions = {}; + final _fieldTypes = {}; + final _fieldCodecs = {}; + final _relations = <(ModelEntity, String, String, InvocationExpression)>[]; + + List read() { + final consumed = {}; + for (final variable in declarations) { + final declaration = variable.parent as VariableDeclarationList; + final element = variable.declaredFragment!.element; + if (!isModel(element.type)) continue; + final expression = variable.initializer; + if (!declaration.isFinal || + element.isPrivate || + databaseMembers.contains(element.name)) { + failAt( + variable, + 'MODEL', + 'Use a public final variable initialized with model(...) that does not shadow a Database member.', + ); + } + if (expression is! InvocationExpression || + callName(expression) != 'model') { + failAt( + variable, + 'MODEL', + 'Initialize the model directly with model(...).', + ); + } + final record = positionalArguments(expression)[1]; + if (record is! RecordLiteral || + record.fields.isEmpty || + record.fields.any((f) => f is! RecordLiteralNamedField)) { + failAt( + record, + 'FIELDS', + 'Use a non-empty named Record of column declarations as the second argument.', + ); + } + final fields = []; + for (final f in record.fields.cast()) { + final (field, valueType, codec) = readColumn(f, typeSystem, names); + _fieldTypes[field] = valueType; + _fieldCodecs[field] = codec; + fields.add(field); + } + final modelName = element.name!; + final row = modelName[0].toUpperCase() + modelName.substring(1); + final model = ModelEntity( + modelName, + stringValue(positionalArguments(expression).first), + row, + fields, + ); + _models[element] = model; + _definitions[model] = expression; + consumed.add(expression); + } + // A nested/list/function declaration must never disappear from the schema. + unit.accept( + _ModelCalls((call) { + if (!consumed.contains(call)) { + failAt( + call, + 'MODEL', + 'Place each model directly in a public final top-level variable.', + ); + } + }), + ); + if (_models.isEmpty) { + failAt(unit, 'MODEL', 'No model declarations found.'); + } + for (final entry in _definitions.entries) { + _constraints(entry.key, entry.value); + } + for (final (model, name, parameter, call) in _relations) { + if (callName(call) == 'references') { + _reference(model, name, parameter, call); + } + } + // Resolve inverses after all forward references, including other libraries. + for (final (model, name, parameter, call) in _relations) { + if (callName(call) == 'referencedBy') { + _inverseReference(model, name, parameter, call); + } + } + final relationOrder = { + for (var i = 0; i < _relations.length; i++) + (_relations[i].$1, _relations[i].$2): i, + }; + for (final model in _models.values) { + model.edges.sort( + (a, b) => relationOrder[(model, a.name)]!.compareTo( + relationOrder[(model, b.name)]!, + ), + ); + } + _validate(); + // Preserve local declaration order and existing snapshots. External models + // have no local source order; use physical identity so Dart renames cannot + // reorder a barrel's migration snapshot. + final external = + _models.values + .where((model) => _definitions[model]!.root != unit) + .toList() + ..sort((a, b) => a.table.compareTo(b.table)); + return [ + for (final model in _models.values) + if (_definitions[model]!.root == unit) model, + ...external, + ]; + } + + void _constraints(ModelEntity model, InvocationExpression call) { + final key = namedArgument(call, 'primaryKey'); + if (key != null) { + if (model.primaryKey.isNotEmpty) { + failAt(key, 'KEY', 'Primary key already declared by identity().'); + } + final (parameter, body) = _selector(key); + model.primaryKey = _columns(body, model, parameter); + } + for (final option in ['uniqueKeys', 'indexes']) { + final expression = namedArgument(call, option); + if (expression == null) continue; + final (parameter, body) = _selector(expression); + for (final item in _list(body)) { + if (option == 'uniqueKeys') { + model.uniqueKeys.add(_columns(item, model, parameter)); + continue; + } + if (item is! InvocationExpression || callName(item) != 'index') { + failAt(item, 'CONSTRAINT', 'Use index(...) directly.'); + } + model.indexes.add( + ModelIndex( + namedString(item, 'name')!, + _columns(positionalArguments(item).first, model, parameter), + namedBoolean(item, 'unique') ?? false, + ), + ); + } + } + final relations = namedArgument(call, 'relations'); + if (relations != null) { + final (parameter, body) = _selector(relations); + if (body is! RecordLiteral || + body.fields.any((f) => f is! RecordLiteralNamedField)) { + failAt(body, 'RELATION', 'Return a named Record of relationships.'); + } + for (final field in body.fields.cast()) { + final expression = field.fieldExpression; + if (expression is! InvocationExpression || + callElement(expression) is! TopLevelFunctionElement || + callElement(expression)?.library?.uri.toString() != + schemaDeclarationUri || + !{'references', 'referencedBy'}.contains(callName(expression))) { + failAt( + field, + 'RELATION', + 'Use references(...) or referencedBy(...) directly.', + ); + } + _relations.add((model, field.name.lexeme, parameter, expression)); + } + } + final checks = namedArgument(call, 'checks'); + if (checks != null) { + for (final item in _list(checks)) { + if (item is! InvocationExpression || callName(item) != 'check') { + failAt(item, 'CHECK', 'Use check(...) directly.'); + } + final sql = stringValue(positionalArguments(item).single); + final check = CheckSchema.forDialects( + namedString(item, 'name'), + sqlite: namedString(item, 'sqlite') ?? sql, + postgres: namedString(item, 'postgres') ?? sql, + mysql: namedString(item, 'mysql') ?? sql, + mariadb: namedString(item, 'mariadb') ?? sql, + ); + if (SqlDialect.values.every( + (d) => check.expression(d).trim().isEmpty, + ) || + check.name != null && + (check.name!.isEmpty || + model.checks.any((c) => c.name == check.name))) { + failAt( + item, + 'CHECK', + 'Use non-empty CHECK expressions and distinct, non-empty constraint names, or omit name.', + ); + } + model.checks.add(check); + } + } + } + + void _reference( + ModelEntity source, + String name, + String parameter, + InvocationExpression call, + ) { + final args = positionalArguments(call); + final target = _target(args[1]); + final named = + args.first is RecordLiteral && + (args.first as RecordLiteral).fields.any( + (f) => f is RecordLiteralNamedField, + ); + var (local, remote) = named + ? _mappedColumns(args.first, source, target, parameter) + : (_columns(args.first, source, parameter), target.primaryKey); + final constraint = namedBoolean(call, 'constraint') ?? true; + if (remote.isEmpty || local.length != remote.length) { + failAt( + call, + 'REFERENCE', + 'Foreign key arity differs or target has no primary key. Use a named Record to map target fields explicitly.', + ); + } + final keys = [ + target.primaryKey, + ...target.uniqueKeys, + for (final index in target.indexes) + if (index.unique) index.keys, + ]; + if (named) { + // Named fields describe a mapping, not tuple order. Normalize to the + // target key, or target field order for a read-only non-unique mapping. + final key = + keys + .where( + (k) => + k.length == remote.length && k.toSet().containsAll(remote), + ) + .firstOrNull ?? + target.fields.map((f) => f.name).where(remote.contains).toList(); + local = [for (final field in key) local[remote.indexOf(field)]]; + remote = key; + } + if (constraint && !keys.any((key) => sameStrings(key, remote))) { + failAt(call, 'REFERENCE', 'Target must be a primary or unique key.'); + } + for (var i = 0; i < local.length; i++) { + final a = source.field(local[i]), b = target.field(remote[i]); + final at = typeSystem.promoteToNonNull(_fieldTypes[a]!); + final bt = typeSystem.promoteToNonNull(_fieldTypes[b]!); + if (a.storage != b.storage || + !typeSystem.isSubtypeOf(at, bt) || + !typeSystem.isSubtypeOf(bt, at) || + _fieldCodecs[a] != _fieldCodecs[b]) { + failAt( + call, + 'REFERENCE', + 'Reference ${a.name} -> ${b.name} requires matching value types and codecs.', + ); + } + } + final action = enumName(namedArgument(call, 'onDelete')) ?? 'restrict'; + final sqlAction = switch (action) { + 'restrict' => 'RESTRICT', + 'cascade' => 'CASCADE', + 'setNull' => 'SET NULL', + 'setDefault' => 'SET DEFAULT', + 'noAction' => 'NO ACTION', + _ => failAt(call, 'REFERENCE', 'Unsupported deletion action.'), + }; + if (constraint && + action == 'setNull' && + local.any((key) => !source.field(key).nullable)) { + failAt(call, 'REFERENCE', 'SET NULL requires nullable source columns.'); + } + _edge( + source, + ModelRelation(name, target, local, remote, constraint ? sqlAction : null), + call, + ); + } + + ModelEntity _target(Expression targetExpression) { + if (targetExpression is! FunctionExpression || + targetExpression.parameters!.parameters.isNotEmpty || + targetExpression.body is! ExpressionFunctionBody) { + failAt( + targetExpression, + 'REFERENCE', + 'Reference the target with () => model.', + ); + } + final targetNode = + (targetExpression.body as ExpressionFunctionBody).expression; + final target = _models[resolvedElement(targetNode)]; + if (target == null) { + failAt( + targetNode, + 'REFERENCE', + 'Target must reference a reachable Model declaration.', + ); + } + return target; + } + + void _inverseReference( + ModelEntity source, + String name, + String parameter, + InvocationExpression call, + ) { + final target = _target(positionalArguments(call).single); + final on = namedArgument(call, 'on'); + final mapping = on == null + ? null + : _mappedColumns(on, source, target, parameter); + final matches = target.edges.where((edge) { + if (edge.inverse || edge.target != source) return false; + if (mapping == null) return true; + final (local, remote) = mapping; + if (remote.length != edge.parentKeys.length) return false; + for (var i = 0; i < remote.length; i++) { + final at = edge.parentKeys.indexOf(remote[i]); + if (at < 0 || edge.childKeys[at] != local[i]) return false; + } + return true; + }).toList(); + if (matches.length != 1) { + final choices = [ + for (final edge in matches) + '${target.name}.${edge.name}: on: (${[for (var i = 0; i < edge.parentKeys.length; i++) '${edge.parentKeys[i]}: $parameter.${edge.childKeys[i]}'].join(', ')})', + ]; + failAt( + call, + 'REFERENCE', + matches.isEmpty + ? 'No matching forward references declaration from ${target.name} to ${source.name}.' + : 'Multiple references from ${target.name} to ${source.name}. Select a mapping: ${choices.join('; ')}.', + ); + } + final forward = matches.single; + _edge( + source, + ModelRelation( + name, + target, + forward.childKeys, + forward.parentKeys, + forward.onDelete, + inverse: true, + ), + call, + ); + } + + void _edge(ModelEntity model, ModelRelation edge, AstNode node) { + if (!RegExp(r'^[a-zA-Z][a-zA-Z0-9_]*$').hasMatch(edge.name) || + { + 'table', + 'column', + 'readColumn', + 'hashCode', + 'runtimeType', + 'toString', + 'noSuchMethod', + }.contains(edge.name) || + model.fields.any((f) => f.name == edge.name) || + model.edges.any((e) => e.name == edge.name)) { + failAt( + node, + 'NAME', + 'Invalid or duplicate relation name ${edge.name} on ${model.name}.', + ); + } + model.edges.add(edge); + } + + void _validate() { + final tables = {}, indexes = {}; + final symbols = {'appSchema', 'AppTables', ...generatedTypeNames}; + for (final model in _models.values) { + final node = _definitions[model]!; + if (!tables.add(model.table)) { + failAt(node, 'DUPLICATE', 'Duplicate physical table ${model.table}.'); + } + if (model.fields.map((f) => f.column).toSet().length != + model.fields.length) { + failAt( + node, + 'DUPLICATE', + 'Duplicate physical column on ${model.name}.', + ); + } + for (final symbol in [ + model.row, + model.fieldsType, + model.setType, + '${model.symbol}Updates', + '${model.name}Schema', + '${model.name}Table', + for (final field in model.fields) columnSymbol(model, field), + ]) { + if (!symbols.add(symbol)) { + failAt( + node, + 'NAME', + 'Generated symbol $symbol is ambiguous. Rename the Dart model or field and keep its physical name.', + ); + } + } + if (model.fields.every((field) => field.computed != null)) { + failAt(node, 'COLUMN', 'A model needs at least one ordinary column.'); + } + for (final key in model.primaryKey) { + if (model.field(key).nullable) { + failAt(node, 'KEY', 'Primary keys cannot be nullable.'); + } + } + if (model.uniqueKeys.map((key) => key.join('\u0000')).toSet().length != + model.uniqueKeys.length) { + failAt(node, 'DUPLICATE', 'Duplicate unique key on ${model.name}.'); + } + for (final field in model.fields.where((f) => f.generated)) { + if (field.storage != 'integer' || + !sameStrings(model.primaryKey, [field.name])) { + failAt( + node, + 'KEY', + 'identity() requires a single integer-storage primary key.', + ); + } + } + for (final index in model.indexes) { + if (!indexes.add(index.name)) { + failAt(node, 'DUPLICATE', 'Duplicate index name ${index.name}.'); + } + } + } + } + + (String, Expression) _selector(Expression expression) { + if (expression is! FunctionExpression || + expression.parameters?.parameters.length != 1 || + expression.body is! ExpressionFunctionBody) { + failAt( + expression, + 'SELECTOR', + 'Use a one-argument arrow selector. Arbitrary callback execution is not supported.', + ); + } + return ( + expression.parameters!.parameters.single.name!.lexeme, + (expression.body as ExpressionFunctionBody).expression, + ); + } + + List _columns( + Expression expression, + ModelEntity model, + String parameter, + ) { + final columns = []; + for (final part in _parts(expression)) { + 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, + _ => failAt( + part, + 'SELECTOR', + 'Select direct fields of $parameter, or an ordered positional Record of them.', + ), + }; + if (!model.fields.any((f) => f.name == name)) { + failAt(part, 'SELECTOR', 'Unknown column $name.'); + } + if (columns.contains(name)) { + failAt(part, 'KEY', 'A key cannot repeat $name.'); + } + columns.add(name); + } + return columns; + } + + (List, List) _mappedColumns( + Expression expression, + ModelEntity source, + ModelEntity target, + String parameter, + ) { + if (expression is! RecordLiteral || + expression.fields.isEmpty || + expression.fields.any((f) => f is! RecordLiteralNamedField)) { + failAt( + expression, + 'REFERENCE', + 'Use a non-empty named Record mapping target fields to local columns.', + ); + } + final local = [], remote = []; + for (final field in expression.fields.cast()) { + final name = field.name.lexeme; + if (!target.fields.any((f) => f.name == name) || remote.contains(name)) { + failAt( + field, + 'REFERENCE', + 'Unknown or repeated target field ${target.name}.$name.', + ); + } + final columns = _columns(field.fieldExpression, source, parameter); + if (columns.length != 1 || local.contains(columns.single)) { + failAt( + field, + 'REFERENCE', + 'Map each target field to one distinct local column.', + ); + } + local.add(columns.single); + remote.add(name); + } + return (local, remote); + } + + List _parts(Expression expression) { + if (expression is! RecordLiteral) return [expression]; + if (expression.fields.isEmpty || + expression.fields.any((f) => f is RecordLiteralNamedField)) { + failAt( + expression, + 'KEY', + 'Use a direct column or non-empty positional Record to preserve key order.', + ); + } + return [for (final f in expression.fields) f.fieldExpression]; + } + + List _list(Expression expression) { + if (expression is! ListLiteral) { + failAt(expression, 'LIST', 'Use a literal list of declarations.'); + } + return [ + for (final item in expression.elements) + if (item is Expression) + item + else + failAt( + item, + 'LIST', + 'Collection control flow and spreads are not supported in schema declarations.', + ), + ]; + } +} + +final class _ModelCalls(final void Function(InvocationExpression) found) + extends RecursiveAstVisitor { + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == 'model' && + node.methodName.element?.library?.uri.toString() == + schemaDeclarationUri) { + found(node); + } + super.visitMethodInvocation(node); + } +} diff --git a/lib/src/generate/schema/sources.dart b/lib/src/generate/schema/sources.dart new file mode 100644 index 00000000..20d3631e --- /dev/null +++ b/lib/src/generate/schema/sources.dart @@ -0,0 +1,75 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; + +import '../exception.dart'; +import 'syntax.dart' show isModel; + +Iterable topLevelVariables(CompilationUnit unit) sync* { + for (final declaration + in unit.declarations.whereType()) { + yield* declaration.variables.variables; + } +} + +VariableElement? modelVariable(Element? element) { + final variable = element is PropertyAccessorElement + ? element.variable + : element; + return variable is VariableElement && isModel(variable.type) + ? variable + : null; +} + +/// Starts at public exports and local declarations, then follows model identity +/// references. Unrelated imports and unexported models in other files are not +/// schema roots. The resolver is supplied by the CLI or build asset owner. +Future> schemaSources( + CompilationUnit root, + LibraryElement library, + Future Function(LibraryElement) resolve, +) async { + final units = {library: root}; + final result = {}; + final exports = library.exportNamespace.definedNames2.values + .map(modelVariable) + .whereType() + .toSet(); + final pending = [ + for (final variable in topLevelVariables(root)) + if (isModel(variable.declaredFragment!.element.type)) + variable.declaredFragment!.element, + ...exports, + ]; + for (var i = 0; i < pending.length; i++) { + final element = pending[i]; + if (result.containsKey(element)) continue; + final owner = element.library!; + final unit = units[owner] ??= await resolve(owner); + final variable = topLevelVariables(unit) + .where((v) => v.declaredFragment!.element == element) + .firstOrNull; + if (variable == null) { + throw GenerationException( + 'Cannot resolve model ${element.name}. Declare models in independent Dart libraries.', + ); + } + result[element] = variable; + variable.initializer?.accept( + _ModelReferences((dependency) { + if (!result.containsKey(dependency)) pending.add(dependency); + }), + ); + } + return result.values.toList(); +} + +final class _ModelReferences(final void Function(VariableElement) found) + extends RecursiveAstVisitor { + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + final variable = modelVariable(node.element); + if (variable != null) found(variable); + super.visitSimpleIdentifier(node); + } +} diff --git a/lib/src/generate/schema/syntax.dart b/lib/src/generate/schema/syntax.dart new file mode 100644 index 00000000..2f24599a --- /dev/null +++ b/lib/src/generate/schema/syntax.dart @@ -0,0 +1,156 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/constant/value.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import '../exception.dart'; + +const schemaDeclarationUri = 'package:orm/src/schema/declaration.dart'; + +bool isModel(DartType? type) => + type is InterfaceType && + type.element.name == 'Model' && + type.element.library.uri.toString() == schemaDeclarationUri; + +List positionalArguments(InvocationExpression call) => [ + for (final argument in call.argumentList.arguments) + if (argument is! NamedArgument) argument.argumentExpression, +]; +Expression? namedArgument(InvocationExpression call, String name) { + for (final argument + in call.argumentList.arguments.whereType()) { + if (argument.name.lexeme == name) { + final expression = argument.argumentExpression; + return expression is NullLiteral || + constantValue(expression)?.isNull == true + ? null + : expression; + } + } + return null; +} + +String? namedString(InvocationExpression call, String name) { + final value = namedArgument(call, name); + return value == null ? null : stringValue(value); +} + +int? namedInteger(InvocationExpression call, String name) { + final expression = namedArgument(call, name); + if (expression == null) return null; + final value = scalarValue(expression); + return value is int + ? value + : failAt( + expression, + 'CONSTANT', + 'Use an integer literal or const reference.', + ); +} + +bool? namedBoolean(InvocationExpression call, String name) { + final expression = namedArgument(call, name); + if (expression == null) return null; + final value = scalarValue(expression); + return value is bool + ? value + : failAt( + expression, + 'CONSTANT', + 'Use a boolean literal or const reference.', + ); +} + +String stringValue(Expression expression) { + final value = scalarValue(expression); + return value is String + ? value + : failAt( + expression, + 'CONSTANT', + 'Use a string literal or const reference.', + ); +} + +Object? scalarValue(Expression expression) { + if (expression is SimpleStringLiteral) return expression.value; + if (expression is BooleanLiteral) return expression.value; + if (expression is IntegerLiteral) return expression.value; + if (expression is DoubleLiteral) return expression.value; + if (expression is PrefixExpression && expression.operator.lexeme == '-') { + final value = scalarValue(expression.operand); + if (value is num) return -value; + } + final value = constantValue(expression); + return value?.toStringValue() ?? + value?.toBoolValue() ?? + value?.toIntValue() ?? + value?.toDoubleValue(); +} + +DartObject? constantValue(Expression expression) { + final element = resolvedElement(expression); + return element is VariableElement && element.isConst + ? element.computeConstantValue() + : null; +} + +String? enumName(Expression? expression) { + if (expression == null) return null; + final element = resolvedElement(expression); + if (element is FieldElement && element.isEnumConstant) return element.name; + final constant = constantValue(expression); + final type = constant?.type; + final index = constant?.getField('index')?.toIntValue(); + if (type is InterfaceType && type.element is EnumElement && index != null) { + return (type.element as EnumElement).fields + .where((f) => f.isEnumConstant) + .elementAt(index) + .name; + } + return failAt(expression, 'CONSTANT', 'Use an enum constant.'); +} + +Element? resolvedElement(Expression expression) { + final element = switch (expression) { + Identifier() => expression.element, + PropertyAccess() => expression.propertyName.element, + DotShorthandPropertyAccess() => expression.propertyName.element, + ConstructorReference() => expression.constructorName.element, + FunctionReference() => resolvedElement(expression.function), + _ => null, + }; + return element is PropertyAccessorElement ? element.variable : element; +} + +String callName(InvocationExpression call) { + if (callElement(call)?.library?.uri.toString() != schemaDeclarationUri) { + failAt( + call, + 'DECLARATION', + 'Use the helpers from package:orm/schema.dart.', + ); + } + return switch (call) { + MethodInvocation() => call.methodName.name, + DotShorthandInvocation() => call.memberName.name, + _ => failAt(call, 'DECLARATION', 'Unsupported declaration expression.'), + }; +} + +Element? callElement(InvocationExpression call) => switch (call) { + MethodInvocation() => call.methodName.element, + DotShorthandInvocation() => call.memberName.element, + _ => null, +}; +Never failAt(AstNode node, String code, String message) { + final sourceUnit = node.root as CompilationUnit; + final location = sourceUnit.lineInfo.getLocation(node.offset); + throw GenerationException( + message, + code: 'SCHEMA.$code', + source: sourceUnit.declaredFragment!.element.uri, + line: location.lineNumber, + column: location.columnNumber, + ); +} diff --git a/lib/src/generate/types.dart b/lib/src/generate/types.dart index ec4835db..653aa7f4 100644 --- a/lib/src/generate/types.dart +++ b/lib/src/generate/types.dart @@ -4,11 +4,8 @@ import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'exception.dart'; -import 'source.dart'; -// Match defining libraries, not consumer import text. Reexports preserve these -// identities, so user-defined lookalike annotations and codecs are rejected. -const schemaDeclarationUri = 'package:orm/src/schema/declaration.dart'; +// Match defining libraries, not consumer import text. Reexports retain identity. const codecLibraryUri = 'package:orm/src/values/codec.dart'; const valueLibraryUris = { codecLibraryUri, @@ -19,7 +16,39 @@ const valueLibraryUris = { /// 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 = {}; + final Map> _exports = {}; + bool usesSource = false; bool typedData = false; + Iterable<(String, Set)> get exports sync* { + final counts = {}; + for (final symbols in _exports.values) { + for (final symbol in symbols) { + counts.update(symbol, (n) => n + 1, ifAbsent: () => 1); + } + } + for (final entry in _exports.entries) { + final symbols = entry.value.where((name) => counts[name] == 1).toSet(); + if (symbols.isNotEmpty) yield (importUri(entry.key), symbols); + } + } + + void exportType(DartType value) { + final element = + value.alias?.element ?? (value is InterfaceType ? value.element : null); + if (element != null) { + final uri = element.library.uri; + if (!uri.toString().startsWith('dart:') && + !valueLibraryUris.contains(uri.toString())) { + _exports.putIfAbsent(uri, () => {}).add(element.name!); + } + } + if (value is InterfaceType) { + for (final argument in value.typeArguments) { + exportType(argument); + } + } + } + Iterable<(String, String)> get imports sync* { for (final entry in _prefixes.entries) { yield (importUri(entry.key), entry.value); @@ -34,6 +63,7 @@ final class DartNames(final Uri source, final String Function(Uri) importUri) { ); } final uri = library.uri; + if (uri == source) usesSource = true; if (uri.toString() == 'dart:typed_data') typedData = true; if ({ 'dart:core', @@ -83,7 +113,7 @@ final class DartNames(final Uri source, final String Function(Uri) importUri) { !variable.isStatic || variable.isPrivate) { throw const GenerationException( - 'UseCodec requires a public const codec variable or static field.', + 'custom() requires a public const codec variable or static field.', ); } if (variable case FieldElement(:final enclosingElement)) { @@ -92,38 +122,6 @@ final class DartNames(final Uri source, final String Function(Uri) importUri) { 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() == schemaDeclarationUri) { - 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}: ${dartLiteral(e.value)}').join(', ')}})'; - } - String factoryReference( Expression expression, ExecutableElement function, @@ -140,7 +138,7 @@ final class DartNames(final Uri source, final String Function(Uri) importUri) { if (variable is VariableElement) { if (!variable.isConst || !variable.isStatic || variable.isPrivate) { throw const GenerationException( - 'ClientDefault factory variables must be public const references.', + 'clientDefault factory variables must be public const references.', ); } if (variable case FieldElement(:final enclosingElement)) { @@ -150,7 +148,7 @@ final class DartNames(final Uri source, final String Function(Uri) importUri) { } if (function.isPrivate) { throw const GenerationException( - 'ClientDefault requires a public factory.', + 'clientDefault requires a public factory.', ); } if (function is ConstructorElement) { @@ -162,7 +160,7 @@ final class DartNames(final Uri source, final String Function(Uri) importUri) { if (function.typeParameters.isNotEmpty && (arguments == null || arguments.isEmpty)) { throw const GenerationException( - 'Instantiate generic ClientDefault factories explicitly.', + 'Instantiate generic clientDefault factories explicitly.', ); } final suffix = arguments == null || arguments.isEmpty @@ -173,7 +171,7 @@ final class DartNames(final Uri source, final String Function(Uri) importUri) { return '${name(function.enclosingElement!)}.${function.name}$suffix'; } throw const GenerationException( - 'ClientDefault requires a public top-level function, static method or constructor.', + 'clientDefault requires a public top-level function, static method or constructor.', ); } } diff --git a/lib/src/schema/declaration.dart b/lib/src/schema/declaration.dart index 55c7a593..121fdf5a 100644 --- a/lib/src/schema/declaration.dart +++ b/lib/src/schema/declaration.dart @@ -1,143 +1,8 @@ -import '../../values.dart' show Codec; -import 'model.dart' show ComputedStorage; - -/// A public const codec reference. Generation reads its type and storage tag; -/// it never executes the application's encode/decode functions. -final class UseCodec { - /// Public constant codec used to encode and decode the annotated field. - final Codec codec; - - /// Associates a field with a public const variable or static const codec. - const UseCodec(this.codec); -} - -/// Stable database text for an enum constant, independent of its Dart name. -final class EnumValue { - /// Unique text label stored for the annotated enum constant. - final String value; - - /// Overrides the default storage label, which is the Dart constant name. - const EnumValue(this.value); -} - -/// Marks a field as part of the table's primary key. -/// -/// Multiple [Id] fields form a composite key in field order. Use [Id.generated] -/// only for a single, non-null integer primary key. -/// -/// ```dart -/// final class User(@Id.generated() final int id, final String name); -/// ``` -/// -/// {@category Declarations} -final class Id { - /// Whether the database generates the identity when an insert omits it. - final bool generated; - - /// Declares an application-supplied primary-key field. - const Id() : generated = false; - - /// Declares a database-generated integer primary key. - const Id.generated() : generated = true; -} - -/// Declares a unique constraint for the annotated field. -final class Unique { - /// Requires stored non-null values to be unique under database semantics. - const Unique(); -} - -/// Overrides the physical column name while preserving the Dart field name. -final class ColumnName { - /// Physical SQL column name, quoted as an identifier during SQL generation. - final String name; - - /// Associates a Dart field with a stable database column name. - const ColumnName(this.name); -} - -/// A database SQL default used when an insert omits the annotated field. -/// -/// The expression is trusted SQL, not a bound parameter. It does not supply a -/// Dart constructor default or run when an existing row is decoded. -final class Default { - /// SQL expression placed in the column DEFAULT clause. - final String expression; - - /// Declares a database default such as `CURRENT_TIMESTAMP` or `false`. - 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 { - /// Public, synchronous factory invoked only when constructing an insert. - final T Function() factory; - - /// Uses a public top-level function, static method, or constructor tear-off. - const ClientDefault(this.factory); -} - -/// SQL computed by the database; omitted from generated writes. -final class Computed { - /// Default trusted SQL expression used when a dialect override is absent. - final String expression; - - /// Optional SQLite expression using physical column names. - final String? sqlite; - - /// Optional PostgreSQL expression using physical column names. - final String? postgres; - - /// Optional MySQL expression using physical column names. - final String? mysql; - - /// Optional MariaDB expression using physical column names. - final String? mariadb; - - /// Whether the database stores the result or computes it when read. - final ComputedStorage storage; - - /// Declares a generated column excluded from generated create and patch APIs. - 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 { - /// Signed storage width: 16, 32, or 64 bits. - final int value; - - /// Constrains an integer column; unsupported widths fail generation. - const IntegerBits(this.value); -} - -/// Decimal column precision and scale, matching PostgreSQL NUMERIC(p, s). -final class DecimalDigits { - /// Total declared decimal precision, from 1 through 1000. - final int precision; - - /// Declared scale, from -1000 through 1000; defaults to zero. - final int scale; - - /// Constrains decimal column storage; each database validates its own limits. - const DecimalDigits(this.precision, [this.scale = 0]); -} +import 'dart:typed_data' show Uint8List; -/// Fractional second digits (0..6) for time, local timestamp or UTC instant columns. -final class TemporalPrecision { - /// Number of fractional second digits, from 0 through 6. - final int digits; - - /// Declares storage precision without changing the Dart field type. - const TemporalPrecision(this.digits); -} +import '../../values.dart' + show Codec, Decimal, LocalDate, LocalDateTime, LocalTime, SqlJson; +import 'model.dart' show ComputedStorage; /// Database behavior when a referenced row is deleted. enum ReferentialAction { @@ -157,148 +22,330 @@ enum ReferentialAction { noAction, } -/// A fixed SQL file with declared result and parameter Record types. -/// Run query generation, then check the output against each target database. -/// Paths are relative to this declaration's Dart library. [R] is a named-record -/// result typedef; [P] is a named record or `()` for no parameters. -/// -/// ```dart -/// typedef UserName = ({String name}); -/// final userNames = sqlQuery( -/// sqlite: 'user_names.sql', -/// ); -/// ``` -/// -/// {@category Declarations} -SqlDeclaration sqlQuery({ - String? sqlite, - String? postgres, - String? mysql, - String? mariadb, -}) => SqlDeclaration(sqlite, postgres, mysql, mariadb); - -/// Source metadata for a typed SQL query; creating it does not execute SQL. +/// One source definition for a physical table and its generated immutable row. /// -/// Use [sqlQuery] in a top-level declaration recognized by the generator. -final class SqlDeclaration { - /// SQLite SQL file, relative to the declaring Dart library. - final String? sqlite; - - /// PostgreSQL SQL file, relative to the declaring Dart library. - final String? postgres; - - /// MySQL SQL file, relative to the declaring Dart library. - final String? mysql; - - /// MariaDB SQL file, relative to the declaring Dart library. - final String? mariadb; - - /// Stores dialect-specific source paths without opening them. - const SqlDeclaration(this.sqlite, this.postgres, this.mysql, this.mariadb); +/// Create declarations with [model]; this type has no public constructor. +/// Use `final Model employee = model(...)` to break type inference cycles in +/// self references or mutually related models. Other declarations can infer +/// their type with `final employee = model(...)`. +/// Model identity belongs to the declaration, independently of Record equality. +final class Model { + /// Explicit physical table name, independent of the Dart declaration name. + final String table; + + /// Column declarations. Runtime Record access does not provide field discovery; + /// generation reads the original named fields and their source order. + final Record fields; + + const Model._(this.table, this.fields); } -/// Declares an independently named table backed by the Dart model [M]. -/// -/// Place this call in a public top-level variable. [M] must be a supported final -/// primary-constructor class or named-record typedef in the same source file. -/// The variable name determines the default physical name unless [table] is set. -/// Model constructors are not executed during generation. +/// Defines a model using named column declarations and local constraints. /// -/// ```dart -/// final class User(@Id() final int id, final String email); -/// final users = entity(table: 'app_users'); -/// final emailIndex = users.index((user) => user.email); -/// ``` +/// Declare a public `final employee = model('employees', (...))`. The named +/// Record's shape supplies the types in local key, index and relation selectors. +/// Add an explicit [Model] type to break self-reference or mutual-reference +/// inference cycles; selector field types are preserved in either form. /// -/// {@category Declarations} -Entity entity({String? table}) => Entity(table); - -/// A source-level table declaration with model, key, and constraint types. -/// -/// Use [entity] to create a declaration the generator can discover. Constraint -/// methods describe source syntax; calling them does not modify a live database. +/// Select keys with a direct column or an ordered positional Record of columns. +/// Selectors must be arrow expressions. [relations] returns a named Record of +/// [references]/[referencedBy] declarations; other collections are literal lists. +/// Every field of [fields] must be a column declaration, checked by generation. +/// Callbacks are not executed and do not register mutable runtime metadata. +/// Generate and import the client to query typed rows. +Model model( + String table, + S fields, { + Object Function(S)? primaryKey, + List Function(S)? uniqueKeys, + List Function(S)? indexes, + Record Function(S)? relations, + List? checks, +}) => Model._(table, fields); + +/// Typed source syntax for a stored column, interpreted by static generation. /// -/// {@category Declarations} -final class Entity { - /// Explicit physical table name, or null to derive it from the declaration. - final String? table; - - /// Stores the optional physical name without registering or opening a table. - const Entity(this.table); - - /// Selects one field or a record of fields for a relationship key. - /// - /// Selector order determines composite-key column order. - EntityKey key(K Function(M) selector) => EntityKey(this, selector); - - /// Declares a primary key in a separate top-level variable. - /// - /// Selected columns must be non-nullable and cannot duplicate an annotated key. - SchemaConstraint primaryKey(K Function(M) selector) => - const SchemaConstraint(); - - /// Declares a unique key on one field or an ordered record of fields. - 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( +/// Helpers such as [integer] and [text] provide the Dart value type. This object +/// does not encode values, execute SQL or expose runtime constraint metadata. +final class ColumnDefinition { + const ColumnDefinition._(); + + /// Permits SQL NULL and generates a nullable Dart field. + /// [clientDefault] may return null and runs only for omitted insert values. + ColumnDefinition nullable({T? Function()? clientDefault}) => + ColumnDefinition._(); + + /// Uses an integer-storage domain codec as a database-generated primary key. + /// The column must be non-nullable and be the table's only primary-key field. + ColumnDefinition identity() => this; + + /// Computes the value in the database and excludes it from generated writes. + /// Expressions are trusted SQL using physical column names, never parameters. + /// Computed columns cannot also declare identity or insert defaults. + ColumnDefinition computed( String expression, { - String? name, String? sqlite, String? postgres, String? mysql, String? mariadb, - }) => const SchemaConstraint(); - - /// Declares an index on the selected physical columns in selector order. - /// - /// The top-level declaration supplies the default name; [name] overrides it. - /// [unique] also permits this index to serve as a foreign-key target. - SchemaConstraint index( - K Function(M) selector, { - String? name, - bool unique = false, - }) => const SchemaConstraint(); + ComputedStorage storage = ComputedStorage.stored, + }) => this; } -/// An ordered set of model fields used to declare a relationship. +/// A generated integer primary key. It may not be nullable or part of a +/// composite key. Omitted insert values are supplied by the database. +ColumnDefinition identity({String? name}) => const ColumnDefinition._(); + +/// A signed integer column. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition integer({ + String? name, + bool unique = false, + int? defaultValue, + String? defaultSql, + int Function()? clientDefault, + int? bits, +}) => const ColumnDefinition._(); + +/// A text column. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition text({ + String? name, + bool unique = false, + String? defaultValue, + String? defaultSql, + String Function()? clientDefault, +}) => const ColumnDefinition._(); + +/// A boolean column. /// -/// Obtain this through [Entity.key]. The generator reads the selector syntax and -/// does not call it against an application object. -final class EntityKey { - /// Table declaration owning the selected fields. - final Entity entity; - - /// Source selector for one field or a record of fields. - final K Function(M) selector; - - /// Stores a table and selector without evaluating the selector. - const EntityKey(this.entity, this.selector); - - /// Declares a foreign key and generated query navigation to [target]. - /// - /// The target must be a primary or unique key in matching column order, with - /// compatible Dart and storage types. [inverse] adds reverse navigation; - /// [onDelete] controls database-enforced deletion behavior. - 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(); +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition boolean({ + String? name, + bool unique = false, + bool? defaultValue, + String? defaultSql, + bool Function()? clientDefault, +}) => const ColumnDefinition._(); + +/// A floating-point column. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition real({ + String? name, + bool unique = false, + double? defaultValue, + String? defaultSql, + double Function()? clientDefault, +}) => const ColumnDefinition._(); + +/// An exact large integer column. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition bigInteger({ + String? name, + bool unique = false, + String? defaultSql, + BigInt Function()? clientDefault, +}) => const ColumnDefinition._(); + +/// An exact decimal column. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition decimal({ + String? name, + bool unique = false, + String? defaultSql, + Decimal Function()? clientDefault, + int? precision, + int? scale, +}) => const ColumnDefinition._(); + +/// A UTC instant column. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition dateTime({ + String? name, + bool unique = false, + String? defaultSql, + DateTime Function()? clientDefault, + int? precision, +}) => const ColumnDefinition._(); + +/// A calendar date without a timezone. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition date({ + String? name, + bool unique = false, + String? defaultSql, + LocalDate Function()? clientDefault, +}) => const ColumnDefinition._(); + +/// A wall-clock time without a timezone. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition time({ + String? name, + bool unique = false, + String? defaultSql, + LocalTime Function()? clientDefault, + int? precision, +}) => const ColumnDefinition._(); + +/// A local timestamp without a timezone. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition localDateTime({ + String? name, + bool unique = false, + String? defaultSql, + LocalDateTime Function()? clientDefault, + int? precision, +}) => const ColumnDefinition._(); + +/// A binary column. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition bytes({ + String? name, + bool unique = false, + String? defaultSql, + Uint8List Function()? clientDefault, +}) => const ColumnDefinition._(); + +/// A JSON document; its content can itself be JSON null. +/// +/// [name] fixes the physical column name; otherwise snake_case is derived from +/// the Record field. [defaultSql] is trusted database SQL. [clientDefault] runs +/// only for omitted insert values and may coexist with a database default. +ColumnDefinition json({ + String? name, + bool unique = false, + String? defaultSql, + SqlJson Function()? clientDefault, +}) => const ColumnDefinition._(); + +/// An enum stored as text. Pass `Status.values`; optional [labels] maps every +/// constant to a distinct stable storage label without annotations. +/// [defaultValue] is encoded into a database default during generation. +ColumnDefinition enumeration( + List values, { + String? name, + bool unique = false, + Map? labels, + E? defaultValue, + String? defaultSql, + E Function()? clientDefault, +}) => ColumnDefinition._(); + +/// A domain value with a public const codec. Generation reads its storage tag +/// and references its encode/decode functions without executing them. +/// [bits] constrains integer storage to 16, 32 or 64 bits. [precision] and +/// [scale] configure decimal storage; temporal storage accepts [precision] +/// from 0 to 6. These options describe storage, independently of the Dart type. +ColumnDefinition custom( + Codec codec, { + String? name, + bool unique = false, + String? defaultSql, + T Function()? clientDefault, + int? bits, + int? precision, + int? scale, +}) => ColumnDefinition._(); + +/// An index declaration for the `indexes` selector of [model]. +final class IndexDefinition { + const IndexDefinition._(); } -/// Marker returned by source-level key, index, check, and relation declarations. +/// Selects a direct column or an ordered positional Record of columns. +/// The physical [name] is explicit and does not depend on local variable names. +IndexDefinition index( + Object columns, { + required String name, + bool unique = false, +}) => const IndexDefinition._(); + +/// A relationship in the named Record returned by the `relations` selector +/// of [model]. +/// Its Record field name becomes the generated query navigation name. +final class ReferenceDefinition { + const ReferenceDefinition._(); +} + +/// Declares a foreign key and forward navigation on the current model. +/// +/// [columns] selects a local column or a positional Record in target primary-key +/// order. A named Record instead maps target field names to local columns, e.g. +/// `(tenantId: m.tenantId, code: m.teamCode)`. Generation validates target names, +/// value types, codecs and uniqueness, and orders the mapping by the target key. +/// Named mappings do not provide Dart completion for target fields. +/// +/// [target] must name a model, e.g. `() => employee`; generation never invokes it. +/// The relation's name comes from its enclosing Record field, e.g. +/// `relations: (e) => (manager: references(e.managerId, () => employee),)`. +/// [constraint] false creates read-only navigation without a database foreign +/// key. It does not infer uniqueness or add an index. +ReferenceDefinition references( + Object columns, + Model Function() target, { + ReferentialAction onDelete = ReferentialAction.restrict, + bool constraint = true, +}) => const ReferenceDefinition._(); + +/// Declares reverse navigation on the current model, without another foreign key. /// -/// The generator reads the original top-level expression. This marker carries no -/// runtime database operation or mutable constraint registry. -final class SchemaConstraint { - /// Creates a marker without evaluating or applying a schema change. - const SchemaConstraint(); +/// [target] must name a model, e.g. `() => employee`, with a forward [references] +/// declaration pointing to the current model. Generation requires one matching +/// reference; no match or ambiguity is an error, never a naming heuristic. +/// +/// When several references exist, [on] maps target foreign-key field names to +/// local columns, e.g. `referencedBy(() => post, on: (authorId: u.id))`. +/// This also supports composite keys. Mapping names are checked by generation; +/// local columns retain native Dart types and completion. A reverse of a +/// read-only forward reference is also read-only. Callbacks are never invoked. +ReferenceDefinition referencedBy(Model Function() target, {Record? on}) => + const ReferenceDefinition._(); + +/// A trusted database CHECK declaration for the `checks` argument of [model]. +final class CheckDefinition { + const CheckDefinition._(); } + +/// Declares a CHECK using physical column names. The database evaluates +/// the trusted SQL expression. Dialect overrides are explicit. Use a stable +/// [name], or `name: null` to preserve an unnamed imported constraint. +CheckDefinition check( + String expression, { + required String? name, + String? sqlite, + String? postgres, + String? mysql, + String? mariadb, +}) => const CheckDefinition._(); diff --git a/lib/src/schema/queries.dart b/lib/src/schema/queries.dart new file mode 100644 index 00000000..364570d2 --- /dev/null +++ b/lib/src/schema/queries.dart @@ -0,0 +1,44 @@ +/// A fixed SQL file with named result and parameter column declarations. +/// +/// Use the same column helpers as a model, such as `text()` and `custom(codec)`. +/// Generation creates the result class and a typed query method. Source paths +/// are relative to this library. Creating a declaration never reads or runs SQL. +/// Run query generation, then validate the SQL against each target database. +SqlDeclaration sqlQuery({ + required Record result, + Record parameters = (), + String? sqlite, + String? postgres, + String? mysql, + String? mariadb, +}) => SqlDeclaration._(result, parameters, sqlite, postgres, mysql, mariadb); + +/// Source metadata for a typed SQL query, created through [sqlQuery]. +final class SqlDeclaration { + /// Named result columns, interpreted by static generation. + final Record result; + + /// Named parameter columns, or an empty Record for no parameters. + final Record parameters; + + /// SQLite SQL file, relative to the declaring Dart library. + final String? sqlite; + + /// PostgreSQL SQL file, relative to the declaring Dart library. + final String? postgres; + + /// MySQL SQL file, relative to the declaring Dart library. + final String? mysql; + + /// MariaDB SQL file, relative to the declaring Dart library. + final String? mariadb; + + const SqlDeclaration._( + this.result, + this.parameters, + this.sqlite, + this.postgres, + this.mysql, + this.mariadb, + ); +} diff --git a/lib/src/sqlite/web_build.dart b/lib/src/sqlite/web_build.dart index 3add1cc6..6fbec54d 100644 --- a/lib/src/sqlite/web_build.dart +++ b/lib/src/sqlite/web_build.dart @@ -1,11 +1,11 @@ // Generated by tool/build_sqlite_web.dart. Do not edit. const sqliteWebProtocol = 1; const sqliteWorkerBuild = - '811c9351ca7a8a06979b43b9b68aaf97fac2c0c721c506e574d7897eed0693d6'; + '1ffcc1c93a300242a8c4ca7badc270240d0550d23254799eda18232f8bd3312d'; const sqliteWorkerFile = - 'worker-0c84ece1c73ab998fbc2c37fe03c3ea9138bf2a717039553dcabf80481929eb1.js'; + 'worker-4360a5ed33bec6997af5514e04d393eb8fae00e331f2532a696ce34b0b9e44a1.js'; const sqliteWorkerSha256 = - '0c84ece1c73ab998fbc2c37fe03c3ea9138bf2a717039553dcabf80481929eb1'; + '4360a5ed33bec6997af5514e04d393eb8fae00e331f2532a696ce34b0b9e44a1'; const sqliteWasmFile = 'sqlite3-13d3f11d05b39ba0618a7115fb41640a5d48b6300f5d3f325f554b42bd6688a4.wasm'; const sqliteWasmSha256 = diff --git a/pubspec.yaml b/pubspec.yaml index 0cca39ba..48dbe327 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: orm description: Typed relational data access for Dart, from declaration to migration. -version: 6.0.0-beta.2 +version: 6.0.0-beta.3 repository: https://github.com/medz/dart-orm issue_tracker: https://github.com/medz/dart-orm/issues documentation: https://github.com/medz/dart-orm/tree/main/doc diff --git a/test/build_runner_test.dart b/test/build_runner_test.dart index 2174238c..6989f915 100644 --- a/test/build_runner_test.dart +++ b/test/build_runner_test.dart @@ -19,8 +19,7 @@ void main() { 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'); +final namedRows = sqlQuery(result: (title: text(), status: enumeration(Status.values, labels: {Status.pending: pendingLabel, Status.ready: 'ready'})), parameters: (minimum: integer(),), sqlite: 'rows.sql'); '''); const sql = 'SELECT title, status FROM rows_0 WHERE score >= :minimum'; await fixture.write('lib/rows.sql', sql); diff --git a/test/builder_test.dart b/test/builder_test.dart index 327cbb2a..52357053 100644 --- a/test/builder_test.dart +++ b/test/builder_test.dart @@ -60,8 +60,7 @@ void main() { 'multiple explicit schema roots resolve shared public types under lib', () async { const types = ''' -import 'package:orm/schema.dart'; -enum Status { @EnumValue('waiting') pending, done } +enum Status { pending, done } '''; final inputs = { 'orm|lib/fixture/types.dart': types, @@ -70,8 +69,7 @@ enum Status { @EnumValue('waiting') pending, done } ''' import 'package:orm/schema.dart'; import 'types.dart'; -typedef Row = ({@Id() int id, Status status}); -final rows = entity(table: '$name'); +final row = model('$name', (id: integer(), status: enumeration(Status.values, labels: {Status.pending: 'waiting', Status.done: 'done'})), primaryKey: (r) => r.id); ''', 'orm|lib/fixture/unrelated.dart': 'const unrelated = 1;', }; @@ -95,14 +93,50 @@ final rows = entity(table: '$name'); }, ); + test( + 'Record roots follow exported and referenced models through build assets', + () async { + final result = await run( + { + 'orm|lib/fixture/schema.dart': + "export 'employee.dart' show employee;", + 'orm|lib/fixture/employee.dart': ''' +import 'package:orm/schema.dart'; +import 'department.dart'; +enum Role { member, manager } +final Model employee = model('employees', ( + id: identity(), role: enumeration(Role.values, defaultValue: Role.member), + departmentId: integer(), +), relations: (e) => (department: references(e.departmentId, () => department),)); +''', + 'orm|lib/fixture/department.dart': ''' +import 'package:orm/schema.dart'; +import 'employee.dart'; +final department = model('departments', (id: identity(), name: text()), relations: (d) => (employees: referencedBy(() => employee),)); +final unused = model('unused', (bad: 'not a column',)); +''', + }, + {'orm|lib/fixture/schema.dart'}, + ); + expect(result.succeeded, true, reason: result.errors.toString()); + final generated = files.testing.readString( + AssetId('orm', 'lib/fixture/schema.orm.dart'), + ); + expect(generated, contains('final class Employee(')); + expect(generated, contains('final class Department(')); + expect(generated, isNot(contains('final class Unused('))); + expect(generated, contains('package:orm/fixture/employee.dart')); + }, + ); + for (final (name, body) in [ ( 'semantic_error', - 'typedef Row = ({@Id() int id}); final rows = entity(); int bad = "wrong";', + 'final row = model("rows", (id: identity(),)); int bad = "wrong";', ), ( 'enum_labels', - "enum Status { @EnumValue('x') a, @EnumValue('x') b } typedef Row = ({Status status}); final rows = entity();", + "enum Status { a, b } final row = model('rows', (status: enumeration(Status.values, labels: {Status.a: 'x', Status.b: 'x'}),));", ), ('no_models', 'const empty = 1;'), ('part_file', "part of 'root.dart';"), @@ -136,8 +170,9 @@ final rows = entity(table: '$name'); _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();", + 'orm|lib/fixture/models.domain': + "const readyLabel = 'generated'; enum Status { ready }", + 'orm|lib/fixture/schema.dart': "import 'package:orm/schema.dart'; import 'models.dart'; final row = model('rows', (status: enumeration(Status.values, labels: {Status.ready: readyLabel}),));", }, rootPackage: 'orm', readerWriter: files, diff --git a/test/check_test.dart b/test/check_test.dart index aa716564..0d2b0e2a 100644 --- a/test/check_test.dart +++ b/test/check_test.dart @@ -37,16 +37,15 @@ void main() { await Migrator(db.sql).apply([ Migration.create('0001_initial', appSchema, dialect: db.dialect), ]); - final row = await db.products.create(price: 10, state: 'draft'); + final row = await db.product.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( + () => db.product.create(stock: .set(-1), price: 10, state: 'draft'), + () => db.product.create(price: -1, state: 'draft'), + () => db.product.create(price: 10, discount: 11, state: 'draft'), + () => db.product.create(price: 10, state: 'unknown'), + () => db.product.create( price: 10, state: 'draft', label: '; CHECK (0)', @@ -54,9 +53,9 @@ void main() { ]) { await expectLater(action(), throwsA(isA())); } - expect(await db.products.count(), 1); + expect(await db.product.count(), 1); await expectLater( - db.products.byId(row.id).patch(discount: .set(-1)), + db.product.byId(row.id).patch(discount: .set(-1)), throwsA(anything), ); final info = await inspectTable(db.sql, 'products'); @@ -74,7 +73,7 @@ void main() { await Migrator(db.sql).apply([ Migration.create('0001_initial', appSchema, dialect: db.dialect), ]); - await db.products.create(price: 10, state: 'draft'); + await db.product.create(price: 10, state: 'draft'); final directory = await Directory( '.dart_tool/orm-check-import-${dialect.name}', ).create(recursive: true); @@ -86,7 +85,7 @@ void main() { final result = await verifySchema(db.sql, generated.snapshot); expect(result.differences, isEmpty); expect(result.unmanaged, isEmpty); - expect(imported.dart, contains('.check(')); + expect(imported.dart, contains('check(')); expect( imported.issues.map((i) => i.code), contains('IMPORT.CHECK_SQL'), @@ -100,7 +99,7 @@ void main() { dialect: db.dialect, ), ], expected: snapshot); - expect(await db.products.count(), 1); + expect(await db.product.count(), 1); } finally { await directory.delete(recursive: true); } diff --git a/test/cli_test.dart b/test/cli_test.dart index 1c176e12..508b6b71 100644 --- a/test/cli_test.dart +++ b/test/cli_test.dart @@ -40,7 +40,7 @@ void main() { TableSchema( 'users', columns: [ - ...usersSchema.columns, + ...userSchema.columns, Column('status', Codecs.text, defaultSql: "'active'"), Column( 'email_size', @@ -48,13 +48,13 @@ void main() { computed: const ComputedColumn('length(email)'), ), ], - primaryKey: usersSchema.primaryKey, - uniqueKeys: usersSchema.uniqueKeys, + primaryKey: userSchema.primaryKey, + uniqueKeys: userSchema.uniqueKeys, checks: const [ CheckSchema('valid_status', "status IN ('active', 'disabled')"), ], ), - postsSchema, + postSchema, ]); await project.target(next); await project.run(['create', '0002_status']); @@ -105,17 +105,17 @@ void main() { for (final sql in createSchema(appSchema, SqlDialect.sqlite)) { await db.execute(sql); } - await db.users.create(email: 'existing'); + await db.user.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 read.user.single()).email, 'existing'); expect(await Migrator(read.sql).history(), hasLength(1)); await expectLater( - read.users.create(email: 'forbidden'), + read.user.create(email: 'forbidden'), throwsA(isA()), ); } finally { diff --git a/test/cli_workflow_test.dart b/test/cli_workflow_test.dart index 3e6d47f1..3be7bb2e 100644 --- a/test/cli_workflow_test.dart +++ b/test/cli_workflow_test.dart @@ -56,7 +56,7 @@ void main() { final declaration = await fixture .file('lib/schema.dart') .readAsString(); - expect(declaration, contains('final class Task')); + expect(declaration, contains('final task = model(')); 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'))); @@ -69,8 +69,8 @@ void main() { await fixture.write( 'lib/schema.dart', declaration.replaceFirst( - 'required final String title,', - 'required final String title,\n required final String? detail,', + 'title: text(),', + 'title: text(),\n detail: text().nullable(),', ), ); expect( @@ -105,8 +105,8 @@ void main() { 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,', + 'title: text(),', + 'title: text(),\n detail: text().nullable(),\n label: text().nullable(),', ), ); // No explicit generate: create must use the edited model, not the config's diff --git a/test/client_default_test.dart b/test/client_default_test.dart index eb1517b5..c9b19067 100644 --- a/test/client_default_test.dart +++ b/test/client_default_test.dart @@ -48,10 +48,10 @@ void main() { tearDown(() => db.close()); test( - 'omitted values use typed Dart factories and return stored records', + 'omitted values use typed Dart factories and return stored rows', () async { final before = DateTime.now().toUtc(); - final row = await db.tickets.create(); + final row = await db.ticket.create(); expect(row.id, d.TicketId(1)); expect(row.name, 'ticket-1'); expect(row.label, isNull); @@ -62,7 +62,17 @@ void main() { [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], [1, 1, 1, 1], ); - expect(await db.tickets.single(), row); + final stored = await db.ticket.single(); + expect( + ( + stored.id, + stored.name, + stored.label, + stored.state, + stored.createdAt, + ), + (row.id, row.name, row.label, row.state, row.createdAt), + ); }, ); @@ -70,7 +80,7 @@ void main() { 'explicit values, null and SQL DEFAULT bypass their client factory', () async { final instant = DateTime.utc(2026, 9, 15); - final row = await db.tickets.create( + final row = await db.ticket.create( id: .set(d.TicketId(20)), name: .set('explicit'), label: .set(null), @@ -85,7 +95,7 @@ void main() { [0, 0, 0, 0], ); await expectLater( - db.tickets.create(name: .defaultValue()), + db.ticket.create(name: .defaultValue()), throwsA( isA().having( (e) => e.code, @@ -94,12 +104,12 @@ void main() { ), ), ); - expect(await db.tickets.count(), 1); + expect(await db.ticket.count(), 1); }, ); test('prepared mutations, repeated compilation and conflicts retain one generated value', () async { - final prepared = db.tickets.insert((row) => []); + final prepared = db.ticket.insert((row) => []); expect( [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], [1, 1, 1, 1], @@ -115,11 +125,11 @@ void main() { [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], [1, 1, 1, 1], ); - expect((await db.tickets.single()).id, d.TicketId(1)); + expect((await db.ticket.single()).id, d.TicketId(1)); }); test('batch insert prepares defaults once per omitted row and preserves explicit values', () async { - final batch = db.tickets.insertMany( + final batch = db.ticket.insertMany( [null, 20, null], (row, int? id) => [ if (id != null) row.id.set(d.TicketId(id)), @@ -152,14 +162,14 @@ void main() { 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'); + final row = await db.ticket.create(); + await db.ticket.byId(row.id).patch(name: .set('changed')); + expect((await db.ticket.single()).state, 'client-1'); expect( [d.idCalls, d.nameCalls, d.stateCalls, d.nullCalls], [1, 1, 1, 1], ); - await db.tickets + await db.ticket .insert((t) => [t.id.set(row.id), t.name.set('incoming')]) .onConflictUpdate( target: (t) => [t.id], @@ -168,26 +178,26 @@ void main() { ], ) .execute(); - expect((await db.tickets.single()).name, 'incoming'); - expect((await db.tickets.single()).state, 'client-1'); + expect((await db.ticket.single()).name, 'incoming'); + expect((await db.ticket.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); + expect(() => db.ticket.insert((t) => []), throwsStateError); + expect(await db.ticket.count(), 0); d.failName = false; await expectLater( db.transaction((tx) async { - await tx.tickets.create(); + await tx.ticket.create(); throw StateError('rollback'); }), throwsStateError, ); - expect(await db.tickets.count(), 0); - final row = await db.tickets.create(); + expect(await db.ticket.count(), 0); + final row = await db.ticket.create(); expect(row.id, d.TicketId(3)); expect(d.nameCalls, 3); }); @@ -217,16 +227,16 @@ void main() { ['state'], ); final imported = await importSchema(db.sql); - expect(imported.dart, isNot(contains('ClientDefault'))); + 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(); + final client = await db.sequenceRow.create(); expect(client.id, 1001); - final server = await db.sequences.create(id: .defaultValue()); + final server = await db.sequenceRow.create(id: .defaultValue()); expect(server.id, dialect == SqlDialect.sqlite ? 1002 : 1); expect(d.identityCalls, 1); }); diff --git a/test/computed_test.dart b/test/computed_test.dart index 24b6e747..ef2a5159 100644 --- a/test/computed_test.dart +++ b/test/computed_test.dart @@ -102,22 +102,18 @@ void main() { 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', - ); + final row = await db.line.create(price: 4, quantity: 3, label: 'cat'); expect((row.total, row.labelSize, row.normalizedNote), (12, 3, null)); - await db.lines + await db.line .byId(row.id) .patch(quantity: .set(5), note: .set('Hello')); expect( - await db.lines + await db.line .select((r) => (r.total, r.labelSize, r.normalizedNote).row) .single(), (20, 3, 'HELLO'), ); - final inserted = await db.lines + final inserted = await db.line .insertMany( [2, 3], (r, int q) => [ @@ -129,7 +125,7 @@ void main() { .returning((r) => r.total) .get(); expect(inserted, [20, 30]); - final updated = await db.lines + final updated = await db.line .insert( (r) => [ r.id.set(row.id), @@ -147,24 +143,24 @@ void main() { .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.line.where((r) => r.total.gt(25)).count(), 2); + await db.band.create(id: 35, name: 'large'); expect( - await db.lines + await db.line .byId(row.id) .select((r) => r.band.select((b) => b.name).one()) .single(), 'large', ); expect( - await db.bands + await db.band .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)]), + () => db.line.update((r) => [r.column(r.total.definition).set(1)]), throwsA( isA().having( (e) => e.code, @@ -216,7 +212,7 @@ void main() { false, reason: '${imported.issues.map((i) => i.toJson())}', ); - expect(imported.dart, contains('@Computed.sql(')); + expect(imported.dart, contains('.computed(')); final source = File('${dir.path}/schema.dart'); await source.writeAsString(imported.dart); final generated = await generateSchema(source.path); diff --git a/test/custom_codec_test.dart b/test/custom_codec_test.dart index cc3bd3c8..99ad082a 100644 --- a/test/custom_codec_test.dart +++ b/test/custom_codec_test.dart @@ -53,7 +53,7 @@ void runCodecTests(String name, Future> Function() open) { Future person({ String email = 'seven@example.com', SqlJson? details, - }) => db.people.create( + }) => db.person.create( email: Email(email), membership: .pending, tags: ['dart', '数据库'], @@ -75,8 +75,8 @@ void runCodecTests(String name, Future> Function() open) { 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 + final Query<({Email email, Location? location}), PersonFields> + projection = db.person .byId(id) .select( (p) => ( @@ -85,7 +85,7 @@ void runCodecTests(String name, Future> Function() open) { ).map((email, location) => (email: email, location: location)), ); expect((await projection.single()).email.value, value.email.value); - await db.people + await db.person .byId(id) .patch( email: .set(const Email('updated@example.com')), @@ -94,7 +94,7 @@ void runCodecTests(String name, Future> Function() open) { tags: .set([]), location: .set(null), ); - final updated = await db.people.byId(id).single(); + final updated = await db.person.byId(id).single(); expect(updated.email.value, 'updated@example.com'); expect(updated.membership, Membership.cancelled); expect(updated.previousMembership, Membership.pending); @@ -111,7 +111,7 @@ void runCodecTests(String name, Future> Function() open) { .single, ['pending-payment'], ); - await db.people.byId(value.id).patch(membership: .set(.cancelled)); + await db.person.byId(value.id).patch(membership: .set(.cancelled)); expect( (await db.execute(SqlCommand('SELECT membership FROM people'))) .rows @@ -122,20 +122,20 @@ void runCodecTests(String name, Future> Function() open) { SqlCommand("UPDATE people SET membership = 'future-value'"), ); await expectLater( - db.people.get(), + db.person.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); + expect(await db.person.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 query = db.person.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]); @@ -143,7 +143,7 @@ void runCodecTests(String name, Future> Function() open) { final events = []; final observed = Database(db.driver, onQuery: events.add); await expectLater( - () => observed.people.create( + () => observed.person.create( email: const Email('invalid'), membership: .active, tags: [], @@ -153,8 +153,8 @@ void runCodecTests(String name, Future> Function() open) { ); expect(events, isEmpty); await db.execute(SqlCommand("UPDATE people SET email = 'invalid'")); - await expectLater(db.people.single(), throwsFormatException); - expect(await db.people.count(), 1); + await expectLater(db.person.single(), throwsFormatException); + expect(await db.person.count(), 1); }, ); @@ -163,7 +163,7 @@ void runCodecTests(String name, Future> Function() open) { () async { final value = await person(); expect(value.details, isNull); - expect(await db.people.where((p) => p.details.isNull()).count(), 1); + expect(await db.person.where((p) => p.details.isNull()).count(), 1); for (final document in [ null, 'plain string', @@ -176,23 +176,23 @@ void runCodecTests(String name, Future> Function() open) { 'nested': [null, '文本'], }, ]) { - await db.people + await db.person .byId(value.id) .patch(details: .set(SqlJson(document))); - final result = await db.people + final result = await db.person .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); + expect(await db.person.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); + await db.person.byId(value.id).patch(details: .set(null)); + expect((await db.person.single()).details, isNull); }, ); @@ -201,24 +201,24 @@ void runCodecTests(String name, Future> Function() open) { 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); + await expectLater(db.person.single(), throwsA(isA())); + await db.person.byId(value.id).patch(location: .set(null)); + expect((await db.person.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'); + await db.note.create(ownerId: owner.id, body: 'note$i'); } final events = []; final observed = Database(db.driver, onQuery: events.add); - final emails = await observed.notes + final emails = await observed.note .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 + final children = await db.person .select( (p) => p.notes .orderBy((n) => [n.id.desc()]) @@ -230,7 +230,7 @@ void runCodecTests(String name, Future> Function() open) { ) .single(); expect(children, [(owner.id, 'note2'), (owner.id, 'note1')]); - final batch = await db.notes + final batch = await db.note .select( (n) => n.owner.select((p) => p.membership).required(strategy: .batch), @@ -242,7 +242,7 @@ void runCodecTests(String name, Future> Function() open) { test( 'custom values participate in atomic batch writes and native upsert', () async { - final ids = await db.people + final ids = await db.person .insertMany( [1, 2, 3], (p, i) => [ @@ -255,7 +255,7 @@ void runCodecTests(String name, Future> Function() open) { .returning((p) => p.id) .get(); expect(ids.map((id) => id.value), [1, 2, 3]); - await db.people + await db.person .insert( (p) => [ p.email.set(const Email('batch1@example.com')), @@ -272,10 +272,10 @@ void runCodecTests(String name, Future> Function() open) { ], ) .execute(); - final value = await db.people.byId(ids.first).single(); + final value = await db.person.byId(ids.first).single(); expect(value.membership, Membership.cancelled); expect(value.tags, ['changed']); - expect(await db.people.count(), 3); + expect(await db.person.count(), 3); }, ); @@ -290,17 +290,17 @@ void runCodecTests(String name, Future> Function() open) { email: 'second@example.com', details: const SqlJson(null), ); - final token = db.people.cursorToken( + final token = db.person.cursorToken( (p) => [p.membership.cursor(first.membership), p.id.cursor(first.id)], ); - final rows = await db.people + final rows = await db.person .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 + final docs = await db.person .orderBy((p) => [p.id.asc()]) .select((p) => p.details) .stream(batchSize: 1) diff --git a/test/decimal_average_test.dart b/test/decimal_average_test.dart index 2d96a08d..6f39b469 100644 --- a/test/decimal_average_test.dart +++ b/test/decimal_average_test.dart @@ -76,7 +76,7 @@ void main() { 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); + await db.entry.create(amount: d(v), bucket: bucket); } } @@ -84,33 +84,33 @@ void main() { 'empty null and constant inputs retain aggregate semantics', () async { expect( - await db.entries.select((e) => e.amount.average(scale: 2)).single(), + await db.entry.select((e) => e.amount.average(scale: 2)).single(), null, ); expect( - await db.entries + await db.entry .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(), + await db.entry.select((e) => e.amount.average(scale: 2)).single(), d('2'), ); expect( - await db.entries.select((e) => e.fee.average(scale: 2)).single(), + await db.entry.select((e) => e.fee.average(scale: 2)).single(), null, ); expect( - await db.entries + await db.entry .select((e) => value(d('2'), Codecs.decimal).average(scale: 2)) .single(), d('2'), ); - await db.entries.byId(2).patch(fee: Change.set(d('4'))); + await db.entry.byId(2).patch(fee: Change.set(d('4'))); expect( - await db.entries.select((e) => e.fee.average(scale: 2)).single(), + await db.entry.select((e) => e.fee.average(scale: 2)).single(), d('4'), ); }, @@ -128,7 +128,7 @@ void main() { ['1250', '1450'], ['-1250', '-1450'], ]) { - await db.entries.delete().execute(); + await db.entry.delete().execute(); await rows(values); final sum = values.map(d).reduce((a, b) => a + b); for (final mode in DecimalRounding.values) { @@ -143,7 +143,7 @@ void main() { } on FormatException { /* Exact mode intentionally rejects rounding. */ } - final result = db.entries + final result = db.entry .select( (e) => e.amount.average(scale: scale, rounding: mode), ) @@ -168,16 +168,16 @@ void main() { () async { await rows(['9e131071', '9e131071']); await expectLater( - db.entries.select((e) => e.amount.sum()).single(), + db.entry.select((e) => e.amount.sum()).single(), throwsA(isA()), ); expect( - await db.entries.select((e) => e.amount.average(scale: 0)).single(), + await db.entry.select((e) => e.amount.average(scale: 0)).single(), d('9e131071'), ); - await db.entries.delete().execute(); + await db.entry.delete().execute(); await rows(['9e131071', '-9e131071', '1']); - final actual = await db.entries + final actual = await db.entry .select( (e) => e.amount.average(scale: 16383, rounding: .halfEven), ) @@ -186,10 +186,10 @@ void main() { actual == d('1').divide(d('3'), scale: 16383, rounding: .halfEven), isTrue, ); - await db.entries.delete().execute(); + await db.entry.delete().execute(); await rows(['-9e131071', '-9e131071']); expect( - await db.entries.select((e) => e.amount.average(scale: 0)).single(), + await db.entry.select((e) => e.amount.average(scale: 0)).single(), d('-9e131071'), ); }, @@ -201,7 +201,7 @@ void main() { () async { await rows(['1e-16383', '0']); expect( - await db.entries + await db.entry .select( (e) => e.amount.average(scale: 16383, rounding: .halfEven), ) @@ -209,7 +209,7 @@ void main() { d('0'), ); expect( - await db.entries + await db.entry .select( (e) => e.amount.average( scale: 16383, @@ -219,10 +219,10 @@ void main() { .single(), d('1e-16383'), ); - await db.entries.delete().execute(); + await db.entry.delete().execute(); await rows(['5e131071', '5e131071']); expect( - await db.entries + await db.entry .select( (e) => e.amount.average(scale: -131072, rounding: .halfEven), ) @@ -230,7 +230,7 @@ void main() { d('0'), ); await expectLater( - db.entries + db.entry .select( (e) => e.amount.average( scale: -131072, @@ -248,7 +248,7 @@ void main() { () async { await rows(['1', '2'], bucket: 'a'); await rows(['2', '3'], bucket: 'b'); - final grouped = db.entries + final grouped = db.entry .groupBy((e) => [e.bucket]) .having((e) => e.amount.average(scale: 1).gt(d('2'))) .orderBy((e) => [e.amount.average(scale: 1).desc()]) @@ -264,7 +264,7 @@ void main() { [('b', d('2.5'))], ); expect(await grouped.union(grouped).get(), [('b', d('2.5'))]); - final unique = db.entries + final unique = db.entry .select((e) => e.amount) .distinct() .asCte('unique_amounts'); @@ -281,15 +281,14 @@ void main() { 'illegal window nesting and aggregate assignments fail before SQL', () { expect( - () => db.entries.select( - (e) => e.amount.sum().over().average(scale: 2), - ), + () => + db.entry.select((e) => e.amount.sum().over().average(scale: 2)), throwsA( isA().having((e) => e.code, 'code', 'QUERY.WINDOW'), ), ); expect( - () => db.entries.select( + () => db.entry.select( (e) => e.amount .average(scale: 2) .over(orderBy: [e.amount.sum().over().asc()]), @@ -299,7 +298,7 @@ void main() { ), ); expect( - () => db.entries + () => db.entry .update( (e) => [e.fee.setExpression(e.amount.average(scale: 2))], ) @@ -320,7 +319,7 @@ void main() { () async { await rows(['1', '2', '3']); await rows(['10', '20'], bucket: 'b'); - Expr mean(EntriesFields e) => e.amount + Expr mean(EntryFields e) => e.amount .average(scale: 2) .over( partitionBy: [e.bucket], @@ -328,11 +327,11 @@ void main() { frame: .rowsToCurrent, ); expect( - await db.entries.orderBy((e) => [e.id.asc()]).select(mean).get(), + await db.entry.orderBy((e) => [e.id.asc()]).select(mean).get(), ['1', '1.5', '2', '10', '15'].map(d), ); expect( - await db.entries + await db.entry .orderBy((e) => [mean(e).desc()]) .select(mean) .skip(1) @@ -341,26 +340,26 @@ void main() { [d('10'), d('2')], ); expect( - await db.entries + await db.entry .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'); + final cte = db.entry.select(mean).asCte('running_means'); expect(await cte.query.where((e) => e.ref(mean).gt(d('10'))).get(), [ d('15'), ]); expect( - await db.entries + await db.entry .orderBy((e) => [e.id.asc()]) .select(mean) .stream() .toList(), ['1', '1.5', '2', '10', '15'].map(d), ); - final mixed = await db.entries + final mixed = await db.entry .where((e) => e.bucket.eq('a')) .orderBy((e) => [e.id.asc()]) .select( @@ -378,10 +377,10 @@ void main() { (d('1.5'), d('3')), (d('2'), d('6')), ]); - Expr complete(EntriesFields e) => + Expr complete(EntryFields e) => e.amount.average(scale: 1).over(frame: .rowsAll); expect( - await db.entries + await db.entry .where((e) => e.bucket.eq('a')) .orderBy((e) => [complete(e).asc()]) .select(complete) @@ -395,8 +394,8 @@ void main() { 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 + final source = db.entry; + await db.entry .where((e) => e.bucket.eq('a')) .update( (e) => [ @@ -410,7 +409,7 @@ void main() { ) .execute(); expect( - await db.entries + await db.entry .where((e) => e.bucket.eq('a')) .select((e) => e.fee) .get(), @@ -418,10 +417,10 @@ void main() { ); await expectLater( db.transaction((tx) async { - await tx.entries + await tx.entry .where((e) => e.bucket.eq('a')) .patch(fee: Change.set(d('99'))); - await tx.entries + await tx.entry .where((e) => e.bucket.eq('b')) .select((e) => e.amount.average(scale: 1)) .single(); @@ -429,7 +428,7 @@ void main() { throwsA(isA()), ); expect( - await db.entries + await db.entry .where((e) => e.bucket.eq('a')) .select((e) => e.fee) .get(), @@ -439,13 +438,13 @@ void main() { test('batched relationship windows keep per-parent limits', () async { for (final n in ['2', '4']) { - await db.rates.create(id: d(n), label: n); + await db.rate.create(id: d(n), label: n); for (var i = 0; i < 3; i++) { - await db.allocations.create(rateId: d(n)); + await db.allocation.create(rateId: d(n)); } } expect( - await db.rates + await db.rate .orderBy((r) => [r.id.asc()]) .select( (r) => ( @@ -473,7 +472,7 @@ void main() { 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( + await db.entry.create( amount: d('0'), fee: fee == null ? null : d(fee), bucket: 'a', @@ -506,7 +505,7 @@ void main() { await rows(['6'], bucket: 'b'); await rows(['100'], bucket: 'excluded'); expect( - await db.entries + await db.entry .groupBy((e) => [e.bucket]) .having((e) => e.amount.sum().lt(d('50'))) .orderBy((e) => [e.bucket.asc()]) @@ -536,7 +535,7 @@ void main() { Codecs.decimal, ); expect( - await db.entries.select((e) => next.average(scale: 1)).single(), + await db.entry.select((e) => next.average(scale: 1)).single(), d('2'), ); expect( @@ -546,7 +545,7 @@ void main() { 3, ); expect( - await db.entries + await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => next @@ -563,7 +562,7 @@ void main() { 6, ); expect( - await db.entries + await db.entry .select( (e) => e.amount .average(scale: 1) @@ -578,7 +577,7 @@ void main() { )).rows.single.single, 9, ); - final repeated = db.entries + final repeated = db.entry .select((e) => e.bucket) .asCte('repeated_anchors'); expect( @@ -594,7 +593,7 @@ void main() { 12, ); expect( - await db.entries + await db.entry .where((e) => e.id.lt(0)) .select((e) => next.average(scale: 1)) .single(), diff --git a/test/decimal_division_test.dart b/test/decimal_division_test.dart index 863ed055..12cd8aac 100644 --- a/test/decimal_division_test.dart +++ b/test/decimal_division_test.dart @@ -48,7 +48,7 @@ void main() { int scale, DecimalRounding mode, ) async { - return db.entries + return db.entry .select( (e) => value( a, @@ -76,13 +76,13 @@ void main() { '.4999999999999999999999999999', ]) { for (final b in ['2', '-2', '.03', '-.03']) { - await db.entries.create(amount: d(a), fee: d(b), bucket: 'a'); + await db.entry.create(amount: d(a), fee: d(b), bucket: 'a'); } } - final input = await db.entries.orderBy((e) => [e.id.asc()]).get(); + final input = await db.entry.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 + final output = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => ( @@ -112,11 +112,11 @@ void main() { test( 'exact defaults reject lost digits and zero divisors, NULL propagates', () async { - await db.entries.create(amount: d('1'), bucket: 'a'); + await db.entry.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(), + await db.entry.select((e) => e.amount.rounded(0)).single(), d('1'), ); await expectLater( @@ -128,23 +128,23 @@ void main() { throwsA(isA()), ); await expectLater( - db.entries.select((e) => e.amount.rounded(-1)).single(), + db.entry.select((e) => e.amount.rounded(-1)).single(), throwsA(isA()), ); expect( - await db.entries + await db.entry .select((e) => e.fee.divide(d('0'), scale: 2)) .single(), null, ); expect( - await db.entries + await db.entry .select((e) => e.amount.divideExpression(e.fee, scale: 2)) .single(), null, ); expect( - await db.entries.select((e) => e.fee.rounded(-2)).single(), + await db.entry.select((e) => e.fee.rounded(-2)).single(), null, ); }, @@ -153,7 +153,7 @@ void main() { test( 'precision exceeds native division scale without double rounding', () async { - await db.entries.create(amount: d('1'), bucket: 'a'); + await db.entry.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( @@ -194,7 +194,7 @@ void main() { test( 'huge remainders avoid overflowing intermediate scaled products', () async { - await db.entries.create(amount: d('1'), bucket: 'a'); + await db.entry.create(amount: d('1'), bucket: 'a'); final huge = d('9e131071'); final pairs = [ (d('8e131071'), huge), @@ -225,7 +225,7 @@ void main() { test( 'outermost integer scale and result overflow are explicit', () async { - await db.entries.create(amount: d('1'), bucket: 'a'); + await db.entry.create(amount: d('1'), bucket: 'a'); expect( await ratio(d('5e131071'), d('1'), -131072, .halfEven), d('0'), @@ -251,10 +251,10 @@ void main() { ); 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'); + await db.entry.create(amount: d('2.345'), bucket: 'a'); + await db.entry.create(amount: d('4.345'), bucket: 'a'); expect( - await db.entries + await db.entry .select( (e) => e.amount.sum().divide( d('2'), @@ -266,7 +266,7 @@ void main() { d('3.34'), ); expect( - await db.entries + await db.entry .groupBy((e) => [e.bucket]) .select( (e) => ( @@ -278,7 +278,7 @@ void main() { ('a', d('6.7')), ); expect( - await db.entries + await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => e.amount @@ -289,7 +289,7 @@ void main() { .get(), [d('1.17'), d('3.34')], ); - final cte = db.entries + final cte = db.entry .select( (e) => e.amount.divide(d('2'), scale: 2, rounding: .halfEven), ) @@ -311,15 +311,15 @@ void main() { [d('2.17')], ); expect( - await db.entries + await db.entry .where((e) => e.amount.rounded(0, rounding: .halfEven).eq(d('2'))) .count(), 1, ); - final set = db.entries + final set = db.entry .select((e) => e.amount.rounded(0, rounding: .halfEven)) .union( - db.entries.select( + db.entry.select( (e) => e.amount.divide(d('1'), scale: 0, rounding: .halfEven), ), ); @@ -329,12 +329,12 @@ void main() { test( 'expression assignments and transaction rollback preserve values', () async { - final row = await db.entries.create( + final row = await db.entry.create( amount: d('10'), fee: d('3'), bucket: 'a', ); - await db.entries + await db.entry .byId(row.id) .update( (e) => [ @@ -342,13 +342,13 @@ void main() { ], ) .execute(); - expect((await db.entries.byId(row.id).single()).amount, d('2.5')); + expect((await db.entry.byId(row.id).single()).amount, d('2.5')); await expectLater( db.transaction((tx) async { - await tx.entries + await tx.entry .byId(row.id) .patch(bucket: const Change.set('changed')); - await tx.entries + await tx.entry .byId(row.id) .update( (e) => [ @@ -359,29 +359,29 @@ void main() { }), throwsA(isA()), ); - expect((await db.entries.byId(row.id).single()).bucket, 'a'); - expect((await db.entries.byId(row.id).single()).amount, d('2.5')); + expect((await db.entry.byId(row.id).single()).bucket, 'a'); + expect((await db.entry.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.entry.create(amount: d(n), bucket: 'a'); } - await db.entries.create(amount: d('100'), bucket: 'excluded'); - Expr running(EntriesFields e) => e.amount + await db.entry.create(amount: d('100'), bucket: 'excluded'); + Expr running(EntryFields e) => e.amount .sum() .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) .divide(d('2'), scale: 1); - final query = db.entries + final query = db.entry .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) => + Expr total(EntryFields e) => e.amount.sum().over(frame: .rowsAll).divide(d('3'), scale: 0); - final distinct = db.entries + final distinct = db.entry .where((e) => e.bucket.eq('a')) .orderBy((e) => [total(e).desc()]) .select(total) @@ -391,11 +391,11 @@ void main() { }); 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 + await db.entry.create(amount: d('1'), bucket: 'a'); + await db.entry.create(amount: d('3'), bucket: 'a'); + await db.entry.create(amount: d('6'), bucket: 'b'); + await db.entry.create(amount: d('100'), bucket: 'excluded'); + final result = await db.entry .groupBy((e) => [e.bucket]) .having((e) => e.amount.sum().lt(d('50'))) .orderBy((e) => [e.bucket.asc()]) @@ -416,13 +416,13 @@ void main() { 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 + await db.entry.create(amount: d('1'), bucket: 'a'); + await db.entry.create(amount: d('3'), bucket: 'a'); + Expr running(EntryFields e) => e.amount .sum() .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) .divide(d('2'), scale: 1); - final projected = db.entries + final projected = db.entry .orderBy((e) => [e.id.asc()]) .select(running); final cte = projected.asCte('running_halves'); @@ -448,8 +448,8 @@ void main() { await projected.union(projected).get(), unorderedEquals([d('.5'), d('2')]), ); - final outer = db.entries; - final inner = db.entries; + final outer = db.entry; + final inner = db.entry; final result = await outer .orderBy((e) => [e.id.asc()]) .select( @@ -468,8 +468,8 @@ void main() { ) .get(); expect(result, [d('.5'), d('2')]); - final alias = entriesTable.alias(); - final joined = await db.entries + final alias = entryTable.alias(); + final joined = await db.entry .leftJoin(alias, on: (e, a) => e.id.equals(a.id).and(a.id.eq(1))) .orderBy((e) => [e.id.asc()]) .select( @@ -486,12 +486,12 @@ void main() { test('batched relations retain per-parent window pagination', () async { for (final n in ['2', '4']) { - await db.rates.create(id: d(n), label: n); + await db.rate.create(id: d(n), label: n); for (var i = 0; i < 2; i++) { - await db.allocations.create(rateId: d(n)); + await db.allocation.create(rateId: d(n)); } } - final result = await db.rates + final result = await db.rate .orderBy((r) => [r.id.asc()]) .select( (r) => ( @@ -518,7 +518,7 @@ void main() { if (backend == 'postgres') { test('volatile operands are evaluated exactly once each', () async { - await db.entries.create(amount: d('1'), bucket: 'a'); + await db.entry.create(amount: d('1'), bucket: 'a'); await db.execute(SqlCommand('CREATE SEQUENCE division_calls')); final next = sql( ["nextval('division_calls')::numeric"], @@ -526,7 +526,7 @@ void main() { Codecs.decimal, ); expect( - await db.entries + await db.entry .select((e) => next.divideExpression(next, scale: 2)) .single(), d('.5'), @@ -538,7 +538,7 @@ void main() { 2, ); expect( - await db.entries.select((e) => next.rounded(0)).single(), + await db.entry.select((e) => next.rounded(0)).single(), d('3'), ); expect( @@ -547,8 +547,8 @@ void main() { )).rows.single.single, 3, ); - await db.entries.create(amount: d('1'), bucket: 'a'); - final windowed = await db.entries + await db.entry.create(amount: d('1'), bucket: 'a'); + final windowed = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => e.amount diff --git a/test/decimal_precision_test.dart b/test/decimal_precision_test.dart index 617adb92..9fa2bbe0 100644 --- a/test/decimal_precision_test.dart +++ b/test/decimal_precision_test.dart @@ -42,7 +42,7 @@ void main() { test('column coercion rounds signed ties and handles negative and excess scales', () async { await create(); - final a = await db.wallets.create( + final a = await db.wallet.create( amount: d('123.455'), hundreds: d('12350'), fraction: d('.001235'), @@ -51,7 +51,7 @@ void main() { (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( + final b = await db.wallet.create( amount: d('-123.455'), hundreds: d('-12350'), fraction: d('-.001235'), @@ -61,7 +61,7 @@ void main() { (b.amount, b.hundreds, b.fraction, b.optional), (d('-123.46'), d('-12400'), d('-.00124'), d('-1.24')), ); - final zero = await db.wallets.create( + final zero = await db.wallet.create( amount: Decimal.zero, hundreds: Decimal.zero, fraction: Decimal.zero, @@ -73,7 +73,7 @@ void main() { ('0', '0', '.009995'), ]) { await expectLater( - db.wallets.create( + db.wallet.create( amount: d(values.$1), hundreds: d(values.$2), fraction: d(values.$3), @@ -81,38 +81,38 @@ void main() { throwsA(isA()), ); } - expect(await db.wallets.count(), 3); + expect(await db.wallet.count(), 3); }); test('writes constrain columns while arithmetic and wider aggregates retain their result precision', () async { await create(); - final a = await db.wallets.create( + final a = await db.wallet.create( amount: d('999.994'), hundreds: d('99949'), fraction: d('.009994'), ); - await db.wallets.create( + await db.wallet.create( amount: d('999.99'), hundreds: Decimal.zero, fraction: Decimal.zero, ); expect( - await db.wallets.select((w) => w.amount.sum()).single(), + await db.wallet.select((w) => w.amount.sum()).single(), d('1999.98'), ); expect( - await db.wallets + await db.wallet .select((w) => w.amount.sum().constrained(6, 1)) .single(), d('2000'), ); expect( - await db.wallets + await db.wallet .where((w) => w.amount.constrained(6, 0).eq(d('1000'))) .count(), 2, ); - final rounded = db.wallets + final rounded = db.wallet .select((w) => w.amount.constrained(5, 1)) .asCte('rounded'); expect( @@ -122,36 +122,36 @@ void main() { [d('1000'), d('1000')], ); expect( - await db.wallets + await db.wallet .byId(a.id) .select((w) => w.amount.times(d('1000'))) .single(), d('999990'), ); await expectLater( - db.wallets + db.wallet .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 + expect((await db.wallet.byId(a.id).single()).amount, d('999.99')); + await db.wallet .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); + expect((await db.wallet.byId(a.id).single()).amount, d('1.24')); + await db.wallet.byId(a.id).patch(optional: const Change.set(null)); + expect((await db.wallet.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 + final ids = await db.price .insertMany([ '1.234', '2.345', @@ -159,7 +159,7 @@ void main() { .returning((p) => p.id) .get(); expect(ids, [d('1.23'), d('2.35')]); - await db.prices + await db.price .insert((p) => [p.id.set(d('1.234')), p.label.set('updated')]) .onConflictUpdate( target: (p) => [p.id], @@ -168,19 +168,19 @@ void main() { ], ) .execute(); - expect((await db.prices.byId(d('1.23')).single()).label, 'updated'); + expect((await db.price.byId(d('1.23')).single()).label, 'updated'); await expectLater( - db.prices.insertMany([ + db.price.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(await db.price.count(), 2); + final child = await db.receipt.create(priceId: d('1.234')); expect(child.priceId, d('1.23')); expect( - await db.receipts + await db.receipt .select((r) => r.price.select((p) => p.label).required()) .single(), 'updated', @@ -207,7 +207,10 @@ void main() { ]); final draft = await importSchema(db.sql); expect(draft.issues, isEmpty); - expect(draft.dart, contains('@DecimalDigits(3, -2)')); + expect( + draft.dart, + allOf(contains('precision: 3'), contains('scale: -2')), + ); final dir = await Directory('.dart_tool/orm-precision-import-$backend') .create(recursive: true); try { @@ -385,7 +388,7 @@ void main() { () async { await create(); for (final n in ['1.234', '2.345', '3.456']) { - await db.prices.create(id: d(n), label: 'pending'); + await db.price.create(id: d(n), label: 'pending'); } final first = Migration.create( '0001_precision', @@ -396,7 +399,7 @@ void main() { '0002_backfill', [ Backfill( - pricesSchema, + priceSchema, set: {'label': "'done'"}, doneWhen: "SELECT NOT EXISTS(SELECT 1 FROM prices WHERE label <> 'done')", batchSize: 1, @@ -407,7 +410,7 @@ void main() { ); 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); + expect(await db.price.where((p) => p.label.eq('done')).count(), 3); }, ); }); @@ -513,21 +516,21 @@ void main() { } }); - test('generation validates DecimalDigits and retains custom decimal codec types', () async { + test('generation validates precision and scale 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', + 'n: decimal(precision: 0)', + 'n: decimal(precision: 1001)', + 'n: decimal(precision: 2, scale: -1001)', + 'n: decimal(precision: 2, scale: 1001)', + 'n: custom(Codecs.integer, precision: 2)', + 'n: decimal(precision: 2, precision: 3)', ]) { await file.writeAsString( - "import 'package:orm/schema.dart'; typedef Row = ({$declaration}); final rows = entity();", + "import 'package:orm/schema.dart'; final row = model('rows', ($declaration,));", ); await expectLater( generateSchema(file.path), @@ -541,8 +544,7 @@ extension type Money(Decimal value) { 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 row = model('rows', (n: custom(Money.codec, precision: 8, scale: 2),)); """); final generated = await generateSchema(file.path); expect(generated.dart, contains('Column')); diff --git a/test/decimal_test.dart b/test/decimal_test.dart index 7b4b286d..9643ae9f 100644 --- a/test/decimal_test.dart +++ b/test/decimal_test.dart @@ -41,23 +41,23 @@ void main() { ]); Future amounts(List values) async { for (final v in values) { - await db.entries.create(amount: d(v), bucket: 'a'); + await db.entry.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'); + final first = await db.entry.create(amount: n, bucket: 'a'); expect(first.amount, n); expect(first.tax, d('.1')); expect(first.fee, null); - await db.entries + await db.entry .byId(first.id) .patch(fee: Change.set(d('.00000000000000000000000000001'))); - final row = await db.entries.byId(first.id).single(); + final row = await db.entry.byId(first.id).single(); expect(row.fee, d('.00000000000000000000000000001')); - final result = await db.entries + final result = await db.entry .select( (e) => ( e.amount.plus(d('.00000000000000000000000000001')), @@ -73,15 +73,13 @@ void main() { d('.3'), d('9007199254740993.1234567890123456789012345679'), )); - await db.entries.byId(first.id).patch(fee: const Change.set(null)); + await db.entry.byId(first.id).patch(fee: const Change.set(null)); expect( - await db.entries - .select((e) => e.amount.plusExpression(e.fee)) - .single(), + await db.entry.select((e) => e.amount.plusExpression(e.fee)).single(), null, ); expect( - await db.entries.select((e) => value(n, Codecs.decimal)).single(), + await db.entry.select((e) => value(n, Codecs.decimal)).single(), n, ); }); @@ -92,33 +90,31 @@ void main() { await create(); await amounts(['10', '2', '-2', '-10', '.00000000000000000001', '2']); expect( - await db.entries + await db.entry .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.entry.where((e) => e.amount.gt(d('2'))).count(), 1); expect( - await db.entries + await db.entry .where((e) => e.amount.isIn([d('2.00'), d('-10')])) .count(), 3, ); expect( - await db.entries + await db.entry .select((e) => e.amount.count(distinct: true)) .single(), 5, ); - final ordered = db.entries.orderBy( - (e) => [e.amount.asc(), e.id.asc()], - ); + final ordered = db.entry.orderBy((e) => [e.amount.asc(), e.id.asc()]); final first = (await ordered.take(2).get()).last; - final token = db.entries.cursorToken( + final token = db.entry.cursorToken( (e) => [e.amount.cursor(first.amount), e.id.cursor(first.id)], ); - final rest = await db.entries + final rest = await db.entry .seekToken(token, orderBy: (e) => [e.amount.asc(), e.id.asc()]) .get(); expect( @@ -132,10 +128,10 @@ void main() { 'sums, min/max, grouped aggregates and sliding windows stay exact', () async { await create(); - expect(await db.entries.select((e) => e.amount.sum()).single(), null); + expect(await db.entry.select((e) => e.amount.sum()).single(), null); await amounts(['.1', '.2', '.3', '.3']); expect( - await db.entries + await db.entry .select( (e) => ( e.amount.sum(), @@ -148,13 +144,13 @@ void main() { (d('.9'), d('.6'), d('.1'), d('.3')), ); expect( - await db.entries + await db.entry .groupBy((e) => [e.bucket]) .select((e) => (e.bucket, e.amount.sum()).row) .single(), ('a', d('.9')), ); - final windows = await db.entries + final windows = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => e.amount.sum().over( @@ -165,7 +161,7 @@ void main() { .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')); + expect(await db.entry.select((e) => e.fee.sum()).single(), d('.9')); if (backend == 'sqlite') { final rows = await db.execute( SqlCommand( @@ -183,7 +179,7 @@ void main() { 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 source = db.entry.select((e) => e.amount.times(d('2'))); final cte = source.asCte('doubled'); expect( await cte.query @@ -191,19 +187,19 @@ void main() { .get(), [d('20')], ); - final set = db.entries + final set = db.entry .select((e) => e.amount) - .union(db.entries.select((e) => e.amount.plus(d('0')))); + .union(db.entry.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 + await db.entry .where( (e) => e.amount.isInQuery( - db.entries + db.entry .where((e) => e.amount.gt(d('2'))) .select((e) => e.amount), ), @@ -217,19 +213,19 @@ void main() { 'numeric primary keys, foreign keys, joined and batched relations', () async { await create(); - await db.rates.create(id: d('2'), label: 'two'); + await db.rate.create(id: d('2'), label: 'two'); await db.execute( SqlCommand( "INSERT INTO allocations (\"rate_id\") VALUES ('2.000')", ), ); expect( - await db.allocations + await db.allocation .select((a) => a.rate.select((r) => r.label).required()) .single(), 'two', ); - final children = await db.rates + final children = await db.rate .select((r) => r.allocations.select((a) => a.rateId).many()) .single(); expect(children, [d('2')]); @@ -242,12 +238,12 @@ void main() { throwsA(isA()), ); await expectLater( - db.allocations.create(rateId: d('3')), + db.allocation.create(rateId: d('3')), throwsA(isA()), ); - expect((await db.rates.byId(d('2.000')).single()).label, 'two'); + expect((await db.rate.byId(d('2.000')).single()).label, 'two'); if (backend == 'sqlite') { - final command = db.rates.byId(d('2')).compile(); + final command = db.rate.byId(d('2')).compile(); final plan = await db.execute( SqlCommand( 'EXPLAIN QUERY PLAN ${command.sql}', @@ -273,7 +269,7 @@ void main() { expect(await verifyColumns(db.sql, appSchema), isEmpty); final imported = await importSchema(db.sql); expect(imported.issues, isEmpty); - expect(imported.dart, contains('Decimal')); + expect(imported.dart, contains('decimal(')); final directory = await Directory( '.dart_tool/orm-decimal-import-$backend', ).create(recursive: true); @@ -289,7 +285,7 @@ void main() { await directory.delete(recursive: true); } expect( - (await db.entries.single()).amount, + (await db.entry.single()).amount, d('12345678901234567890.00000000001'), ); }); @@ -297,25 +293,25 @@ void main() { 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 + await db.entry.create(amount: huge, bucket: 'a'); + await db.entry.create(amount: huge, bucket: 'a'); + await db.entry.create(amount: -huge, bucket: 'a'); + expect(await db.entry.select((e) => e.amount.sum()).single(), huge); + await db.entry .where((e) => e.amount.lt(Decimal.zero)) .delete() .execute(); await expectLater( - db.entries.select((e) => e.amount.sum()).single(), + db.entry.select((e) => e.amount.sum()).single(), throwsA(isA()), ); - expect(await db.entries.count(), 2); + expect(await db.entry.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'); + await db.rate.create(id: d(n), label: 'pending'); } final first = Migration.create( '0001_decimal', @@ -326,7 +322,7 @@ void main() { '0002_backfill', [ Backfill( - ratesSchema, + rateSchema, set: {'label': "'done'"}, where: "label = 'pending'", doneWhen: "SELECT NOT EXISTS (SELECT 1 FROM rates WHERE label = 'pending')", @@ -337,9 +333,9 @@ void main() { dialect: db.dialect, ); await Migrator(db.sql).apply([first, second], maxBackfillBatches: 1); - expect(await db.rates.where((r) => r.label.eq('done')).count(), 1); + expect(await db.rate.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); + expect(await db.rate.where((r) => r.label.eq('done')).count(), 4); }); test('reviewed text-to-decimal migration rejects duplicate numeric keys and rolls back', () async { @@ -408,7 +404,7 @@ void main() { "INSERT INTO entries (amount, bucket) VALUES ('${backend == 'sqlite' ? 'invalid' : 'NaN'}', 'bad')", ), ); - await expectLater(db.entries.get(), throwsFormatException); + await expectLater(db.entry.get(), throwsFormatException); if (backend == 'sqlite') { await amounts(['2', '-10', '10']); final raw = await db.execute( @@ -416,12 +412,12 @@ void main() { ); expect(raw.rows.map((r) => r.single), ['-10', '2', '10', 'invalid']); await expectLater( - db.entries.select((e) => e.amount.sum()).single(), + db.entry.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); + await db.entry.where((e) => e.bucket.eq('bad')).delete().execute(); + expect(await db.entry.where((e) => e.bucket.eq('bad')).count(), 0); }); }); } @@ -431,7 +427,7 @@ void main() { () async { final db = Database(_NoDecimalDriver()); expect( - () => db.entries.select((e) => e.amount).compile(), + () => db.entry.select((e) => e.amount).compile(), throwsA( isA().having( (e) => e.code, @@ -492,7 +488,7 @@ void main() { ); expect((await inspectTable(db.sql, 'custom')).unmanaged, isNotEmpty); final imported = await importSchema(db.sql, tables: ['missing']); - expect(imported.dart, contains('String amount')); + expect(imported.dart, contains('amount: text(')); await db.execute( SqlCommand( 'CREATE TABLE unicode_names (Ä TEXT COLLATE orm_decimal_v1, ä TEXT)', diff --git a/test/generated_database_test.dart b/test/generated_database_test.dart index 586e41de..76e5e7fa 100644 --- a/test/generated_database_test.dart +++ b/test/generated_database_test.dart @@ -59,11 +59,11 @@ void runGeneratedTests(String name, Future> Function() open) { 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( + final user = await tx.user.create( email: 'seven@example.com', score: .set(10), ); - await tx.posts.create( + await tx.post.create( authorId: user.id, title: 'Hello', createdAt: time, @@ -71,18 +71,18 @@ void runGeneratedTests(String name, Future> Function() open) { 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 + await db.user.byId(user.id).patch(nickname: .set('Seven')); + expect((await db.user.byId(user.id).single()).nickname, 'Seven'); + await db.user.byId(user.id).patch(nickname: .set(null)); + final titles = await db.user .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 db.post.single()).createdAt, time); expect(await verifyColumns(db.sql, appSchema), isEmpty); - await db.users.byId(user.id).delete().execute(); - expect(await db.posts.count(), 0); + await db.user.byId(user.id).delete().execute(); + expect(await db.post.count(), 0); }, ); @@ -119,11 +119,11 @@ void runGeneratedTests(String name, Future> Function() open) { for (final statement in createSchema(appSchema, db.dialect)) { await db.execute(statement); } - final user = await db.users.create(email: 'already-exists'); + final user = await db.user.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 db.user.byId(user.id).single()).email, 'already-exists'); expect(await Migrator(db.sql).plan([initial]), isEmpty); await expectLater( Migrator(db.sql) @@ -145,13 +145,13 @@ void runGeneratedTests(String name, Future> Function() open) { TableSchema( 'users', columns: [ - ...usersSchema.columns.where((c) => c.name != 'score'), + ...userSchema.columns.where((c) => c.name != 'score'), Column('score', Codecs.integer, defaultSql: '1'), ], - primaryKey: usersSchema.primaryKey, - uniqueKeys: usersSchema.uniqueKeys, + primaryKey: userSchema.primaryKey, + uniqueKeys: userSchema.uniqueKeys, ), - postsSchema, + postSchema, ]); final differences = (await verifySchema(db.sql, changed)).differences; expect(differences, contains('users.score default differs')); @@ -173,7 +173,7 @@ void runGeneratedTests(String name, Future> Function() open) { Migrator(db.sql).plan([]), throwsA(isA()), ); - expect(await db.users.count(), 0); + expect(await db.user.count(), 0); }); test('failed DDL rolls back schema and history atomically', () async { diff --git a/test/generator_dialects_test.dart b/test/generator_dialects_test.dart index feaed8a3..cc323eab 100644 --- a/test/generator_dialects_test.dart +++ b/test/generator_dialects_test.dart @@ -15,13 +15,10 @@ void main() { 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 entry = model('entries', ( + id: integer(), source: integer(), + computed: integer().nullable().computed('source + 1', postgres: 'source + 2', mysql: 'source + 3', mariadb: 'source + 4'), +), primaryKey: (e) => e.id, checks: [check('source >= 1', name: 'valid', postgres: 'source >= 2', mysql: 'source >= 3', mariadb: 'source >= 4')]); '''); final result = await generateSchema(source.path); for (final (index, dialect) in SqlDialect.values.indexed) { @@ -44,15 +41,14 @@ final valid = entries.check('source >= 1', postgres: 'source >= 2', mysql: 'sour final source = File('${directory.path}/queries.dart'); await source.writeAsString(''' import 'package:orm/schema.dart'; -typedef Result = ({int value}); -final result = sqlQuery( +final result = sqlQuery(result: (value: integer(),), parameters: (minimum: integer(),), sqlite: 'query.sql', postgres: 'query.sql', mysql: 'query.sql', mariadb: 'query.sql', ); -final mysqlOnly = sqlQuery(mysql: 'query.sql'); -final mariaOnly = sqlQuery(mariadb: 'query.sql'); +final mysqlOnly = sqlQuery(result: (value: integer(),), parameters: (minimum: integer(),), mysql: 'query.sql'); +final mariaOnly = sqlQuery(result: (value: integer(),), parameters: (minimum: integer(),), mariadb: 'query.sql'); '''); await File('${directory.path}/query.sql') .writeAsString('SELECT :minimum AS value'); diff --git a/test/generator_test.dart b/test/generator_test.dart index 87798440..db6af47c 100644 --- a/test/generator_test.dart +++ b/test/generator_test.dart @@ -7,8 +7,7 @@ import 'package:test/test.dart'; void main() { late Directory fixtures; setUpAll(() async { - fixtures = await Directory('.dart_tool/orm-generator-tests') - .create(recursive: true); + fixtures = await Directory('.dart_tool').createTemp('schema-generation-'); }); tearDownAll(() => fixtures.delete(recursive: true)); @@ -22,9 +21,10 @@ void main() { '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: ''); +final item = model('items', ( + id: integer(), + value: integer(unique: true).computed('id + 1', storage: .virtual, postgres: ''), +), checks: [check('id > 0', name: 'positive', postgres: '')]); '''); expect(sqlite.snapshot.forDialect(.sqlite).tables, hasLength(1)); expect( @@ -32,10 +32,9 @@ final positive = items.check('id > 0', 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'); +final item = model('items', (source: integer(), id: integer().computed('source + 1')), + primaryKey: (i) => i.id, + checks: [check('source > 0', name: 'valid'), check('source < 10', name: 'VALID')]); '''); expect(postgres.snapshot.forDialect(.postgres).tables, hasLength(1)); expect( @@ -45,24 +44,9 @@ final upper = items.check('source < 10', name: 'VALID'); }, ); - 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"; + "import 'package:orm/schema.dart';\nfinal row = model('rows', (id: integer(),));\n"; for (final (name, outputName) in [ ('same.dart', 'same.dart'), ('overlap.snapshot.dart', 'overlap.dart'), @@ -79,545 +63,110 @@ final upper = items.check('source < 10', name: 'VALID'); } }); - 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 for all executable schemas', () async { + final sources = [ + 'example/schema.dart', + 'example/teams/schema.dart', + 'example/company/schema.dart', + for (final directory in Directory( + 'test/support', + ).listSync().whereType()) + if (File('${directory.path}/schema.dart').existsSync()) + '${directory.path}/schema.dart', + ]; + for (final source in sources) { + final result = await generateSchema(source); + expect( + result.dart, + await File(source.replaceAll('.dart', '.orm.dart')).readAsString(), + reason: source, + ); + expect( + result.snapshotDart, + await File(source.replaceAll('.dart', '.snapshot.dart')).readAsString(), + reason: source, + ); + expect(result.dart, isNot(contains('export "schema.dart" show User'))); + } }); test( - 'output is deterministic and matches the committed generated client', + 'custom types resolve their defining libraries when output moves', () async { - for (final source in [ - 'example/schema.dart', - 'example/teams/schema.dart', - 'test/support/relations/schema.dart', + final output = '${fixtures.path}/moved.dart'; + await writeGeneratedSchema( '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, [ + output: output, + ); + final analyzed = await Process.run(Platform.resolvedExecutable, [ 'analyze', - '${fixtures.path}/composite.orm.dart', + output, ]); expect( - analysis.exitCode, + analyzed.exitCode, 0, - reason: '${analysis.stdout}\n${analysis.stderr}', + reason: '${analyzed.stdout}\n${analyzed.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', + 'SQL and nullable client defaults preserve independent insert ownership', () 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 generated = await generate('defaults', ''' +String? label() => null; +String state() => 'client'; +final item = model('items', ( + id: identity(), + label: text().nullable(clientDefault: label), + state: text(defaultSql: "'server'", clientDefault: state), +)); '''); - 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), - ), - ), + expect(generated.dart, contains('clientDefault: models.label')); + expect(generated.dart, contains('clientDefault: models.state')); + expect( + generated.snapshot.tables.single.columns.last.defaultSql, + "'server'", ); - }); - } - - 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')); + expect(generated.snapshotDart, isNot(contains('clientDefault'))); }, ); - 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 { + final invalid = { + 'reserved_root': "final close = model('items', (id: integer(),));", + 'private_root': "final _item = model('items', (id: integer(),));", + 'schema_symbol': "final app = model('items', (id: integer(),));", + 'row_collision': "final item = model('items', (id: integer(),)); final Item = model('others', (id: integer(),));", + 'column_symbol_collision': + "final item = model('items', (iD: integer(), i_d: integer()));", + 'selector_expression': "final item = model('items', (id: integer(),), primaryKey: (i) => (i.id, 1));", + 'computed_identity': + "final item = model('items', (id: identity().computed('1'),));", + 'computed_sql_default': "final item = model('items', (id: integer(defaultValue: 1).computed('1'),));", + 'computed_client_default': "int value() => 1; final item = model('items', (id: integer(clientDefault: value).computed('1'),));", + 'computed_empty': + "final item = model('items', (id: integer().computed(''),));", + 'wrong_factory': "String value() => 'one'; final item = model('items', (id: integer(clientDefault: value),));", + 'async_factory': "Future value() async => 1; final item = model('items', (id: integer(clientDefault: value),));", + 'private_factory': "int _value() => 1; final item = model('items', (id: integer(clientDefault: _value),));", + 'required_factory': "int value(int input) => input; final item = model('items', (id: integer(clientDefault: value),));", + 'factory_closure': + "final item = model('items', (id: integer(clientDefault: () => 1),));", + 'duplicate_factory': "String value() => 'a'; final item = model('items', (name: text(clientDefault: value).nullable(clientDefault: value),));", + 'dynamic_check': "String sql() => 'id > 0'; final item = model('items', (id: integer(),), checks: [check(sql(), name: 'valid')]);", + 'empty_check': "final item = model('items', (id: integer(),), checks: [check('', name: 'valid')]);", + 'duplicate_check': "final item = model('items', (id: integer(),), checks: [check('id > 0', name: 'valid'), check('id < 10', name: 'valid')]);", + 'private_codec': "int decode(Object? v) => v as int; Object? encode(int v) => v; const _codec = Codec.integer(decode, encode); final item = model('items', (id: custom(_codec),));", + 'unknown_codec': "int decode(Object? v) => v as int; Object? encode(int v) => v; const codec = Codec('unknown', decode, encode); final item = model('items', (id: custom(codec),));", + 'dart_type_error': + "final item = model('items', (id: integer(defaultValue: 'one'),));", + }; + for (final entry in invalid.entries) { + test('rejects ${entry.key}', () async { await expectLater( - generate(name, source), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains(expected), - ), - ), + generate(entry.key, entry.value), + throwsA(isA()), ); }); } - - 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_test.dart b/test/import_test.dart index ab87acdc..dfdd4a6b 100644 --- a/test/import_test.dart +++ b/test/import_test.dart @@ -74,7 +74,7 @@ void main() { final draft = await importSchema(db.sql); expect(draft.issues, isEmpty); expect(draft.entities, {'accounts': 'accounts', 'notes': 'notes'}); - expect(draft.dart, contains('@Id.generated()')); + expect(draft.dart, contains('.identity()')); final client = await generate(draft); final snapshot = client.snapshot; expect((await verifySchema(db.sql, snapshot)).differences, isEmpty); @@ -170,6 +170,9 @@ Future main() async { final names = [ r'user-$name', 'user name', + 'query', + 'fields', + 'expr', 'class', '123', '中文', @@ -343,9 +346,9 @@ Future main() async { ), ); final draft = await importSchema(db.sql); - expect(draft.dart, contains('String? document')); - expect(draft.dart, contains('String? timestamp')); - expect(draft.dart, contains('int flag')); + expect(draft.dart, contains('document: text(')); + expect(draft.dart, contains('timestamp: text(')); + expect(draft.dart, contains('flag: integer(')); await generate(draft); } else { await db.execute( @@ -354,8 +357,8 @@ Future main() async { ), ); final draft = await importSchema(db.sql); - expect(draft.dart, contains('@UseCodec(Codecs.jsonDocument)')); - expect(draft.dart, contains('SqlJson? document')); + expect(draft.dart, contains('json(')); + expect(draft.dart, contains('document: json(')); final result = await generate(draft); expect( (await verifySchema(db.sql, result.snapshot)).differences, diff --git a/test/instant_test.dart b/test/instant_test.dart index 62f1af04..a2f362a5 100644 --- a/test/instant_test.dart +++ b/test/instant_test.dart @@ -124,7 +124,7 @@ void main() { Migration upgrade(Migration first) => Migration.diff( '0002_instant', from: first.snapshot!, - to: SchemaSnapshot([momentsSchema]), + to: SchemaSnapshot([momentSchema]), previous: first.checksum, using: (db.dialect == SqlDialect.sqlite ? { @@ -148,11 +148,11 @@ void main() { DateTime.fromMicrosecondsSinceEpoch(8640000000000000000, isUtc: true), ]; for (final date in dates) { - final row = await db.events.create(at: date); + final row = await db.event.create(at: date); expect(row.at, date); expect(row.created.isUtc, true); expect( - await db.events + await db.event .byId(row.id) .select((e) => value(date, Codecs.dateTime)) .single(), @@ -160,7 +160,7 @@ void main() { ); } expect( - (await db.events + (await db.event .orderBy((e) => [e.id.asc()]) .stream(batchSize: 1) .toList()) @@ -172,7 +172,7 @@ void main() { 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( + (await s.event.orderBy((e) => [e.id.asc()]).get()).map( (e) => e.at, ), dates, @@ -185,26 +185,26 @@ void main() { 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.event.create(at: epoch); + await db.event.create(at: next); await db.execute( SqlCommand("INSERT INTO events (at) VALUES ('2024-01-01 08:00+08')"), ); expect( - await db.events + await db.event .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.event.where((e) => e.at.gt(epoch)).count(), 1); + expect(await db.event.where((e) => e.at.isIn([epoch])).count(), 2); expect( - await db.events.select((e) => (e.at.min(), e.at.max()).row).single(), + await db.event.select((e) => (e.at.min(), e.at.max()).row).single(), (epoch, next), ); expect( - await db.events + await db.event .groupBy((e) => [e.at]) .orderBy((e) => [e.at.asc()]) .select((e) => (e.at, e.id.count()).row) @@ -212,7 +212,7 @@ void main() { [(epoch, 2), (next, 1)], ); expect( - await db.events + await db.event .orderBy((e) => [e.id.asc()]) .select( (e) => e.at.max().over( @@ -223,7 +223,7 @@ void main() { .get(), [epoch, next, next], ); - final source = db.events.select((e) => e.at), + final source = db.event.select((e) => e.at), cte = source.asCte('times'); expect( await cte.query.where((c) => c.ref((e) => e.at).gt(epoch)).get(), @@ -233,11 +233,11 @@ void main() { await source.union(source).get(), unorderedEquals([epoch, next]), ); - final token = db.events.cursorToken( + final token = db.event.cursorToken( (e) => [e.at.cursor(epoch), e.id.cursor(3)], ); expect( - (await db.events + (await db.event .seekToken(token, orderBy: (e) => [e.at.asc(), e.id.asc()]) .get()) .map((e) => e.at), @@ -250,20 +250,20 @@ void main() { () async { await create(); final date = DateTime.utc(2024); - await db.moments.create(at: date); + await db.moment.create(at: date); await db.execute( SqlCommand( "INSERT INTO links (at) VALUES ('2024-01-01 08:00:00+0800')", ), ); expect( - await db.moments + await db.moment .select((m) => m.links.select((l) => l.at).many()) .single(), [date], ); expect( - await db.links + await db.link .select((l) => l.moment.select((m) => m.at).one()) .single(), date, @@ -277,7 +277,7 @@ void main() { throwsA(isA()), ); if (backend == 'sqlite') { - final command = db.moments.byId(date).compile(); + final command = db.moment.byId(date).compile(); final plan = await db.execute( SqlCommand( 'EXPLAIN QUERY PLAN ${command.sql}', @@ -300,7 +300,7 @@ void main() { expect(await verifyColumns(db.sql, appSchema), isEmpty); final imported = await importSchema(db.sql); expect(imported.issues, isEmpty); - expect(imported.dart, contains('DateTime')); + expect(imported.dart, contains('dateTime(')); final directory = await Directory( '.dart_tool/orm-instant-import-$backend', ).create(recursive: true); @@ -384,7 +384,7 @@ void main() { isEmpty, ); expect( - await db.moments + await db.moment .orderBy((m) => [m.at.asc()]) .select((m) => m.at) .get(), @@ -426,7 +426,7 @@ void main() { 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)); + expect((await db.moment.single()).at, DateTime.utc(2024)); }); } @@ -438,7 +438,7 @@ void main() { DateTime.utc(0), ]; for (final time in times) { - await db.moments.create(at: time); + await db.moment.create(at: time); } final first = Migration.create( '0001_instant', @@ -449,7 +449,7 @@ void main() { '0002_backfill', [ Backfill( - momentsSchema, + momentSchema, set: {'label': "'done'"}, where: "label = 'pending'", doneWhen: "SELECT NOT EXISTS (SELECT 1 FROM moments WHERE label = 'pending')", @@ -461,11 +461,11 @@ void main() { ); await Migrator(db.sql).apply([first, second], maxBackfillBatches: 1); expect( - (await db.moments.where((m) => m.label.eq('done')).single()).at, + (await db.moment.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); + expect(await db.moment.where((m) => m.label.eq('done')).count(), 3); }); test('external infinity and out-of-DateTime values fail decoding without wrapping', () async { @@ -489,9 +489,9 @@ void main() { throwsFormatException, ); } - await expectLater(db.events.get(), throwsFormatException); + await expectLater(db.event.get(), throwsFormatException); expect( - (await db.events.create(at: DateTime.utc(2024))).at, + (await db.event.create(at: DateTime.utc(2024))).at, DateTime.utc(2024), ); }); diff --git a/test/integer_test.dart b/test/integer_test.dart index e3803fdd..cf0ce49e 100644 --- a/test/integer_test.dart +++ b/test/integer_test.dart @@ -40,12 +40,12 @@ void main() { test('storage bounds, nulls and wider sums keep the normal integer value codec', () async { await create(); - final a = await db.samples.create( + final a = await db.sample.create( small: 32767, medium: 2147483647, large: 9007199254740993, ); - final b = await db.samples.create( + final b = await db.sample.create( small: 32767, medium: 2147483647, large: -9007199254740993, @@ -53,11 +53,11 @@ void main() { ); expect(a.optional, null); expect(b.optional, -32768); - final sums = await db.samples + final sums = await db.sample .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); + expect((await db.sample.byId(a.id).single()).large, 9007199254740993); for (final values in [ (32768, 0), (-32769, 0), @@ -65,11 +65,11 @@ void main() { (0, -2147483649), ]) { await expectLater( - db.samples.create(small: values.$1, medium: values.$2, large: 0), + db.sample.create(small: values.$1, medium: values.$2, large: 0), throwsA(isA()), ); } - expect(await db.samples.count(), 2); + expect(await db.sample.count(), 2); await expectLater( db.execute(SqlCommand('UPDATE samples SET small = 32768')), throwsA(isA()), @@ -91,26 +91,26 @@ void main() { 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); + final a = await db.sample.create(small: 10, medium: 20, large: 30); + await db.owner.create(id: 1, sampleId: a.id); expect( - await db.owners + await db.owner .select((o) => o.sample.select((s) => s.small).required()) .single(), 10, ); - final set = db.samples + final set = db.sample .select((s) => s.small) - .unionAll(db.samples.select((s) => s.medium)); + .unionAll(db.sample.select((s) => s.medium)); expect(await set.get(), unorderedEquals([10, 20])); - final values = await db.samples.orderBy((s) => [s.id.asc()]).get(); + final values = await db.sample.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)]); + final b = await db.sample.create(small: 11, medium: 21, large: 31); + final token = db.sample.cursorToken((s) => [s.id.cursor(a.id)]); expect( - (await db.samples + (await db.sample .seekToken(token, orderBy: (s) => [s.id.asc()]) .single()) .id, @@ -171,8 +171,8 @@ void main() { 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)')); + expect(imported.dart, contains('bits: 16')); + expect(imported.dart, contains('bits: 32')); final directory = await Directory( '.dart_tool/orm-integer-import-$backend', ).create(recursive: true); @@ -436,18 +436,18 @@ void main() { }); } - test('analyzer validates width annotations and integer-backed domain codecs', () async { + test('analyzer validates width declarations 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', + 'id: integer(bits: 8)', + 'id: custom(Codecs.text, bits: 32)', + 'id: integer(bits: 16, bits: 32)', ]) { await source.writeAsString( - "import 'package:orm/schema.dart';\ntypedef Row = ({$declaration});\nfinal rows = entity();", + "import 'package:orm/schema.dart';\nfinal row = model('rows', ($declaration,));", ); await expectLater( generateSchema(source.path), @@ -459,8 +459,7 @@ 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 row = model('rows', (id: custom(idCodec, bits: 16),)); '''); final generated = await generateSchema(source.path); final column = generated.snapshot.tables.single.columns.single; diff --git a/test/layer_boundaries_test.dart b/test/layer_boundaries_test.dart index b3e537ac..ea735dfe 100644 --- a/test/layer_boundaries_test.dart +++ b/test/layer_boundaries_test.dart @@ -123,7 +123,10 @@ void main() { // Otherwise importing src/query directly could evade the SQL-layer ban. String layer(String name) { if (name == 'src/schema/model.dart') return 'schema_model.dart'; - if (name == 'src/schema/declaration.dart') return 'schema.dart'; + if (name == 'src/schema/declaration.dart' || + name == 'src/schema/queries.dart') { + return 'schema.dart'; + } for (final (prefix, entry) in [ ('src/values/', 'values.dart'), ('src/driver/', 'driver.dart'), diff --git a/test/many_to_many_test.dart b/test/many_to_many_test.dart index 21e9fe18..8904fe26 100644 --- a/test/many_to_many_test.dart +++ b/test/many_to_many_test.dart @@ -6,7 +6,6 @@ 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); @@ -46,15 +45,15 @@ void main() { Migration.create('0001_teams', appSchema, dialect: db.dialect), ]); await db.transaction((tx) async { - await tx.users.insertMany( + await tx.user.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( + await tx.team.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( + await tx.membership.insertMany( [ (10, 1, MembershipRole.owner, day1), (10, 2, MembershipRole.member, day2), @@ -89,22 +88,22 @@ void main() { isEmpty, ); await expectLater( - db.memberships.create(teamId: 10, userId: 1, joinedAt: day2), + db.membership.create(teamId: 10, userId: 1, joinedAt: day2), throwsA(isA()), ); await expectLater( - db.memberships.create(teamId: 999, userId: 4, joinedAt: day2), + db.membership.create(teamId: 999, userId: 4, joinedAt: day2), throwsA(isA()), ); await expectLater( - db.memberships.create(teamId: 40, userId: 999, joinedAt: day2), + db.membership.create(teamId: 40, userId: 999, joinedAt: day2), throwsA(isA()), ); - expect(await db.memberships.count(), 6); + expect(await db.membership.count(), 6); }); test('forward projection keeps payload, shared targets and per-user limits in two statements', () async { - final query = db.users + final query = db.user .orderBy((u) => [u.id.asc()]) .select( (u) => @@ -166,7 +165,7 @@ void main() { }); test('reverse projection applies tie-breaking and offset to each team without changing root pagination', () async { - final rows = await db.teams + final rows = await db.team .orderBy((t) => [t.id.asc()]) .take(3) .select( @@ -191,7 +190,7 @@ void main() { expect(events.map((e) => e.rowCount), [3, 2]); events.clear(); expect( - await db.users + await db.user .where((u) => u.id.eq(999)) .select((u) => u.memberships.many()) .get(), @@ -203,7 +202,7 @@ void main() { test( 'nested team rosters deduplicate shared team keys and batch by level', () async { - final rows = await db.users + final rows = await db.user .orderBy((u) => [u.id.asc()]) .select( (u) => ( @@ -267,7 +266,7 @@ void main() { test( 'association predicates and counts stay in one SQL statement', () async { - final rows = await db.users + final rows = await db.user .orderBy((u) => [u.id.asc()]) .select( (u) => ( @@ -300,7 +299,7 @@ void main() { _LimitedDriver(db.driver), onQuery: events.add, ); - final rows = await limited.users + final rows = await limited.user .orderBy((u) => [u.id.asc()]) .select( (u) => u.memberships @@ -325,17 +324,13 @@ void main() { test('transaction rollback restores payload changes and new endpoints after an invalid association', () async { await expectLater( db.transaction((tx) async { - await tx.memberships + await tx.membership .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( + await tx.user.create(id: 5, name: 'Eve'); + await tx.team.create(id: 50, name: 'New'); + await tx.membership.create(teamId: 50, userId: 5, joinedAt: day2); + await tx.membership.create( teamId: 999, userId: 5, joinedAt: day2, @@ -344,17 +339,17 @@ void main() { throwsA(isA()), ); expect( - (await db.memberships.byId(teamId: 10, userId: 1).single()).role, + (await db.membership.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); + expect(await db.user.byId(5).exists(), false); + expect(await db.team.byId(50).exists(), false); + expect(await db.membership.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 + final rows = await tx.membership .insert( (m) => [ m.teamId.set(10), @@ -372,18 +367,18 @@ void main() { .returning((m) => (m.role, m.joinedAt).row) .get(); expect(rows, [(MembershipRole.owner, day2)]); - await tx.memberships.byId(teamId: 10, userId: 1).delete().execute(); + await tx.membership.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.user.count(), 4); + expect(await db.team.count(), 4); + expect(await db.membership.count(), 5); + await db.team.byId(20).delete().execute(); + await db.user.byId(1).delete().execute(); + expect(await db.membership.count(), 2); + expect(await db.user.count(), 3); + expect(await db.team.count(), 3); expect( - (await db.memberships.orderBy((m) => [m.userId.asc()]).get()).map( + (await db.membership.orderBy((m) => [m.userId.asc()]).get()).map( (m) => (m.teamId, m.userId), ), [(10, 2), (10, 3)], @@ -392,7 +387,7 @@ void main() { test('watch tracks junction payload and opposite endpoint changes after commit', () async { final iterator = StreamIterator( - db.users + db.user .byId(1) .select( (u) => u.memberships @@ -417,8 +412,8 @@ void main() { MembershipRole.owner, )); await db.transaction((tx) async { - await tx.teams.byId(10).patch(name: .set('Kernel')); - await tx.memberships + await tx.team.byId(10).patch(name: .set('Kernel')); + await tx.membership .byId(teamId: 10, userId: 1) .patch(role: .set(MembershipRole.member)); }); @@ -430,7 +425,7 @@ void main() { 'Kernel', MembershipRole.member, )); - await db.teams.byId(10).delete().execute(); + await db.team.byId(10).delete().execute(); expect( await iterator.moveNext().timeout(const Duration(seconds: 5)), true, diff --git a/test/mysql_import_test.dart b/test/mysql_import_test.dart index 1a3fc8b3..b80da469 100644 --- a/test/mysql_import_test.dart +++ b/test/mysql_import_test.dart @@ -72,9 +72,9 @@ void main() { false, reason: imported.toJson().toString(), ); - expect(imported.dart, contains('final class')); - expect(imported.dart, contains('@Id.generated()')); - expect(imported.dart, contains('LocalDateTime')); + expect(imported.dart, contains('model(')); + expect(imported.dart, contains('.identity()')); + expect(imported.dart, contains('localDateTime(')); expect( imported.issues.map((i) => i.code), contains('IMPORT.TEMPORAL_SEMANTICS'), @@ -114,8 +114,7 @@ void main() { 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 probe = sqlQuery(result: (value: integer(),), parameters: (minimum: integer(),), $engine: 'query.sql'); '''); await File('${directory.path}/query.sql') .writeAsString('SELECT :minimum AS value'); @@ -140,8 +139,7 @@ BEGIN INSERT INTO `$table` VALUES (1); RETURN 1; END; /* final delimiter */'''), 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 probe = sqlQuery(result: (value: integer(),), parameters: (minimum: integer(),), $engine: 'query.sql'); '''); final sql = File('${directory.path}/query.sql'); await sql.writeAsString('SELECT `$function`() + :minimum AS value'); diff --git a/test/named_sql_generation_test.dart b/test/named_sql_generation_test.dart index b754d602..75ea5936 100644 --- a/test/named_sql_generation_test.dart +++ b/test/named_sql_generation_test.dart @@ -17,69 +17,86 @@ void main() { tearDown(() => directory.delete(recursive: true)); File file(String name) => File('${directory.path}/$name'); - Future declaration(String record, String call, String sql) async { + Future declaration(String result, String options, String sql) async { await file('query.dart').writeAsString( - "import 'package:orm/schema.dart';\n$record\nfinal query = $call;\n", + "import 'package:orm/schema.dart';\nfinal sample = sqlQuery(result: $result, $options);\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', - ), + test( + 'source contracts reject missing/extra parameters and row metadata', + () async { + for (final (result, options, sql) in [ + ( + '(n: integer(),)', + "parameters: (missing: integer(),), sqlite: 'query.sql'", + 'SELECT :n AS n', + ), + ('(n: identity(),)', "sqlite: 'query.sql'", 'SELECT 1 AS n'), + ( + "(n: integer().computed('1'),)", + "sqlite: 'query.sql'", + 'SELECT 1 AS n', + ), + ( + "(n: text(clientDefault: String.fromEnvironment),)", + "sqlite: 'query.sql'", + 'SELECT 1 AS n', + ), + ( + '(n: dateTime(precision: 2),)', + "sqlite: 'query.sql'", + 'SELECT 1 AS n', + ), + ( + '(n: integer(), duplicate: text(name: "n"))', + "sqlite: 'query.sql'", + 'SELECT 1 AS n', + ), + ('(integer(),)', "sqlite: 'query.sql'", 'SELECT 1 AS n'), + ('(n: integer(),)', '', 'SELECT 1 AS n'), + ( + '(n: integer(),)', + "sqlite: 'query.sql'", + 'SELECT 1; DELETE FROM posts', + ), + ]) { + await declaration(result, options, sql); + await expectLater( + generateQueries(file('query.dart').path), + throwsA(anyOf(isA(), isA())), + ); + } + }, + ); + + test('generated query classes reject symbol collisions', () async { + await file('query.sql').writeAsString('SELECT 1 AS n'); + for (final declarations in [ + "final query = sqlQuery(result: (n: integer(),), sqlite: 'query.sql');", + "final stats = sqlQuery(result: (n: integer(),), sqlite: 'query.sql');" + "final statsFields = sqlQuery(result: (n: integer(),), sqlite: 'query.sql');", ]) { - await declaration(record, call, sql); + await file('query.dart') + .writeAsString("import 'package:orm/schema.dart';\n$declarations"); await expectLater( generateQueries(file('query.dart').path), - throwsA(anyOf(isA(), isA())), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('ambiguous'), + ), + ), ); } }); test('SQL edits and modified generated code fail freshness checks', () async { await declaration( - 'typedef Row = ({int n});', - "sqlQuery(sqlite: 'query.sql')", + '(n: integer(),)', + "sqlite: 'query.sql'", 'SELECT 1 AS n', ); final source = file('query.dart').path; @@ -107,7 +124,9 @@ void main() { 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 file( + 'query.dart', + ).readAsString()).replaceAll('integer()', 'real()'), ); await expectLater( checkGeneratedQueries(source, output: output), @@ -199,8 +218,8 @@ void main() { 'CLI checks a persistent SQLite database without creating missing files', () async { await declaration( - 'typedef Row = ({int n});', - "sqlQuery(sqlite: 'query.sql')", + '(n: integer(),)', + "sqlite: 'query.sql'", 'SELECT COUNT(*) AS n FROM entries', ); Future run(List arguments) => Process.run( diff --git a/test/named_sql_test.dart b/test/named_sql_test.dart index 71e6fbb8..95627c99 100644 --- a/test/named_sql_test.dart +++ b/test/named_sql_test.dart @@ -163,15 +163,30 @@ void main() { 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.get()).map( + (row) => ( + author: row.author, + postCount: row.postCount, + points: row.points, + ), + ), + [ + (author: 'a', postCount: 2, points: 4), + (author: 'b', postCount: 1, points: 7), + ], + ); + final stats = await db + .authorStats(minimum: 2, author: 'a') + .single(); + expect( + ( + author: stats.author, + postCount: stats.postCount, + points: stats.points, + ), + (author: 'a', postCount: 1, points: 3), + ); expect( await query .where((s) => s.points.gt(4)) @@ -188,7 +203,7 @@ void main() { await db.authorStats(minimum: 0, author: "a' OR 1=1 --").get(), isEmpty, ); - expect(await db.constant().single(), (n: 42)); + expect((await db.constant().single()).n, 42); }, ); @@ -204,12 +219,16 @@ void main() { amount: amount, day: day, ); - expect(await query.single(), ( - value: ':ignored; " --', - at: instant, - amount: amount, - day: day, - )); + final echo = await query.single(); + expect( + ( + value: echo.value, + at: echo.at, + amount: echo.amount, + day: echo.day, + ), + (value: ':ignored; " --', at: instant, amount: amount, day: day), + ); expect(await query.select((q) => q.amount).stream().toList(), [ amount, ]); @@ -316,9 +335,7 @@ void main() { ), ); expect(left.rows.single.single, 0); - expect(await (db as Database).postgresOnly().single(), ( - n: 42, - )); + expect((await db.postgresOnly().single()).n, 42); } }); diff --git a/test/nominal_database_test.dart b/test/nominal_database_test.dart index 2b86edab..0a57e0bd 100644 --- a/test/nominal_database_test.dart +++ b/test/nominal_database_test.dart @@ -5,7 +5,6 @@ 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() { @@ -54,7 +53,7 @@ void runNominalTests(String name, Future> Function() open) { test( 'rows, insert defaults, codecs and patches retain nominal types', () async { - final Account account = await db.accounts.create( + final Account account = await db.account.create( email: const Email('a@example.com'), a: 2, b: 3, @@ -66,14 +65,14 @@ void runNominalTests(String name, Future> Function() open) { expect(account.enabled, false); expect(account.marker, 'seed'); expect(account.total, 5); - await db.accounts + await db.account .byId(account.id) .patch(label: .set('Account'), enabled: .set(true)); - final Account updated = await db.accounts.byId(account.id).single(); + final Account updated = await db.account.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); + await db.account.byId(account.id).patch(label: .set(null)); + expect((await db.account.byId(account.id).single()).label, isNull); }, ); @@ -81,29 +80,29 @@ void runNominalTests(String name, Future> Function() open) { '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( + final Account owner = await tx.account.create( email: const Email('owner@example.com'), a: 1, b: 2, c: 3, ); - final Note note = await tx.notes.create( + final Note note = await tx.note.create( accountId: owner.id, body: 'hello', ); expect(note, isA()); return owner; }); - final List children = await db.accounts + final List children = await db.account .byId(owner.id) .select((a) => a.notes.many()) .single(); expect(children.single.body, 'hello'); - final Account related = await db.notes + final Account related = await db.note .select((n) => n.account.required()) .single(); expect(related.email.value, 'owner@example.com'); - final ({int id, List notes}) card = await db.accounts + final ({int id, List notes}) card = await db.account .byId(owner.id) .select( (a) => ( @@ -114,8 +113,8 @@ void runNominalTests(String name, Future> Function() open) { .single(); expect(card.id, owner.id); expect(card.notes, ['hello']); - await db.accounts.byId(owner.id).delete().execute(); - expect(await db.notes.count(), 0); + await db.account.byId(owner.id).delete().execute(); + expect(await db.note.count(), 0); }, ); }); diff --git a/test/nominal_generation_test.dart b/test/nominal_generation_test.dart deleted file mode 100644 index a0766cbc..00000000 --- a/test/nominal_generation_test.dart +++ /dev/null @@ -1,202 +0,0 @@ -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 index aa2de06f..ed387105 100644 --- a/test/observation_test.dart +++ b/test/observation_test.dart @@ -45,9 +45,9 @@ void main() { 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( + await db.user.create(id: 1, name: 'Ada'); + await db.team.create(id: 10, name: 'Core'); + await db.membership.create( teamId: 10, userId: 1, joinedAt: DateTime.utc(2026), @@ -68,7 +68,7 @@ void main() { }); await held.future; acquired.clear(); - final pending = db.users.select((u) => u.name).map((name) { + final pending = db.user.select((u) => u.name).map((name) { final clock = Stopwatch()..start(); while (clock.elapsedMilliseconds < 20) {} // Deliberate synchronous mapper cost. @@ -120,7 +120,7 @@ void main() { await held.future; acquired.clear(); final token = CancellationToken(); - final pending = db.users.get( + final pending = db.user.get( options: ExecutionOptions( cancellation: cancel ? token : null, acquireTimeout: cancel @@ -159,10 +159,10 @@ void main() { 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'); + expect(await session.user.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(await child.user.select((u) => u.id).single(), 1); }), ); }); @@ -177,7 +177,7 @@ void main() { }); test('nested reads attribute decoding to each statement without repeating SQL', () async { - final query = db.users.select( + final query = db.user.select( (u) => ( u.name, u.memberships @@ -209,7 +209,7 @@ void main() { expect(decoded, isEmpty); final failure = StateError('mapper failed'); await expectLater( - db.users.map((row) => throw failure).get(), + db.user.map((row) => throw failure).get(), throwsA(same(failure)), ); expect(acquired.last.error, isNull); @@ -221,7 +221,7 @@ void main() { test( 'cursor batches and returning writes produce decoding observations', () async { - await db.users + await db.user .insertMany([ 2, 3, @@ -234,7 +234,7 @@ void main() { statements.clear(); acquired.clear(); expect( - await db.users + await db.user .orderBy((u) => [u.id.asc()]) .select((u) => u.id) .stream(batchSize: 2) @@ -250,7 +250,7 @@ void main() { [2, 1], ); decoded.clear(); - final row = await db.users.create(id: 4, name: 'new'); + final row = await db.user.create(id: 4, name: 'new'); expect(row.id, 4); expect(decoded.single.inputRows, 1); expect(decoded.single.sql, startsWith('INSERT')); @@ -265,12 +265,12 @@ void main() { onDecode: (_) => throw StateError('observer'), ); await observed.transaction( - (tx) => tx.users.create(id: 2, name: 'committed'), + (tx) => tx.user.create(id: 2, name: 'committed'), ); - expect((await db.users.byId(2).single()).name, 'committed'); + expect((await db.user.byId(2).single()).name, 'committed'); final failure = StateError('actual mapper'); await expectLater( - observed.users.map((row) => throw failure).get(), + observed.user.map((row) => throw failure).get(), throwsA(same(failure)), ); }); diff --git a/test/plan_test.dart b/test/plan_test.dart index eb4f0c69..9f92410e 100644 --- a/test/plan_test.dart +++ b/test/plan_test.dart @@ -6,7 +6,6 @@ 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; @@ -17,7 +16,7 @@ void main() { () { final db = Database(_NoConnection(dialect)); final query = db - .table(composite.accountsTable) + .table(composite.accountTable) .select( (a) => a.events .orderBy((e) => [e.id.asc()]) @@ -33,14 +32,14 @@ void main() { expect(load.maxKeysPerBatch, 1); expect(load.query!.sql, contains('PARTITION BY')); expect( - () => db.users.select((u) => u.memberships.take(1).many()).inspect(), + () => db.user.select((u) => u.memberships.take(1).many()).inspect(), throwsA( isA().having((e) => e.code, 'code', 'RELATION.ORDER'), ), ); expect( () => db - .table(composite.accountsTable) + .table(composite.accountTable) .select( (a) => a.events .where((e) => e.id.gt(0).and(e.score.gt(0))) @@ -64,7 +63,7 @@ void main() { () { final driver = _NoConnection(dialect), db = Database(driver); var decoded = false; - final query = db.users + final query = db.user .where((u) => u.name.eq('bound-secret')) .select( (u) => @@ -136,7 +135,7 @@ void main() { 'inspect ${dialect.name} distinguishes same-statement joins and skipped batches', () { final db = Database(_NoConnection(dialect)); - final plan = db.memberships + final plan = db.membership .select( (m) => ( m.team.select((t) => t.name).one(), @@ -147,7 +146,7 @@ void main() { expect(plan.sqlTemplateCount, 1); expect(plan.loads, isEmpty); expect(plan.joins.map((j) => j.table), ['teams', 'users']); - final skipped = db.users + final skipped = db.user .select((u) => u.memberships.take(0).many()) .inspect(); expect(skipped.loads.single.skipped, true); @@ -160,12 +159,12 @@ void main() { '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(); + final cte = db.user.select((u) => u.name).asCte('names'); + final plan = cte.query.union(db.team.select((t) => t.name)).inspect(); expect(plan.reads, ['teams', 'users']); expect(plan.sql, contains('UNION')); expect(plan.sqlTemplateCount, 1); - final raw = db.users + final raw = db.user .select((u) => sql(['length(name)'], [], Codecs.integer)) .inspect(); expect(raw.opaqueReads, true); @@ -200,9 +199,9 @@ void main() { 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( + await db.user.create(id: 1, name: 'Ada'); + await db.team.create(id: 10, name: 'Core'); + await db.membership.create( teamId: 10, userId: 1, joinedAt: DateTime.utc(2026), @@ -213,7 +212,7 @@ void main() { test( 'one-key SQL templates match executed SQL and internal slots', () async { - final query = db.users.select( + final query = db.user.select( (u) => u.memberships .orderBy((m) => [m.teamId.asc()]) .take(2) diff --git a/test/public_api_test.dart b/test/public_api_test.dart index 609359b1..f91c5f74 100644 --- a/test/public_api_test.dart +++ b/test/public_api_test.dart @@ -73,7 +73,47 @@ void main() { ); expect( exports(schema), - containsAll(['entity', 'Id', 'Codec', 'Decimal', 'ComputedStorage']), + containsAll([ + 'Model', + 'model', + 'Codec', + 'Decimal', + 'ComputedStorage', + ]), + ); + expect( + exports(schema), + isNot( + anyOf([ + contains('entity'), + contains('Entity'), + contains('EntityKey'), + contains('SchemaConstraint'), + contains('Id'), + contains('UseCodec'), + contains('ColumnName'), + contains('Unique'), + contains('Default'), + contains('ClientDefault'), + contains('Computed'), + contains('EnumValue'), + contains('IntegerBits'), + contains('DecimalDigits'), + contains('TemporalPrecision'), + ]), + ), + ); + final modelType = + schema.exportNamespace.definedNames2['Model'] as ClassElement; + expect(modelType.typeParameters, isEmpty); + expect( + modelType.constructors.where((c) => c.isPublic), + isEmpty, + reason: 'Create declarations only through model(...).', + ); + expect( + modelType.methods.where((m) => m.isPublic && m.isStatic), + isEmpty, ); expect(exports(runtime), isNot(contains('Migration'))); for (final path in ['lib/mysql.dart', 'lib/drivers/mysql.dart']) { @@ -164,7 +204,7 @@ void main() { final client = exports(await library('example/schema.orm.dart')); expect( client, - containsAll(['User', 'Post', 'UsersFields', 'AppTables']), + containsAll(['User', 'Post', 'UserFields', 'AppTables']), ); expect( client, diff --git a/test/record_database_test.dart b/test/record_database_test.dart new file mode 100644 index 00000000..823da682 --- /dev/null +++ b/test/record_database_test.dart @@ -0,0 +1,248 @@ +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/company/schema.orm.dart'; +import '../example/company/schema.snapshot.dart' as physical; + +void main() { + runRecordTests( + 'sqlite', + (observe) => sqlite(const SqliteOptions.memory(), onQuery: observe), + ); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url != null) { + const schema = 'orm_record_schema_tests'; + late Database admin; + setUpAll(() async { + admin = postgres( + PostgresOptions(url: Uri.parse(url), tls: PostgresTls.disable), + ); + await admin.execute(SqlCommand('CREATE SCHEMA "$schema"')); + }); + tearDownAll(() async { + await admin.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + await admin.close(); + }); + runRecordTests( + 'postgres', + (observe) async => postgres( + PostgresOptions( + url: Uri.parse(url), + tls: PostgresTls.disable, + schema: schema, + ), + onQuery: observe, + ), + ); + } +} + +void runRecordTests( + String name, + Future> Function(void Function(QueryEvent)) open, +) { + group('Record schema $name', () { + late Database db; + final events = []; + final constraintFailure = anyOf( + isA().having((e) => e.code, 'code', 19), + isA().having( + (e) => e.code, + 'SQLSTATE', + startsWith('23'), + ), + ); + final initial = Migration.create( + '0001_initial', + appSchema, + dialect: SqlDialect.values.byName(name), + ); + setUp(() async { + db = await open(events.add); + for (final table in [ + 'project_members', + 'projects', + 'employees', + 'departments', + '_orm_migrations', + ]) { + await db.execute(SqlCommand('DROP TABLE IF EXISTS "$table"')); + } + await Migrator(db.sql).apply([initial]); + events.clear(); + }); + tearDown(() => db.close()); + + Future employee( + String label, + int departmentId, { + int? managerId, + }) => db.employee.create( + name: label, + email: '$label@example.com', + departmentId: departmentId, + managerId: managerId, + ); + + test( + 'generated rows, defaults, explicit relations and query counts', + () async { + final team = await db.department.create(name: 'Engineering'); + final manager = await employee('manager', team.id); + final worker = await employee('worker', team.id, managerId: manager.id); + expect(worker.active, true); + expect( + worker.createdAt + .toUtc() + .difference(DateTime.now().toUtc()) + .inSeconds + .abs(), + lessThan(10), + ); + events.clear(); + expect( + await db.employee + .byId(worker.id) + .select((e) => e.manager.select((m) => m.name).one()) + .single(), + 'manager', + ); + expect(events.length, 1); + expect(events.single.sql, contains('LEFT JOIN')); + events.clear(); + expect( + await db.employee + .byId(manager.id) + .select((e) => e.directReports.select((r) => r.name).many()) + .single(), + ['worker'], + ); + expect(events.length, 2); + await db.employee.byId(worker.id).patch(name: .set('changed')); + expect( + (await db.employee.byId(worker.id).single()).managerId, + manager.id, + ); + await db.employee.byId(worker.id).patch(managerId: .set(null)); + expect((await db.employee.byId(worker.id).single()).managerId, isNull); + final project = await db.project.create( + name: 'Schema', + ownerId: manager.id, + ); + expect(project.status, ProjectStatus.draft); + final member = await db.projectMember.create( + projectId: project.id, + employeeId: worker.id, + ); + expect(member.role, MemberRole.member); + expect( + (await db.projectMember + .byId(projectId: project.id, employeeId: worker.id) + .single()) + .employeeId, + worker.id, + ); + await db.projectMember + .byId(projectId: project.id, employeeId: worker.id) + .patch(role: .set(MemberRole.maintainer)); + expect((await db.projectMember.single()).role, MemberRole.maintainer); + }, + ); + + test( + 'database enforces foreign keys, uniqueness and composite primary keys', + () async { + final team = await db.department.create(name: 'Engineering'); + final owner = await employee('owner', team.id); + final project = await db.project.create( + name: 'Schema', + ownerId: owner.id, + ); + await db.projectMember.create( + projectId: project.id, + employeeId: owner.id, + ); + await expectLater( + employee('owner', team.id), + throwsA(constraintFailure), + ); + await expectLater(employee('orphan', -1), throwsA(constraintFailure)); + await expectLater( + db.projectMember.create(projectId: project.id, employeeId: owner.id), + throwsA(constraintFailure), + ); + expect(await db.employee.count(), 1); + expect(await db.projectMember.count(), 1); + }, + ); + + test( + 'restrict, cascade and self-reference SET NULL reach the database', + () async { + final team = await db.department.create(name: 'Engineering'); + final manager = await employee('manager', team.id); + final worker = await employee('worker', team.id, managerId: manager.id); + final project = await db.project.create( + name: 'Schema', + ownerId: worker.id, + ); + await db.projectMember.create( + projectId: project.id, + employeeId: worker.id, + ); + await expectLater( + db.department.byId(team.id).delete().execute(), + throwsA(constraintFailure), + ); + await expectLater( + db.employee.byId(worker.id).delete().execute(), + throwsA(constraintFailure), + ); + await db.employee.byId(manager.id).delete().execute(); + expect((await db.employee.byId(worker.id).single()).managerId, isNull); + await db.project.byId(project.id).delete().execute(); + expect(await db.projectMember.count(), 0); + expect(await db.employee.count(), 1); + }, + ); + + test('generated creates respect transaction rollback', () async { + await expectLater( + db.transaction((tx) async { + final team = await tx.department.create(name: 'rolled-back'); + await tx.employee.create( + name: 'first', + email: 'same@example.com', + departmentId: team.id, + ); + await tx.employee.create( + name: 'second', + email: 'same@example.com', + departmentId: team.id, + ); + }), + throwsA(constraintFailure), + ); + expect(await db.department.count(), 0); + expect(await db.employee.count(), 0); + }); + + test( + 'frozen snapshot matches catalog and migration history is repeatable', + () async { + final verification = await verifySchema(db.sql, physical.schema); + expect(verification.differences, isEmpty); + expect(verification.unmanaged, isEmpty); + expect(await Migrator(db.sql).apply([initial]), isEmpty); + expect( + (await Migrator(db.sql).history()).single.checksum, + initial.checksum, + ); + }, + ); + }); +} diff --git a/test/record_generation_test.dart b/test/record_generation_test.dart new file mode 100644 index 00000000..a73bada5 --- /dev/null +++ b/test/record_generation_test.dart @@ -0,0 +1,489 @@ +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-record-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('one Record definition generates nominal rows, queries and independent snapshots', () async { + final generated = await generateSchema('example/company/schema.dart'); + expect( + generated.dart, + await File('example/company/schema.orm.dart').readAsString(), + ); + expect( + generated.snapshotDart, + await File('example/company/schema.snapshot.dart').readAsString(), + ); + expect(generated.dart, contains('final class Employee(')); + expect( + generated.dart, + contains('Relation get manager'), + ); + expect(generated.dart, contains('required final int? managerId')); + expect(generated.dart, contains('models.ProjectStatus')); + expect(generated.snapshotDart, isNot(contains('models.'))); + expect(generated.snapshotDart, isNot(contains('schema.dart'))); + final result = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + 'example/company', + ]); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + }); + + test( + 'const SQL names and typed defaults preserve quoting and physical identity', + () async { + const common = ''' +const tableName = 'people'; +const columnName = 'contact_email'; +const defaultName = "O'Brien"; +'''; + final before = await generate('names_before', ''' +$common +final Model person = model(tableName, ( + id: identity(), email: text(name: columnName, defaultValue: defaultName), +)); +'''); + final after = await generate('names_after', ''' +$common +final Model customer = model(tableName, ( + id: identity(), address: text(name: columnName, defaultValue: defaultName), +)); +'''); + expect(after.snapshotDart, before.snapshotDart); + expect(after.snapshotDart, contains("O''Brien")); + }, + ); + + test( + 'generation references client factories without executing them', + () async { + final generated = await generate('factory', ''' +int forbidden() => throw StateError('generation executed application code'); +final Model sample = model('sample', (id: identity(), value: integer(clientDefault: forbidden))); +'''); + expect(generated.dart, contains('clientDefault: models.forbidden')); + expect(generated.snapshotDart, isNot(contains('forbidden'))); + }, + ); + + test('same field shape keeps distinct model and table identities', () async { + final result = await generate('identity', ''' +final primaryUser = model('primary_users', (id: identity(), name: text())); +final archivedUser = model('archived_users', (id: identity(), name: text())); +'''); + expect(result.dart, contains('final class PrimaryUser(')); + expect(result.dart, contains('final class ArchivedUser(')); + expect(result.snapshot.tables.map((t) => t.name), [ + 'primary_users', + 'archived_users', + ]); + }); + + test('composite, alternate unique and read-only relationships preserve key order', () async { + final result = await generate('composite', ''' +final Model team = model('teams', (tenant: integer(), code: text(), slug: text(unique: true)), + primaryKey: (t) => (t.tenant, t.code), + relations: (t) => (members: referencedBy(() => member, on: (tenant: t.tenant, teamCode: t.code)),)); +final Model member = model('members', (id: identity(), tenant: integer(), teamCode: text(), slug: text()), + relations: (m) => ( + team: references((m.tenant, m.teamCode), () => team, onDelete: .cascade), + bySlug: references((slug: m.slug), () => team), + byCode: references((code: m.teamCode), () => team, constraint: false), + )); +'''); + final foreignKeys = result.snapshot.tables.last.foreignKeys; + expect(foreignKeys.length, 2); + expect(foreignKeys.first.columns, ['tenant', 'team_code']); + expect(foreignKeys.first.targetColumns, ['tenant', 'code']); + expect(result.dart, contains('get byCode')); + }); + + test( + 'enum labels and defaults share one encoding without annotations', + () async { + final result = await generate('enums', ''' +enum Status { waiting, done } +final Model job = model('jobs', ( + id: identity(), + status: enumeration(Status.values, labels: {Status.waiting: 'pending', Status.done: 'complete'}, defaultValue: Status.waiting), +)); +'''); + expect(result.dart, contains('models.Status.waiting: "pending"')); + expect(result.snapshotDart, contains("'pending'")); + }, + ); + + test( + 'prefixed model calls preserve inferred fields and self references', + () async { + final generated = await generate('prefixed', ''' +import 'package:orm/schema.dart' as schema; +final Model employee = schema.model('employees', ( + id: identity(), managerId: integer().nullable(), +), relations: (e) => ( + manager: references(e.managerId, () => employee), + reports: referencedBy(() => employee), +), indexes: (e) => [index(e.managerId, name: 'employees_manager')]); +'''); + expect(generated.snapshot.tables.single.foreignKeys.single.columns, [ + 'manager_id', + ]); + expect(generated.dart, contains('get manager =>')); + expect(generated.dart, contains('get reports =>')); + }, + ); + + test('barrel exports follow mutual model references and leave unrelated imports out', () async { + final split = await Directory('${fixtures.path}/split').create(); + await File('${split.path}/department.dart').writeAsString(''' +import 'package:orm/schema.dart'; +import 'employee.dart'; +final department = model('departments', ( + id: identity(), headId: integer().nullable(), +), relations: (d) => (head: references(d.headId, () => employee), employees: referencedBy(() => employee))); +final unused = model('unused', (invalid: 'not a column',)); +'''); + await File('${split.path}/employee.dart').writeAsString(''' +import 'package:orm/schema.dart'; +import 'department.dart'; +enum Role { member, manager } +final Model employee = model('employees', ( + id: identity(), role: enumeration(Role.values), + departmentId: integer(), +), relations: (e) => (department: references(e.departmentId, () => department),)); +'''); + final root = File('${split.path}/schema.dart'); + await root.writeAsString("export 'employee.dart' show employee;\n"); + await writeGeneratedSchema(root.path); + final generated = await generateSchema(root.path); + expect(generated.snapshot.tables.map((t) => t.name).toSet(), { + 'employees', + 'departments', + }); + expect(generated.dart, contains('show Role')); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${split.path}/schema.orm.dart', + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + }); + + for (final local in [false, true]) { + test( + 'exported model renames preserve snapshot identity (local: $local)', + () async { + final split = await Directory('${fixtures.path}/rename_$local') + .create(); + final first = File('${split.path}/first.dart'); + Future rename(String name) => first.writeAsString(''' +import 'package:orm/schema.dart'; +final $name = model('first_table', (id: identity(),)); +'''); + await rename('a'); + await File('${split.path}/second.dart').writeAsString(''' +import 'package:orm/schema.dart'; +final m = model('second_table', (id: identity(),)); +'''); + final root = File('${split.path}/schema.dart'); + await root.writeAsString(''' +${local ? "import 'package:orm/schema.dart';" : ''} +export 'first.dart'; +export 'second.dart'; +${local ? "final root = model('root_table', (id: identity(),));" : ''} +'''); + final before = await generateSchema(root.path); + await rename('z'); + final after = await generateSchema(root.path); + expect(after.snapshotDart, before.snapshotDart); + expect(after.snapshot.checksum, before.snapshot.checksum); + expect(after.snapshot.tables.map((table) => table.name), [ + if (local) 'root_table', + 'first_table', + 'second_table', + ]); + }, + ); + } + + test( + 'ordinary field names cannot shadow generated callback variables', + () async { + await generate( + 'callback_names', + "final Model entry = model('entries', (row: integer(), row2: text(), value: text(), models: text(), createRow: text(), update: text(), where: integer()), primaryKey: (e) => (e.row, e.where));", + ); + await writeGeneratedSchema('${fixtures.path}/callback_names.dart'); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${fixtures.path}/callback_names.orm.dart', + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + }, + ); + + test('all storage families, domain identity, precision, computed and CHECK compile', () async { + await generate('storage', ''' +extension type const EntryId(int value) { + static const codec = Codec.integer(decode, encode); + static EntryId decode(Object? value) => EntryId(value as int); + static int encode(EntryId value) => value.value; +} +enum Status { pending, done } +const statusDefault = Status.pending; +const deleteAction = ReferentialAction.setNull; +final Model entry = model('entries', ( + id: custom(EntryId.codec).identity(), + parentId: custom(EntryId.codec).nullable(), + price: integer(bits: 32), quantity: integer(), total: integer().computed('price * quantity'), + amount: decimal(precision: 12, scale: 2), ratio: real(defaultValue: 1.5), + big: bigInteger(), day: date(), clock: time(precision: 3), + local: localDateTime(precision: 3).nullable(), instant: dateTime(precision: 3), + payload: bytes(), document: json().nullable(), + status: enumeration(Status.values, defaultValue: statusDefault), +), relations: (e) => (parent: references(e.parentId, () => entry, onDelete: deleteAction),), checks: [check('price >= 0', name: 'positive_price')]); +'''); + await writeGeneratedSchema('${fixtures.path}/storage.dart'); + final result = await generateSchema('${fixtures.path}/storage.dart'); + final table = result.snapshot.tables.single; + expect(table.columns.first.generated, true); + expect( + table.columns.map((c) => c.codec.sqlType).toSet(), + containsAll([ + 'integer', + 'decimal', + 'real', + 'bigint', + 'date', + 'time', + 'local_datetime', + 'instant', + 'blob', + 'json', + 'text', + ]), + ); + expect(table.columns.firstWhere((c) => c.name == 'price').integerBits, 32); + expect( + table.columns.firstWhere((c) => c.name == 'amount').decimalPrecision, + 12, + ); + expect( + table.columns.firstWhere((c) => c.name == 'clock').temporalPrecision, + 3, + ); + expect(table.checks.single.name, 'positive_price'); + expect(table.foreignKeys.single.onDelete, 'SET NULL'); + expect(result.snapshotDart, contains("'pending'")); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${fixtures.path}/storage.orm.dart', + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + }); + + test('distinct imported domain types with equal names do not create ambiguous exports', () async { + for (final name in ['first', 'second']) { + await File('${fixtures.path}/$name.dart').writeAsString(''' +import 'package:orm/schema.dart'; +final class Email(final String value); +const codec = Codec.text(decode, encode); +Email decode(Object? value) => Email(value as String); +String encode(Email value) => value.value; +'''); + } + await generate('domains', ''' +import 'first.dart' as first; +import 'second.dart' as second; +final Model contact = model('contacts', (id: identity(), primary: custom(first.codec), secondary: custom(second.codec))); +'''); + await writeGeneratedSchema('${fixtures.path}/domains.dart'); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + '${fixtures.path}/domains.orm.dart', + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}\n${analysis.stderr}', + ); + }); + + test( + 'nullable configuration arguments have the same meaning as omission', + () async { + final omitted = await generate( + 'omitted_options', + "final Model person = model('people', (id: identity(), name: text()));", + ); + final explicit = await generate('null_options', ''' +const String? absent = null; +final Model person = model('people', ( + id: identity(name: absent), + name: text(name: null, defaultValue: null, defaultSql: absent, clientDefault: null), +), indexes: null, primaryKey: null, checks: null, relations: null, uniqueKeys: null); +'''); + expect(explicit.snapshotDart, omitted.snapshotDart); + }, + ); + + test('codec const aliases preserve reference identity; different encoders do not', () async { + const source = ''' +extension type const PersonId(int value) {} +PersonId decode(Object? value) => PersonId(value as int); +int encode(PersonId id) => id.value; +int shifted(PersonId id) => id.value + 1; +const original = Codec.integer(decode, encode); +const alias = original; +const different = Codec.integer(decode, shifted); +final Model person = model('people', (id: custom(original).identity(),)); +final Model note = model('notes', (id: identity(), ownerId: custom(alias).nullable()), relations: (n) => (owner: references(n.ownerId, () => person),)); +'''; + final result = await generate('codec_alias', source); + expect(result.snapshot.tables.last.foreignKeys.single.target, 'people'); + await expectLater( + generate( + 'codec_different', + source.replaceFirst('custom(alias)', 'custom(different)'), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'SCHEMA.REFERENCE', + ), + ), + ); + }); + + final invalid = { + 'wrong_identity': ( + "final Model entry = model('entries', (id: text().identity(),));", + 'KEY', + ), + 'non_column': ( + "final Model person = model('people', (id: identity(), name: 'oops'));", + 'COLUMN', + ), + 'positional': ( + "final Model person = model('people', (identity(), text()));", + 'FIELDS', + ), + 'empty': ("final Model person = model('people', ());", 'FIELDS'), + 'empty_check_name': ( + "final entry = model('entries', (id: identity(),), checks: [check('id > 0', name: '')]);", + 'CHECK', + ), + 'only_computed': ( + "final entry = model('entries', (total: integer().computed('1'),));", + 'COLUMN', + ), + 'key_nullable': ( + "final Model person = model('people', (id: integer().nullable(),), primaryKey: (p) => p.id);", + 'KEY', + ), + 'key_duplicate': ( + "final Model person = model('people', (id: integer(),), primaryKey: (p) => (p.id, p.id));", + 'KEY', + ), + 'key_identity': ( + "final Model person = model('people', (id: identity(),), primaryKey: (p) => p.id);", + 'KEY', + ), + 'block': ( + "final Model person = model('people', (id: identity(),), indexes: (p) { return [index(p.id, name: 'id')]; });", + 'SELECTOR', + ), + 'duplicate_column': ( + "final Model person = model('people', (id: integer(name: 'same'), name: text(name: 'same')));", + 'DUPLICATE', + ), + 'defaults': ( + "final Model person = model('people', (id: identity(), name: text(defaultValue: 'a', defaultSql: \"'b'\")));", + 'DEFAULT', + ), + 'factory': ( + "ColumnDefinition make() => integer(); final Model person = model('people', (id: make(),));", + 'COLUMN', + ), + 'nested': ("final models = [model('people', (id: identity(),))];", 'MODEL'), + 'function': ( + "Model make() => model('people', (id: identity(),));", + 'MODEL', + ), + 'set_null': ( + "final Model person = model('people', (id: identity(), manager: integer()), relations: (p) => (boss: references(p.manager, () => person, onDelete: .setNull),));", + 'REFERENCE', + ), + 'target_type': ( + "final Model person = model('people', (id: identity(), manager: text()), relations: (p) => (boss: references(p.manager, () => person),));", + 'REFERENCE', + ), + 'target_no_key': ( + "final Model person = model('people', (id: integer(), manager: integer()), relations: (p) => (boss: references(p.manager, () => person),));", + 'REFERENCE', + ), + 'relation_collision': ( + "final Model person = model('people', (id: identity(), manager: integer()), relations: (p) => (id: references(p.manager, () => person),));", + 'NAME', + ), + 'nullable_twice': ( + "final Model person = model('people', (id: integer().nullable().nullable(),));", + 'DUPLICATE', + ), + 'generated_name': ( + "final Model table = model('tables', (id: identity(),));", + 'MODEL', + ), + 'enum_labels': ( + "enum Status { a,b } final Model job = model('jobs', (status: enumeration(Status.values, labels: {Status.a: 'x', Status.b: 'x'}),));", + 'ENUM', + ), + }; + for (final entry in invalid.entries) { + test( + 'rejects ${entry.key} with a source location and diagnostic code', + () async { + await expectLater( + generate(entry.key, entry.value.$1), + throwsA( + isA() + .having((e) => e.code, 'code', 'SCHEMA.${entry.value.$2}') + .having((e) => e.line, 'line', greaterThan(0)) + .having((e) => e.column, 'column', greaterThan(0)) + .having( + (e) => e.source?.path, + 'source', + endsWith('${entry.key}.dart'), + ), + ), + ); + }, + ); + } +} diff --git a/test/record_relations_test.dart b/test/record_relations_test.dart new file mode 100644 index 00000000..2803e6c3 --- /dev/null +++ b/test/record_relations_test.dart @@ -0,0 +1,315 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:orm/generate.dart'; +import 'package:test/test.dart'; + +const _roles = ''' +final Model person = model('people', (id: identity(), name: text()), + relations: (p) => ( + authored: referencedBy(() => article, on: (authorId: p.id)), + reviewed: referencedBy(() => article, on: (reviewerId: p.id)), + )); +final Model article = model('articles', ( + id: identity(), title: text(), authorId: integer(), reviewerId: integer().nullable(), +), relations: (a) => ( + author: references(a.authorId, () => person), + reviewer: references(a.reviewerId, () => person, onDelete: .setNull), +)); +'''; + +void main() { + late Directory fixtures; + setUpAll(() async { + fixtures = await Directory('.dart_tool/orm-relation-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( + 'named forward and reverse members belong to their declaring model', + () async { + final result = await generate('roles', _roles); + final person = result.snapshot.tables.first; + expect(person.foreignKeys, isEmpty); + expect(result.snapshot.tables.last.foreignKeys, hasLength(2)); + for (final name in ['authored', 'reviewed', 'author', 'reviewer']) { + expect(result.dart, contains('get $name =>')); + } + final changed = await generate( + 'renamed_roles', + _roles + .replaceFirst('authored:', 'written:') + .replaceFirst('author:', 'writer:'), + ); + expect(changed.snapshotDart, result.snapshotDart); + expect(changed.dart, contains('get written =>')); + expect(changed.dart, contains('get writer =>')); + expect(changed.dart, isNot(contains('get authored =>'))); + }, + ); + + test('a forward reference does not inject an inverse member', () async { + final result = await generate('no_implicit_inverse', ''' +final Model person = model('people', (id: identity(),)); +final Model article = model('articles', (id: identity(), authorId: integer()), + relations: (a) => (author: references(a.authorId, () => person),)); +'''); + expect( + result.dart, + isNot(contains('Relation get')), + ); + }); + + test( + 'named composite mappings normalize order and inverses reuse the mapping', + () async { + const source = ''' +final Model team = model('teams', (tenant: integer(name: 'tenant_key'), code: text(name: 'team_code')), + primaryKey: (t) => (t.tenant, t.code), + relations: (t) => (members: referencedBy(() => member, on: (teamCode: t.code, tenantId: t.tenant)),)); +final Model member = model('members', (id: identity(), tenantId: integer(), teamCode: text()), + relations: (m) => (team: references((code: m.teamCode, tenant: m.tenantId), () => team),)); +'''; + final result = await generate('composite', source); + final fk = result.snapshot.tables.last.foreignKeys.single; + expect(fk.columns, ['tenant_id', 'team_code']); + expect(fk.targetColumns, ['tenant_key', 'team_code']); + expect(result.snapshot.tables.first.foreignKeys, isEmpty); + expect(result.dart, contains('get members =>')); + final reordered = await generate( + 'reordered', + source + .replaceFirst( + '(code: m.teamCode, tenant: m.tenantId)', + '(tenant: m.tenantId, code: m.teamCode)', + ) + .replaceFirst( + '(teamCode: t.code, tenantId: t.tenant)', + '(tenantId: t.tenant, teamCode: t.code)', + ), + ); + expect(reordered.snapshotDart, result.snapshotDart); + }, + ); + + test('reverse navigation preserves read-only reference ownership', () async { + final result = await generate('read_only', ''' +final Model team = model('teams', (id: identity(), code: text()), + relations: (t) => (members: referencedBy(() => member, on: null),)); +final Model member = model('members', (id: identity(), code: text()), + relations: (m) => (team: references((code: m.code), () => team, constraint: false),)); +'''); + expect(result.snapshot.tables.expand((t) => t.foreignKeys), isEmpty); + expect('Read-only navigation'.allMatches(result.dart), hasLength(2)); + }); + + test('an empty relations Record is valid', () async { + final result = await generate( + 'empty', + "final Model item = model('items', (id: identity(),), relations: (i) => ());", + ); + expect(result.snapshot.tables, hasLength(1)); + }); + + test('ambiguity reports concrete mappings for each candidate', () async { + await expectLater( + generate('ambiguous', _roles.replaceFirst(', on: (authorId: p.id)', '')), + throwsA( + isA() + .having((e) => e.code, 'code', 'SCHEMA.REFERENCE') + .having( + (e) => e.message, + 'choices', + allOf( + contains('article.author: on: (authorId: p.id)'), + contains('article.reviewer: on: (reviewerId: p.id)'), + ), + ), + ), + ); + }); + + final invalid = { + 'wrong_inverse_mapping': ( + _roles.replaceFirst('(authorId: p.id)', '(title: p.name)'), + 'REFERENCE', + ), + 'unknown_remote_field': ( + _roles.replaceFirst('(authorId: p.id)', '(authroId: p.id)'), + 'REFERENCE', + ), + 'empty_inverse_mapping': ( + _roles.replaceFirst('(authorId: p.id)', '()'), + 'REFERENCE', + ), + 'positional_inverse_mapping': ( + _roles.replaceFirst('(authorId: p.id)', '(p.id,)'), + 'REFERENCE', + ), + 'inverse_without_forward': ( + "final Model item = model('items', (id: identity(), parentId: integer()), relations: (i) => (children: referencedBy(() => item),));", + 'REFERENCE', + ), + 'inverse_chain': ( + "final Model item = model('items', (id: identity(),), relations: (i) => (children: referencedBy(() => item), parents: referencedBy(() => item)));", + 'REFERENCE', + ), + 'invalid_relation_value': ( + "final Model item = model('items', (id: identity(),), relations: (i) => (children: 1,));", + 'RELATION', + ), + 'positional_relation': ( + "final Model item = model('items', (id: identity(), parentId: integer()), relations: (i) => (references(i.parentId, () => item),));", + 'RELATION', + ), + 'relation_factory': ( + "ReferenceDefinition make() => throw StateError('must not execute'); final Model item = model('items', (id: identity(),), relations: (i) => (children: make(),));", + 'RELATION', + ), + 'named_nonunique_target': ( + "final Model item = model('items', (id: identity(), code: text()), relations: (i) => (parent: references((code: i.code), () => item),));", + 'REFERENCE', + ), + 'named_wrong_type': ( + "final Model item = model('items', (id: identity(), code: text()), relations: (i) => (parent: references((id: i.code), () => item),));", + 'REFERENCE', + ), + 'mixed_mapping': ( + "final Model item = model('items', (id: identity(),), relations: (i) => (parent: references((i.id, id: i.id), () => item),));", + 'REFERENCE', + ), + 'duplicate_local_mapping': ( + "final Model item = model('items', (a: integer(), b: integer()), primaryKey: (i) => (i.a, i.b), relations: (i) => (parent: references((a: i.a, b: i.a), () => item),));", + 'REFERENCE', + ), + 'inverse_name_collision': ( + "final Model item = model('items', (id: identity(), parentId: integer()), relations: (i) => (parent: references(i.parentId, () => item), id: referencedBy(() => item)));", + 'NAME', + ), + }; + for (final entry in invalid.entries) { + test('rejects ${entry.key} at its declaration', () async { + await expectLater( + generate(entry.key, entry.value.$1), + throwsA( + isA() + .having((e) => e.code, 'code', 'SCHEMA.${entry.value.$2}') + .having( + (e) => e.source?.path, + 'source', + endsWith('${entry.key}.dart'), + ) + .having((e) => e.line, 'line', greaterThan(0)), + ), + ); + }); + } + + test('native Dart still checks local relation field access', () async { + await expectLater( + generate( + 'unknown_local', + _roles.replaceFirst('references(a.authorId', 'references(a.authroId'), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("getter 'authroId' isn't defined"), + ), + ), + ); + }); + + test( + 'generated multi-role relations query the correct keys on real databases', + () async { + await generate('runtime', _roles); + await writeGeneratedSchema('${fixtures.path}/runtime.dart'); + final script = File('${fixtures.path}/consumer.dart'); + await script.writeAsString(_consumer); + final result = await Process.run(Platform.resolvedExecutable, [ + 'run', + script.path, + ]); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + final lines = (result.stdout as String) + .split('\n') + .where((line) => line.startsWith('{')); + final engines = []; + for (final line in lines) { + final data = jsonDecode(line) as Map; + engines.add(data['engine'] as String); + expect(data['authored'], ['written']); + expect(data['reviewed'], ['reviewed']); + expect(data['author'], 'Alice'); + expect(data['reviewer'], 'Bob'); + expect(data['clearedReviewer'], null); + expect(data['manyQueries'], 2); + expect(data['oneQueries'], 1); + } + expect(engines, contains('sqlite')); + if (Platform.environment.containsKey('ORM_TEST_POSTGRES')) { + expect(engines, contains('postgres')); + } + }, + ); +} + +const _consumer = r''' +import 'dart:convert'; +import 'dart:io'; +import 'package:orm/migrate.dart'; +import 'package:orm/sqlite.dart'; +import 'package:orm/postgres.dart'; +import 'runtime.orm.dart'; + +Future main() async { + final events = []; + await verify(await sqlite(const SqliteOptions.memory(), onQuery: events.add), events); + final url = Platform.environment['ORM_TEST_POSTGRES']; + if (url == null) return; + final admin = postgres(PostgresOptions(url: Uri.parse(url), tls: PostgresTls.disable)); + const schema = 'orm_named_relation_tests'; + try { + await admin.execute(SqlCommand('CREATE SCHEMA "$schema"')); + try { + await verify(postgres(PostgresOptions(url: Uri.parse(url), schema: schema, tls: PostgresTls.disable), onQuery: events.add), events); + } finally { + await admin.execute(SqlCommand('DROP SCHEMA "$schema" CASCADE')); + } + } finally { + await admin.close(); + } +} + +Future verify(Database db, List events) async { + try { + await Migrator(db.sql).apply([Migration.create('0001_relations', appSchema, dialect: db.dialect)]); + final alice = await db.person.create(name: 'Alice'); + final bob = await db.person.create(name: 'Bob'); + final written = await db.article.create(title: 'written', authorId: alice.id, reviewerId: bob.id); + await db.article.create(title: 'reviewed', authorId: bob.id, reviewerId: alice.id); + events.clear(); + final authored = await db.person.byId(alice.id).select((p) => p.authored.select((a) => a.title).many()).single(); + final manyQueries = events.length; + final reviewed = await db.person.byId(alice.id).select((p) => p.reviewed.select((a) => a.title).many()).single(); + events.clear(); + final author = await db.article.byId(written.id).select((a) => a.author.select((p) => p.name).one()).single(); + final oneQueries = events.length; + final reviewer = await db.article.byId(written.id).select((a) => a.reviewer.select((p) => p.name).one()).single(); + await db.article.byId(written.id).patch(reviewerId: .set(null)); + final clearedReviewer = await db.article.byId(written.id).select((a) => a.reviewer.select((p) => p.name).one()).single(); + print(jsonEncode({'engine': db.dialect.name, 'authored': authored, 'reviewed': reviewed, 'author': author, 'reviewer': reviewer, 'clearedReviewer': clearedReviewer, 'manyQueries': manyQueries, 'oneQueries': oneQueries})); + } finally { + await db.close(); + } +} +'''; diff --git a/test/relation_test.dart b/test/relation_test.dart index 89d8c6d4..d2f9a3cc 100644 --- a/test/relation_test.dart +++ b/test/relation_test.dart @@ -68,7 +68,7 @@ void runTests( test( 'automatic unique joins preserve root pagination in one statement', () async { - final rows = await db.events + final rows = await db.event .orderBy((e) => [e.id.asc()]) .skip(2) .take(4) @@ -101,7 +101,7 @@ void runTests( test( 'all-null projections still distinguish a present row from absence', () async { - final rows = await db.events + final rows = await db.event .orderBy((e) => [e.id.asc()]) .select( (e) => e.author @@ -128,7 +128,7 @@ void runTests( _LimitedDriver(db.driver, 7), onQuery: events.add, ); - final rows = await limited.events + final rows = await limited.event .orderBy((e) => [e.id.asc()]) .select( (e) => e.author @@ -157,7 +157,7 @@ void runTests( expect(events.skip(1).every((e) => e.parameterCount <= 7), isTrue); events.clear(); expect( - await limited.events + await limited.event .where((e) => e.id.gt(8)) .select((e) => e.author.one(strategy: .batch)) .get(), @@ -174,7 +174,7 @@ void runTests( _LimitedDriver(db.driver, 9), onQuery: events.add, ); - final rows = await limited.accounts + final rows = await limited.account .orderBy((a) => [a.tenant.asc(), a.id.asc()]) .select( (a) => a.events @@ -219,7 +219,7 @@ void runTests( test( 'nested self joins and two edges to the same table use distinct aliases', () async { - final rows = await db.events + final rows = await db.event .where((e) => e.id.eq(3)) .select( (e) => ( @@ -244,7 +244,7 @@ void runTests( test( 'batch collection below a joined optional parent loads on the same plan', () async { - final rows = await db.events + final rows = await db.event .where((e) => e.id.eq(1).or(e.id.eq(9))) .orderBy((e) => [e.id.asc()]) .select( @@ -273,15 +273,12 @@ void runTests( 'join refuses unproven cardinality while automatic batch checks it', () async { expect( - () => db.accounts.select((a) => a.events.one(strategy: .join)), + () => db.account.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(), + db.account.byId(tenant: 1, id: 1).select((a) => a.events.one()).get(), throwsA(code('RELATION.CARDINALITY')), ); }, @@ -289,7 +286,7 @@ void runTests( 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)); + final query = db.event.where((e) => e.id.eq(3)); expect( await query .select( @@ -325,7 +322,7 @@ void runTests( }); test('joined records survive CTE projection and cursor decoding', () async { - final cte = db.events + final cte = db.event .orderBy((e) => [e.id.asc()]) .select( (e) => ( @@ -350,7 +347,7 @@ void runTests( 'required tests row presence independently of nullable selected values', () async { for (final strategy in [ToOneStrategy.join, ToOneStrategy.batch]) { - final result = await db.events + final result = await db.event .byId(1) .select( (e) => e.author @@ -360,7 +357,7 @@ void runTests( .single(); expect(result, isNull); await expectLater( - db.events + db.event .byId(9) .select( (e) => e.author @@ -377,7 +374,7 @@ void runTests( test( 'relation aggregates restore the alias used by a joined selection', () async { - final row = await db.events.byId(3).select((e) { + final row = await db.event.byId(3).select((e) { final author = e.author; return ( author.select((a) => a.id).required(), @@ -391,7 +388,7 @@ void runTests( ); test('reusing an identical edge deduplicates its join', () async { - final query = db.events.where((e) => e.id.eq(3)).select((e) { + final query = db.event.where((e) => e.id.eq(3)).select((e) { final author = e.author; return ( author.select((a) => a.id).required(), @@ -403,7 +400,7 @@ void runTests( }); test('different filters need fresh relation occurrences', () async { - final query = db.events.select((e) { + final query = db.event.select((e) { final author = e.author; return ( author.where((a) => a.id.eq(1)).one(), @@ -429,7 +426,7 @@ void runTests( ), ); events.clear(); - final rows = await db.accounts + final rows = await db.account .where((a) => a.tenant.eq(3)) .orderBy((a) => [a.id.asc()]) .select((a) => a.events.select((e) => e.title).many()) diff --git a/test/schema_boundary_test.dart b/test/schema_boundary_test.dart index 7c208440..29bade04 100644 --- a/test/schema_boundary_test.dart +++ b/test/schema_boundary_test.dart @@ -92,13 +92,9 @@ void main() { 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(); +final item = model('items', (id: integer(), value: integer(unique: true).computed('id + 1', storage: .virtual))); ''' : ''' -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 item = model('Items', (source: integer(), id: integer().computed('source + 1')), primaryKey: (i) => i.id, checks: [check('source > 0', name: 'valid'), check('source < 10', name: 'VALID')]); '''}", ); final generated = await generateSchema(file.path); diff --git a/test/support/browser/main.dart b/test/support/browser/main.dart index 171edfa9..4a2a2e75 100644 --- a/test/support/browser/main.dart +++ b/test/support/browser/main.dart @@ -54,7 +54,7 @@ Future main() async { try { await check('page reload recovers committed rows and rolls back interrupted transaction', () async { expect( - (await recovered.users.single()).email == 'durable', + (await recovered.user.single()).email == 'durable', 'Committed rows changed or interrupted write survived', ); expect( @@ -94,7 +94,7 @@ Future main() async { 'Upgrade schema differs', ); expect( - (await recovered.users.single()).email == 'durable', + (await recovered.user.single()).email == 'durable', 'Upgrade removed existing data', ); }, @@ -121,33 +121,33 @@ Future main() async { (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, 'Schema differs', ); - await rejects(() => db.posts.create(authorId: 999, title: 'invalid')); + await rejects(() => db.post.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.user.create(email: 'boundary'); await isolated.transaction((tx) async { - final ids = isolated.users.select((u) => u.id); + final ids = isolated.user.select((u) => u.id); await rejects( - () => tx.users.where((u) => u.id.isInQuery(ids)).get(), + () => tx.user.where((u) => u.id.isInQuery(ids)).get(), code: 'QUERY.SESSION', ); final cte = ids.asCte('root_ids').alias(); await rejects( - () => tx.users + () => tx.user .join(cte, on: (u, c) => u.id.equals(c.ref((u) => u.id))) .get(), code: 'QUERY.SESSION', ); expect( - await tx.users.count() == 1, + await tx.user.count() == 1, 'Rejected queries damaged the transaction', ); }); - final present = usersTable.alias(), absent = usersTable.alias(); - final query = isolated.users + final present = userTable.alias(), absent = userTable.alias(); + final query = isolated.user .leftJoin(present, on: (u, p) => u.id.equals(p.id)) .leftJoin(absent, on: (u, a) => a.id.eq(-1)); await rejects( @@ -171,21 +171,21 @@ Future main() async { 'Nested optional decoding differs', ); await rejects( - () => isolated.users + () => isolated.user .where((u) => u.id.count().gt(0)) .delete() .execute(), code: 'QUERY.AGGREGATE', ); await rejects( - () => isolated.users + () => isolated.user .insert((u) => [u.email.set('invalid')]) .returning((_) => fields({})) .get(), code: 'QUERY.EMPTY_SELECTION', ); expect( - await isolated.users.count() == 1, + await isolated.user.count() == 1, 'Rejected writes changed data', ); } finally { @@ -197,17 +197,17 @@ Future main() async { try { await initialize(isolated); final initialCalls = models.nicknameCalls; - final row = await isolated.users.create(email: 'default'); + final row = await isolated.user.create(email: 'default'); expect( row.nickname == 'guest', 'Omitted value did not use Dart factory', ); - final explicit = await isolated.users.create( + final explicit = await isolated.user.create( email: 'explicit', nickname: .set(null), ); expect(explicit.nickname == null, 'Explicit null used Dart factory'); - final batch = isolated.users.insertMany([ + final batch = isolated.user.insertMany([ 'batch-a', 'batch-b', ], (u, email) => [u.email.set(email)]); @@ -223,7 +223,7 @@ Future main() async { 'Compiling or executing reran factories', ); expect( - await isolated.users.where((u) => u.nickname.eq('guest')).count() == + await isolated.user.where((u) => u.nickname.eq('guest')).count() == 3, 'Batch defaults were not stored', ); @@ -235,15 +235,15 @@ Future main() async { final isolated = await memory(); try { await initialize(isolated); - final row = await isolated.users.create(email: 'computed'); + final row = await isolated.user.create(email: 'computed'); expect( row.emailSize == 8 && row.upperNickname == 'GUEST', 'Computed creation differs', ); - await isolated.users + await isolated.user .byId(row.id) .patch(email: .set('edited'), nickname: .set(null)); - final updated = await isolated.users.single(); + final updated = await isolated.user.single(); expect( updated.emailSize == 6 && updated.upperNickname == null, 'Computed update differs', @@ -288,7 +288,7 @@ Future main() async { ); await Migrator(isolated.sql).apply([initial, migration]); expect( - (await isolated.users.single()).emailSize == 7, + (await isolated.user.single()).emailSize == 7, 'Migration did not recompute existing row', ); expect( @@ -302,7 +302,7 @@ Future main() async { await check( 'generated CHECK enforcement and atomic constraint migrations', () async { - await rejects(() => db.users.create(email: '')); + await rejects(() => db.user.create(email: '')); expect( (await inspectTable(db.sql, 'users')).checks.length == 1, 'Generated CHECK missing', @@ -361,17 +361,14 @@ Future main() async { 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'); + final a = await db.user.create(email: 'a'); + final b = await db.user.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', - ); + await db.post.create(authorId: user.id, title: '${user.email}$i'); } } - final rows = await db.users + final rows = await db.user .orderBy((u) => [u.id.asc()]) .select( (u) => ( @@ -390,10 +387,10 @@ Future main() async { 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)); + await db.user.byId(a.id).patch(nickname: .set('named')); + await db.user.byId(a.id).patch(nickname: .set(null)); expect( - (await db.users.byId(a.id).single()).nickname == null, + (await db.user.byId(a.id).single()).nickname == null, 'Explicit NULL failed', ); }, @@ -403,45 +400,45 @@ Future main() async { await rejects( () => db.transaction((tx) async { escaped = tx; - await tx.users.create(email: 'rolled-back'); + await tx.user.create(email: 'rolled-back'); throw StateError('rollback'); }), ); expect( - !await db.users.where((u) => u.email.eq('rolled-back')).exists(), + !await db.user.where((u) => u.email.eq('rolled-back')).exists(), 'Rollback leaked data', ); - await rejects(() => escaped!.users.get(), code: 'SESSION.CLOSED'); + await rejects(() => escaped!.user.get(), code: 'SESSION.CLOSED'); await db.transaction((tx) async { await rejects( () => tx.savepoint((sp) async { - await sp.users.create(email: 'savepoint'); + await sp.user.create(email: 'savepoint'); throw StateError('rollback'); }), ); expect( - !await tx.users.where((u) => u.email.eq('savepoint')).exists(), + !await tx.user.where((u) => u.email.eq('savepoint')).exists(), 'Savepoint leaked data', ); }); }); await check('stream cursor batches and early release', () async { - final all = await db.posts + final all = await db.post .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 db.post.stream(batchSize: 2).take(1).drain(); + expect(await db.post.count() == 10, 'Cursor retained the lease'); }); await check('commit invalidation and watch snapshots', () async { - final stream = StreamIterator(db.users.select((u) => u.email).watch()); + final stream = StreamIterator(db.user.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'); + await tx.user.create(email: 'watched'); }); expect(await next, 'Missing commit snapshot'); expect(stream.current.contains('watched'), 'Watch missed commit'); @@ -454,7 +451,7 @@ Future main() async { () async { final big = BigInt.parse('9223372036854775807'); final amount = Decimal.parse('123456789012345678901.000000001'); - final row = await db.values.create( + final row = await db.value.create( wide: big, bytes: Uint8List.fromList([0, 127, 255]), amount: amount, @@ -487,7 +484,7 @@ Future main() async { raw.rows.single[1] == -big - BigInt.one, 'Raw int64 was rounded', ); - final total = await db.values.select((v) => v.amount.sum()).single(); + final total = await db.value.select((v) => v.amount.sum()).single(); expect(total == amount, 'Decimal aggregate changed'); }, ); @@ -497,14 +494,14 @@ Future main() async { 'Cancellation was falsely advertised', ); await rejects( - () => db.users.get( + () => db.user.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 + final result = await db.user .take(1) .select((u) => value(1e20, Codecs.real)) .single(); @@ -522,20 +519,20 @@ Future main() async { ), 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( + await db.reading.create(id: 1, value: 1e20); + await db.reading.create(id: 2, value: 1e20); + await db.reading.create(id: 3, value: 2e20); + final token = db.reading.cursorToken( (r) => [r.value.cursor(1e20), r.id.cursor(1)], ); - final next = await db.readings + final next = await db.reading .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 + final peers = await db.reading .orderBy((r) => [r.id.asc()]) .select( (r) => r.peers @@ -548,7 +545,7 @@ Future main() async { peers.toString() == '[[1, 2], [1, 2], [3]]', 'Query-only REAL relation keys lost their storage intent or equality', ); - final same = await db.readings + final same = await db.reading .orderBy((r) => [r.id.asc()]) .select((r) => r.sameReading.select((p) => p.id).many()) .get(); @@ -665,10 +662,10 @@ Future main() async { var persistent = await sqlite(options); try { await initialize(persistent); - await persistent.users.create(email: 'persisted'); + await persistent.user.create(email: 'persisted'); await rejects(() => sqlite(options)); expect( - (await persistent.users.single()).email == 'persisted', + (await persistent.user.single()).email == 'persisted', 'Failed competing open disturbed the owner', ); } finally { @@ -677,7 +674,7 @@ Future main() async { persistent = await sqlite(options); try { expect( - (await persistent.users.single()).email == 'persisted', + (await persistent.user.single()).email == 'persisted', 'OPFS lost committed data', ); } finally { @@ -688,9 +685,9 @@ Future main() async { final name = 'reload-${DateTime.now().millisecondsSinceEpoch}'; final persistent = await sqlite(SqliteOptions.persistent(name)); await initialize(persistent); - await persistent.users.create(email: 'durable'); + await persistent.user.create(email: 'durable'); await persistent.transaction((tx) async { - await tx.users.create(email: 'interrupted'); + await tx.user.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. diff --git a/test/support/browser/precision.dart b/test/support/browser/precision.dart index b4880a85..f5dc9078 100644 --- a/test/support/browser/precision.dart +++ b/test/support/browser/precision.dart @@ -13,7 +13,7 @@ Future checkTemporalPrecision() async { await Migrator(db.sql).apply([ Migration.create('0001_precision', appSchema, dialect: db.dialect), ]); - final moment = await db.moments.create( + final moment = await db.moment.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'), @@ -37,7 +37,7 @@ Future checkTemporalPrecision() async { '0001-01-01 00:00:00.0005 BC', ]) { final valueToRound = LocalDateTime.parse(text); - final actual = await db.moments + final actual = await db.moment .select( (_) => value(valueToRound, Codecs.localDateTime).withPrecision(3), ) @@ -48,7 +48,7 @@ Future checkTemporalPrecision() async { ); } final upper = Codecs.dateTime.decode('275760-09-12 23:59:59.999999+00'); - final rounded = await db.moments + final rounded = await db.moment .select((_) => value(upper, Codecs.dateTime).withPrecision(0)) .single(); expect( @@ -56,11 +56,11 @@ Future checkTemporalPrecision() async { 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( + await db.slot.create(time: LocalTime.parse('01:00:00.1235'), label: 'key'); + final booking = await db.booking.create( time: LocalTime.parse('01:00:00.1239'), ); - final label = await db.bookings + final label = await db.booking .byId(booking.id) .select((b) => b.slot.select((s) => s.label).one()) .single(); diff --git a/test/support/browser/schema.dart b/test/support/browser/schema.dart index 1917d89c..0736c299 100644 --- a/test/support/browser/schema.dart +++ b/test/support/browser/schema.dart @@ -1,44 +1,74 @@ -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'; } + +final Model user = model( + "users", + ( + id: integer().identity(), + email: text(), + nickname: text().nullable(clientDefault: defaultNickname), + emailSize: integer().computed( + "length(email)", + postgres: "length(email)", + mysql: "length(email)", + mariadb: "length(email)", + storage: .stored, + ), + upperNickname: text().nullable().computed( + "upper(nickname)", + postgres: "upper(nickname)", + mysql: "upper(nickname)", + mariadb: "upper(nickname)", + storage: .virtual, + ), + ), + uniqueKeys: (r) => [r.email], + checks: [ + check( + "length(email) > 0", + name: "valid_email", + postgres: "length(email) > 0", + mysql: "length(email) > 0", + mariadb: "length(email) > 0", + ), + ], + relations: (r) => (posts: referencedBy(() => post, on: (authorId: r.id))), +); + +final Model post = model( + "posts", + (id: integer().identity(), authorId: integer(), title: text()), + relations: (r) => + (author: references((id: r.authorId), () => user, onDelete: .cascade)), +); + +final Model value = model("values", ( + id: integer().identity(), + wide: bigInteger(), + bytes: bytes(), + amount: decimal(), + day: date(), + time: time(), + stamp: localDateTime(), + instant: dateTime(), +)); + +final Model reading = model( + "readings", + (id: integer(), value: real()), + primaryKey: (r) => r.id, + relations: (r) => ( + peers: references((value: r.value), () => reading, constraint: false), + sameReading: references( + (value: r.value, id: r.id), + () => reading, + constraint: false, + ), + ), +); diff --git a/test/support/browser/schema.orm.dart b/test/support/browser/schema.orm.dart index 03ce5bfd..ea91682e 100644 --- a/test/support/browser/schema.orm.dart +++ b/test/support/browser/schema.orm.dart @@ -3,30 +3,37 @@ 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( +/// A complete immutable row from "users". +final class User({ + required final int id, + required final String email, + required final String? nickname, + required final int emailSize, + required final String? upperNickname, +}); +final _userId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _usersEmail = Column( +final _userEmail = Column( "email", Codecs.text, nullable: false, generated: false, ); -final _usersNickname = Column( +final _userNickname = Column( "nickname", Codecs.text.nullable(), nullable: true, generated: false, clientDefault: models.defaultNickname, ); -final _usersEmailSize = Column( +final _userEmailSize = Column( "email_size", Codecs.integer, nullable: false, @@ -39,7 +46,7 @@ final _usersEmailSize = Column( storage: ComputedStorage.stored, ), ); -final _usersUpperNickname = Column( +final _userUpperNickname = Column( "upper_nickname", Codecs.text.nullable(), nullable: true, @@ -52,14 +59,14 @@ final _usersUpperNickname = Column( storage: ComputedStorage.virtual, ), ); -final usersSchema = TableSchema( +final userSchema = TableSchema( "users", columns: [ - _usersId, - _usersEmail, - _usersNickname, - _usersEmailSize, - _usersUpperNickname, + _userId, + _userEmail, + _userNickname, + _userEmailSize, + _userUpperNickname, ], primaryKey: ["id"], uniqueKeys: [ @@ -78,37 +85,37 @@ final usersSchema = TableSchema( 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 class UserFields extends Fields { + UserFields(super.table); + late final id = column(_userId); + late final email = column(_userEmail); + late final nickname = column(_userNickname); + late final emailSize = readColumn(_userEmailSize); + late final upperNickname = readColumn(_userUpperNickname); + Relation get posts => + Relation(postTable, parent: [id], child: (row) => [row.authorId]); } -final usersTable = Table( - usersSchema, - UsersFields.new, +final userTable = Table( + userSchema, + UserFields.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, + (v0, v1, v2, v3, v4) => User( + id: v0, + email: v1, + nickname: v2, + emailSize: v3, + upperNickname: v4, ), ), ); -final class UsersTableSet extends TableSet { - UsersTableSet(QueryContext db) : super(db, usersTable) { +final class UserTableSet extends TableSet { + UserTableSet(QueryContext db) : super(db, userTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required String email, Change nickname = const Change.keep(), @@ -119,10 +126,10 @@ final class UsersTableSet extends TableSet { ...row.nickname.change(nickname), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension UsersUpdates on Query { +extension UserUpdates on Query { Future patch({ Change email = const Change.keep(), Change nickname = const Change.keep(), @@ -131,27 +138,33 @@ extension UsersUpdates on Query { ).execute(); } -final _postsId = Column( +/// A complete immutable row from "posts". +final class Post({ + required final int id, + required final int authorId, + required final String title, +}); +final _postId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _postsAuthorId = Column( +final _postAuthorId = Column( "author_id", Codecs.integer, nullable: false, generated: false, ); -final _postsTitle = Column( +final _postTitle = Column( "title", Codecs.text, nullable: false, generated: false, ); -final postsSchema = TableSchema( +final postSchema = TableSchema( "posts", - columns: [_postsId, _postsAuthorId, _postsTitle], + columns: [_postId, _postAuthorId, _postTitle], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -160,30 +173,30 @@ final postsSchema = TableSchema( ], ); -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 class PostFields extends Fields { + PostFields(super.table); + late final id = column(_postId); + late final authorId = column(_postAuthorId); + late final title = column(_postTitle); + Relation get author => + Relation(userTable, parent: [authorId], child: (row) => [row.id]); } -final postsTable = Table( - postsSchema, - PostsFields.new, +final postTable = Table( + postSchema, + PostFields.new, (row) => ( row.id, row.authorId, row.title, - ).map((id, authorId, title) => (id: id, authorId: authorId, title: title)), + ).map((v0, v1, v2) => Post(id: v0, authorId: v1, title: v2)), ); -final class PostsTableSet extends TableSet { - PostsTableSet(QueryContext db) : super(db, postsTable) { +final class PostTableSet extends TableSet { + PostTableSet(QueryContext db) : super(db, postTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required int authorId, required String title, @@ -194,10 +207,10 @@ final class PostsTableSet extends TableSet { row.title.set(title), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension PostsUpdates on Query { +extension PostUpdates on Query { Future patch({ Change authorId = const Change.keep(), Change title = const Change.keep(), @@ -206,65 +219,76 @@ extension PostsUpdates on Query { ).execute(); } -final _valuesId = Column( +/// A complete immutable row from "values". +final class Value({ + required final int id, + required final BigInt wide, + required final Uint8List bytes, + required final Decimal amount, + required final LocalDate day, + required final LocalTime time, + required final LocalDateTime stamp, + required final DateTime instant, +}); +final _valueId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _valuesWide = Column( +final _valueWide = Column( "wide", Codecs.bigint, nullable: false, generated: false, ); -final _valuesBytes = Column( +final _valueBytes = Column( "bytes", Codecs.bytes, nullable: false, generated: false, ); -final _valuesAmount = Column( +final _valueAmount = Column( "amount", Codecs.decimal, nullable: false, generated: false, ); -final _valuesDay = Column( +final _valueDay = Column( "day", Codecs.date, nullable: false, generated: false, ); -final _valuesTime = Column( +final _valueTime = Column( "time", Codecs.time, nullable: false, generated: false, ); -final _valuesStamp = Column( +final _valueStamp = Column( "stamp", Codecs.localDateTime, nullable: false, generated: false, ); -final _valuesInstant = Column( +final _valueInstant = Column( "instant", Codecs.dateTime, nullable: false, generated: false, ); -final valuesSchema = TableSchema( +final valueSchema = TableSchema( "values", columns: [ - _valuesId, - _valuesWide, - _valuesBytes, - _valuesAmount, - _valuesDay, - _valuesTime, - _valuesStamp, - _valuesInstant, + _valueId, + _valueWide, + _valueBytes, + _valueAmount, + _valueDay, + _valueTime, + _valueStamp, + _valueInstant, ], primaryKey: ["id"], uniqueKeys: [], @@ -272,21 +296,21 @@ final valuesSchema = TableSchema( 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 class ValueFields extends Fields { + ValueFields(super.table); + late final id = column(_valueId); + late final wide = column(_valueWide); + late final bytes = column(_valueBytes); + late final amount = column(_valueAmount); + late final day = column(_valueDay); + late final time = column(_valueTime); + late final stamp = column(_valueStamp); + late final instant = column(_valueInstant); } -final valuesTable = Table( - valuesSchema, - ValuesFields.new, +final valueTable = Table( + valueSchema, + ValueFields.new, (row) => ( (row.id, row.wide, row.bytes, row.amount, row.day).map( @@ -298,7 +322,7 @@ final valuesTable = Table( (time: time, stamp: stamp, instant: instant), ), ).map( - (left, right) => ( + (left, right) => Value( id: left.id, wide: left.wide, bytes: left.bytes, @@ -311,11 +335,11 @@ final valuesTable = Table( ), ); -final class ValuesTableSet extends TableSet { - ValuesTableSet(QueryContext db) : super(db, valuesTable) { +final class ValueTableSet extends TableSet { + ValueTableSet(QueryContext db) : super(db, valueTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required BigInt wide, required Uint8List bytes, @@ -336,11 +360,10 @@ final class ValuesTableSet extends TableSet { row.instant.set(instant), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension ValuesUpdates on Query { +extension ValueUpdates on Query { Future patch({ Change wide = const Change.keep(), Change bytes = const Change.keep(), @@ -362,61 +385,62 @@ extension ValuesUpdates on Query { ).execute(); } -final _readingsId = Column( +/// A complete immutable row from "readings". +final class Reading({required final int id, required final double value}); +final _readingId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _readingsValue = Column( +final _readingValue = Column( "value", Codecs.real, nullable: false, generated: false, ); -final readingsSchema = TableSchema( +final readingSchema = TableSchema( "readings", - columns: [_readingsId, _readingsValue], + columns: [_readingId, _readingValue], primaryKey: ["id"], uniqueKeys: [], indexes: [], foreignKeys: [], ); -final class ReadingsFields extends Fields { - ReadingsFields(super.table); - late final id = column(_readingsId); - late final value = column(_readingsValue); +final class ReadingFields extends Fields { + ReadingFields(super.table); + late final id = column(_readingId); + late final value = column(_readingValue); /// Read-only navigation; no database foreign key or write effects. - Relation get peers => - Relation(readingsTable, parent: [value], child: (row) => [row.value]); + Relation get peers => + Relation(readingTable, 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], + Relation get sameReading => Relation( + readingTable, + parent: [id, value], + child: (row) => [row.id, row.value], ); } -final readingsTable = Table( - readingsSchema, - ReadingsFields.new, - (row) => (row.id, row.value).map((id, value) => (id: id, value: value)), +final readingTable = Table( + readingSchema, + ReadingFields.new, + (row) => (row.id, row.value).map((v0, v1) => Reading(id: v0, value: v1)), ); -final class ReadingsTableSet extends TableSet { - ReadingsTableSet(QueryContext db) : super(db, readingsTable) { +final class ReadingTableSet extends TableSet { + ReadingTableSet(QueryContext db) : super(db, readingTable) { db.registerSchema(appSchema); } - Future create({required int id, required double value}) => + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension ReadingsUpdates on Query { +extension ReadingUpdates on Query { Future patch({ Change id = const Change.keep(), Change value = const Change.keep(), @@ -426,15 +450,15 @@ extension ReadingsUpdates on Query { } final appSchema = List.unmodifiable([ - usersSchema, - postsSchema, - valuesSchema, - readingsSchema, + userSchema, + postSchema, + valueSchema, + readingSchema, ]); extension AppTables on QueryContext { - UsersTableSet get users => UsersTableSet(this); - PostsTableSet get posts => PostsTableSet(this); - ValuesTableSet get values => ValuesTableSet(this); - ReadingsTableSet get readings => ReadingsTableSet(this); + UserTableSet get user => UserTableSet(this); + PostTableSet get post => PostTableSet(this); + ValueTableSet get value => ValueTableSet(this); + ReadingTableSet get reading => ReadingTableSet(this); } diff --git a/test/support/browser/teams.dart b/test/support/browser/teams.dart index cf4cfeca..288500c7 100644 --- a/test/support/browser/teams.dart +++ b/test/support/browser/teams.dart @@ -1,7 +1,6 @@ import 'package:orm/migrate.dart'; import 'package:orm/sqlite.dart'; -import '../../../example/teams/schema.dart'; import '../../../example/teams/schema.orm.dart'; Future checkTeams() async { @@ -22,23 +21,23 @@ Future checkTeams() async { 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( + await tx.user.create(id: 1, name: 'Ada'); + await tx.user.create(id: 2, name: 'Ben'); + await tx.user.create(id: 3, name: 'Cy'); + await tx.team.create(id: 10, name: 'Core'); + await tx.team.create(id: 20, name: 'Docs'); + await tx.membership.create( teamId: 10, userId: 1, role: .set(MembershipRole.owner), joinedAt: DateTime.utc(2026, 1, 1), ); - await tx.memberships.create( + await tx.membership.create( teamId: 20, userId: 1, joinedAt: DateTime.utc(2026, 1, 2), ); - await tx.memberships.create( + await tx.membership.create( teamId: 10, userId: 2, joinedAt: DateTime.utc(2026, 1, 1), @@ -47,7 +46,7 @@ Future checkTeams() async { events.clear(); acquired.clear(); decoded.clear(); - final query = db.users + final query = db.user .orderBy((u) => [u.id.asc()]) .select( (u) => u.memberships @@ -103,7 +102,7 @@ Future checkTeams() async { return value; } - final mixed = db.users + final mixed = db.user .where((u) => u.id.eq(1)) .select( (u) => @@ -143,8 +142,8 @@ Future checkTeams() async { 'Mixed projection evaluation order differs', ); await db.transaction((tx) async { - await tx.teams.byId(10).patch(name: .set('Kernel')); - await tx.memberships + await tx.team.byId(10).patch(name: .set('Kernel')); + await tx.membership .byId(teamId: 10, userId: 2) .patch(role: .set(MembershipRole.owner)); }); @@ -153,9 +152,9 @@ Future checkTeams() async { (team: 'Kernel', role: MembershipRole.owner), 'Transaction did not retain payload/endpoint changes', ); - await db.teams.byId(10).delete().execute(); + await db.team.byId(10).delete().execute(); expect( - await db.memberships.count() == 1 && await db.users.count() == 3, + await db.membership.count() == 1 && await db.user.count() == 3, 'Cascade removed an endpoint or retained invalid associations', ); expect( diff --git a/test/support/checks/schema.dart b/test/support/checks/schema.dart index b5e5c5d6..c5f7531d 100644 --- a/test/support/checks/schema.dart +++ b/test/support/checks/schema.dart @@ -1,28 +1,66 @@ 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 Model product = model( + "products", + ( + id: integer().identity(), + stock: integer(defaultSql: "0", bits: 16), + price: real(), + discount: real().nullable(), + state: text(), + label: text().nullable(), + ), + checks: [ + check( + "stock >= 0", + name: "stock_nonnegative", + postgres: "stock >= 0", + mysql: "stock >= 0", + mariadb: "stock >= 0", + ), + check( + "price >= 0", + name: "nonnegative_price", + postgres: "price >= 0", + mysql: "price >= 0", + mariadb: "price >= 0", + ), + check( + "discount >= 0 AND discount <= price", + name: "valid_discount", + postgres: "discount >= 0 AND discount <= price", + mysql: "discount >= 0 AND discount <= price", + mariadb: "discount >= 0 AND discount <= price", + ), + check( + "state IN ('draft', 'published')", + name: "valid_state", + postgres: "state IN ('draft', 'published')", + mysql: "state IN ('draft', 'published')", + mariadb: "state IN ('draft', 'published')", + ), + check( + "length(label) <= 20", + name: "short_label", + postgres: "char_length(label) <= 20", + mysql: "length(label) <= 20", + mariadb: "length(label) <= 20", + ), + check( + "label <> '; CHECK (0)'", + name: null, + postgres: "label <> '; CHECK (0)'", + mysql: "label <> '; CHECK (0)'", + mariadb: "label <> '; CHECK (0)'", + ), + ], + relations: (r) => (lines: referencedBy(() => line, on: (productId: r.id))), ); -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 Model line = model( + "lines", + (id: integer().identity(), productId: integer()), + relations: (r) => ( + product: references((id: r.productId), () => product, onDelete: .restrict), + ), ); -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 index 5e06b91e..ff01a419 100644 --- a/test/support/checks/schema.orm.dart +++ b/test/support/checks/schema.orm.dart @@ -2,16 +2,22 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Product, Line; - -final _productsId = Column( +/// A complete immutable row from "products". +final class Product({ + required final int id, + required final int stock, + required final double price, + required final double? discount, + required final String state, + required final String? label, +}); +final _productId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _productsStock = Column( +final _productStock = Column( "stock", Codecs.integer, nullable: false, @@ -19,39 +25,39 @@ final _productsStock = Column( defaultSql: "0", integerBits: 16, ); -final _productsPrice = Column( +final _productPrice = Column( "price", Codecs.real, nullable: false, generated: false, ); -final _productsDiscount = Column( +final _productDiscount = Column( "discount", Codecs.real.nullable(), nullable: true, generated: false, ); -final _productsState = Column( +final _productState = Column( "state", Codecs.text, nullable: false, generated: false, ); -final _productsLabel = Column( +final _productLabel = Column( "label", Codecs.text.nullable(), nullable: true, generated: false, ); -final productsSchema = TableSchema( +final productSchema = TableSchema( "products", columns: [ - _productsId, - _productsStock, - _productsPrice, - _productsDiscount, - _productsState, - _productsLabel, + _productId, + _productStock, + _productPrice, + _productDiscount, + _productState, + _productLabel, ], primaryKey: ["id"], uniqueKeys: [], @@ -103,39 +109,39 @@ final productsSchema = TableSchema( 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 class ProductFields extends Fields { + ProductFields(super.table); + late final id = column(_productId); + late final stock = column(_productStock); + late final price = column(_productPrice); + late final discount = column(_productDiscount); + late final state = column(_productState); + late final label = column(_productLabel); + Relation get lines => + Relation(lineTable, parent: [id], child: (row) => [row.productId]); } -final productsTable = Table( - productsSchema, - ProductsFields.new, +final productTable = Table( + productSchema, + ProductFields.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, + (v0, v1, v2, v3, v4, v5) => Product( + id: v0, + stock: v1, + price: v2, + discount: v3, + state: v4, + label: v5, ), ), ); -final class ProductsTableSet extends TableSet { - ProductsTableSet(QueryContext db) : super(db, productsTable) { +final class ProductTableSet extends TableSet { + ProductTableSet(QueryContext db) : super(db, productTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), Change stock = const Change.keep(), required double price, @@ -152,11 +158,10 @@ final class ProductsTableSet extends TableSet { row.label.set(label), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension ProductsUpdates on Query { +extension ProductUpdates on Query { Future patch({ Change stock = const Change.keep(), Change price = const Change.keep(), @@ -174,21 +179,23 @@ extension ProductsUpdates on Query { ).execute(); } -final _linesId = Column( +/// A complete immutable row from "lines". +final class Line({required final int id, required final int productId}); +final _lineId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _linesProductId = Column( +final _lineProductId = Column( "product_id", Codecs.integer, nullable: false, generated: false, ); -final linesSchema = TableSchema( +final lineSchema = TableSchema( "lines", - columns: [_linesId, _linesProductId], + columns: [_lineId, _lineProductId], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -197,43 +204,40 @@ final linesSchema = TableSchema( ], ); -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 class LineFields extends Fields { + LineFields(super.table); + late final id = column(_lineId); + late final productId = column(_lineProductId); + Relation get product => + Relation(productTable, 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 lineTable = Table( + lineSchema, + LineFields.new, + (row) => (row.id, row.productId).map((v0, v1) => Line(id: v0, productId: v1)), ); -final class LinesTableSet extends TableSet { - LinesTableSet(QueryContext db) : super(db, linesTable) { +final class LineTableSet extends TableSet { + LineTableSet(QueryContext db) : super(db, lineTable) { db.registerSchema(appSchema); } - Future create({ + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension LinesUpdates on Query { +extension LineUpdates on Query { Future patch({Change productId = const Change.keep()}) => update((row) => [...row.productId.change(productId)]).execute(); } -final appSchema = List.unmodifiable([productsSchema, linesSchema]); +final appSchema = List.unmodifiable([productSchema, lineSchema]); extension AppTables on QueryContext { - ProductsTableSet get products => ProductsTableSet(this); - LinesTableSet get lines => LinesTableSet(this); + ProductTableSet get product => ProductTableSet(this); + LineTableSet get line => LineTableSet(this); } diff --git a/test/support/codecs/native.dart b/test/support/codecs/native.dart index 56912015..502a7938 100644 --- a/test/support/codecs/native.dart +++ b/test/support/codecs/native.dart @@ -11,7 +11,7 @@ Future main() async { await Migrator( db.sql, ).apply([Migration.create('0001_initial', appSchema, dialect: db.dialect)]); - final first = await db.people.create( + final first = await db.person.create( email: const Email('aot@example.com'), membership: .pending, tags: ['AOT', '类型'], @@ -27,22 +27,22 @@ Future main() async { first.details?.value != 'scalar') { throw StateError('Custom value decoding failed'); } - await db.notes.create(ownerId: first.id, body: 'relation'); - final email = await db.notes + await db.note.create(ownerId: first.id, body: 'relation'); + final email = await db.note .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( + final token = db.person.cursorToken((p) => [p.id.cursor(first.id)]); + final second = await db.person.create( email: const Email('second@example.com'), membership: .active, tags: [], alternate: const alt.Email('other'), details: const SqlJson(null), ); - final rows = await db.people + final rows = await db.person .seekToken(token, orderBy: (p) => [p.id.asc()]) .stream(batchSize: 1) .toList(); diff --git a/test/support/codecs/schema.dart b/test/support/codecs/schema.dart index 56be4fff..5088e370 100644 --- a/test/support/codecs/schema.dart +++ b/test/support/codecs/schema.dart @@ -1,27 +1,46 @@ 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, -}); +import 'alternate.dart' as alt; -typedef Note = ({ - @Id.generated() int id, - @UseCodec(domain.PersonId.codec) domain.PersonId ownerId, - String body, -}); +final Model person = model( + "people", + ( + id: custom(domain.PersonId.codec).identity(), + email: custom(domain.Email.codec), + membership: enumeration( + domain.Membership.values, + labels: { + domain.Membership.pending: "pending-payment", + domain.Membership.active: "active", + domain.Membership.cancelled: "closed", + }, + ), + previousMembership: enumeration( + domain.Membership.values, + labels: { + domain.Membership.pending: "pending-payment", + domain.Membership.active: "active", + domain.Membership.cancelled: "closed", + }, + ).nullable(), + tags: custom(domain.tagsCodec), + location: custom(domain.locationCodec).nullable(), + alternate: custom(alt.emailCodec), + details: json().nullable(), + ), + uniqueKeys: (r) => [r.email], + relations: (r) => (notes: referencedBy(() => note, on: (ownerId: r.id))), +); -final people = entity(); -final notes = entity(); -final owner = notes - .key((n) => n.ownerId) - .references(people.key((p) => p.id), inverse: 'notes'); +final Model note = model( + "notes", + ( + id: integer().identity(), + ownerId: custom(domain.PersonId.codec), + body: text(), + ), + relations: (r) => + (owner: references((id: r.ownerId), () => person, onDelete: .restrict)), +); diff --git a/test/support/codecs/schema.orm.dart b/test/support/codecs/schema.orm.dart index 07d771a8..af4f2be7 100644 --- a/test/support/codecs/schema.orm.dart +++ b/test/support/codecs/schema.orm.dart @@ -2,24 +2,34 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Person, Note; +export "types.dart" show PersonId, Membership, Location; import "types.dart" as types0; import "alternate.dart" as types1; -final _peopleId = Column( +/// A complete immutable row from "people". +final class Person({ + required final types0.PersonId id, + required final types0.Email email, + required final types0.Membership membership, + required final types0.Membership? previousMembership, + required final List tags, + required final types0.Location? location, + required final types1.Email alternate, + required final SqlJson? details, +}); +final _personId = Column( "id", types0.PersonId.codec, nullable: false, generated: true, ); -final _peopleEmail = Column( +final _personEmail = Column( "email", types0.Email.codec, nullable: false, generated: false, ); -final _peopleMembership = Column( +final _personMembership = Column( "membership", Codecs.enumeration({ types0.Membership.pending: "pending-payment", @@ -29,7 +39,7 @@ final _peopleMembership = Column( nullable: false, generated: false, ); -final _peoplePreviousMembership = Column( +final _personPreviousMembership = Column( "previous_membership", Codecs.enumeration({ types0.Membership.pending: "pending-payment", @@ -39,41 +49,41 @@ final _peoplePreviousMembership = Column( nullable: true, generated: false, ); -final _peopleTags = Column>( +final _personTags = Column>( "tags", types0.tagsCodec, nullable: false, generated: false, ); -final _peopleLocation = Column( +final _personLocation = Column( "location", types0.locationCodec.nullable(), nullable: true, generated: false, ); -final _peopleAlternate = Column( +final _personAlternate = Column( "alternate", types1.emailCodec, nullable: false, generated: false, ); -final _peopleDetails = Column( +final _personDetails = Column( "details", Codecs.jsonDocument.nullable(), nullable: true, generated: false, ); -final peopleSchema = TableSchema( +final personSchema = TableSchema( "people", columns: [ - _peopleId, - _peopleEmail, - _peopleMembership, - _peoplePreviousMembership, - _peopleTags, - _peopleLocation, - _peopleAlternate, - _peopleDetails, + _personId, + _personEmail, + _personMembership, + _personPreviousMembership, + _personTags, + _personLocation, + _personAlternate, + _personDetails, ], primaryKey: ["id"], uniqueKeys: [ @@ -83,23 +93,23 @@ final peopleSchema = TableSchema( 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 class PersonFields extends Fields { + PersonFields(super.table); + late final id = column(_personId); + late final email = column(_personEmail); + late final membership = column(_personMembership); + late final previousMembership = column(_personPreviousMembership); + late final tags = column(_personTags); + late final location = column(_personLocation); + late final alternate = column(_personAlternate); + late final details = column(_personDetails); + Relation get notes => + Relation(noteTable, parent: [id], child: (row) => [row.ownerId]); } -final peopleTable = Table( - peopleSchema, - PeopleFields.new, +final personTable = Table( + personSchema, + PersonFields.new, (row) => ( ( @@ -122,7 +132,7 @@ final peopleTable = Table( (location: location, alternate: alternate, details: details), ), ).map( - (left, right) => ( + (left, right) => Person( id: left.id, email: left.email, membership: left.membership, @@ -135,11 +145,11 @@ final peopleTable = Table( ), ); -final class PeopleTableSet extends TableSet { - PeopleTableSet(QueryContext db) : super(db, peopleTable) { +final class PersonTableSet extends TableSet { + PersonTableSet(QueryContext db) : super(db, personTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required types0.Email email, required types0.Membership membership, @@ -160,11 +170,11 @@ final class PeopleTableSet extends TableSet { row.details.set(details), ], ); - Query byId(types0.PersonId id) => + Query byId(types0.PersonId id) => where((row) => row.id.eq(id)); } -extension PeopleUpdates on Query { +extension PersonUpdates on Query { Future patch({ Change email = const Change.keep(), Change membership = const Change.keep(), @@ -186,27 +196,33 @@ extension PeopleUpdates on Query { ).execute(); } -final _notesId = Column( +/// A complete immutable row from "notes". +final class Note({ + required final int id, + required final types0.PersonId ownerId, + required final String body, +}); +final _noteId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _notesOwnerId = Column( +final _noteOwnerId = Column( "owner_id", types0.PersonId.codec, nullable: false, generated: false, ); -final _notesBody = Column( +final _noteBody = Column( "body", Codecs.text, nullable: false, generated: false, ); -final notesSchema = TableSchema( +final noteSchema = TableSchema( "notes", - columns: [_notesId, _notesOwnerId, _notesBody], + columns: [_noteId, _noteOwnerId, _noteBody], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -215,30 +231,30 @@ final notesSchema = TableSchema( ], ); -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 class NoteFields extends Fields { + NoteFields(super.table); + late final id = column(_noteId); + late final ownerId = column(_noteOwnerId); + late final body = column(_noteBody); + Relation get owner => + Relation(personTable, parent: [ownerId], child: (row) => [row.id]); } -final notesTable = Table( - notesSchema, - NotesFields.new, +final noteTable = Table( + noteSchema, + NoteFields.new, (row) => ( row.id, row.ownerId, row.body, - ).map((id, ownerId, body) => (id: id, ownerId: ownerId, body: body)), + ).map((v0, v1, v2) => Note(id: v0, ownerId: v1, body: v2)), ); -final class NotesTableSet extends TableSet { - NotesTableSet(QueryContext db) : super(db, notesTable) { +final class NoteTableSet extends TableSet { + NoteTableSet(QueryContext db) : super(db, noteTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required types0.PersonId ownerId, required String body, @@ -249,10 +265,10 @@ final class NotesTableSet extends TableSet { row.body.set(body), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension NotesUpdates on Query { +extension NoteUpdates on Query { Future patch({ Change ownerId = const Change.keep(), Change body = const Change.keep(), @@ -261,9 +277,9 @@ extension NotesUpdates on Query { ).execute(); } -final appSchema = List.unmodifiable([peopleSchema, notesSchema]); +final appSchema = List.unmodifiable([personSchema, noteSchema]); extension AppTables on QueryContext { - PeopleTableSet get people => PeopleTableSet(this); - NotesTableSet get notes => NotesTableSet(this); + PersonTableSet get person => PersonTableSet(this); + NoteTableSet get note => NoteTableSet(this); } diff --git a/test/support/codecs/types.dart b/test/support/codecs/types.dart index 0e190911..815167d5 100644 --- a/test/support/codecs/types.dart +++ b/test/support/codecs/types.dart @@ -22,13 +22,7 @@ final class Email { } } -enum Membership { - @EnumValue('pending-payment') - pending, - active, - @EnumValue('closed') - cancelled, -} +enum Membership { pending, active, cancelled } typedef Location = ({String city, int zone}); const locationCodec = Codec('json', _location, _locationJson); diff --git a/test/support/computed/schema.dart b/test/support/computed/schema.dart index 6d57a49a..888a7516 100644 --- a/test/support/computed/schema.dart +++ b/test/support/computed/schema.dart @@ -1,27 +1,52 @@ 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 Model line = model( + "lines", + ( + id: integer().identity(), + price: integer(), + quantity: integer(), + label: text(), + note: text().nullable(), + total: integer().computed( + "price * quantity", + postgres: "price * quantity", + mysql: "price * quantity", + mariadb: "price * quantity", + storage: .stored, + ), + labelSize: integer().computed( + "length(label)", + postgres: "char_length(label)", + mysql: "length(label)", + mariadb: "length(label)", + storage: .virtual, + ), + normalizedNote: text().nullable().computed( + "upper(note)", + postgres: "upper(note)", + mysql: "upper(note)", + mariadb: "upper(note)", + storage: .stored, + ), + ), + indexes: (r) => [index(r.total, name: "by_total", unique: false)], + checks: [ + check( + "price >= 0 AND quantity >= 0", + name: "nonnegative", + postgres: "price >= 0 AND quantity >= 0", + mysql: "price >= 0 AND quantity >= 0", + mariadb: "price >= 0 AND quantity >= 0", + ), + ], + relations: (r) => + (band: references((id: r.total), () => band, constraint: false)), +); -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'); +final Model band = model( + "bands", + (id: integer(), name: text()), + primaryKey: (r) => r.id, + relations: (r) => (lines: referencedBy(() => line, on: (total: r.id))), +); diff --git a/test/support/computed/schema.orm.dart b/test/support/computed/schema.orm.dart index 7ebaecc5..65c7eb96 100644 --- a/test/support/computed/schema.orm.dart +++ b/test/support/computed/schema.orm.dart @@ -2,40 +2,48 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Line, Band; - -final _linesId = Column( +/// A complete immutable row from "lines". +final class Line({ + required final int id, + required final int price, + required final int quantity, + required final String label, + required final String? note, + required final int total, + required final int labelSize, + required final String? normalizedNote, +}); +final _lineId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _linesPrice = Column( +final _linePrice = Column( "price", Codecs.integer, nullable: false, generated: false, ); -final _linesQuantity = Column( +final _lineQuantity = Column( "quantity", Codecs.integer, nullable: false, generated: false, ); -final _linesLabel = Column( +final _lineLabel = Column( "label", Codecs.text, nullable: false, generated: false, ); -final _linesNote = Column( +final _lineNote = Column( "note", Codecs.text.nullable(), nullable: true, generated: false, ); -final _linesTotal = Column( +final _lineTotal = Column( "total", Codecs.integer, nullable: false, @@ -48,7 +56,7 @@ final _linesTotal = Column( storage: ComputedStorage.stored, ), ); -final _linesLabelSize = Column( +final _lineLabelSize = Column( "label_size", Codecs.integer, nullable: false, @@ -61,7 +69,7 @@ final _linesLabelSize = Column( storage: ComputedStorage.virtual, ), ); -final _linesNormalizedNote = Column( +final _lineNormalizedNote = Column( "normalized_note", Codecs.text.nullable(), nullable: true, @@ -74,17 +82,17 @@ final _linesNormalizedNote = Column( storage: ComputedStorage.stored, ), ); -final linesSchema = TableSchema( +final lineSchema = TableSchema( "lines", columns: [ - _linesId, - _linesPrice, - _linesQuantity, - _linesLabel, - _linesNote, - _linesTotal, - _linesLabelSize, - _linesNormalizedNote, + _lineId, + _linePrice, + _lineQuantity, + _lineLabel, + _lineNote, + _lineTotal, + _lineLabelSize, + _lineNormalizedNote, ], primaryKey: ["id"], uniqueKeys: [], @@ -103,25 +111,25 @@ final linesSchema = TableSchema( 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); +final class LineFields extends Fields { + LineFields(super.table); + late final id = column(_lineId); + late final price = column(_linePrice); + late final quantity = column(_lineQuantity); + late final label = column(_lineLabel); + late final note = column(_lineNote); + late final total = readColumn(_lineTotal); + late final labelSize = readColumn(_lineLabelSize); + late final normalizedNote = readColumn(_lineNormalizedNote); /// Read-only navigation; no database foreign key or write effects. - Relation get band => - Relation(bandsTable, parent: [total], child: (row) => [row.id]); + Relation get band => + Relation(bandTable, parent: [total], child: (row) => [row.id]); } -final linesTable = Table( - linesSchema, - LinesFields.new, +final lineTable = Table( + lineSchema, + LineFields.new, (row) => ( (row.id, row.price, row.quantity, row.label, row.note).map( @@ -141,7 +149,7 @@ final linesTable = Table( ), ), ).map( - (left, right) => ( + (left, right) => Line( id: left.id, price: left.price, quantity: left.quantity, @@ -154,11 +162,11 @@ final linesTable = Table( ), ); -final class LinesTableSet extends TableSet { - LinesTableSet(QueryContext db) : super(db, linesTable) { +final class LineTableSet extends TableSet { + LineTableSet(QueryContext db) : super(db, lineTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required int price, required int quantity, @@ -173,10 +181,10 @@ final class LinesTableSet extends TableSet { row.note.set(note), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension LinesUpdates on Query { +extension LineUpdates on Query { Future patch({ Change price = const Change.keep(), Change quantity = const Change.keep(), @@ -192,53 +200,55 @@ extension LinesUpdates on Query { ).execute(); } -final _bandsId = Column( +/// A complete immutable row from "bands". +final class Band({required final int id, required final String name}); +final _bandId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _bandsName = Column( +final _bandName = Column( "name", Codecs.text, nullable: false, generated: false, ); -final bandsSchema = TableSchema( +final bandSchema = TableSchema( "bands", - columns: [_bandsId, _bandsName], + columns: [_bandId, _bandName], primaryKey: ["id"], uniqueKeys: [], indexes: [], foreignKeys: [], ); -final class BandsFields extends Fields { - BandsFields(super.table); - late final id = column(_bandsId); - late final name = column(_bandsName); +final class BandFields extends Fields { + BandFields(super.table); + late final id = column(_bandId); + late final name = column(_bandName); /// Read-only navigation; no database foreign key or write effects. - Relation get lines => - Relation(linesTable, parent: [id], child: (row) => [row.total]); + Relation get lines => + Relation(lineTable, 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 bandTable = Table( + bandSchema, + BandFields.new, + (row) => (row.id, row.name).map((v0, v1) => Band(id: v0, name: v1)), ); -final class BandsTableSet extends TableSet { - BandsTableSet(QueryContext db) : super(db, bandsTable) { +final class BandTableSet extends TableSet { + BandTableSet(QueryContext db) : super(db, bandTable) { db.registerSchema(appSchema); } - Future create({required int id, required String name}) => + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension BandsUpdates on Query { +extension BandUpdates on Query { Future patch({ Change id = const Change.keep(), Change name = const Change.keep(), @@ -247,9 +257,9 @@ extension BandsUpdates on Query { .execute(); } -final appSchema = List.unmodifiable([linesSchema, bandsSchema]); +final appSchema = List.unmodifiable([lineSchema, bandSchema]); extension AppTables on QueryContext { - LinesTableSet get lines => LinesTableSet(this); - BandsTableSet get bands => BandsTableSet(this); + LineTableSet get line => LineTableSet(this); + BandTableSet get band => BandTableSet(this); } diff --git a/test/support/decimals/native.dart b/test/support/decimals/native.dart index eb5effda..7c6d7f80 100644 --- a/test/support/decimals/native.dart +++ b/test/support/decimals/native.dart @@ -10,28 +10,28 @@ Future main() async { 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( + await db.entry.create(amount: exact, bucket: 'a'); + await db.entry.create( amount: Decimal.parse('.0000000000000000001'), bucket: 'a', ); - if (await db.entries.select((e) => e.amount.sum()).single() != + if (await db.entry.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(); + final sorted = await db.entry.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.rate.create(id: Decimal.parse('2'), label: 'two'); await db.execute( SqlCommand("INSERT INTO allocations (rate_id) VALUES ('2.000')"), ); - final children = await db.rates + final children = await db.rate .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 + final window = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => @@ -41,7 +41,7 @@ Future main() async { if (window.last != Decimal.parse('9007199254740993.123456789012345679')) { throw StateError('Window sum differs'); } - final halves = await db.entries + final halves = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => e.amount @@ -53,18 +53,18 @@ Future main() async { if (halves.last != Decimal.parse('4503599627370496.5617283945061728395')) { throw StateError('Exact window division differs'); } - final rounded = await db.entries + final rounded = await db.entry .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 + final average = await db.entry .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 + final runningMean = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => e.amount diff --git a/test/support/decimals/schema.dart b/test/support/decimals/schema.dart index 144b0e05..68d22480 100644 --- a/test/support/decimals/schema.dart +++ b/test/support/decimals/schema.dart @@ -1,17 +1,24 @@ 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'); +final Model entry = model("entries", ( + id: integer().identity(), + amount: decimal(), + fee: decimal().nullable(), + tax: decimal(defaultSql: "'0.10'"), + bucket: text(), +)); + +final Model rate = model( + "rates", + (id: decimal(), label: text()), + primaryKey: (r) => r.id, + relations: (r) => + (allocations: referencedBy(() => allocation, on: (rateId: r.id))), +); + +final Model allocation = model( + "allocations", + (id: integer().identity(), rateId: decimal()), + relations: (r) => + (rate: references((id: r.rateId), () => rate, onDelete: .restrict)), +); diff --git a/test/support/decimals/schema.orm.dart b/test/support/decimals/schema.orm.dart index d1d26e37..29fb38e7 100644 --- a/test/support/decimals/schema.orm.dart +++ b/test/support/decimals/schema.orm.dart @@ -2,78 +2,77 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Entry, Rate, Allocation; - -final _entriesId = Column( +/// A complete immutable row from "entries". +final class Entry({ + required final int id, + required final Decimal amount, + required final Decimal? fee, + required final Decimal tax, + required final String bucket, +}); +final _entryId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _entriesAmount = Column( +final _entryAmount = Column( "amount", Codecs.decimal, nullable: false, generated: false, ); -final _entriesFee = Column( +final _entryFee = Column( "fee", Codecs.decimal.nullable(), nullable: true, generated: false, ); -final _entriesTax = Column( +final _entryTax = Column( "tax", Codecs.decimal, nullable: false, generated: false, defaultSql: "'0.10'", ); -final _entriesBucket = Column( +final _entryBucket = Column( "bucket", Codecs.text, nullable: false, generated: false, ); -final entriesSchema = TableSchema( +final entrySchema = TableSchema( "entries", - columns: [ - _entriesId, - _entriesAmount, - _entriesFee, - _entriesTax, - _entriesBucket, - ], + columns: [_entryId, _entryAmount, _entryFee, _entryTax, _entryBucket], 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 class EntryFields extends Fields { + EntryFields(super.table); + late final id = column(_entryId); + late final amount = column(_entryAmount); + late final fee = column(_entryFee); + late final tax = column(_entryTax); + late final bucket = column(_entryBucket); } -final entriesTable = Table( - entriesSchema, - EntriesFields.new, +final entryTable = Table( + entrySchema, + EntryFields.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), + (v0, v1, v2, v3, v4) => + Entry(id: v0, amount: v1, fee: v2, tax: v3, bucket: v4), ), ); -final class EntriesTableSet extends TableSet { - EntriesTableSet(QueryContext db) : super(db, entriesTable) { +final class EntryTableSet extends TableSet { + EntryTableSet(QueryContext db) : super(db, entryTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required Decimal amount, Decimal? fee, @@ -88,11 +87,10 @@ final class EntriesTableSet extends TableSet { row.bucket.set(bucket), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension EntriesUpdates on Query { +extension EntryUpdates on Query { Future patch({ Change amount = const Change.keep(), Change fee = const Change.keep(), @@ -108,52 +106,53 @@ extension EntriesUpdates on Query { ).execute(); } -final _ratesId = Column( +/// A complete immutable row from "rates". +final class Rate({required final Decimal id, required final String label}); +final _rateId = Column( "id", Codecs.decimal, nullable: false, generated: false, ); -final _ratesLabel = Column( +final _rateLabel = Column( "label", Codecs.text, nullable: false, generated: false, ); -final ratesSchema = TableSchema( +final rateSchema = TableSchema( "rates", - columns: [_ratesId, _ratesLabel], + columns: [_rateId, _rateLabel], 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 class RateFields extends Fields { + RateFields(super.table); + late final id = column(_rateId); + late final label = column(_rateLabel); + Relation get allocations => + Relation(allocationTable, 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 rateTable = Table( + rateSchema, + RateFields.new, + (row) => (row.id, row.label).map((v0, v1) => Rate(id: v0, label: v1)), ); -final class RatesTableSet extends TableSet { - RatesTableSet(QueryContext db) : super(db, ratesTable) { +final class RateTableSet extends TableSet { + RateTableSet(QueryContext db) : super(db, rateTable) { db.registerSchema(appSchema); } - Future create({required Decimal id, required String label}) => + 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)); + Query byId(Decimal id) => where((row) => row.id.eq(id)); } -extension RatesUpdates on Query { +extension RateUpdates on Query { Future patch({ Change id = const Change.keep(), Change label = const Change.keep(), @@ -162,21 +161,23 @@ extension RatesUpdates on Query { .execute(); } -final _allocationsId = Column( +/// A complete immutable row from "allocations". +final class Allocation({required final int id, required final Decimal rateId}); +final _allocationId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _allocationsRateId = Column( +final _allocationRateId = Column( "rate_id", Codecs.decimal, nullable: false, generated: false, ); -final allocationsSchema = TableSchema( +final allocationSchema = TableSchema( "allocations", - columns: [_allocationsId, _allocationsRateId], + columns: [_allocationId, _allocationRateId], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -185,46 +186,45 @@ final allocationsSchema = TableSchema( ], ); -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 class AllocationFields extends Fields { + AllocationFields(super.table); + late final id = column(_allocationId); + late final rateId = column(_allocationRateId); + Relation get rate => + Relation(rateTable, 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 allocationTable = Table( + allocationSchema, + AllocationFields.new, + (row) => (row.id, row.rateId).map((v0, v1) => Allocation(id: v0, rateId: v1)), ); -final class AllocationsTableSet - extends TableSet { - AllocationsTableSet(QueryContext db) : super(db, allocationsTable) { +final class AllocationTableSet extends TableSet { + AllocationTableSet(QueryContext db) : super(db, allocationTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required Decimal rateId, }) => createRow((row) => [...row.id.change(id), row.rateId.set(rateId)]); - Query byId(int id) => + Query byId(int id) => where((row) => row.id.eq(id)); } -extension AllocationsUpdates on Query { +extension AllocationUpdates on Query { Future patch({Change rateId = const Change.keep()}) => update((row) => [...row.rateId.change(rateId)]).execute(); } final appSchema = List.unmodifiable([ - entriesSchema, - ratesSchema, - allocationsSchema, + entrySchema, + rateSchema, + allocationSchema, ]); extension AppTables on QueryContext { - EntriesTableSet get entries => EntriesTableSet(this); - RatesTableSet get rates => RatesTableSet(this); - AllocationsTableSet get allocations => AllocationsTableSet(this); + EntryTableSet get entry => EntryTableSet(this); + RateTableSet get rate => RateTableSet(this); + AllocationTableSet get allocation => AllocationTableSet(this); } diff --git a/test/support/defaults/schema.dart b/test/support/defaults/schema.dart index b93e6023..d84f074d 100644 --- a/test/support/defaults/schema.dart +++ b/test/support/defaults/schema.dart @@ -2,17 +2,14 @@ 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(); +final Model ticket = model("tickets", ( + id: custom(d.TicketId.codec, clientDefault: d.nextId), + name: text(clientDefault: d.nameFactory), + label: text().nullable(clientDefault: d.empty), + state: text(defaultSql: "'server'", clientDefault: d.Defaults.state), + createdAt: dateTime(clientDefault: DateTime.now), +), primaryKey: (r) => r.id); + +final Model sequenceRow = model("sequences", ( + id: integer(clientDefault: d.clientIdentity).identity(), +)); diff --git a/test/support/defaults/schema.orm.dart b/test/support/defaults/schema.orm.dart index 173df0e8..3c101b64 100644 --- a/test/support/defaults/schema.orm.dart +++ b/test/support/defaults/schema.orm.dart @@ -2,32 +2,39 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Ticket, SequenceRow; +export "types.dart" show TicketId; import "types.dart" as types0; -final _ticketsId = Column( +/// A complete immutable row from "tickets". +final class Ticket({ + required final types0.TicketId id, + required final String name, + required final String? label, + required final String state, + required final DateTime createdAt, +}); +final _ticketId = Column( "id", types0.TicketId.codec, nullable: false, generated: false, clientDefault: types0.nextId, ); -final _ticketsName = Column( +final _ticketName = Column( "name", Codecs.text, nullable: false, generated: false, clientDefault: types0.nameFactory, ); -final _ticketsLabel = Column( +final _ticketLabel = Column( "label", Codecs.text.nullable(), nullable: true, generated: false, clientDefault: types0.empty, ); -final _ticketsState = Column( +final _ticketState = Column( "state", Codecs.text, nullable: false, @@ -35,21 +42,21 @@ final _ticketsState = Column( defaultSql: "'server'", clientDefault: types0.Defaults.state, ); -final _ticketsCreatedAt = Column( +final _ticketCreatedAt = Column( "created_at", Codecs.dateTime, nullable: false, generated: false, clientDefault: DateTime.now, ); -final ticketsSchema = TableSchema( +final ticketSchema = TableSchema( "tickets", columns: [ - _ticketsId, - _ticketsName, - _ticketsLabel, - _ticketsState, - _ticketsCreatedAt, + _ticketId, + _ticketName, + _ticketLabel, + _ticketState, + _ticketCreatedAt, ], primaryKey: ["id"], uniqueKeys: [], @@ -57,29 +64,29 @@ final ticketsSchema = TableSchema( 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 class TicketFields extends Fields { + TicketFields(super.table); + late final id = column(_ticketId); + late final name = column(_ticketName); + late final label = column(_ticketLabel); + late final state = column(_ticketState); + late final createdAt = column(_ticketCreatedAt); } -final ticketsTable = Table( - ticketsSchema, - TicketsFields.new, +final ticketTable = Table( + ticketSchema, + TicketFields.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), + (v0, v1, v2, v3, v4) => + Ticket(id: v0, name: v1, label: v2, state: v3, createdAt: v4), ), ); -final class TicketsTableSet extends TableSet { - TicketsTableSet(QueryContext db) : super(db, ticketsTable) { +final class TicketTableSet extends TableSet { + TicketTableSet(QueryContext db) : super(db, ticketTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), Change name = const Change.keep(), Change label = const Change.keep(), @@ -94,11 +101,11 @@ final class TicketsTableSet extends TableSet { ...row.createdAt.change(createdAt), ], ); - Query byId(types0.TicketId id) => + Query byId(types0.TicketId id) => where((row) => row.id.eq(id)); } -extension TicketsUpdates on Query { +extension TicketUpdates on Query { Future patch({ Change id = const Change.keep(), Change name = const Change.keep(), @@ -116,50 +123,52 @@ extension TicketsUpdates on Query { ).execute(); } -final _sequencesId = Column( +/// A complete immutable row from "sequences". +final class SequenceRow({required final int id}); +final _sequenceRowId = Column( "id", Codecs.integer, nullable: false, generated: true, clientDefault: types0.clientIdentity, ); -final sequencesSchema = TableSchema( +final sequenceRowSchema = TableSchema( "sequences", - columns: [_sequencesId], + columns: [_sequenceRowId], primaryKey: ["id"], uniqueKeys: [], indexes: [], foreignKeys: [], ); -final class SequencesFields extends Fields { - SequencesFields(super.table); - late final id = column(_sequencesId); +final class SequenceRowFields extends Fields { + SequenceRowFields(super.table); + late final id = column(_sequenceRowId); } -final sequencesTable = Table( - sequencesSchema, - SequencesFields.new, - (row) => row.id.map((v) => (id: v)), +final sequenceRowTable = Table( + sequenceRowSchema, + SequenceRowFields.new, + (row) => row.id.map((value) => SequenceRow(id: value)), ); -final class SequencesTableSet - extends TableSet { - SequencesTableSet(QueryContext db) : super(db, sequencesTable) { +final class SequenceRowTableSet + extends TableSet { + SequenceRowTableSet(QueryContext db) : super(db, sequenceRowTable) { db.registerSchema(appSchema); } - Future create({Change id = const Change.keep()}) => + Future create({Change id = const Change.keep()}) => createRow((row) => [...row.id.change(id)]); - Query byId(int id) => + Query byId(int id) => where((row) => row.id.eq(id)); } final appSchema = List.unmodifiable([ - ticketsSchema, - sequencesSchema, + ticketSchema, + sequenceRowSchema, ]); extension AppTables on QueryContext { - TicketsTableSet get tickets => TicketsTableSet(this); - SequencesTableSet get sequences => SequencesTableSet(this); + TicketTableSet get ticket => TicketTableSet(this); + SequenceRowTableSet get sequenceRow => SequenceRowTableSet(this); } diff --git a/test/support/instants/native.dart b/test/support/instants/native.dart index a7b5fa2c..8aedfc0e 100644 --- a/test/support/instants/native.dart +++ b/test/support/instants/native.dart @@ -18,7 +18,7 @@ Future main() async { local.toUtc()) { throw StateError('Local input changed instant'); } - final first = await db.events.create(at: expected); + final first = await db.event.create(at: expected); final raw = (await db.execute( SqlCommand('SELECT created FROM events WHERE id = 1'), @@ -28,16 +28,16 @@ Future main() async { 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(); + await db.event.create(at: next); + final ordered = await db.event.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.moment.create(at: expected); await db.execute( SqlCommand("INSERT INTO links (at) VALUES ('2024-01-01 08:00+08')"), ); - if ((await db.moments + if ((await db.moment .select((m) => m.links.select((l) => l.at).many()) .single()) .single != diff --git a/test/support/instants/schema.dart b/test/support/instants/schema.dart index ad8471f2..91389cef 100644 --- a/test/support/instants/schema.dart +++ b/test/support/instants/schema.dart @@ -1,16 +1,22 @@ 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'); +final Model event = model("events", ( + id: integer().identity(), + at: dateTime(), + created: dateTime(defaultSql: "CURRENT_TIMESTAMP"), + optional: dateTime().nullable(), +)); + +final Model moment = model( + "moments", + (at: dateTime(), label: text(defaultSql: "'pending'")), + primaryKey: (r) => r.at, + relations: (r) => (links: referencedBy(() => link, on: (at: r.at))), +); + +final Model link = model( + "links", + (id: integer().identity(), at: dateTime()), + relations: (r) => + (moment: references((at: r.at), () => moment, onDelete: .restrict)), +); diff --git a/test/support/instants/schema.orm.dart b/test/support/instants/schema.orm.dart index a2b64c7f..99807878 100644 --- a/test/support/instants/schema.orm.dart +++ b/test/support/instants/schema.orm.dart @@ -2,65 +2,71 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Event, Moment, Link; - -final _eventsId = Column( +/// A complete immutable row from "events". +final class Event({ + required final int id, + required final DateTime at, + required final DateTime created, + required final DateTime? optional, +}); +final _eventId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _eventsAt = Column( +final _eventAt = Column( "at", Codecs.dateTime, nullable: false, generated: false, ); -final _eventsCreated = Column( +final _eventCreated = Column( "created", Codecs.dateTime, nullable: false, generated: false, defaultSql: "CURRENT_TIMESTAMP", ); -final _eventsOptional = Column( +final _eventOptional = Column( "optional", Codecs.dateTime.nullable(), nullable: true, generated: false, ); -final eventsSchema = TableSchema( +final eventSchema = TableSchema( "events", - columns: [_eventsId, _eventsAt, _eventsCreated, _eventsOptional], + columns: [_eventId, _eventAt, _eventCreated, _eventOptional], 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 class EventFields extends Fields { + EventFields(super.table); + late final id = column(_eventId); + late final at = column(_eventAt); + late final created = column(_eventCreated); + late final optional = column(_eventOptional); } -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 eventTable = Table( + eventSchema, + EventFields.new, + (row) => ( + row.id, + row.at, + row.created, + row.optional, + ).map((v0, v1, v2, v3) => Event(id: v0, at: v1, created: v2, optional: v3)), ); -final class EventsTableSet extends TableSet { - EventsTableSet(QueryContext db) : super(db, eventsTable) { +final class EventTableSet extends TableSet { + EventTableSet(QueryContext db) : super(db, eventTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required DateTime at, Change created = const Change.keep(), @@ -73,11 +79,10 @@ final class EventsTableSet extends TableSet { row.optional.set(optional), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension EventsUpdates on Query { +extension EventUpdates on Query { Future patch({ Change at = const Change.keep(), Change created = const Change.keep(), @@ -91,55 +96,57 @@ extension EventsUpdates on Query { ).execute(); } -final _momentsAt = Column( +/// A complete immutable row from "moments". +final class Moment({required final DateTime at, required final String label}); +final _momentAt = Column( "at", Codecs.dateTime, nullable: false, generated: false, ); -final _momentsLabel = Column( +final _momentLabel = Column( "label", Codecs.text, nullable: false, generated: false, defaultSql: "'pending'", ); -final momentsSchema = TableSchema( +final momentSchema = TableSchema( "moments", - columns: [_momentsAt, _momentsLabel], + columns: [_momentAt, _momentLabel], 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 class MomentFields extends Fields { + MomentFields(super.table); + late final at = column(_momentAt); + late final label = column(_momentLabel); + Relation get links => + Relation(linkTable, 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 momentTable = Table( + momentSchema, + MomentFields.new, + (row) => (row.at, row.label).map((v0, v1) => Moment(at: v0, label: v1)), ); -final class MomentsTableSet extends TableSet { - MomentsTableSet(QueryContext db) : super(db, momentsTable) { +final class MomentTableSet extends TableSet { + MomentTableSet(QueryContext db) : super(db, momentTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ required DateTime at, Change label = const Change.keep(), }) => createRow((row) => [row.at.set(at), ...row.label.change(label)]); - Query byId(DateTime at) => + Query byId(DateTime at) => where((row) => row.at.eq(at)); } -extension MomentsUpdates on Query { +extension MomentUpdates on Query { Future patch({ Change at = const Change.keep(), Change label = const Change.keep(), @@ -148,21 +155,23 @@ extension MomentsUpdates on Query { .execute(); } -final _linksId = Column( +/// A complete immutable row from "links". +final class Link({required final int id, required final DateTime at}); +final _linkId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _linksAt = Column( +final _linkAt = Column( "at", Codecs.dateTime, nullable: false, generated: false, ); -final linksSchema = TableSchema( +final linkSchema = TableSchema( "links", - columns: [_linksId, _linksAt], + columns: [_linkId, _linkAt], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -171,44 +180,44 @@ final linksSchema = TableSchema( ], ); -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 class LinkFields extends Fields { + LinkFields(super.table); + late final id = column(_linkId); + late final at = column(_linkAt); + Relation get moment => + Relation(momentTable, 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 linkTable = Table( + linkSchema, + LinkFields.new, + (row) => (row.id, row.at).map((v0, v1) => Link(id: v0, at: v1)), ); -final class LinksTableSet extends TableSet { - LinksTableSet(QueryContext db) : super(db, linksTable) { +final class LinkTableSet extends TableSet { + LinkTableSet(QueryContext db) : super(db, linkTable) { db.registerSchema(appSchema); } - Future create({ + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension LinksUpdates on Query { +extension LinkUpdates on Query { Future patch({Change at = const Change.keep()}) => update((row) => [...row.at.change(at)]).execute(); } final appSchema = List.unmodifiable([ - eventsSchema, - momentsSchema, - linksSchema, + eventSchema, + momentSchema, + linkSchema, ]); extension AppTables on QueryContext { - EventsTableSet get events => EventsTableSet(this); - MomentsTableSet get moments => MomentsTableSet(this); - LinksTableSet get links => LinksTableSet(this); + EventTableSet get event => EventTableSet(this); + MomentTableSet get moment => MomentTableSet(this); + LinkTableSet get link => LinkTableSet(this); } diff --git a/test/support/integers/native.dart b/test/support/integers/native.dart index 7512cd11..102ff722 100644 --- a/test/support/integers/native.dart +++ b/test/support/integers/native.dart @@ -9,24 +9,24 @@ Future main() async { await Migrator( db.sql, ).apply([Migration.create('0001_initial', appSchema, dialect: db.dialect)]); - final first = await db.samples.create( + final first = await db.sample.create( small: 32767, medium: 2147483647, large: 9007199254740993, ); - await db.samples.create( + await db.sample.create( small: 32767, medium: 2147483647, large: -9007199254740993, ); - final sums = await db.samples + final sums = await db.sample .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 + await db.owner.create(id: 1, sampleId: first.id); + if (await db.owner .select((o) => o.sample.select((s) => s.small).required()) .single() != 32767) { @@ -34,7 +34,7 @@ Future main() async { } var rejected = false; try { - await db.samples.create(small: 32768, medium: 0, large: 0); + await db.sample.create(small: 32768, medium: 0, large: 0); } on SqlFailure { rejected = true; } diff --git a/test/support/integers/schema.dart b/test/support/integers/schema.dart index 69455f21..187bb06f 100644 --- a/test/support/integers/schema.dart +++ b/test/support/integers/schema.dart @@ -1,17 +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, -}); +final Model sample = model("samples", ( + id: integer(bits: 32).identity(), + small: integer(bits: 16), + medium: integer(bits: 32), + large: integer(), + optional: integer(bits: 16).nullable(), +), relations: (r) => (owners: referencedBy(() => owner, on: (sampleId: r.id)))); -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'); +final Model owner = model( + "owners", + (id: integer(), sampleId: integer(bits: 32)), + primaryKey: (r) => r.id, + relations: (r) => + (sample: references((id: r.sampleId), () => sample, onDelete: .restrict)), +); diff --git a/test/support/integers/schema.orm.dart b/test/support/integers/schema.orm.dart index db7f0df1..4721dd2d 100644 --- a/test/support/integers/schema.orm.dart +++ b/test/support/integers/schema.orm.dart @@ -2,51 +2,56 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Sample, Owner; - -final _samplesId = Column( +/// A complete immutable row from "samples". +final class Sample({ + required final int id, + required final int small, + required final int medium, + required final int large, + required final int? optional, +}); +final _sampleId = Column( "id", Codecs.integer, nullable: false, generated: true, integerBits: 32, ); -final _samplesSmall = Column( +final _sampleSmall = Column( "small", Codecs.integer, nullable: false, generated: false, integerBits: 16, ); -final _samplesMedium = Column( +final _sampleMedium = Column( "medium", Codecs.integer, nullable: false, generated: false, integerBits: 32, ); -final _samplesLarge = Column( +final _sampleLarge = Column( "large", Codecs.integer, nullable: false, generated: false, ); -final _samplesOptional = Column( +final _sampleOptional = Column( "optional", Codecs.integer.nullable(), nullable: true, generated: false, integerBits: 16, ); -final samplesSchema = TableSchema( +final sampleSchema = TableSchema( "samples", columns: [ - _samplesId, - _samplesSmall, - _samplesMedium, - _samplesLarge, - _samplesOptional, + _sampleId, + _sampleSmall, + _sampleMedium, + _sampleLarge, + _sampleOptional, ], primaryKey: ["id"], uniqueKeys: [], @@ -54,36 +59,31 @@ final samplesSchema = TableSchema( 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 class SampleFields extends Fields { + SampleFields(super.table); + late final id = column(_sampleId); + late final small = column(_sampleSmall); + late final medium = column(_sampleMedium); + late final large = column(_sampleLarge); + late final optional = column(_sampleOptional); + Relation get owners => + Relation(ownerTable, parent: [id], child: (row) => [row.sampleId]); } -final samplesTable = Table( - samplesSchema, - SamplesFields.new, +final sampleTable = Table( + sampleSchema, + SampleFields.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, - ), + (v0, v1, v2, v3, v4) => + Sample(id: v0, small: v1, medium: v2, large: v3, optional: v4), ), ); -final class SamplesTableSet extends TableSet { - SamplesTableSet(QueryContext db) : super(db, samplesTable) { +final class SampleTableSet extends TableSet { + SampleTableSet(QueryContext db) : super(db, sampleTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required int small, required int medium, @@ -98,11 +98,10 @@ final class SamplesTableSet extends TableSet { row.optional.set(optional), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension SamplesUpdates on Query { +extension SampleUpdates on Query { Future patch({ Change small = const Change.keep(), Change medium = const Change.keep(), @@ -118,22 +117,24 @@ extension SamplesUpdates on Query { ).execute(); } -final _ownersId = Column( +/// A complete immutable row from "owners". +final class Owner({required final int id, required final int sampleId}); +final _ownerId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _ownersSampleId = Column( +final _ownerSampleId = Column( "sample_id", Codecs.integer, nullable: false, generated: false, integerBits: 32, ); -final ownersSchema = TableSchema( +final ownerSchema = TableSchema( "owners", - columns: [_ownersId, _ownersSampleId], + columns: [_ownerId, _ownerSampleId], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -142,34 +143,30 @@ final ownersSchema = TableSchema( ], ); -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 class OwnerFields extends Fields { + OwnerFields(super.table); + late final id = column(_ownerId); + late final sampleId = column(_ownerSampleId); + Relation get sample => + Relation(sampleTable, 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 ownerTable = Table( + ownerSchema, + OwnerFields.new, + (row) => (row.id, row.sampleId).map((v0, v1) => Owner(id: v0, sampleId: v1)), ); -final class OwnersTableSet extends TableSet { - OwnersTableSet(QueryContext db) : super(db, ownersTable) { +final class OwnerTableSet extends TableSet { + OwnerTableSet(QueryContext db) : super(db, ownerTable) { db.registerSchema(appSchema); } - Future create({required int id, required int sampleId}) => + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension OwnersUpdates on Query { +extension OwnerUpdates on Query { Future patch({ Change id = const Change.keep(), Change sampleId = const Change.keep(), @@ -178,9 +175,9 @@ extension OwnersUpdates on Query { .execute(); } -final appSchema = List.unmodifiable([samplesSchema, ownersSchema]); +final appSchema = List.unmodifiable([sampleSchema, ownerSchema]); extension AppTables on QueryContext { - SamplesTableSet get samples => SamplesTableSet(this); - OwnersTableSet get owners => OwnersTableSet(this); + SampleTableSet get sample => SampleTableSet(this); + OwnerTableSet get owner => OwnerTableSet(this); } diff --git a/test/support/named_sql/native.dart b/test/support/named_sql/native.dart index 9f7d840d..03195119 100644 --- a/test/support/named_sql/native.dart +++ b/test/support/named_sql/native.dart @@ -33,7 +33,15 @@ Future main() async { day: expected.day, ) .single(); - if (actual != expected) throw StateError('Named SQL codecs differ'); + if (( + value: actual.value, + at: actual.at, + amount: actual.amount, + day: actual.day, + ) != + 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 != @@ -42,7 +50,7 @@ Future main() async { } }); print( - 'Named SQL parameters, records, native codecs, streaming and transactions verified.', + 'Named SQL parameters, rows, 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 index 8846e5df..5bce119f 100644 --- a/test/support/named_sql/queries.dart +++ b/test/support/named_sql/queries.dart @@ -1,17 +1,20 @@ import 'package:orm/schema.dart'; -typedef AuthorStats = ({String author, int postCount, int points}); -final authorStats = sqlQuery( +final authorStats = sqlQuery( + result: (author: text(), postCount: integer(), points: integer()), + parameters: (minimum: integer(), author: text().nullable()), 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( +final echo = sqlQuery( + result: (value: text(), at: dateTime(), amount: decimal(), day: date()), + parameters: (value: text(), at: dateTime(), amount: decimal(), day: date()), + sqlite: 'echo.sql', + postgres: 'echo.sql', +); +final constant = sqlQuery( + result: (n: integer()), sqlite: 'constant.sql', postgres: 'constant.sql', ); -final postgresOnly = sqlQuery(postgres: 'constant.sql'); +final postgresOnly = sqlQuery(result: (n: integer()), postgres: 'constant.sql'); diff --git a/test/support/named_sql/queries.queries.dart b/test/support/named_sql/queries.queries.dart index db8563cf..1ff207a7 100644 --- a/test/support/named_sql/queries.queries.dart +++ b/test/support/named_sql/queries.queries.dart @@ -2,10 +2,12 @@ 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 class AuthorStats({ + required final String author, + required final int postCount, + required final int points, +}); final _sqlColumnAuthorStats_0 = Column( "author", Codecs.text, @@ -30,7 +32,7 @@ final class AuthorStatsFields extends Fields { } final _sqlDefinitionAuthorStats = - SqlQueryDefinition( + SqlQueryDefinition( Table( TableSchema( "_orm_sql_author_stats", @@ -42,8 +44,7 @@ final _sqlDefinitionAuthorStats = ), AuthorStatsFields.new, (row) => (row.author, row.postCount, row.points).map( - (author, postCount, points) => - (author: author, postCount: postCount, points: points), + (v0, v1, v2) => AuthorStats(author: v0, postCount: v1, points: v2), ), ), { @@ -59,7 +60,7 @@ final _sqlDefinitionAuthorStats = ); extension AuthorStatsSql on QueryContext { - Query authorStats({ + Query authorStats({ required int minimum, String? author, }) => _sqlDefinitionAuthorStats.bind(this, { @@ -68,6 +69,12 @@ extension AuthorStatsSql on QueryContext { }); } +final class Echo({ + required final String value, + required final DateTime at, + required final Decimal amount, + required final LocalDate day, +}); final _sqlColumnEcho_0 = Column("value", Codecs.text, nullable: false); final _sqlColumnEcho_1 = Column( "at", @@ -89,7 +96,7 @@ final class EchoFields extends Fields { late final day = column(_sqlColumnEcho_3); } -final _sqlDefinitionEcho = SqlQueryDefinition( +final _sqlDefinitionEcho = SqlQueryDefinition( Table( TableSchema( "_orm_sql_echo", @@ -101,10 +108,12 @@ final _sqlDefinitionEcho = SqlQueryDefinition( ], ), EchoFields.new, - (row) => (row.value, row.at, row.amount, row.day).map( - (value, at, amount, day) => - (value: value, at: at, amount: amount, day: day), - ), + (row) => ( + row.value, + row.at, + row.amount, + row.day, + ).map((v0, v1, v2, v3) => Echo(value: v0, at: v1, amount: v2, day: v3)), ), { SqlDialect.sqlite: SqlTemplate( @@ -119,7 +128,7 @@ final _sqlDefinitionEcho = SqlQueryDefinition( ); extension EchoSql on QueryContext { - Query echo({ + Query echo({ required String value, required DateTime at, required Decimal amount, @@ -132,6 +141,7 @@ extension EchoSql on QueryContext { }); } +final class Constant({required final int n}); final _sqlColumnConstant_0 = Column("n", Codecs.integer, nullable: false); final class ConstantFields extends Fields { @@ -139,30 +149,30 @@ final class ConstantFields extends Fields { 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, - ), - }, - ); +final _sqlDefinitionConstant = SqlQueryDefinition( + Table( + TableSchema("_orm_sql_constant", columns: [_sqlColumnConstant_0]), + ConstantFields.new, + (row) => row.n.map((value) => Constant(n: value)), + ), + { + 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() => + Query constant() => _sqlDefinitionConstant.bind(this, {}); } +final class PostgresOnly({required final int n}); final _sqlColumnPostgresOnly_0 = Column( "n", Codecs.integer, @@ -175,14 +185,14 @@ final class PostgresOnlyFields extends Fields { } final _sqlDefinitionPostgresOnly = - SqlQueryDefinition( + SqlQueryDefinition( Table( TableSchema( "_orm_sql_postgres_only", columns: [_sqlColumnPostgresOnly_0], ), PostgresOnlyFields.new, - (row) => row.n.map((v) => (n: v)), + (row) => row.n.map((value) => PostgresOnly(n: value)), ), { SqlDialect.postgres: SqlTemplate( @@ -193,6 +203,6 @@ final _sqlDefinitionPostgresOnly = ); extension PostgresOnlySql on QueryContext { - Query postgresOnly() => + Query postgresOnly() => _sqlDefinitionPostgresOnly.bind(this, {}); } diff --git a/test/support/native_execution.dart b/test/support/native_execution.dart index 4320bfcd..89df5c8f 100644 --- a/test/support/native_execution.dart +++ b/test/support/native_execution.dart @@ -31,14 +31,14 @@ Future main() async { if (error.code != 'MIGRATION.VERSION') rethrow; } for (var i = 0; i < 5; i++) { - await db.users.create(email: 'aot$i@example.com'); + await db.user.create(email: 'aot$i@example.com'); } - await db.posts.create( + await db.post.create( authorId: 1, title: 'AOT', createdAt: DateTime.utc(2026), ); - final author = await db.posts + final author = await db.post .select( (p) => p.author .select( @@ -49,17 +49,17 @@ Future main() async { ) .single(); if (author != (id: 1, name: null)) throw StateError('Joined record failed'); - final nullable = await db.posts + final nullable = await db.post .select((p) => p.author.select((a) => a.nickname).required()) .single(); if (nullable != null) { throw StateError('Nullable required projection failed'); } - final absent = await db.posts + final absent = await db.post .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 + final rows = await db.user .orderBy((u) => [u.id.asc()]) .select((u) => u.email) .stream(batchSize: 2) @@ -68,9 +68,9 @@ Future main() async { if (rows.length != 3 || rows.last != 'aot2@example.com') { throw StateError('Cursor decoding failed'); } - final combined = db.users + final combined = db.user .select((u) => (u.id, u.email).row) - .unionAll(db.posts.select((p) => (p.id, p.title).row)) + .unionAll(db.post.select((p) => (p.id, p.title).row)) .orderBy( (u) => [u.ref((u) => u.id).asc(), u.ref((u) => u.email).asc()], ); @@ -87,7 +87,7 @@ Future main() async { }); await acquired.future; try { - await db.users + await db.user .insert((u) => [u.email.set('must-not-execute@example.com')]) .execute( options: const ExecutionOptions( @@ -101,13 +101,13 @@ Future main() async { release.complete(); await held; } - if (await db.users.count() != 5) throw StateError('Abandoned SQL executed'); + if (await db.user.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 tx.user.create(email: 'deadline@example.com'); await resume.future; }, timeout: const Duration(milliseconds: 60)); throw StateError('Transaction deadline failed'); @@ -124,12 +124,12 @@ Future main() async { if (!db.capabilities.cancellation && deadlineCallback) { throw StateError('Unsupported transaction deadline entered the callback'); } - if (await db.users.count() != 5) { + if (await db.user.count() != 5) { throw StateError('Timed-out transaction persisted'); } try { await db.transaction((tx) async { - await tx.users.create(email: 'rollback@example.com'); + await tx.user.create(email: 'rollback@example.com'); try { await tx.savepoint( (child) => child.execute( @@ -146,7 +146,7 @@ Future main() async { } on OrmException catch (error) { if (error.code != 'TRANSACTION.FAILED') rethrow; } - if (await db.users.count() != 5) { + if (await db.user.count() != 5) { throw StateError('Automatic rollback lost connection state'); } final token = CancellationToken(); @@ -171,17 +171,17 @@ Future main() async { } finally { timer.cancel(); } - if (await db.users.count() != 5) { + if (await db.user.count() != 5) { throw StateError('Connection recovery failed'); } - final changes = StreamIterator(db.users.select((u) => u.email).watch()); + final changes = StreamIterator(db.user.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'); + await tx.user.create(email: 'watch1@example.com'); + await tx.user.create(email: 'watch2@example.com'); }); if (!await changed || changes.current.length != 7) { throw StateError('Committed watch snapshot failed'); diff --git a/test/support/nominal/schema.dart b/test/support/nominal/schema.dart index 9fcf134e..adfc5f37 100644 --- a/test/support/nominal/schema.dart +++ b/test/support/nominal/schema.dart @@ -3,34 +3,40 @@ 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 Model account = model( + "nominal_accounts", + ( + id: integer().identity(), + email: custom(emailCodec), + label: text(name: "display_name").nullable(), + enabled: boolean(defaultSql: "false"), + marker: text(clientDefault: defaultMarker), + a: integer(), + b: integer(), + c: integer(), + total: integer().computed( + "a + b", + postgres: "a + b", + mysql: "a + b", + mariadb: "a + b", + storage: .stored, + ), + ), + uniqueKeys: (r) => [r.email], + relations: (r) => (notes: referencedBy(() => note, on: (accountId: r.id))), ); -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, - ); +final Model note = model( + "nominal_notes", + (id: integer().identity(), accountId: integer(), body: text()), + relations: (r) => ( + account: references((id: r.accountId), () => account, onDelete: .cascade), + ), +); diff --git a/test/support/nominal/schema.orm.dart b/test/support/nominal/schema.orm.dart index c6bd30cc..19a68cae 100644 --- a/test/support/nominal/schema.orm.dart +++ b/test/support/nominal/schema.orm.dart @@ -3,59 +3,71 @@ import 'package:orm/sql.dart'; import "schema.dart" as models; -export "schema.dart" show Account, Note; +export "schema.dart" show Email; -final _accountsId = Column( +/// A complete immutable row from "nominal_accounts". +final class Account({ + required final int id, + required final models.Email email, + required final String? label, + required final bool enabled, + required final String marker, + required final int a, + required final int b, + required final int c, + required final int total, +}); +final _accountId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _accountsEmail = Column( +final _accountEmail = Column( "email", models.emailCodec, nullable: false, generated: false, ); -final _accountsLabel = Column( +final _accountLabel = Column( "display_name", Codecs.text.nullable(), nullable: true, generated: false, ); -final _accountsEnabled = Column( +final _accountEnabled = Column( "enabled", Codecs.boolean, nullable: false, generated: false, defaultSql: "false", ); -final _accountsMarker = Column( +final _accountMarker = Column( "marker", Codecs.text, nullable: false, generated: false, clientDefault: models.defaultMarker, ); -final _accountsA = Column( +final _accountA = Column( "a", Codecs.integer, nullable: false, generated: false, ); -final _accountsB = Column( +final _accountB = Column( "b", Codecs.integer, nullable: false, generated: false, ); -final _accountsC = Column( +final _accountC = Column( "c", Codecs.integer, nullable: false, generated: false, ); -final _accountsTotal = Column( +final _accountTotal = Column( "total", Codecs.integer, nullable: false, @@ -68,18 +80,18 @@ final _accountsTotal = Column( storage: ComputedStorage.stored, ), ); -final accountsSchema = TableSchema( +final accountSchema = TableSchema( "nominal_accounts", columns: [ - _accountsId, - _accountsEmail, - _accountsLabel, - _accountsEnabled, - _accountsMarker, - _accountsA, - _accountsB, - _accountsC, - _accountsTotal, + _accountId, + _accountEmail, + _accountLabel, + _accountEnabled, + _accountMarker, + _accountA, + _accountB, + _accountC, + _accountTotal, ], primaryKey: ["id"], uniqueKeys: [ @@ -89,24 +101,24 @@ final accountsSchema = TableSchema( 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 class AccountFields extends Fields { + AccountFields(super.table); + late final id = column(_accountId); + late final email = column(_accountEmail); + late final label = column(_accountLabel); + late final enabled = column(_accountEnabled); + late final marker = column(_accountMarker); + late final a = column(_accountA); + late final b = column(_accountB); + late final c = column(_accountC); + late final total = readColumn(_accountTotal); + Relation get notes => + Relation(noteTable, parent: [id], child: (row) => [row.accountId]); } -final accountsTable = Table( - accountsSchema, - AccountsFields.new, +final accountTable = Table( + accountSchema, + AccountFields.new, (row) => ( (row.id, row.email, row.label, row.enabled, row.marker).map( @@ -125,25 +137,25 @@ final accountsTable = Table( 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, + (left, right) => Account( + id: left.id, + email: left.email, + label: left.label, + enabled: left.enabled, + marker: left.marker, + a: right.a, + b: right.b, + c: right.c, + total: right.total, ), ), ); -final class AccountsTableSet extends TableSet { - AccountsTableSet(QueryContext db) : super(db, accountsTable) { +final class AccountTableSet extends TableSet { + AccountTableSet(QueryContext db) : super(db, accountTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required models.Email email, String? label, @@ -164,11 +176,10 @@ final class AccountsTableSet extends TableSet { row.c.set(c), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension AccountsUpdates on Query { +extension AccountUpdates on Query { Future patch({ Change email = const Change.keep(), Change label = const Change.keep(), @@ -190,27 +201,33 @@ extension AccountsUpdates on Query { ).execute(); } -final _notesId = Column( +/// A complete immutable row from "nominal_notes". +final class Note({ + required final int id, + required final int accountId, + required final String body, +}); +final _noteId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _notesAccountId = Column( +final _noteAccountId = Column( "account_id", Codecs.integer, nullable: false, generated: false, ); -final _notesBody = Column( +final _noteBody = Column( "body", Codecs.text, nullable: false, generated: false, ); -final notesSchema = TableSchema( +final noteSchema = TableSchema( "nominal_notes", - columns: [_notesId, _notesAccountId, _notesBody], + columns: [_noteId, _noteAccountId, _noteBody], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -219,30 +236,30 @@ final notesSchema = TableSchema( ], ); -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 class NoteFields extends Fields { + NoteFields(super.table); + late final id = column(_noteId); + late final accountId = column(_noteAccountId); + late final body = column(_noteBody); + Relation get account => + Relation(accountTable, parent: [accountId], child: (row) => [row.id]); } -final notesTable = Table( - notesSchema, - NotesFields.new, +final noteTable = Table( + noteSchema, + NoteFields.new, (row) => ( row.id, row.accountId, row.body, - ).map((v0, v1, v2) => models.Note(id: v0, accountId: v1, body: v2)), + ).map((v0, v1, v2) => Note(id: v0, accountId: v1, body: v2)), ); -final class NotesTableSet extends TableSet { - NotesTableSet(QueryContext db) : super(db, notesTable) { +final class NoteTableSet extends TableSet { + NoteTableSet(QueryContext db) : super(db, noteTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required int accountId, required String body, @@ -253,10 +270,10 @@ final class NotesTableSet extends TableSet { row.body.set(body), ], ); - Query byId(int id) => where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension NotesUpdates on Query { +extension NoteUpdates on Query { Future patch({ Change accountId = const Change.keep(), Change body = const Change.keep(), @@ -265,9 +282,9 @@ extension NotesUpdates on Query { ).execute(); } -final appSchema = List.unmodifiable([accountsSchema, notesSchema]); +final appSchema = List.unmodifiable([accountSchema, noteSchema]); extension AppTables on QueryContext { - AccountsTableSet get accounts => AccountsTableSet(this); - NotesTableSet get notes => NotesTableSet(this); + AccountTableSet get account => AccountTableSet(this); + NoteTableSet get note => NoteTableSet(this); } diff --git a/test/support/precision/native.dart b/test/support/precision/native.dart index e8a123c1..60f48b36 100644 --- a/test/support/precision/native.dart +++ b/test/support/precision/native.dart @@ -9,7 +9,7 @@ Future main() async { await Migrator(db.sql).apply([ Migration.create('0001_precision', appSchema, dialect: db.dialect), ]); - final row = await db.wallets.create( + final row = await db.wallet.create( amount: Decimal.parse('1.235'), hundreds: Decimal.parse('-12350'), fraction: Decimal.parse('.001235'), @@ -20,9 +20,9 @@ Future main() async { 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 + await db.price.create(id: Decimal.parse('1.234'), label: 'one'); + await db.receipt.create(priceId: Decimal.parse('1.234')); + if (await db.receipt .select((r) => r.price.select((p) => p.label).required()) .single() != 'one') { @@ -30,11 +30,11 @@ Future main() async { } var rejected = false; try { - await db.prices.create(id: Decimal.parse('99.995'), label: 'overflow'); + await db.price.create(id: Decimal.parse('99.995'), label: 'overflow'); } on SqlFailure { rejected = true; } - if (!rejected || await db.prices.count() != 1) { + if (!rejected || await db.price.count() != 1) { throw StateError('Precision overflow was not rejected'); } final check = await verifySchema(db.sql, SchemaSnapshot(appSchema)); diff --git a/test/support/precision/schema.dart b/test/support/precision/schema.dart index 9dc453d9..dc760804 100644 --- a/test/support/precision/schema.dart +++ b/test/support/precision/schema.dart @@ -1,21 +1,25 @@ 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'); +final Model wallet = model("wallets", ( + id: integer().identity(), + amount: decimal(precision: 5, scale: 2), + hundreds: decimal(precision: 3, scale: -2), + fraction: decimal(precision: 3, scale: 5), + defaulted: decimal(defaultSql: "'1.235'", precision: 5, scale: 2), + optional: decimal(precision: 5, scale: 2).nullable(), +)); + +final Model price = model( + "prices", + (id: decimal(precision: 4, scale: 2), label: text()), + primaryKey: (r) => r.id, + relations: (r) => + (receipts: referencedBy(() => receipt, on: (priceId: r.id))), +); + +final Model receipt = model( + "receipts", + (id: integer().identity(), priceId: decimal(precision: 4, scale: 2)), + relations: (r) => + (price: references((id: r.priceId), () => price, onDelete: .restrict)), +); diff --git a/test/support/precision/schema.orm.dart b/test/support/precision/schema.orm.dart index f79288c7..97f9fba6 100644 --- a/test/support/precision/schema.orm.dart +++ b/test/support/precision/schema.orm.dart @@ -2,16 +2,22 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Wallet, Price, Receipt; - -final _walletsId = Column( +/// A complete immutable row from "wallets". +final class Wallet({ + required final int id, + required final Decimal amount, + required final Decimal hundreds, + required final Decimal fraction, + required final Decimal defaulted, + required final Decimal? optional, +}); +final _walletId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _walletsAmount = Column( +final _walletAmount = Column( "amount", Codecs.decimal, nullable: false, @@ -19,7 +25,7 @@ final _walletsAmount = Column( decimalPrecision: 5, decimalScale: 2, ); -final _walletsHundreds = Column( +final _walletHundreds = Column( "hundreds", Codecs.decimal, nullable: false, @@ -27,7 +33,7 @@ final _walletsHundreds = Column( decimalPrecision: 3, decimalScale: -2, ); -final _walletsFraction = Column( +final _walletFraction = Column( "fraction", Codecs.decimal, nullable: false, @@ -35,7 +41,7 @@ final _walletsFraction = Column( decimalPrecision: 3, decimalScale: 5, ); -final _walletsDefaulted = Column( +final _walletDefaulted = Column( "defaulted", Codecs.decimal, nullable: false, @@ -44,7 +50,7 @@ final _walletsDefaulted = Column( decimalPrecision: 5, decimalScale: 2, ); -final _walletsOptional = Column( +final _walletOptional = Column( "optional", Codecs.decimal.nullable(), nullable: true, @@ -52,15 +58,15 @@ final _walletsOptional = Column( decimalPrecision: 5, decimalScale: 2, ); -final walletsSchema = TableSchema( +final walletSchema = TableSchema( "wallets", columns: [ - _walletsId, - _walletsAmount, - _walletsHundreds, - _walletsFraction, - _walletsDefaulted, - _walletsOptional, + _walletId, + _walletAmount, + _walletHundreds, + _walletFraction, + _walletDefaulted, + _walletOptional, ], primaryKey: ["id"], uniqueKeys: [], @@ -68,19 +74,19 @@ final walletsSchema = TableSchema( 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 class WalletFields extends Fields { + WalletFields(super.table); + late final id = column(_walletId); + late final amount = column(_walletAmount); + late final hundreds = column(_walletHundreds); + late final fraction = column(_walletFraction); + late final defaulted = column(_walletDefaulted); + late final optional = column(_walletOptional); } -final walletsTable = Table( - walletsSchema, - WalletsFields.new, +final walletTable = Table( + walletSchema, + WalletFields.new, (row) => ( row.id, @@ -90,22 +96,22 @@ final walletsTable = Table( row.defaulted, row.optional, ).map( - (id, amount, hundreds, fraction, defaulted, optional) => ( - id: id, - amount: amount, - hundreds: hundreds, - fraction: fraction, - defaulted: defaulted, - optional: optional, + (v0, v1, v2, v3, v4, v5) => Wallet( + id: v0, + amount: v1, + hundreds: v2, + fraction: v3, + defaulted: v4, + optional: v5, ), ), ); -final class WalletsTableSet extends TableSet { - WalletsTableSet(QueryContext db) : super(db, walletsTable) { +final class WalletTableSet extends TableSet { + WalletTableSet(QueryContext db) : super(db, walletTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required Decimal amount, required Decimal hundreds, @@ -122,11 +128,10 @@ final class WalletsTableSet extends TableSet { row.optional.set(optional), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension WalletsUpdates on Query { +extension WalletUpdates on Query { Future patch({ Change amount = const Change.keep(), Change hundreds = const Change.keep(), @@ -144,7 +149,9 @@ extension WalletsUpdates on Query { ).execute(); } -final _pricesId = Column( +/// A complete immutable row from "prices". +final class Price({required final Decimal id, required final String label}); +final _priceId = Column( "id", Codecs.decimal, nullable: false, @@ -152,46 +159,45 @@ final _pricesId = Column( decimalPrecision: 4, decimalScale: 2, ); -final _pricesLabel = Column( +final _priceLabel = Column( "label", Codecs.text, nullable: false, generated: false, ); -final pricesSchema = TableSchema( +final priceSchema = TableSchema( "prices", - columns: [_pricesId, _pricesLabel], + columns: [_priceId, _priceLabel], 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 class PriceFields extends Fields { + PriceFields(super.table); + late final id = column(_priceId); + late final label = column(_priceLabel); + Relation get receipts => + Relation(receiptTable, 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 priceTable = Table( + priceSchema, + PriceFields.new, + (row) => (row.id, row.label).map((v0, v1) => Price(id: v0, label: v1)), ); -final class PricesTableSet extends TableSet { - PricesTableSet(QueryContext db) : super(db, pricesTable) { +final class PriceTableSet extends TableSet { + PriceTableSet(QueryContext db) : super(db, priceTable) { db.registerSchema(appSchema); } - Future create({required Decimal id, required String label}) => + 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)); + Query byId(Decimal id) => where((row) => row.id.eq(id)); } -extension PricesUpdates on Query { +extension PriceUpdates on Query { Future patch({ Change id = const Change.keep(), Change label = const Change.keep(), @@ -200,13 +206,15 @@ extension PricesUpdates on Query { .execute(); } -final _receiptsId = Column( +/// A complete immutable row from "receipts". +final class Receipt({required final int id, required final Decimal priceId}); +final _receiptId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _receiptsPriceId = Column( +final _receiptPriceId = Column( "price_id", Codecs.decimal, nullable: false, @@ -214,9 +222,9 @@ final _receiptsPriceId = Column( decimalPrecision: 4, decimalScale: 2, ); -final receiptsSchema = TableSchema( +final receiptSchema = TableSchema( "receipts", - columns: [_receiptsId, _receiptsPriceId], + columns: [_receiptId, _receiptPriceId], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -225,46 +233,44 @@ final receiptsSchema = TableSchema( ], ); -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 class ReceiptFields extends Fields { + ReceiptFields(super.table); + late final id = column(_receiptId); + late final priceId = column(_receiptPriceId); + Relation get price => + Relation(priceTable, 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 receiptTable = Table( + receiptSchema, + ReceiptFields.new, + (row) => (row.id, row.priceId).map((v0, v1) => Receipt(id: v0, priceId: v1)), ); -final class ReceiptsTableSet extends TableSet { - ReceiptsTableSet(QueryContext db) : super(db, receiptsTable) { +final class ReceiptTableSet extends TableSet { + ReceiptTableSet(QueryContext db) : super(db, receiptTable) { db.registerSchema(appSchema); } - Future create({ + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension ReceiptsUpdates on Query { +extension ReceiptUpdates on Query { Future patch({Change priceId = const Change.keep()}) => update((row) => [...row.priceId.change(priceId)]).execute(); } final appSchema = List.unmodifiable([ - walletsSchema, - pricesSchema, - receiptsSchema, + walletSchema, + priceSchema, + receiptSchema, ]); extension AppTables on QueryContext { - WalletsTableSet get wallets => WalletsTableSet(this); - PricesTableSet get prices => PricesTableSet(this); - ReceiptsTableSet get receipts => ReceiptsTableSet(this); + WalletTableSet get wallet => WalletTableSet(this); + PriceTableSet get price => PriceTableSet(this); + ReceiptTableSet get receipt => ReceiptTableSet(this); } diff --git a/test/support/relations/schema.dart b/test/support/relations/schema.dart index 6146c5e5..0cb8860e 100644 --- a/test/support/relations/schema.dart +++ b/test/support/relations/schema.dart @@ -1,32 +1,52 @@ import 'package:orm/schema.dart'; -typedef Account = ({ - int tenant, - int id, - String? label, - String? note, - int? managerId, - @ColumnName('_ORM_PRESENT') String? marker, -}); +final Model account = model( + "accounts", + ( + tenant: integer(), + id: integer(), + label: text().nullable(), + note: text().nullable(), + managerId: integer().nullable(), + marker: text(name: "_ORM_PRESENT").nullable(), + ), + primaryKey: (r) => (r.tenant, r.id), + relations: (r) => ( + manager: references( + (tenant: r.tenant, id: r.managerId), + () => account, + onDelete: .restrict, + ), + reports: referencedBy( + () => account, + on: (tenant: r.tenant, managerId: r.id), + ), + events: referencedBy(() => event, on: (tenant: r.tenant, owner: r.id)), + reviews: referencedBy(() => event, on: (tenant: r.tenant, reviewer: r.id)), + ), +); -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'); +final Model event = model( + "events", + ( + id: integer(), + tenant: integer().nullable(), + owner: integer().nullable(), + reviewer: integer().nullable(), + title: text(), + score: integer(), + ), + primaryKey: (r) => r.id, + relations: (r) => ( + author: references( + (tenant: r.tenant, id: r.owner), + () => account, + onDelete: .restrict, + ), + reviewerAccount: references( + (tenant: r.tenant, id: r.reviewer), + () => account, + onDelete: .restrict, + ), + ), +); diff --git a/test/support/relations/schema.orm.dart b/test/support/relations/schema.orm.dart index b32fffa9..eb636751 100644 --- a/test/support/relations/schema.orm.dart +++ b/test/support/relations/schema.orm.dart @@ -2,54 +2,60 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Account, Event; - -final _accountsTenant = Column( +/// A complete immutable row from "accounts". +final class Account({ + required final int tenant, + required final int id, + required final String? label, + required final String? note, + required final int? managerId, + required final String? marker, +}); +final _accountTenant = Column( "tenant", Codecs.integer, nullable: false, generated: false, ); -final _accountsId = Column( +final _accountId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _accountsLabel = Column( +final _accountLabel = Column( "label", Codecs.text.nullable(), nullable: true, generated: false, ); -final _accountsNote = Column( +final _accountNote = Column( "note", Codecs.text.nullable(), nullable: true, generated: false, ); -final _accountsManagerId = Column( +final _accountManagerId = Column( "manager_id", Codecs.integer.nullable(), nullable: true, generated: false, ); -final _accountsMarker = Column( +final _accountMarker = Column( "_ORM_PRESENT", Codecs.text.nullable(), nullable: true, generated: false, ); -final accountsSchema = TableSchema( +final accountSchema = TableSchema( "accounts", columns: [ - _accountsTenant, - _accountsId, - _accountsLabel, - _accountsNote, - _accountsManagerId, - _accountsMarker, + _accountTenant, + _accountId, + _accountLabel, + _accountNote, + _accountManagerId, + _accountMarker, ], primaryKey: ["tenant", "id"], uniqueKeys: [], @@ -64,57 +70,57 @@ final accountsSchema = TableSchema( ], ); -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, +final class AccountFields extends Fields { + AccountFields(super.table); + late final tenant = column(_accountTenant); + late final id = column(_accountId); + late final label = column(_accountLabel); + late final note = column(_accountNote); + late final managerId = column(_accountManagerId); + late final marker = column(_accountMarker); + Relation get manager => Relation( + accountTable, parent: [tenant, managerId], child: (row) => [row.tenant, row.id], ); - Relation get reports => Relation( - accountsTable, + Relation get reports => Relation( + accountTable, parent: [tenant, id], child: (row) => [row.tenant, row.managerId], ); - Relation get events => Relation( - eventsTable, + Relation get events => Relation( + eventTable, parent: [tenant, id], child: (row) => [row.tenant, row.owner], ); - Relation get reviews => Relation( - eventsTable, + Relation get reviews => Relation( + eventTable, parent: [tenant, id], child: (row) => [row.tenant, row.reviewer], ); } -final accountsTable = Table( - accountsSchema, - AccountsFields.new, +final accountTable = Table( + accountSchema, + AccountFields.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, + (v0, v1, v2, v3, v4, v5) => Account( + tenant: v0, + id: v1, + label: v2, + note: v3, + managerId: v4, + marker: v5, ), ), ); -final class AccountsTableSet extends TableSet { - AccountsTableSet(QueryContext db) : super(db, accountsTable) { +final class AccountTableSet extends TableSet { + AccountTableSet(QueryContext db) : super(db, accountTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ required int tenant, required int id, String? label, @@ -131,13 +137,11 @@ final class AccountsTableSet extends TableSet { row.marker.set(marker), ], ); - Query byId({ - required int tenant, - required int id, - }) => where((row) => row.tenant.eq(tenant).and(row.id.eq(id))); + Query byId({required int tenant, required int id}) => + where((row) => row.tenant.eq(tenant).and(row.id.eq(id))); } -extension AccountsUpdates on Query { +extension AccountUpdates on Query { Future patch({ Change tenant = const Change.keep(), Change id = const Change.keep(), @@ -157,51 +161,60 @@ extension AccountsUpdates on Query { ).execute(); } -final _eventsId = Column( +/// A complete immutable row from "events". +final class Event({ + required final int id, + required final int? tenant, + required final int? owner, + required final int? reviewer, + required final String title, + required final int score, +}); +final _eventId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _eventsTenant = Column( +final _eventTenant = Column( "tenant", Codecs.integer.nullable(), nullable: true, generated: false, ); -final _eventsOwner = Column( +final _eventOwner = Column( "owner", Codecs.integer.nullable(), nullable: true, generated: false, ); -final _eventsReviewer = Column( +final _eventReviewer = Column( "reviewer", Codecs.integer.nullable(), nullable: true, generated: false, ); -final _eventsTitle = Column( +final _eventTitle = Column( "title", Codecs.text, nullable: false, generated: false, ); -final _eventsScore = Column( +final _eventScore = Column( "score", Codecs.integer, nullable: false, generated: false, ); -final eventsSchema = TableSchema( +final eventSchema = TableSchema( "events", columns: [ - _eventsId, - _eventsTenant, - _eventsOwner, - _eventsReviewer, - _eventsTitle, - _eventsScore, + _eventId, + _eventTenant, + _eventOwner, + _eventReviewer, + _eventTitle, + _eventScore, ], primaryKey: ["id"], uniqueKeys: [], @@ -222,47 +235,47 @@ final eventsSchema = TableSchema( ], ); -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, +final class EventFields extends Fields { + EventFields(super.table); + late final id = column(_eventId); + late final tenant = column(_eventTenant); + late final owner = column(_eventOwner); + late final reviewer = column(_eventReviewer); + late final title = column(_eventTitle); + late final score = column(_eventScore); + Relation get author => Relation( + accountTable, parent: [tenant, owner], child: (row) => [row.tenant, row.id], ); - Relation get reviewerAccount => Relation( - accountsTable, + Relation get reviewerAccount => Relation( + accountTable, parent: [tenant, reviewer], child: (row) => [row.tenant, row.id], ); } -final eventsTable = Table( - eventsSchema, - EventsFields.new, +final eventTable = Table( + eventSchema, + EventFields.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, + (v0, v1, v2, v3, v4, v5) => Event( + id: v0, + tenant: v1, + owner: v2, + reviewer: v3, + title: v4, + score: v5, ), ), ); -final class EventsTableSet extends TableSet { - EventsTableSet(QueryContext db) : super(db, eventsTable) { +final class EventTableSet extends TableSet { + EventTableSet(QueryContext db) : super(db, eventTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ required int id, int? tenant, int? owner, @@ -279,11 +292,10 @@ final class EventsTableSet extends TableSet { row.score.set(score), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension EventsUpdates on Query { +extension EventUpdates on Query { Future patch({ Change id = const Change.keep(), Change tenant = const Change.keep(), @@ -303,12 +315,9 @@ extension EventsUpdates on Query { ).execute(); } -final appSchema = List.unmodifiable([ - accountsSchema, - eventsSchema, -]); +final appSchema = List.unmodifiable([accountSchema, eventSchema]); extension AppTables on QueryContext { - AccountsTableSet get accounts => AccountsTableSet(this); - EventsTableSet get events => EventsTableSet(this); + AccountTableSet get account => AccountTableSet(this); + EventTableSet get event => EventTableSet(this); } diff --git a/test/support/temporal_precision/schema.dart b/test/support/temporal_precision/schema.dart index 53db1138..98c04f18 100644 --- a/test/support/temporal_precision/schema.dart +++ b/test/support/temporal_precision/schema.dart @@ -1,22 +1,31 @@ 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'); +final Model moment = model("moments", ( + id: integer().identity(), + clock: time(precision: 3), + local: localDateTime(precision: 3), + instant: dateTime(precision: 0), + optional: time(precision: 2).nullable(), + defaulted: time(defaultSql: "'23:59:59.9995'", precision: 3), + rounded: time(precision: 0).computed( + "\"clock\"", + postgres: "\"clock\"", + mysql: "\"clock\"", + mariadb: "\"clock\"", + storage: .stored, + ), +)); + +final Model slot = model( + "slots", + (time: time(precision: 3), label: text()), + primaryKey: (r) => r.time, + relations: (r) => (bookings: referencedBy(() => booking, on: (time: r.time))), +); + +final Model booking = model( + "bookings", + (id: integer().identity(), time: time(precision: 3)), + relations: (r) => + (slot: references((time: r.time), () => slot, onDelete: .restrict)), +); diff --git a/test/support/temporal_precision/schema.orm.dart b/test/support/temporal_precision/schema.orm.dart index ba034f57..c83312b6 100644 --- a/test/support/temporal_precision/schema.orm.dart +++ b/test/support/temporal_precision/schema.orm.dart @@ -2,44 +2,51 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Moment, Slot, Booking; - -final _momentsId = Column( +/// A complete immutable row from "moments". +final class Moment({ + required final int id, + required final LocalTime clock, + required final LocalDateTime local, + required final DateTime instant, + required final LocalTime? optional, + required final LocalTime defaulted, + required final LocalTime rounded, +}); +final _momentId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _momentsClock = Column( +final _momentClock = Column( "clock", Codecs.time, nullable: false, generated: false, temporalPrecision: 3, ); -final _momentsLocal = Column( +final _momentLocal = Column( "local", Codecs.localDateTime, nullable: false, generated: false, temporalPrecision: 3, ); -final _momentsInstant = Column( +final _momentInstant = Column( "instant", Codecs.dateTime, nullable: false, generated: false, temporalPrecision: 0, ); -final _momentsOptional = Column( +final _momentOptional = Column( "optional", Codecs.time.nullable(), nullable: true, generated: false, temporalPrecision: 2, ); -final _momentsDefaulted = Column( +final _momentDefaulted = Column( "defaulted", Codecs.time, nullable: false, @@ -47,7 +54,7 @@ final _momentsDefaulted = Column( defaultSql: "'23:59:59.9995'", temporalPrecision: 3, ); -final _momentsRounded = Column( +final _momentRounded = Column( "rounded", Codecs.time, nullable: false, @@ -61,16 +68,16 @@ final _momentsRounded = Column( ), temporalPrecision: 0, ); -final momentsSchema = TableSchema( +final momentSchema = TableSchema( "moments", columns: [ - _momentsId, - _momentsClock, - _momentsLocal, - _momentsInstant, - _momentsOptional, - _momentsDefaulted, - _momentsRounded, + _momentId, + _momentClock, + _momentLocal, + _momentInstant, + _momentOptional, + _momentDefaulted, + _momentRounded, ], primaryKey: ["id"], uniqueKeys: [], @@ -78,20 +85,20 @@ final momentsSchema = TableSchema( 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 class MomentFields extends Fields { + MomentFields(super.table); + late final id = column(_momentId); + late final clock = column(_momentClock); + late final local = column(_momentLocal); + late final instant = column(_momentInstant); + late final optional = column(_momentOptional); + late final defaulted = column(_momentDefaulted); + late final rounded = readColumn(_momentRounded); } -final momentsTable = Table( - momentsSchema, - MomentsFields.new, +final momentTable = Table( + momentSchema, + MomentFields.new, (row) => ( (row.id, row.clock, row.local, row.instant, row.optional).map( @@ -108,7 +115,7 @@ final momentsTable = Table( row.rounded, ).map((defaulted, rounded) => (defaulted: defaulted, rounded: rounded)), ).map( - (left, right) => ( + (left, right) => Moment( id: left.id, clock: left.clock, local: left.local, @@ -120,11 +127,11 @@ final momentsTable = Table( ), ); -final class MomentsTableSet extends TableSet { - MomentsTableSet(QueryContext db) : super(db, momentsTable) { +final class MomentTableSet extends TableSet { + MomentTableSet(QueryContext db) : super(db, momentTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required LocalTime clock, required LocalDateTime local, @@ -141,11 +148,10 @@ final class MomentsTableSet extends TableSet { ...row.defaulted.change(defaulted), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension MomentsUpdates on Query { +extension MomentUpdates on Query { Future patch({ Change clock = const Change.keep(), Change local = const Change.keep(), @@ -163,56 +169,55 @@ extension MomentsUpdates on Query { ).execute(); } -final _slotsTime = Column( +/// A complete immutable row from "slots". +final class Slot({required final LocalTime time, required final String label}); +final _slotTime = Column( "time", Codecs.time, nullable: false, generated: false, temporalPrecision: 3, ); -final _slotsLabel = Column( +final _slotLabel = Column( "label", Codecs.text, nullable: false, generated: false, ); -final slotsSchema = TableSchema( +final slotSchema = TableSchema( "slots", - columns: [_slotsTime, _slotsLabel], + columns: [_slotTime, _slotLabel], 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 class SlotFields extends Fields { + SlotFields(super.table); + late final time = column(_slotTime); + late final label = column(_slotLabel); + Relation get bookings => + Relation(bookingTable, 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 slotTable = Table( + slotSchema, + SlotFields.new, + (row) => (row.time, row.label).map((v0, v1) => Slot(time: v0, label: v1)), ); -final class SlotsTableSet extends TableSet { - SlotsTableSet(QueryContext db) : super(db, slotsTable) { +final class SlotTableSet extends TableSet { + SlotTableSet(QueryContext db) : super(db, slotTable) { 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) => + 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 { +extension SlotUpdates on Query { Future patch({ Change time = const Change.keep(), Change label = const Change.keep(), @@ -221,22 +226,24 @@ extension SlotsUpdates on Query { .execute(); } -final _bookingsId = Column( +/// A complete immutable row from "bookings". +final class Booking({required final int id, required final LocalTime time}); +final _bookingId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _bookingsTime = Column( +final _bookingTime = Column( "time", Codecs.time, nullable: false, generated: false, temporalPrecision: 3, ); -final bookingsSchema = TableSchema( +final bookingSchema = TableSchema( "bookings", - columns: [_bookingsId, _bookingsTime], + columns: [_bookingId, _bookingTime], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -245,45 +252,44 @@ final bookingsSchema = TableSchema( ], ); -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 class BookingFields extends Fields { + BookingFields(super.table); + late final id = column(_bookingId); + late final time = column(_bookingTime); + Relation get slot => + Relation(slotTable, 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 bookingTable = Table( + bookingSchema, + BookingFields.new, + (row) => (row.id, row.time).map((v0, v1) => Booking(id: v0, time: v1)), ); -final class BookingsTableSet extends TableSet { - BookingsTableSet(QueryContext db) : super(db, bookingsTable) { +final class BookingTableSet extends TableSet { + BookingTableSet(QueryContext db) : super(db, bookingTable) { db.registerSchema(appSchema); } - Future create({ + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension BookingsUpdates on Query { +extension BookingUpdates on Query { Future patch({Change time = const Change.keep()}) => update((row) => [...row.time.change(time)]).execute(); } final appSchema = List.unmodifiable([ - momentsSchema, - slotsSchema, - bookingsSchema, + momentSchema, + slotSchema, + bookingSchema, ]); extension AppTables on QueryContext { - MomentsTableSet get moments => MomentsTableSet(this); - SlotsTableSet get slots => SlotsTableSet(this); - BookingsTableSet get bookings => BookingsTableSet(this); + MomentTableSet get moment => MomentTableSet(this); + SlotTableSet get slot => SlotTableSet(this); + BookingTableSet get booking => BookingTableSet(this); } diff --git a/test/support/temporals/native.dart b/test/support/temporals/native.dart index 155d9f3e..b2ed90e4 100644 --- a/test/support/temporals/native.dart +++ b/test/support/temporals/native.dart @@ -11,7 +11,7 @@ Future main() async { ).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( + final row = await db.appointment.create( day: last, time: Change.set(LocalTime(24)), starts: stamp, @@ -19,19 +19,19 @@ Future main() async { 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 + await db.appointment.create(day: LocalDate.fromJulianDay(0)); + final sorted = await db.appointment .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.holiday.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 + final children = await db.holiday .select((h) => h.visits.select((v) => v.day).many()) .single(); if (children.single != LocalDate(0, 1, 1)) { diff --git a/test/support/temporals/schema.dart b/test/support/temporals/schema.dart index 68bb8063..333855a6 100644 --- a/test/support/temporals/schema.dart +++ b/test/support/temporals/schema.dart @@ -1,16 +1,22 @@ 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'); +final Model appointment = model("appointments", ( + id: integer().identity(), + day: date(), + time: time(defaultSql: "'12:30'"), + starts: localDateTime().nullable(), +)); + +final Model holiday = model( + "holidays", + (day: date(), label: text()), + primaryKey: (r) => r.day, + relations: (r) => (visits: referencedBy(() => visit, on: (day: r.day))), +); + +final Model visit = model( + "visits", + (id: integer().identity(), day: date()), + relations: (r) => + (holiday: references((day: r.day), () => holiday, onDelete: .restrict)), +); diff --git a/test/support/temporals/schema.orm.dart b/test/support/temporals/schema.orm.dart index a18bd482..11197a1d 100644 --- a/test/support/temporals/schema.orm.dart +++ b/test/support/temporals/schema.orm.dart @@ -2,41 +2,45 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Appointment, Holiday, Visit; - -final _appointmentsId = Column( +/// A complete immutable row from "appointments". +final class Appointment({ + required final int id, + required final LocalDate day, + required final LocalTime time, + required final LocalDateTime? starts, +}); +final _appointmentId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _appointmentsDay = Column( +final _appointmentDay = Column( "day", Codecs.date, nullable: false, generated: false, ); -final _appointmentsTime = Column( +final _appointmentTime = Column( "time", Codecs.time, nullable: false, generated: false, defaultSql: "'12:30'", ); -final _appointmentsStarts = Column( +final _appointmentStarts = Column( "starts", Codecs.localDateTime.nullable(), nullable: true, generated: false, ); -final appointmentsSchema = TableSchema( +final appointmentSchema = TableSchema( "appointments", columns: [ - _appointmentsId, - _appointmentsDay, - _appointmentsTime, - _appointmentsStarts, + _appointmentId, + _appointmentDay, + _appointmentTime, + _appointmentStarts, ], primaryKey: ["id"], uniqueKeys: [], @@ -44,28 +48,31 @@ final appointmentsSchema = TableSchema( 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 class AppointmentFields extends Fields { + AppointmentFields(super.table); + late final id = column(_appointmentId); + late final day = column(_appointmentDay); + late final time = column(_appointmentTime); + late final starts = column(_appointmentStarts); } -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 appointmentTable = Table( + appointmentSchema, + AppointmentFields.new, + (row) => ( + row.id, + row.day, + row.time, + row.starts, + ).map((v0, v1, v2, v3) => Appointment(id: v0, day: v1, time: v2, starts: v3)), ); -final class AppointmentsTableSet - extends TableSet { - AppointmentsTableSet(QueryContext db) : super(db, appointmentsTable) { +final class AppointmentTableSet + extends TableSet { + AppointmentTableSet(QueryContext db) : super(db, appointmentTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ Change id = const Change.keep(), required LocalDate day, Change time = const Change.keep(), @@ -78,11 +85,11 @@ final class AppointmentsTableSet row.starts.set(starts), ], ); - Query byId(int id) => + Query byId(int id) => where((row) => row.id.eq(id)); } -extension AppointmentsUpdates on Query { +extension AppointmentUpdates on Query { Future patch({ Change day = const Change.keep(), Change time = const Change.keep(), @@ -96,54 +103,57 @@ extension AppointmentsUpdates on Query { ).execute(); } -final _holidaysDay = Column( +/// A complete immutable row from "holidays". +final class Holiday({ + required final LocalDate day, + required final String label, +}); +final _holidayDay = Column( "day", Codecs.date, nullable: false, generated: false, ); -final _holidaysLabel = Column( +final _holidayLabel = Column( "label", Codecs.text, nullable: false, generated: false, ); -final holidaysSchema = TableSchema( +final holidaySchema = TableSchema( "holidays", - columns: [_holidaysDay, _holidaysLabel], + columns: [_holidayDay, _holidayLabel], 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 class HolidayFields extends Fields { + HolidayFields(super.table); + late final day = column(_holidayDay); + late final label = column(_holidayLabel); + Relation get visits => + Relation(visitTable, 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 holidayTable = Table( + holidaySchema, + HolidayFields.new, + (row) => (row.day, row.label).map((v0, v1) => Holiday(day: v0, label: v1)), ); -final class HolidaysTableSet extends TableSet { - HolidaysTableSet(QueryContext db) : super(db, holidaysTable) { +final class HolidayTableSet extends TableSet { + HolidayTableSet(QueryContext db) : super(db, holidayTable) { 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) => + 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 { +extension HolidayUpdates on Query { Future patch({ Change day = const Change.keep(), Change label = const Change.keep(), @@ -152,21 +162,23 @@ extension HolidaysUpdates on Query { .execute(); } -final _visitsId = Column( +/// A complete immutable row from "visits". +final class Visit({required final int id, required final LocalDate day}); +final _visitId = Column( "id", Codecs.integer, nullable: false, generated: true, ); -final _visitsDay = Column( +final _visitDay = Column( "day", Codecs.date, nullable: false, generated: false, ); -final visitsSchema = TableSchema( +final visitSchema = TableSchema( "visits", - columns: [_visitsId, _visitsDay], + columns: [_visitId, _visitDay], primaryKey: ["id"], uniqueKeys: [], indexes: [], @@ -175,45 +187,44 @@ final visitsSchema = TableSchema( ], ); -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 class VisitFields extends Fields { + VisitFields(super.table); + late final id = column(_visitId); + late final day = column(_visitDay); + Relation get holiday => + Relation(holidayTable, 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 visitTable = Table( + visitSchema, + VisitFields.new, + (row) => (row.id, row.day).map((v0, v1) => Visit(id: v0, day: v1)), ); -final class VisitsTableSet extends TableSet { - VisitsTableSet(QueryContext db) : super(db, visitsTable) { +final class VisitTableSet extends TableSet { + VisitTableSet(QueryContext db) : super(db, visitTable) { db.registerSchema(appSchema); } - Future create({ + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension VisitsUpdates on Query { +extension VisitUpdates on Query { Future patch({Change day = const Change.keep()}) => update((row) => [...row.day.change(day)]).execute(); } final appSchema = List.unmodifiable([ - appointmentsSchema, - holidaysSchema, - visitsSchema, + appointmentSchema, + holidaySchema, + visitSchema, ]); extension AppTables on QueryContext { - AppointmentsTableSet get appointments => AppointmentsTableSet(this); - HolidaysTableSet get holidays => HolidaysTableSet(this); - VisitsTableSet get visits => VisitsTableSet(this); + AppointmentTableSet get appointment => AppointmentTableSet(this); + HolidayTableSet get holiday => HolidayTableSet(this); + VisitTableSet get visit => VisitTableSet(this); } diff --git a/test/support/unconstrained/schema.dart b/test/support/unconstrained/schema.dart index ed19c270..7253a235 100644 --- a/test/support/unconstrained/schema.dart +++ b/test/support/unconstrained/schema.dart @@ -1,32 +1,56 @@ 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 Model account = model( + "accounts", + ( + tenant: integer(), + id: integer(), + label: text(name: "display_label").nullable(), + managerId: integer().nullable(), + ), + primaryKey: (r) => (r.tenant, r.id), + relations: (r) => ( + entries: referencedBy(() => entry, on: (tenant: r.tenant, owner: r.id)), + matches: referencedBy(() => entry, on: (tenant: r.tenant, label: r.label)), + manager: references( + (tenant: r.tenant, id: r.managerId), + () => account, + constraint: false, + ), + reports: referencedBy( + () => account, + on: (tenant: r.tenant, managerId: r.id), + ), + ), +); -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)); +final Model entry = model( + "entries", + ( + id: integer(), + tenant: integer().nullable(), + owner: integer().nullable(), + label: text(name: "lookup_label").nullable(), + ), + primaryKey: (r) => r.id, + relations: (r) => ( + ownerAccount: references( + (tenant: r.tenant, id: r.owner), + () => account, + constraint: false, + ), + matchingAccounts: references( + (tenant: r.tenant, label: r.label), + () => account, + constraint: false, + ), + ), +); + +final Model reading = model( + "readings", + (id: integer(), value: real()), + primaryKey: (r) => r.id, + relations: (r) => + (peers: references((value: r.value), () => reading, constraint: false)), +); diff --git a/test/support/unconstrained/schema.orm.dart b/test/support/unconstrained/schema.orm.dart index 4510b545..28a1bd87 100644 --- a/test/support/unconstrained/schema.orm.dart +++ b/test/support/unconstrained/schema.orm.dart @@ -2,92 +2,95 @@ import 'package:orm/sql.dart'; -import "schema.dart" as models; -export "schema.dart" show Account, Entry, Reading; - -final _accountsTenant = Column( +/// A complete immutable row from "accounts". +final class Account({ + required final int tenant, + required final int id, + required final String? label, + required final int? managerId, +}); +final _accountTenant = Column( "tenant", Codecs.integer, nullable: false, generated: false, ); -final _accountsId = Column( +final _accountId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _accountsLabel = Column( +final _accountLabel = Column( "display_label", Codecs.text.nullable(), nullable: true, generated: false, ); -final _accountsManagerId = Column( +final _accountManagerId = Column( "manager_id", Codecs.integer.nullable(), nullable: true, generated: false, ); -final accountsSchema = TableSchema( +final accountSchema = TableSchema( "accounts", - columns: [_accountsTenant, _accountsId, _accountsLabel, _accountsManagerId], + columns: [_accountTenant, _accountId, _accountLabel, _accountManagerId], 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); +final class AccountFields extends Fields { + AccountFields(super.table); + late final tenant = column(_accountTenant); + late final id = column(_accountId); + late final label = column(_accountLabel); + late final managerId = column(_accountManagerId); /// Read-only navigation; no database foreign key or write effects. - Relation get entries => Relation( - entriesTable, + Relation get entries => Relation( + entryTable, parent: [tenant, id], child: (row) => [row.tenant, row.owner], ); /// Read-only navigation; no database foreign key or write effects. - Relation get matches => Relation( - entriesTable, + Relation get matches => Relation( + entryTable, parent: [tenant, label], child: (row) => [row.tenant, row.label], ); /// Read-only navigation; no database foreign key or write effects. - Relation get manager => Relation( - accountsTable, + Relation get manager => Relation( + accountTable, parent: [tenant, managerId], child: (row) => [row.tenant, row.id], ); /// Read-only navigation; no database foreign key or write effects. - Relation get reports => Relation( - accountsTable, + Relation get reports => Relation( + accountTable, parent: [tenant, id], child: (row) => [row.tenant, row.managerId], ); } -final accountsTable = Table( - accountsSchema, - AccountsFields.new, +final accountTable = Table( + accountSchema, + AccountFields.new, (row) => (row.tenant, row.id, row.label, row.managerId).map( - (tenant, id, label, managerId) => - (tenant: tenant, id: id, label: label, managerId: managerId), + (v0, v1, v2, v3) => Account(tenant: v0, id: v1, label: v2, managerId: v3), ), ); -final class AccountsTableSet extends TableSet { - AccountsTableSet(QueryContext db) : super(db, accountsTable) { +final class AccountTableSet extends TableSet { + AccountTableSet(QueryContext db) : super(db, accountTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ required int tenant, required int id, String? label, @@ -100,13 +103,11 @@ final class AccountsTableSet extends TableSet { row.managerId.set(managerId), ], ); - Query byId({ - required int tenant, - required int id, - }) => where((row) => row.tenant.eq(tenant).and(row.id.eq(id))); + Query byId({required int tenant, required int id}) => + where((row) => row.tenant.eq(tenant).and(row.id.eq(id))); } -extension AccountsUpdates on Query { +extension AccountUpdates on Query { Future patch({ Change tenant = const Change.keep(), Change id = const Change.keep(), @@ -122,75 +123,84 @@ extension AccountsUpdates on Query { ).execute(); } -final _entriesId = Column( +/// A complete immutable row from "entries". +final class Entry({ + required final int id, + required final int? tenant, + required final int? owner, + required final String? label, +}); +final _entryId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _entriesTenant = Column( +final _entryTenant = Column( "tenant", Codecs.integer.nullable(), nullable: true, generated: false, ); -final _entriesOwner = Column( +final _entryOwner = Column( "owner", Codecs.integer.nullable(), nullable: true, generated: false, ); -final _entriesLabel = Column( +final _entryLabel = Column( "lookup_label", Codecs.text.nullable(), nullable: true, generated: false, ); -final entriesSchema = TableSchema( +final entrySchema = TableSchema( "entries", - columns: [_entriesId, _entriesTenant, _entriesOwner, _entriesLabel], + columns: [_entryId, _entryTenant, _entryOwner, _entryLabel], 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); +final class EntryFields extends Fields { + EntryFields(super.table); + late final id = column(_entryId); + late final tenant = column(_entryTenant); + late final owner = column(_entryOwner); + late final label = column(_entryLabel); /// Read-only navigation; no database foreign key or write effects. - Relation get ownerAccount => Relation( - accountsTable, + Relation get ownerAccount => Relation( + accountTable, parent: [tenant, owner], child: (row) => [row.tenant, row.id], ); /// Read-only navigation; no database foreign key or write effects. - Relation get matchingAccounts => Relation( - accountsTable, + Relation get matchingAccounts => Relation( + accountTable, 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 entryTable = Table( + entrySchema, + EntryFields.new, + (row) => ( + row.id, + row.tenant, + row.owner, + row.label, + ).map((v0, v1, v2, v3) => Entry(id: v0, tenant: v1, owner: v2, label: v3)), ); -final class EntriesTableSet extends TableSet { - EntriesTableSet(QueryContext db) : super(db, entriesTable) { +final class EntryTableSet extends TableSet { + EntryTableSet(QueryContext db) : super(db, entryTable) { db.registerSchema(appSchema); } - Future create({ + Future create({ required int id, int? tenant, int? owner, @@ -203,11 +213,10 @@ final class EntriesTableSet extends TableSet { row.label.set(label), ], ); - Query byId(int id) => - where((row) => row.id.eq(id)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension EntriesUpdates on Query { +extension EntryUpdates on Query { Future patch({ Change id = const Change.keep(), Change tenant = const Change.keep(), @@ -223,54 +232,55 @@ extension EntriesUpdates on Query { ).execute(); } -final _readingsId = Column( +/// A complete immutable row from "readings". +final class Reading({required final int id, required final double value}); +final _readingId = Column( "id", Codecs.integer, nullable: false, generated: false, ); -final _readingsValue = Column( +final _readingValue = Column( "value", Codecs.real, nullable: false, generated: false, ); -final readingsSchema = TableSchema( +final readingSchema = TableSchema( "readings", - columns: [_readingsId, _readingsValue], + columns: [_readingId, _readingValue], primaryKey: ["id"], uniqueKeys: [], indexes: [], foreignKeys: [], ); -final class ReadingsFields extends Fields { - ReadingsFields(super.table); - late final id = column(_readingsId); - late final value = column(_readingsValue); +final class ReadingFields extends Fields { + ReadingFields(super.table); + late final id = column(_readingId); + late final value = column(_readingValue); /// Read-only navigation; no database foreign key or write effects. - Relation get peers => - Relation(readingsTable, parent: [value], child: (row) => [row.value]); + Relation get peers => + Relation(readingTable, 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 readingTable = Table( + readingSchema, + ReadingFields.new, + (row) => (row.id, row.value).map((v0, v1) => Reading(id: v0, value: v1)), ); -final class ReadingsTableSet extends TableSet { - ReadingsTableSet(QueryContext db) : super(db, readingsTable) { +final class ReadingTableSet extends TableSet { + ReadingTableSet(QueryContext db) : super(db, readingTable) { db.registerSchema(appSchema); } - Future create({required int id, required double value}) => + 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)); + Query byId(int id) => where((row) => row.id.eq(id)); } -extension ReadingsUpdates on Query { +extension ReadingUpdates on Query { Future patch({ Change id = const Change.keep(), Change value = const Change.keep(), @@ -280,13 +290,13 @@ extension ReadingsUpdates on Query { } final appSchema = List.unmodifiable([ - accountsSchema, - entriesSchema, - readingsSchema, + accountSchema, + entrySchema, + readingSchema, ]); extension AppTables on QueryContext { - AccountsTableSet get accounts => AccountsTableSet(this); - EntriesTableSet get entries => EntriesTableSet(this); - ReadingsTableSet get readings => ReadingsTableSet(this); + AccountTableSet get account => AccountTableSet(this); + EntryTableSet get entry => EntryTableSet(this); + ReadingTableSet get reading => ReadingTableSet(this); } diff --git a/test/temporal_precision_test.dart b/test/temporal_precision_test.dart index 30ccad1e..03c4e2d7 100644 --- a/test/temporal_precision_test.dart +++ b/test/temporal_precision_test.dart @@ -7,7 +7,6 @@ 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); @@ -46,7 +45,7 @@ void main() { Future create() => Migrator(db.sql).apply([ Migration.create('0001_precision', appSchema, dialect: db.dialect), ]); - Future sample() => db.moments.create( + Future sample() => db.moment.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'), @@ -61,16 +60,16 @@ void main() { expect(a.optional, isNull); expect(a.defaulted, t('24:00')); expect(a.rounded, t('12:00')); - await db.moments + await db.moment .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(); + final updated = await db.moment.byId(a.id).single(); expect(updated.clock, t('24:00')); expect(updated.optional, t('01:02:03.13')); - await db.moments + await db.moment .byId(a.id) .update( (m) => [ @@ -78,7 +77,7 @@ void main() { ], ) .execute(); - expect((await db.moments.byId(a.id).single()).clock, t('01:00:00.778')); + expect((await db.moment.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); @@ -109,7 +108,7 @@ void main() { .add(Duration(microseconds: expectedTicks)); final expectedBefore = dt('2000-01-01 00:00') .add(Duration(microseconds: -expectedTicks)); - final result = await db.moments + final result = await db.moment .select( (_) => ( value(time, Codecs.time).withPrecision(p), @@ -134,7 +133,7 @@ void main() { expect(instant('${before}Z').withPrecision(p), result.$4); } } - final end = await db.moments + final end = await db.moment .select( (_) => value(t('23:59:59.999999'), Codecs.time).withPrecision(0), @@ -148,10 +147,10 @@ void main() { await create(); await sample(); expect( - await db.moments.select((m) => m.optional.withPrecision(0)).single(), + await db.moment.select((m) => m.optional.withPrecision(0)).single(), isNull, ); - final cte = db.moments + final cte = db.moment .select((m) => m.clock.withPrecision(0)) .asCte('whole_seconds'); expect( @@ -161,14 +160,14 @@ void main() { t('12:00'), ); expect( - await db.moments + await db.moment .select((m) => m.clock.withPrecision(0)) .stream(batchSize: 1) .single, t('12:00'), ); expect( - await db.moments + await db.moment .groupBy((m) => [m.clock.withPrecision(0)]) .select( (m) => ( @@ -180,14 +179,14 @@ void main() { (t('12:00'), 1), ); expect( - () => db.moments + () => db.moment .groupBy((m) => [m.clock]) .select((m) => m.clock.withPrecision(0)) .compile(), returnsNormally, ); expect( - () => db.moments + () => db.moment .groupBy((m) => [m.clock.withPrecision(1)]) .select((m) => m.clock.withPrecision(0)) .compile(), @@ -197,25 +196,25 @@ void main() { test('rounded keys work in batch writes, upserts and foreign-key relation loading', () async { await create(); - await db.slots.insertMany([ + await db.slot.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 + final booking = await db.booking.create(time: t('01:00:00.1239')); + final related = await db.booking .byId(booking.id) .select((b) => b.slot.select((s) => s.label).one()) .single(); expect(related, '01:00:00.1235'); await expectLater( - db.slots.insertMany([ + db.slot.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 + expect(await db.slot.count(), 2); + await db.slot .insert( (s) => [s.time.set(t('01:00:00.1238')), s.label.set('updated')], ) @@ -227,10 +226,7 @@ void main() { ) .execute(); expect( - await db.slots - .byId(t('01:00:00.124')) - .select((s) => s.label) - .single(), + await db.slot.byId(t('01:00:00.124')).select((s) => s.label).single(), 'updated', ); }); @@ -258,7 +254,7 @@ void main() { tables: ['moments', 'slots', 'bookings'], ); expect(draft.issues.where((i) => i.blocking), isEmpty); - expect(draft.dart, contains('@TemporalPrecision(3)')); + expect(draft.dart, contains('precision: 3')); final dir = await Directory( '.dart_tool/temporal-precision-import-$backend', ).create(recursive: true); @@ -421,7 +417,7 @@ void main() { ); final draft = await importSchema(db.sql, tables: ['explicit_six']); expect(draft.issues.where((i) => i.blocking), isEmpty); - expect(draft.dart, isNot(contains('@TemporalPrecision'))); + expect(draft.dart, isNot(contains('precision:'))); } }, ); @@ -437,7 +433,7 @@ void main() { ]) { final original = dt(text); expect( - await db.moments + await db.moment .select( (_) => value(original, Codecs.localDateTime).withPrecision(3), @@ -448,7 +444,7 @@ void main() { } final upper = instant('275760-09-12 23:59:59.999999+00'); expect( - await db.moments + await db.moment .select((_) => value(upper, Codecs.dateTime).withPrecision(0)) .single(), DateTime.utc(275760, 9, 13), @@ -475,7 +471,7 @@ void main() { await db.execute( SqlCommand("INSERT INTO slots VALUES ('01:00:00.1234','raw')"), ); - expect((await db.slots.single()).time, t('01:00:00.123')); + expect((await db.slot.single()).time, t('01:00:00.123')); } }, ); @@ -546,7 +542,7 @@ void main() { } }); - test('generator rejects invalid annotations and preserves generated fixture metadata', () async { + test('generator rejects invalid precision declarations and preserves generated fixture metadata', () async { final generated = await generateSchema( 'test/support/temporal_precision/schema.dart', ); @@ -565,14 +561,14 @@ void main() { 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', + 't: time(precision: -1)', + 't: localDateTime(precision: 7)', + 't: custom(Codecs.date, precision: 3)', + 't: custom(Codecs.text, precision: 3)', + 't: dateTime(precision: 3, precision: 2)', ]) { await file.writeAsString( - "import 'package:orm/schema.dart'; typedef Row=({$field}); final rows=entity();", + "import 'package:orm/schema.dart'; final row=model('rows', ($field,));", ); await expectLater( generateSchema(file.path), diff --git a/test/temporal_test.dart b/test/temporal_test.dart index e723cb04..3a7e6c37 100644 --- a/test/temporal_test.dart +++ b/test/temporal_test.dart @@ -48,7 +48,7 @@ void main() { try { if (!enabled) { expect( - () => db.appointments.select((a) => a.day).compile(), + () => db.appointment.select((a) => a.day).compile(), throwsA( isA().having( (e) => e.code, @@ -69,7 +69,7 @@ void main() { "INSERT INTO appointments VALUES (1, '5874897-12-31', '24:00', '294276-12-31 23:59:59.999999')", ), ); - final row = await session.appointments.single(); + final row = await session.appointment.single(); expect(row.day, LocalDate(5874897, 12, 31)); expect(row.time, LocalTime(24)); expect( @@ -253,18 +253,16 @@ void main() { () 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 row = await db.appointment.create(day: day); + expect( + (id: row.id, day: row.day, time: row.time, starts: row.starts), + (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); + await db.appointment.byId(row.id).patch(starts: Change.set(stamp)); + expect((await db.appointment.byId(row.id).single()).starts, stamp); expect( - await db.appointments + await db.appointment .select( (a) => ( value(day, Codecs.date), @@ -275,10 +273,10 @@ void main() { .single(), (day, LocalTime(24), stamp), ); - await db.appointments + await db.appointment .byId(row.id) .patch(starts: const Change.set(null)); - expect((await db.appointments.single()).starts, null); + expect((await db.appointment.single()).starts, null); }, ); @@ -301,7 +299,7 @@ void main() { for (var i = 0; i < dates.length; i++) { final time = i == 3 ? LocalTime(24) : LocalTime(0, 0, 0, i); expect( - (await db.appointments.create( + (await db.appointment.create( day: dates[i], time: Change.set(time), starts: stamps[i], @@ -309,7 +307,7 @@ void main() { stamps[i], ); } - final query = db.appointments.orderBy((a) => [a.id.asc()]); + final query = db.appointment.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), @@ -320,7 +318,7 @@ void main() { 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( + (await s.appointment.orderBy((a) => [a.id.asc()]).get()).map( (a) => a.starts, ), stamps, @@ -341,39 +339,39 @@ void main() { LocalDate(0, 1, 1), ]; for (final day in dates) { - await db.appointments.create(day: day); + await db.appointment.create(day: day); } final ordered = [...dates]..sort(); expect( - await db.appointments + await db.appointment .orderBy((a) => [a.day.asc()]) .select((a) => a.day) .get(), ordered, ); expect( - await db.appointments + await db.appointment .select((a) => (a.day.min(), a.day.max()).row) .single(), (ordered.first, ordered.last), ); expect( - await db.appointments + await db.appointment .where((a) => a.day.isIn(dates.take(2))) .count(), 2, ); - final token = db.appointments.cursorToken( + final token = db.appointment.cursorToken( (a) => [a.day.cursor(ordered[1]), a.id.cursor(4)], ); expect( - (await db.appointments + (await db.appointment .seekToken(token, orderBy: (a) => [a.day.asc(), a.id.asc()]) .get()) .map((a) => a.day), ordered.skip(2), ); - final rolling = await db.appointments + final rolling = await db.appointment .orderBy((a) => [a.id.asc()]) .select( (a) => a.day.min().over( @@ -391,24 +389,24 @@ void main() { () async { await create(); final bc = LocalDate(0, 1, 1); - await db.holidays.create(day: bc, label: 'era'); + await db.holiday.create(day: bc, label: 'era'); await db.execute( SqlCommand("INSERT INTO visits (day) VALUES ('0001-01-01 BC')"), ); expect( - await db.holidays + await db.holiday .select((h) => h.visits.select((v) => v.day).many()) .single(), [bc], ); expect( - await db.visits + await db.visit .select((v) => v.holiday.select((h) => h.label).one()) .single(), 'era', ); await expectLater( - db.holidays.create(day: bc, label: 'duplicate'), + db.holiday.create(day: bc, label: 'duplicate'), throwsA(isA()), ); if (backend == 'sqlite') { @@ -416,12 +414,12 @@ void main() { SqlCommand("UPDATE visits SET day = '0000-01-01'"), ); expect( - await db.holidays + await db.holiday .select((h) => h.visits.select((v) => v.day).many()) .single(), [bc], ); - final command = db.holidays.byId(bc).compile(); + final command = db.holiday.byId(bc).compile(); final plan = await db.execute( SqlCommand( 'EXPLAIN QUERY PLAN ${command.sql}', @@ -453,32 +451,32 @@ void main() { LocalDateTime.parse('2024-01-01 00:00'), ]; for (var i = 0; i < times.length; i++) { - await db.appointments.create( + await db.appointment.create( day: LocalDate(2024, 1, 1), time: Change.set(times[i]), starts: stamps[i], ); } expect( - await db.appointments + await db.appointment .orderBy((a) => [a.time.asc()]) .select((a) => a.time) .get(), [...times]..sort(), ); expect( - await db.appointments + await db.appointment .orderBy((a) => [a.starts.asc()]) .select((a) => a.starts) .get(), [...stamps]..sort(), ); expect( - await db.appointments.where((a) => a.time.gt(LocalTime(0))).count(), + await db.appointment.where((a) => a.time.gt(LocalTime(0))).count(), 3, ); expect( - await db.appointments + await db.appointment .select( (a) => ( a.time.min(), @@ -490,12 +488,12 @@ void main() { .single(), (times[2], times[0], stamps[2], stamps[0]), ); - final grouped = await db.appointments + final grouped = await db.appointment .groupBy((a) => [a.day]) .select((a) => (a.day, a.time.count()).row) .single(); expect(grouped, (LocalDate(2024, 1, 1), 4)); - final cte = db.appointments + final cte = db.appointment .select((a) => a.starts) .asCte('local_starts'); expect( @@ -504,7 +502,7 @@ void main() { .get(), [stamps[0]], ); - final query = db.appointments.select((a) => a.time); + final query = db.appointment.select((a) => a.time); expect(await query.union(query).get(), unorderedEquals(times)); }, ); @@ -577,16 +575,16 @@ void main() { ), ); await expectLater( - db.appointments.select((a) => a.day).get(), + db.appointment.select((a) => a.day).get(), throwsFormatException, ); await expectLater( - db.appointments.select((a) => a.starts).get(), + db.appointment.select((a) => a.starts).get(), throwsFormatException, ); - await db.appointments.create(day: LocalDate(2024, 1, 1)); + await db.appointment.create(day: LocalDate(2024, 1, 1)); expect( - await db.appointments + await db.appointment .where((a) => a.day.eq(LocalDate(2024, 1, 1))) .count(), 1, @@ -604,7 +602,7 @@ void main() { expect(await verifyColumns(db.sql, appSchema), isEmpty); final imported = await importSchema(db.sql); expect(imported.issues, isEmpty); - expect(imported.dart, contains('LocalDateTime')); + expect(imported.dart, contains('localDateTime(')); final directory = await Directory( '.dart_tool/orm-temporal-import-$backend', ).create(recursive: true); @@ -627,7 +625,7 @@ void main() { () async { await create(); for (final year in [10000, 2, -10, 0]) { - await db.holidays.create( + await db.holiday.create( day: LocalDate(year, 1, 1), label: 'pending', ); @@ -641,7 +639,7 @@ void main() { '0002_backfill', [ Backfill( - holidaysSchema, + holidaySchema, set: {'label': "'done'"}, where: "label = 'pending'", doneWhen: "SELECT NOT EXISTS (SELECT 1 FROM holidays WHERE label = 'pending')", @@ -653,11 +651,11 @@ void main() { ); await Migrator(db.sql).apply([first, second], maxBackfillBatches: 1); expect( - (await db.holidays.where((h) => h.label.eq('done')).single()).day, + (await db.holiday.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); + expect(await db.holiday.where((h) => h.label.eq('done')).count(), 4); }, ); }); diff --git a/test/types_test.dart b/test/types_test.dart index 5734dd08..e1a115a4 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -251,7 +251,7 @@ void wrong(Database db) { 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); + final Query bad = db.users.select((u) => u.email); db.users.seekAfter((u) => [u.id.cursor('wrong')]); print(bad); db.people.byId(1); diff --git a/test/unconstrained_relation_test.dart b/test/unconstrained_relation_test.dart index 4a33eb60..10310f11 100644 --- a/test/unconstrained_relation_test.dart +++ b/test/unconstrained_relation_test.dart @@ -77,11 +77,8 @@ void main() { 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, - ); + expect(await db.entry.count(), 7); + expect(await db.account.where((a) => a.label.eq('dup')).count(), 3); }, ); @@ -89,11 +86,11 @@ void main() { 'adding and removing a real FK remains a reviewed physical migration', () async { final constrained = [ - accountsSchema, + accountSchema, TableSchema( - entriesSchema.name, - columns: entriesSchema.columns, - primaryKey: entriesSchema.primaryKey, + entrySchema.name, + columns: entrySchema.columns, + primaryKey: entrySchema.primaryKey, foreignKeys: [ const ForeignKey( ['tenant', 'owner'], @@ -102,7 +99,7 @@ void main() { ), ], ), - readingsSchema, + readingSchema, ]; final first = Migration.create( '0001_initial', @@ -124,9 +121,9 @@ void main() { (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)); + expect(await db.entry.count(), 7); + await db.entry.byId(2).patch(owner: .set(1)); + await db.entry.byId(6).patch(tenant: .set(1)); await Migrator(db.sql).apply([first, enforce]); expect( (await inspectTable(db.sql, 'entries')).foreignKeys.length, @@ -144,10 +141,10 @@ void main() { (await verifySchema(db.sql, SchemaSnapshot(appSchema))).matches, true, ); - await db.entries.create(id: 8, tenant: 9, owner: 99); - expect(await db.entries.count(), 8); + await db.entry.create(id: 8, tenant: 9, owner: 99); + expect(await db.entry.count(), 8); expect( - await db.entries + await db.entry .byId(8) .select((e) => e.ownerAccount.one()) .single(), @@ -157,7 +154,7 @@ void main() { ); test('unique joins preserve root rows, NULL components and missing targets', () async { - final rows = await db.entries + final rows = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => ( @@ -178,7 +175,7 @@ void main() { expect(events.length, 1); expect(events.single.sql, contains('LEFT JOIN')); events.clear(); - final page = await db.entries + final page = await db.entry .orderBy((e) => [e.id.asc()]) .skip(1) .take(2) @@ -195,33 +192,30 @@ void main() { test('required target and filter visibility remain explicit', () async { await expectLater( - db.entries - .byId(2) - .select((e) => e.ownerAccount.required()) - .single(), + db.entry.byId(2).select((e) => e.ownerAccount.required()).single(), throwsA(code('RELATION.MISSING')), ); expect( - await db.entries + await db.entry .byId(1) .select((e) => e.ownerAccount.where((a) => a.id.eq(2)).one()) .single(), isNull, ); expect( - await db.entries + await db.entry .byId(7) .select((e) => e.ownerAccount.select((a) => a.label).required()) .single(), isNull, ); - expect(await db.entries.count(), 7); + expect(await db.entry.count(), 7); }); test( 'nonunique composite collections batch by tuple and page per parent', () async { - final rows = await db.entries + final rows = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => e.matchingAccounts @@ -241,7 +235,7 @@ void main() { ]); expect(events.length, 2); events.clear(); - final pages = await db.entries + final pages = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => e.matchingAccounts @@ -270,22 +264,19 @@ void main() { 'to-one uses cardinality checks without assuming a unique lookup', () async { expect( - () => db.entries.select( + () => db.entry.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(), + db.entry.byId(1).select((e) => e.matchingAccounts.one()).single(), throwsA(code('RELATION.CARDINALITY')), ); expect(events.length, 2); expect( - await db.entries + await db.entry .byId(1) .select( (e) => e.matchingAccounts @@ -297,7 +288,7 @@ void main() { 2, ); expect( - await db.entries + await db.entry .byId(1) .select( (e) => e.matchingAccounts @@ -315,7 +306,7 @@ void main() { test( 'correlated filters and counts do not materialize related rows', () async { - final rows = await db.entries + final rows = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => ( @@ -342,7 +333,7 @@ void main() { test( 'self relations and inverse collections share the existing planner', () async { - final rows = await db.accounts + final rows = await db.account .orderBy((a) => [a.tenant.asc(), a.id.asc()]) .select( (a) => ( @@ -362,7 +353,7 @@ void main() { [], ]); expect(events.length, 2); - final entries = await db.accounts + final entries = await db.account .byId(tenant: 1, id: 1) .select((a) => a.entries.select((e) => e.id).many()) .single(); @@ -374,7 +365,7 @@ void main() { 'delete has no cascade and watches track the joined target', () async { final snapshots = StreamIterator( - db.entries + db.entry .byId(1) .select((e) => e.ownerAccount.select((a) => a.id).one()) .watch(), @@ -385,16 +376,16 @@ void main() { true, ); expect(snapshots.current, [1]); - await db.accounts.byId(tenant: 1, id: 1).delete().execute(); + await db.account.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.entry.count(), 7); + expect((await db.entry.byId(1).single()).owner, 1); expect( - (await db.accounts.byId(tenant: 1, id: 2).single()).managerId, + (await db.account.byId(tenant: 1, id: 2).single()).managerId, 1, ); } finally { @@ -406,9 +397,9 @@ void main() { 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(); + await tx.account.byId(tenant: 1, id: 1).delete().execute(); expect( - await tx.entries + await tx.entry .byId(1) .select((e) => e.ownerAccount.one()) .single(), @@ -419,7 +410,7 @@ void main() { throwsStateError, ); expect( - await db.entries + await db.entry .byId(1) .select((e) => e.ownerAccount.select((a) => a.id).one()) .single(), @@ -430,7 +421,7 @@ void main() { test( 'streaming preserves optional and batched relation result shapes', () async { - final rows = await db.entries + final rows = await db.entry .orderBy((e) => [e.id.asc()]) .select( (e) => ( @@ -459,10 +450,10 @@ void main() { 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 + await db.reading.create(id: 1, value: 1e20); + await db.reading.create(id: 2, value: 1e20); + await db.reading.create(id: 3, value: 2e20); + final rows = await db.reading .orderBy((r) => [r.id.asc()]) .select( (r) => r.peers diff --git a/test/watch_test.dart b/test/watch_test.dart index e0ac969b..ffd4ad3e 100644 --- a/test/watch_test.dart +++ b/test/watch_test.dart @@ -29,14 +29,14 @@ void main() { 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()); + final user = await db.user.create(email: 'before'); + result = _Snapshots(db.user.select((u) => u.email).watch()); expect(await result.next(), ['before']); - await external.users.byId(user.id).patch(email: .set('external')); + await external.user.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]); + db.invalidate([userSchema]); expect(await result.next(), ['external']); } finally { await result?.close(); @@ -111,19 +111,19 @@ void runTests( await Future.delayed(Duration.zero); } - Future post(int author, [String title = 'post']) => db.posts.create( + Future post(int author, [String title = 'post']) => db.post.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(); + final stream = db.user.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'); + final user = await db.user.create(email: 'first'); expect(await result.next(), ['first']); events.clear(); await post(user.id); @@ -135,9 +135,9 @@ void runTests( ), isEmpty, ); - await db.users.byId(user.id).patch(email: .set('second')); + await db.user.byId(user.id).patch(email: .set('second')); expect(await result.next(), ['second']); - await db.users.byId(user.id).delete().execute(); + await db.user.byId(user.id).delete().execute(); expect(await result.next(), isEmpty); }); @@ -145,38 +145,38 @@ void runTests( '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(), + db.user.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'); + await tx.user.create(email: 'a'); + await tx.user.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'); + await tx.user.create(email: 'rollback'); throw StateError('rollback'); }), throwsStateError, ); await barrier(); expect(result.rows.length, 2); - expect(await db.users.count(), 2); + expect(await db.user.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()); + final user = await db.user.create(email: 'initial'); + final result = watch(db.user.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')); + await child.user.byId(user.id).patch(email: .set('rolled-back')); throw StateError('recoverable'); }), throwsStateError, @@ -189,7 +189,7 @@ void runTests( await tx.savepoint((child) async { await child.savepoint( (nested) => - nested.users.byId(user.id).patch(email: .set('committed')), + nested.user.byId(user.id).patch(email: .set('committed')), ); }); expect(result.rows.length, 1); @@ -201,18 +201,18 @@ void runTests( 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()); + final user = await db.user.create(email: 'unique'); + final result = watch(db.user.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 + await expectLater(db.user.create(email: 'unique'), throwsA(anything)); + await db.user.byId(999).patch(email: .set('absent')); + await db.user .insert((u) => [u.email.set('unique')]) .onConflictDoNothing() .execute(); await expectLater( db.transaction((tx) async { - await tx.users.byId(user.id).patch(email: .set('temporary')); + await tx.user.byId(user.id).patch(email: .set('temporary')); try { await tx.execute(SqlCommand('SELECT broken_column FROM users')); } catch (_) {} @@ -228,16 +228,16 @@ void runTests( 'batch writes and upsert returning publish committed changes', () async { final result = watch( - db.users.orderBy((u) => [u.id.asc()]).select((u) => u.email).watch(), + db.user.orderBy((u) => [u.id.asc()]).select((u) => u.email).watch(), ); expect(await result.next(), isEmpty); - final ids = await db.users + final ids = await db.user .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 + await db.user .insert((u) => [u.email.set('b'), u.score.set(7)]) .onConflictUpdate( target: (u) => [u.email], @@ -250,9 +250,9 @@ void runTests( ); test('joined and nested batch relations track child writes', () async { - final user = await db.users.create(email: 'author'); + final user = await db.user.create(email: 'author'); final children = watch( - db.users + db.user .select( (u) => u.posts .orderBy((p) => [p.id.asc()]) @@ -272,12 +272,12 @@ void runTests( [('post', 'author')], ]); final authors = watch( - db.posts + db.post .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')); + await db.user.byId(user.id).patch(email: .set('updated')); expect(await authors.next(), ['updated']); expect(await children.next(), [ [('post', 'updated')], @@ -287,13 +287,13 @@ void runTests( test( 'relation predicates, SQL subqueries and CTE sources are dependencies', () async { - final user = await db.users.create(email: 'a'); + final user = await db.user.create(email: 'a'); final hasPosts = watch( - db.users.where((u) => u.posts.any()).select((u) => u.email).watch(), + db.user.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 total = db.post.select((p) => p.id.count()).scalar(); + final subquery = watch(db.user.select((u) => total).watch()); + final cte = db.post.select((p) => p.title).asCte('titles'); final titles = watch(cte.query.watch()); expect(await hasPosts.next(), isEmpty); expect(await subquery.next(), [0]); @@ -306,14 +306,14 @@ void runTests( ); test('declared cascade deletion invalidates a child-only query', () async { - final user = await db.users.create(email: 'parent'); + final user = await db.user.create(email: 'parent'); await post(user.id); - final result = watch(db.posts.select((p) => p.title).watch()); + final result = watch(db.post.select((p) => p.title).watch()); expect(await result.next(), ['post']); - await db.users.byId(user.id).patch(email: .set('no cascade')); + await db.user.byId(user.id).patch(email: .set('no cascade')); await barrier(); expect(result.rows.length, 1); - await db.users.byId(user.id).delete().execute(); + await db.user.byId(user.id).delete().execute(); expect(await result.next(), isEmpty); }); @@ -387,23 +387,23 @@ void runTests( test( 'explicit JOINs and joined CTE definitions track both physical sources', () async { - final user = await db.users.create(email: 'a'); + final user = await db.user.create(email: 'a'); await post(user.id); - final alias = postsTable.alias(); + final alias = postTable.alias(); final joined = watch( - db.users + db.user .join(alias, on: (u, p) => u.id.equals(p.authorId)) .select((_) => alias.fields.title) .watch(), ); - final cte = db.posts + final cte = db.post .select( (p) => (p.authorId, p.title).map((id, title) => (id, title)), ) .asCte('post_names'); final cteAlias = cte.alias(); final viaCte = watch( - db.users + db.user .join( cteAlias, on: (u, c) => u.id.equals(c.ref((p) => p.authorId)), @@ -413,38 +413,38 @@ void runTests( ); expect(await joined.next(), ['post']); expect(await viaCte.next(), ['post']); - await db.posts.byId(1).patch(title: .set('joined')); + await db.post.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 user = await db.user.create(email: 'a'); final count = sql( ['(SELECT COUNT(*) FROM posts)'], [], Codecs.integer, ); final result = watch( - db.users.select((_) => count).watch(reads: [postsSchema]), + db.user.select((_) => count).watch(reads: [postSchema]), ); expect(await result.next(), [0]); await post(user.id); expect(await result.next(), [1]); await db.execute( SqlCommand('DELETE FROM posts'), - changedTables: [postsSchema], + changedTables: [postSchema], ); expect(await result.next(), [0]); await db.transaction((tx) async { - tx.invalidate([postsSchema]); + tx.invalidate([postSchema]); expect(result.rows.length, 3); }); expect(await result.next(), [0]); await expectLater( db.transaction((tx) async { - tx.invalidate([postsSchema]); + tx.invalidate([postSchema]); throw StateError('rollback invalidation'); }), throwsStateError, @@ -456,12 +456,12 @@ void runTests( test( 'paused listeners coalesce writes and stop fetching until resumed', () async { - final result = watch(db.users.select((u) => u.email).watch()); + final result = watch(db.user.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 db.user.create(email: '$i'); } await barrier(); expect(result.rows.length, 1); @@ -480,17 +480,17 @@ void runTests( test( 'invalidation during a read suppresses the stale snapshot and refetches', () async { - await db.users.create(email: 'before'); + await db.user.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()); + final result = watch(intercepted.user.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')); + await other.user.byId(1).patch(email: .set('after')); release.complete(); expect(await result.next(), ['after']); expect(result.rows.length, 1); @@ -500,9 +500,9 @@ void runTests( test( 'decoding errors are reported and a later write can recover', () async { - final user = await db.users.create(email: 'bad'); + final user = await db.user.create(email: 'bad'); final result = watch( - db.users + db.user .select( (u) => u.email.map((value) { if (value == 'bad') throw const FormatException('bad value'); @@ -513,7 +513,7 @@ void runTests( ); await result.waitForError(); expect(result.errors.single, isA()); - await db.users.byId(user.id).patch(email: .set('good')); + await db.user.byId(user.id).patch(email: .set('good')); expect(await result.next(), ['good']); }, ); @@ -522,16 +522,16 @@ void runTests( '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 stream = db.user.select((u) => u.email).watch(); final one = watch(stream); - final two = watch(other.users.select((u) => u.email).watch()); + final two = watch(other.user.select((u) => u.email).watch()); expect(await one.next(), isEmpty); expect(await two.next(), isEmpty); - await other.users.create(email: 'shared'); + await other.user.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')); + await db.user.byId(1).patch(email: .set('still open')); expect(await two.next(), ['still open']); expect(one.rows.length, 2); }, @@ -539,14 +539,14 @@ void runTests( test('watching leased sessions is rejected and cancelled tokens close the stream', () async { await db.transaction((tx) async { - final result = watch(tx.users.watch()); + final result = watch(tx.user.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)), + db.user.watch(options: ExecutionOptions(cancellation: cancellation)), ); expect(await result.next(), isEmpty); cancellation.cancel(); @@ -557,13 +557,13 @@ void runTests( test( 'database close finishes watchers even when a listener is paused', () async { - final result = watch(db.users.watch()); + final result = watch(db.user.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()); + final late = watch(db.user.watch()); await late.waitForError(); expect(late.errors.single, code('SESSION.CLOSED')); }, @@ -576,7 +576,7 @@ void runTests( ); return; } - await db.users.create(email: 'a'); + await db.user.create(email: 'a'); final started = Completer(); final expression = db.dialect == .postgres ? sql(['(SELECT 1 FROM pg_sleep(20))'], [], Codecs.integer) @@ -589,13 +589,13 @@ void runTests( ); // 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()); + final result = watch(db.user.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(await db.user.count(), 1); expect(events.any((e) => e.error != null), true); }); @@ -605,7 +605,7 @@ void runTests( return; } final rejected = watch( - db.users.watch( + db.user.watch( options: const ExecutionOptions(timeout: Duration(milliseconds: 40)), ), ); @@ -615,9 +615,9 @@ void runTests( expect(events, isEmpty); await rejected.close(); - final ordinary = watch(db.users.select((u) => u.email).watch()); + final ordinary = watch(db.user.select((u) => u.email).watch()); expect(await ordinary.next(), isEmpty); - await db.users.create(email: 'still-watching'); + await db.user.create(email: 'still-watching'); expect(await ordinary.next(), ['still-watching']); expect(ordinary.errors, isEmpty); }); @@ -627,27 +627,27 @@ void runTests( '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()); + final result = watch(uncertain.user.select((u) => u.email).watch()); expect(await result.next(), isEmpty); await expectLater( - uncertain.transaction((tx) => tx.users.create(email: 'committed')), + uncertain.transaction((tx) => tx.user.create(email: 'committed')), throwsA(code('TRANSACTION.COMMIT')), ); expect(await result.next(), ['committed']); - expect((await db.users.single()).email, 'committed'); + expect((await db.user.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()); + final user = await db.user.create(email: 'before'); + final result = watch(db.user.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 external.user.byId(user.id).patch(email: .set('external')); await barrier(); expect(result.rows.length, 1); - db.invalidate([usersSchema]); + db.invalidate([userSchema]); expect(await result.next(), ['external']); } finally { await external.close(); @@ -658,7 +658,7 @@ void runTests( } Future postIn(Database db, int user) async { - await db.posts.create( + await db.post.create( authorId: user, title: 'unrelated', createdAt: DateTime.utc(2026), diff --git a/tool/benchmark_decimal.dart b/tool/benchmark_decimal.dart index d52a1b20..d5eb6f24 100644 --- a/tool/benchmark_decimal.dart +++ b/tool/benchmark_decimal.dart @@ -45,26 +45,26 @@ Future main() async { ), ); final cases = { - 'read': db.entries.select((e) => e.amount), - 'average_half_even_scale_2': db.entries.select( + 'read': db.entry.select((e) => e.amount), + 'average_half_even_scale_2': db.entry.select( (e) => e.amount.average(scale: 2, rounding: .halfEven), ), - 'window_average_half_even_scale_2': db.entries.select( + 'window_average_half_even_scale_2': db.entry.select( (e) => e.amount .average(scale: 2, rounding: .halfEven) .over(orderBy: [e.id.asc()], frame: .rowsToCurrent), ), - 'divide_half_even_scale_2': db.entries.select( + 'divide_half_even_scale_2': db.entry.select( (e) => e.amount.divide( Decimal.parse('3'), scale: 2, rounding: .halfEven, ), ), - 'round_half_even_scale_minus_1': db.entries.select( + 'round_half_even_scale_minus_1': db.entry.select( (e) => e.amount.rounded(-1, rounding: .halfEven), ), - 'window_divide_half_even_scale_2': db.entries.select( + 'window_divide_half_even_scale_2': db.entry.select( (e) => e.amount .sum() .over(orderBy: [e.id.asc()], frame: .rowsToCurrent) diff --git a/tool/benchmark_editor.dart b/tool/benchmark_editor.dart index 7ec1b206..aff14c52 100644 --- a/tool/benchmark_editor.dart +++ b/tool/benchmark_editor.dart @@ -40,28 +40,28 @@ Future main(List args) async { name: 'table_entries', file: 'lib/client.dart', source: _client('db./*caret*/;'), - required: ['rows0', 'rows${models - 1}'], + required: ['row0', 'row${models - 1}'], forbidden: [], ), ( name: 'schema_record_fields', file: 'lib/schema.dart', source: - '$source\nfinal editorProbe = rows0.key((r) => r./*caret*/);\n', + "$source\nfinal editorProbe = model('editor_probes', (id: integer(), title: text(), score: integer(), status: enumeration(Status.values)), primaryKey: (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*/);'), + source: _client('db.row0.where((r) => r./*caret*/);'), required: ['id', 'title', 'score', 'status'], forbidden: [], ), ( name: 'create_arguments', file: 'lib/client.dart', - source: _client('await db.rows0.create(/*caret*/);'), + source: _client('await db.row0.create(/*caret*/);'), required: ['title:', 'status:'], forbidden: [], ), @@ -69,7 +69,7 @@ Future main(List args) async { 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*/;', + 'final rows = await db.row0.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'], @@ -166,25 +166,25 @@ Future main(List args) async { for (final (name, body, code, token) in [ ( 'create_type', - "await db.rows0.create(title: 123, status: Status.pending);", + "await db.row0.create(title: 123, status: Status.pending);", 'argument_type_not_assignable', '123', ), ( 'predicate_type', - "db.rows0.where((r) => r.score.eq('wrong'));", + "db.row0.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);", + "final rows = await db.row0.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);", + "final rows = await db.row0.select((r) => (r.id,r.title).map((id,title) => (id:id,title:title))).get(); print(rows.first.score);", 'undefined_getter', 'score', ), @@ -216,7 +216,7 @@ Future main(List args) async { stdout.writeln(' diagnostic: $name'); } final clean = _client( - "await db.rows0.create(title: 'ok', status: Status.pending);", + "await db.row0.create(title: 'ok', status: Status.pending);", ); final repair = Stopwatch()..start(); final cleanVersion = server.open(client.path, clean); @@ -241,10 +241,8 @@ Future main(List args) async { 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'), + ('model_handle', 'Model person', 'employee'), + ('schema_field', 'managerId:', 'supervisorId'), ]) { final text = await symbols.readAsString(); final offset = text.indexOf(marker) + marker.lastIndexOf(' ') + 1; @@ -278,9 +276,8 @@ Future main(List args) async { : applied == null ? 'unavailable' : 'applied'; - if (name != 'record_field' && - (applied?['lib/symbols.dart'] as Map?)?['edits'] != - (name == 'record_alias' ? 3 : 2)) { + if (name == 'model_handle' && + (applied?['lib/symbols.dart'] as Map?)?['edits'] != 3) { throw StateError('Unexpected $name rename: $response / $error'); } renames.add({ @@ -296,7 +293,7 @@ Future main(List args) async { } // 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'; + "$source\nfinal invalid = model('invalid', (id: integer(),), indexes: (r) => [index([r.id], name: 'invalid_index')]);\n"; await fixture.write('lib/schema.dart', invalid); server.open(schema.path, invalid); final dartAnalysis = await fixture.run(['analyze', 'lib/schema.dart']); @@ -364,10 +361,10 @@ Future main(List args) async { 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.', + '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. Rename probes use model handles and named column declarations, including self and forward references. Regeneration remains a separate step after schema edits.', '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.', + 'Named Record field rename support depends on the SDK; inspect each probe response. A successful bounded sequence does not establish every IDE/plugin workflow.', ], 'results': results, }; @@ -403,20 +400,12 @@ Map _stats(List times) { } 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; +import 'package:orm/schema.dart'; +final Model person = model('people', ( + id: identity(), managerId: integer().nullable(), +), relations: (p) => (manager: references(p.managerId, () => person),)); +final report = model('reports', (id: identity(), authorId: integer()), + relations: (r) => (author: references(r.authorId, () => person),)); '''; Object? _relative(Object? value, Directory root) => switch (value) { diff --git a/tool/compare_authoring.dart b/tool/compare_authoring.dart deleted file mode 100644 index 4b80dc23..00000000 --- a/tool/compare_authoring.dart +++ /dev/null @@ -1,399 +0,0 @@ -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/benchmarks/authoring-smoke.json' - : '.dart_tool/benchmarks/authoring.json'; - final output = File(path); - await output.parent.create(recursive: true); - await output.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/src/authoring.dart b/tool/src/authoring.dart deleted file mode 100644 index dac3d5d3..00000000 --- a/tool/src/authoring.dart +++ /dev/null @@ -1,330 +0,0 @@ -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 deleted file mode 100644 index 085bf719..00000000 --- a/tool/src/authoring_fixture.dart +++ /dev/null @@ -1,92 +0,0 @@ -/// 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/build_fixture.dart b/tool/src/build_fixture.dart index 7782f452..bbc9573d 100644 --- a/tool/src/build_fixture.dart +++ b/tool/src/build_fixture.dart @@ -73,9 +73,9 @@ targets: static String domain({String label = 'waiting', int defaultScore = 0}) => ''' -import 'package:orm/schema.dart'; const defaultScore = '$defaultScore'; -enum Status { @EnumValue('$label') pending, ready } +const pendingLabel = '$label'; +enum Status { pending, ready } '''; static String schema( @@ -87,8 +87,7 @@ enum Status { @EnumValue('$label') pending, ready } 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'); +final row$i = model('rows_$i', (id: identity(), title: text(), score: integer(defaultSql: defaultScore), status: enumeration(Status.values, labels: {Status.pending: pendingLabel, Status.ready: 'ready'})${extra && i == 0 ? ', enabled: boolean()' : ''})); '''].join()} ${invalid ? 'int invalid = "not an integer";' : ''} '''; diff --git a/tool/src/runtime_cases.dart b/tool/src/runtime_cases.dart index 8f9be921..fadd496a 100644 --- a/tool/src/runtime_cases.dart +++ b/tool/src/runtime_cases.dart @@ -13,7 +13,7 @@ const userCount = 100; final _nullableText = Codecs.text.nullable(); Query runtimeQuery(Database db, String name) { - final users = db.users.where((u) => u.id.gte(firstUser)); + final users = db.user.where((u) => u.id.gte(firstUser)); return switch (name) { 'full_rows' => users.orderBy((u) => [u.id.asc()]), 'two_columns' =>