diff --git a/AGENTS.md b/AGENTS.md index e7615d7e..06657d02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,290 +1,258 @@ -# AGENTS.md - SQLC Gen C# Codebase Guide +# AGENTS.md - sqlc-gen-csharp -## ๐ŸŽฏ Project Overview +**sqlc-gen-csharp** is a [sqlc](https://github.com/sqlc-dev/sqlc) plugin (written in C#) that turns a sqlc +`GenerateRequest` (protobuf: catalog + queries + options) into C# data access code for +PostgreSQL (Npgsql), MySQL (MySqlConnector) and SQLite (Microsoft.Data.Sqlite), optionally wrapped with Dapper. -**sqlc-gen-csharp** is a .NET plugin for [sqlc](https://github.com/sqlc-dev/sqlc) that generates type-safe C# data access code from SQL queries. - -**Key Purpose:** Generate C# classes (queries, models, utils) from SQL queries using database drivers (Npgsql, MySqlConnector, Microsoft.Data.Sqlite) or optionally Dapper. - -**Supported Databases:** -- PostgreSQL (Npgsql driver) -- MySQL (MySqlConnector driver) -- SQLite (Microsoft.Data.Sqlite driver) - ---- - -## ๐Ÿ“ Core Architecture - -### Main Entry Point -| File | Purpose | -|------|---------| -| `SqlcGenCsharp/PluginRunner.cs` | Main plugin entry point - handles requests | -| `CodeGenerator/CodeGenerator.cs` | Orchestrates code generation flow | - -### Generator Components -| File | Purpose | -|------|---------| -| `CodeGenerator/Generators/QueriesGen.cs` | Generates query classes with CRUD methods | -| `CodeGenerator/Generators/ModelsGen.cs` | Generates POCOs for database tables | -| `CodeGenerator/Generators/UtilsGen.cs` | Generates utility classes | -| `CodeGenerator/Generators/CsprojGen.cs` | Generates .csproj files | - -### Database Drivers -| File | Purpose | -|------|---------| -| `Drivers/DbDriver.cs` | Abstract base driver class | -| `Drivers/NpgsqlDriver.cs` | PostgreSQL driver implementation | -| `Drivers/MySqlConnectorDriver.cs` | MySQL driver implementation | -| `Drivers/SqliteDriver.cs` | SQLite driver implementation | -| `Drivers/EnumDbDriver.cs` | Generates enum types | - ---- - -## ๐Ÿงฉ Target Frameworks -- The **plugin** (all projects but `examples`) targets `net10.0`; the SDK is pinned in `global.json`. -- The **generated code** targets the `targetFramework` option: `netstandard2.0`, `netstandard2.1`, `net8.0` (default), `net9.0`, `net10.0`. `DotnetFramework.IsDotnetCore()` is true for every `net*` value; `netstandard*` is "legacy" output. -- The core examples in `sqlc.ci.yaml` are spread over net8.0 / net9.0 / net10.0 so that every framework is built and tested end-to-end. -- The WASM plugin is built as `wasip1` (core module) via overrides in `WasmRunner/WasmRunner.csproj`, because the .NET 10 WASI SDK emits a wasip2 component that sqlc (wazero) cannot load. Re-verify with `sqlc -f sqlc.ci.yaml diff` after SDK upgrades. +More specific instructions live next to the code - read them before working in those areas: +- `Drivers/AGENTS.md` - type mappings, driver hooks, annotation generators, enums, per-DB quirks +- `end2end/AGENTS.md` - end-to-end test scaffolding +- `docs/AGENTS.md` - documentation and README generation --- -## ๐Ÿ—๏ธ Code Generation Flow +## 1. Architecture and module ownership +### Project dependency graph (enforced by `unit-tests/RepositoryTests/ArchitectureTests.cs`) ``` -1. Generate() method in CodeGenerator.cs - โ†“ -2. InitGenerators() - Parse options, instantiate drivers - โ†“ -3. InstantiateDriver() - Select driver based on DriverName - โ†“ -4. GetFileQueries() - Group queries by table - โ†“ -5. GenerateFiles() - Call appropriate generators - โ†“ -6. Return GenerateResponse with generated files +GeneratedProtobuf (sqlc protobuf, generated) refs: - +Extensions (generic string/collection/Roslyn helpers) refs: - +PluginOptions (RawOptions -> Options, enums) refs: GeneratedProtobuf +Drivers (DB knowledge + method-body emission) refs: Extensions, PluginOptions +CodeGenerator (orchestration + file assembly) refs: PluginOptions, GeneratedProtobuf, Drivers +SqlcGenCsharp (PluginRunner: stdin/stdout I/O) refs: CodeGenerator +LocalRunner (process plugin host, 1-liner) refs: SqlcGenCsharp +WasmRunner (WASM plugin host, 1-liner) refs: SqlcGenCsharp ``` - -### Driver Instantiation -```csharp -DbDriver InstantiateDriver() -{ - return Options.DriverName switch - { - DriverName.MySqlConnector => new MySqlConnectorDriver(...), - DriverName.Npgsql => new NpgsqlDriver(...), - DriverName.Sqlite => new SqliteDriver(...), - _ => throw new ArgumentException("unknown driver") - } -} +Never add a `ProjectReference` that is not in this graph. If a new edge is truly needed, update the +architecture test in the same change and explain why. + +> Naming trap: `Drivers/` is not only "DB drivers". It also owns **all generated method bodies** +> (`Drivers/Generators/*DeclareGen.cs`). `CodeGenerator/Generators/` only assembles **files** +> (namespace, usings, header, which members go in which file). + +### Where code goes +| You are changing... | Put it in | Never in | +|------------------------------------------------------------------------|---------------------------------------------------------------------------------|-------------------------------------------| +| Reading the request / writing the response | `SqlcGenCsharp/PluginRunner.cs` | the runners, CodeGenerator | +| Which files are generated, their names, namespace style, usings order, header | `CodeGenerator/CodeGenerator.cs`, `CodeGenerator/Generators/*Gen.cs` | Drivers | +| Shape of Row / Args / Model types (record vs class, `required`/`init`) | `CodeGenerator/Generators/DataClassesGen.cs` | Drivers | +| Method body flow shared by all DBs (tx / no-tx, Dapper / driver) | `Drivers/Generators/DeclareGen.cs`, shared bits in `CommonGen.cs` | a concrete driver, CodeGenerator | +| A DB-specific code fragment | an override of a `DbDriver` hook in that driver (see `Drivers/AGENTS.md`) | `*DeclareGen`, `CodeGenerator`, `DbDriver` | +| DB type -> C# type mapping | that driver's `ColumnMappings` | `DbDriver` base | +| Enum generation / reading / writing | `Drivers/EnumDbDriver.cs` (shared) or the driver's override | `CommonGen`, CodeGenerator | +| `CancellationToken` in generated code | only via `dbDriver.Cancellation` (`Drivers/Generators/CancellationGen.cs`) | hand-written `cancellationToken` strings | +| Names of generated locals/fields/properties | the `Variable` enum (`Drivers/Variable.cs`) + `AsVarName/AsPropertyName/AsFieldName` | string literals like `"reader"` | +| Row/Args/Sql member naming | `ClassMember` (`CodeGenerator/ClassMember.cs`) | ad-hoc `$"{name}Row"` | +| A new plugin option | `PluginOptions/RawOptions.cs` + `PluginOptions/Options.cs` (see recipe below) | code that parses raw JSON | +| Pure helpers (strings, sets, dictionaries, Roslyn trivia) | `Extensions/` - must stay free of Query/Column/driver knowledge | - | +| Protobuf types | regenerate `GeneratedProtobuf/` (`make generate-protobuf`) | hand edits | + +### Interfaces between the layers +- **CodeGenerator -> Drivers**: CodeGenerator may only use the public surface of `DbDriver`, the + annotation interfaces in `Drivers/QueryAnnotations.cs` (`IOne`, `IMany`, `IExec`, `IExecRows`, `IExecLastId`, + `ICopyFrom`) and `EnumDbDriver`. It must not reference concrete drivers (`NpgsqlDriver`, ...) - the only exception + is `CodeGenerator.InstantiateDriver`. The same applies to `Drivers/Generators/`. Enforced by + `ArchitectureTests.TestConcreteDriversAreNotReferenced`. +- **Annotation dispatch** is a hard cast in `QueriesGen.AddMethodDeclaration` (`((IExec)dbDriver).ExecDeclare(...)`). + A driver that doesn't implement an interface fails at runtime with a wrapped `SystemException`, not at compile time. +- **Drivers -> generated code**: drivers return C# source as strings / `MemberDeclarationSyntax`. Hooks return + fragments (`ConnectionGenCommands`, `CommandGenCommands`, `GenCommand.WrapBlock`), never whole files. +- **Opt-in contract**: every option that is off by default (e.g. `withCancellationToken`, `useProperSingularization`) + must leave generated output **byte-identical** when off. `sqlc diff` on the examples is the check. + +### Code generation flow ``` - ---- - -## ๐Ÿ—„๏ธ Database-Specific Implementation Differences - -### PostgreSQL -- **`:execlastid`**: Uses `RETURNING` clause -- **`:copyfrom`**: Uses `COPY FROM` binary command -- **Supported types**: Boolean, integer types, decimal, date/time, JSON/JSONB, arrays, enums, etc. - -### MySQL -- **`:execlastid`** (Driver): Uses `MySqlConnector.LastInsertedId` property -- **`:execlastid`** (Dapper): Appends `SELECT LAST_INSERT_ID()` to query -- **`:copyfrom`**: Uses `LOAD DATA` from CSV file -- **Supported types**: Boolean, integer types, decimal, date/time, JSON, geometry - -### SQLite -- **`:execlastid`**: Uses `RETURNING` clause (integer only) -- **`:copyfrom`**: Uses multi-VALUES clause -- **Supported types**: Limited - integer, real, text, blob -- **Override support**: Can override to DateTime, bool, etc. - ---- - -## โš™๏ธ Configuration Options - -| Option | Default | Description | -|--------|---------|-------------| -| `overrideDriverVersion` | 2.4.0 (MySql), 8.0.6 (Npgsql), 10.0.13 (Sqlite) | Override DB driver version | -| `targetFramework` | net8.0 | .NET Standard 2.0/2.1, .NET 8.0, 9.0 or 10.0 (the plugin itself runs on .NET 10, independent of this option) | -| `generateCsproj` | true | Generate .csproj file | -| `namespaceName` | Project name | Override generated namespace | -| `useDapper` | false | Use Dapper wrapper | -| `overrideDapperVersion` | 2.1.66 | Override Dapper version | -| `useCentralPackageManagement` | false | Support central package management | -| `withAsyncSuffix` | true | Append "Async" to method names | -| `overrides` | - | Per-column type overrides | - ---- - -## ๐Ÿ”ง Query Annotations Support - -| Annotation | PostgreSQL | MySQL | SQLite | Description | -|------------|-----------|-------|--------|-------------| -| `:one` | โœ… | โœ… | โœ… | Returns 0...1 records | -| `:many` | โœ… | โœ… | โœ… | Returns 0...n records | -| `:exec` | โœ… | โœ… | โœ… | DML/DDDL - no return | -| `:execrows` | โœ… | โœ… | โœ… | Returns affected rows count | -| `:execlastid` | โœ… | โœ… | โœ… | INSERT with returned ID | -| `:copyfrom` | โœ… | โœ… | โœ… | Batch insert | - -### Macro Annotations -| Macro | PostgreSQL | MySQL | SQLite | Description | -|-------|-----------|-------|--------|-------------| -| `sqlc.arg` | โœ… | โœ… | โœ… | Named parameters | -| `sqlc.narg` | โœ… | โœ… | โœ… | Nullable named parameters | -| `sqlc.slice` | ๐Ÿšซ | โœ… | โœ… | Dynamic query for arrays | -| `sqlc.embed` | โœ… | โœ… | โœ… | Embed existing models | - ---- - -## ๐ŸŽจ Code Generation Patterns - -### Query Class Structure -```csharp -public partial class AuthorsQueries -{ - private readonly string _connectionString; - private IDbConnection? _connection; - private IDbTransaction? _transaction; - - // Constructor with connection string - public AuthorsQueries(string connectionString) - { - _connectionString = connectionString; - } - - // Query methods - public Task GetAuthorAsync(int id) => ... - public Task> GetAuthorsAsync() => ... - public Task CreateAuthorAsync(CreateAuthorParams params) => ... -} +PluginRunner.Run read GenerateRequest from stdin + CodeGenerator.Generate + InitGenerators Options(request); debugRequest -> dump request.json/request.message and stop + InstantiateDriver engine (postgresql|mysql|sqlite) -> NpgsqlDriver | MySqlConnectorDriver | SqliteDriver + group queries by query *file name* -> one QueriesGen file per group + ModelsGen (Models.cs), UtilsGen (Utils.cs, only if it has members), CsprojGen (if generateCsproj) +PluginRunner write GenerateResponse to stdout ``` -### Model Structure +### Generated code shape (excerpt of `examples/NpgsqlExample/QuerySql.cs`) ```csharp -public partial class Author +public class QuerySql : IDisposable // Npgsql/MySQL: IDisposable (own a DataSource); SQLite: no base type { - public int AuthorID { get; set; } - public string Name { get; set; } = string.Empty; - public string? Bio { get; set; } -} -``` - -### Type Mapping -- **Column types** are mapped to C# types via `ColumnMappings` -- **Overrides** can be applied per column: `overrideOption.Column == "GetAuthor:Name"` -- **Nullable types** in .NET Core are inferred from `notNull` column attribute - ---- - -## ๐Ÿงช Testing & Development - -### Local Development -```bash -# Prerequisites -dotnet --version # .NET 10 SDK (pinned in global.json) -buf --version -# WASM build: `dotnet workload install wasi-experimental` + WASI SDK 25 exposed via WASI_SDK_PATH (see docs/08_Contributing.md) - -# Generate protobuf -make generate-protobuf - -# Generate code -make sqlc-generate # process plugin -make setup-wasm-plugin # WASM plugin -> dist/plugin.wasm - -# Test plugins -make unit-tests -make test-plugin -sqlc -f sqlc.ci.yaml diff # WASM plugin output must equal the committed examples -``` + public QuerySql(string connectionString) : this() { ... } + public static QuerySql WithTransaction(NpgsqlTransaction transaction) { ... } -### Pre-commit Setup -```bash -pip install pre-commit -pre-commit install + private const string GetAuthorSql = @"SELECT id, name, bio FROM authors WHERE name = @name LIMIT 1"; + public readonly record struct GetAuthorRow(long Id, string Name, string? Bio); // nested in the query class + public readonly record struct GetAuthorArgs(string Name); + public async Task GetAuthorAsync(GetAuthorArgs args) { ... } // :many -> Task> +} ``` +- `net8.0/net9.0/net10.0` without Dapper: Row/Args/Models are `readonly record struct` (so `:one` returns `Row?`). +- With Dapper, or on `netstandard*`: classes. `net*` -> `{ get; init; }` + `required` on not-null; legacy -> `{ get; set; }`. +- Every method has a no-transaction branch and a `this.Transaction` branch. --- -## ๐Ÿ“ Common Patterns - -### Type Handler Registration (Dapper) -```csharp -public static void ConfigureSqlMapper() -{ - SqlMapper.TypeHandler.Register(new NodaInstantTypeHandler()); - // Other type handlers... -} -``` +## 2. Generated / derived artifacts - never hand-edit -### Parameter Binding -```csharp -command.Parameters.AddWithValue("@column_name", - param.Value ?? (object)DBNull.Value); -``` +| Artifact | Source of truth | Regenerate with | +|-----------------------------------------------------------------------|----------------------------------------------------------|--------------------------------------------------| +| `examples/**` (code, `.csproj`, `request.json`, `request.message`) | plugin code + `examples/config/**` + `sqlc.ci.yaml` | `make sqlc-generate` | +| `sqlc.local.generated.yaml`, `sqlc.request.generated.yaml` | `sqlc.ci.yaml` (`.sql` section) | `make sync-sqlc-options` (needs `yq`; part of `make sqlc-generate`) | +| `end2end/EndToEndTests*/**/*.generated.cs` | `end2end/EndToEndScaffold/` templates + `Config.cs` | `make generate-end2end-tests` | +| `unit-tests/CodegenTests/test-requests/*/request.*` | `unit-tests/sqlc.yaml` + sibling `schema.sql`/`query.sql` | `cd unit-tests && sqlc generate` (part of `make unit-tests`) | +| `GeneratedProtobuf/Codegen.cs` | sqlc's `plugin/codegen.proto` | `make generate-protobuf` | +| `docs/09_Examples.md` | `sqlc.ci.yaml` | `dotnet run --project docs/ExamplesDocGen/ExampleDocGen.csproj > docs/09_Examples.md` | +| `README.md`, `docs/02_Quickstart.md` | `docs/0*.md`, latest release | **release flow only** - see `docs/AGENTS.md` | +| `sqlc.ci.yaml` `wasm.sha256` | `dist/plugin.wasm` | `make setup-wasm-plugin` (CI recomputes it) | -### Slice Arguments (MySQL/SQLite) -```csharp -for (int i = 0; i < paramArray.Length; i++) - command.Parameters.AddWithValue($"@paramArg{i}", paramArray[i]); -``` +`sqlc.ci.yaml` is the single source of truth for example configurations. Edit it, never the `*.generated.yaml` files. +CI fails if any of the generated artifacts above is stale (`sqlc diff`, `git diff --quiet`). --- -## ๐Ÿ› Known Issues & TODOs - -1. **2D arrays** in batch inserts work but may have edge cases -2. **Time with time zone** not supported (PostgreSQL) -3. **Array columns** override handling marked as TODO -4. **Geometry types** (MySQL) marked with โš ๏ธ - partial support -5. **macaddr8 conversion** requires explicit cast in INSERT +## 3. Outliers - things you would not guess from the design + +**Naming** +- The query class name comes from the query **file name** including the extension: `query.sql` -> `QuerySql`. + Query files with the same basename in different directories are **merged into one class** (all examples rely on + this: `authors/query.sql` + `types/query.sql` -> one `QuerySql`). +- Model names: `ToModelName` defaults to `TrimEnd('s')`, which strips **all** trailing `s` (`address` -> `Addre`). + `useProperSingularization` fixes it but is opt-in because it renames classes. + Tables in a non-default schema get the schema as a prefix (`DummySchemaDummyTable`). +- The default schema is stored under the key `""` in `DbDriver.Tables` and `EnumDbDriver.Enums`. + +**Options and overrides** +- Overrides match `":"` or `"*:"`; the first matching entry in list order wins, and + `csharp_type.notNull` replaces the column's nullability. +- Overrides apply only where a query is in context: Row/Args types and query method bodies. They **never** affect + `Models.cs` (table models are generated with `query == null`). +- An override `type` must be a key in the driver's `ColumnMappings`, otherwise reader generation throws. +- `debugRequest: true` makes the plugin dump the request instead of generating code; it is rejected for the WASM plugin. +- `targetFramework` `net9.0`/`net10.0` generate exactly the same code as `net8.0`; only `` differs. + `netstandard2.0/2.1` ("legacy") output: block-scoped namespace, no nullable reference types, different multi-line + SQL constant indentation (`QueriesGen.GetQueryTextConstant`), `System.Text.Json` package reference for `JsonElement`. + +**Code emission** +- Generated code is built from interpolated strings, parsed with `ParseMemberDeclaration(...)!` and formatted with + `NormalizeWhitespace()`. Template indentation does not matter (except inside SQL string literals). + A malformed template **does not throw** - it produces broken C#. The only real check is building the examples + (`dotnet build`) and running the end-to-end tests. +- Generated files mix `\n` (`JoinByNewLine`) and CRLF trivia. Do not normalize line endings in `examples/` - + `sqlc diff` will fail. Plugin sources are CRLF (`.editorconfig` `end_of_line = crlf`). +- The `:one`/`:many` generators silently fall back from Dapper to the raw driver when the query uses `sqlc.embed`. +- Dapper mode mutates global static state in the generated constructor: `Utils.ConfigureSqlMapper()` and + `DefaultTypeMap.MatchNamesWithUnderscores = true`. + +**WASM** +- The WASM build is trimmed; only `PluginOptions` is rooted (System.Text.Json reflection). New reflection-based code + can work in the process plugin and break only in WASM. +- `WasmRunner.csproj` forces a `wasip1` core module because sqlc's wazero cannot load the wasip2 component the .NET 10 + WASI SDK produces. The override relies on private SDK item names - re-verify after any SDK upgrade. + +**Repository** +- `.env` is shared by docker-compose, the end-to-end tests and CI; `RepositoryTests` asserts it is consistent with + `benchmark/.env`. +- `make unit-tests` needs the process plugin published to `unit-tests/dist/` (`unit-tests/sqlc.yaml` uses + `./dist/LocalRunner`); CI does `dotnet publish ../LocalRunner -c release --output dist/` from `unit-tests/`. +- `benchmark/` has its own `sqlc.yaml` and committed `*SqlcImpl` generated projects; it is excluded from format/lint. --- -## ๐Ÿ“š Key Files to Know - -- **`CodeGenerator/CodeGenerator.cs`** - Main orchestration -- **`Drivers/DbDriver.cs`** - Base driver with shared logic -- **`Drivers/NpgsqlDriver.cs`** - Most complex driver (33KB) -- **`Drivers/MySqlConnectorDriver.cs`** - MySQL-specific logic -- **`Drivers/SqliteDriver.cs`** - Simplest driver -- **`Extensions/*`** - Helper methods for code generation +## 4. Recipes (definition of done) + +**Every change** +1. `make sqlc-generate` (process plugin -> examples + requests) and commit the regenerated files. +2. `make unit-tests`. +3. `make test-plugin` (needs Docker) - unit tests, regeneration, `dotnet build`, end-to-end tests. +4. For changes that can affect the WASM build: `make setup-wasm-plugin && sqlc -f sqlc.ci.yaml diff`. +5. `make dotnet-format` (CI lints the same scope: everything except `GeneratedProtobuf`, `examples`, `benchmark/*SqlcImpl`). +6. PR label: `major` / `minor` / `patch`, or `skip-release` for non-release changes. + +**Add support for a DB type** - see `Drivers/AGENTS.md` ("Adding a type mapping"). + +**Add a plugin option** +1. `RawOptions` property with `[JsonPropertyName("camelCase")]` and a default that preserves current output. +2. Matching read-only property in `Options` (constructor maps it); validate there. +3. Consume it via `dbDriver.Options` / `Options` - never re-read the raw JSON. +4. Unit test in `unit-tests/CodegenTests/OptionsTests.cs` (and a codegen test if it changes output). +5. Enable it in at least one example in `sqlc.ci.yaml` so it is covered end-to-end, then `make sqlc-generate`. +6. Document it in the options table in `docs/03_Usage.md` and in section 5 below. + +**Add a query annotation** +1. New `I` interface in `Drivers/QueryAnnotations.cs`. +2. New `Drivers/Generators/DeclareGen.cs` following the existing ones (tx / no-tx, Dapper / driver). +3. New case in the `QueriesGen.AddMethodDeclaration` switch. +4. Implement the interface in every driver (or throw `NotSupportedException` with a clear message). +5. Queries in `examples/config//authors/query.sql`, templates in `end2end/EndToEndScaffold/Templates/AnnotationTests.cs`. +6. Update the annotation tables in `docs/03_Usage.md` and this file. + +**Add a database driver** +1. `Drivers/Driver.cs` deriving from `DbDriver` (or `EnumDbDriver` if the DB has enums), implementing every `I*` interface. +2. Enum value in `PluginOptions/DriverName.cs`, engine mapping in `Options.EngineToDriverMapping`, case in `CodeGenerator.InstantiateDriver`. +3. Examples (normal / Dapper / Legacy / DapperLegacy) in `sqlc.ci.yaml`, `examples/config//`, e2e testers, docker-compose service, `docs/0X_.md`. --- -## ๐Ÿš€ Quick Reference - -### Generate Code -```bash -make sqlc-generate # Process-based plugin -make setup-wasm-plugin # WASM plugin -``` - -### Test +## 5. Reference + +### Options (`PluginOptions/RawOptions.cs`) +| Option | Default | Notes | +|-------------------------------|----------------------------------------------------------------|-------| +| `targetFramework` | `net8.0` | `netstandard2.0`, `netstandard2.1`, `net8.0`, `net9.0`, `net10.0` | +| `overrideDriverVersion` | `2.4.0` MySqlConnector, `8.0.6` Npgsql, `10.0.13` Microsoft.Data.Sqlite | constants in each driver | +| `generateCsproj` | `true` | | +| `namespaceName` | output directory name | | +| `useDapper` | `false` | | +| `overrideDapperVersion` | `2.1.66` | `DbDriver.DefaultDapperVersion` | +| `useCentralPackageManagement` | `false` | omits `Version=` in the generated csproj | +| `withAsyncSuffix` | `true` | | +| `withCancellationToken` | `false` | must be byte-identical when off | +| `useProperSingularization` | `false` | renames models when on | +| `overrides` | - | `Query:column` / `*:column`, see section 3 | +| `debugRequest` | `false` | internal: dumps the request; not allowed with WASM | + +### Annotation support +| Annotation / macro | PostgreSQL | MySQL | SQLite | Implementation | +|--------------------|-----------|-------|--------|----------------| +| `:one`, `:many`, `:exec`, `:execrows` | โœ… | โœ… | โœ… | shared `*DeclareGen` | +| `:execlastid` | โœ… | โœ… | โœ… | PG/SQLite: user writes `RETURNING`; MySQL: plugin appends `; SELECT LAST_INSERT_ID()` | +| `:copyfrom` | โœ… | โœ… | โœ… | PG: binary `COPY`; MySQL: CSV + `MySqlBulkLoader`; SQLite: multi-row `VALUES` | +| `sqlc.arg`, `sqlc.narg`, `sqlc.embed` | โœ… | โœ… | โœ… | | +| `sqlc.slice` | ๐Ÿšซ (use arrays) | โœ… | โœ… | runtime `Utils.TransformQueryForSliceArgs` | + +### Target frameworks +- The plugin projects (section 1 graph) and the tooling/test projects target `net10.0`; SDK pinned in `global.json`. + Exceptions: `end2end/EndToEndCommon` (`netstandard2.0`) and `end2end/EndToEndTestsLegacy` (`net472`). +- Core examples are spread over net8.0 / net9.0 / net10.0 so every framework is built and tested. +- Legacy examples (`netstandard2.0`) are tested by `end2end/EndToEndTestsLegacy` (`net472`, Windows, `.github/workflows/legacy-tests.yml`). + +### Commands ```bash -make unit-tests -make test-plugin +make unit-tests # RepositoryTests + CodegenTests +make sqlc-generate # sync yaml, publish LocalRunner, regenerate requests + examples +make generate-end2end-tests +make test-plugin # everything above + build + end-to-end tests (Docker) +make setup-wasm-plugin # WASM plugin -> dist/plugin.wasm (needs wasi-experimental workload + WASI SDK 25 in WASI_SDK_PATH) +sqlc -f sqlc.ci.yaml diff # WASM output must equal the committed examples +make dotnet-format ``` ### Release -The release flow uses three automated workflows: - -1. **Release Assistant** (`release-assistant.yml`) - runs on every PR to `main`. Validates that version-worthy PRs have a `major`/`minor`/`patch` label. Sets `release-assistant/requirements` status check. - -2. **Generate Release PR** (`gen-release-pr.yml`) - triggered when Build completes on `main`. Aggregates all labeled, merged PRs since the last release, computes the new version, regenerates docs with the wasm sha, and opens a `release-prep` PR. - -3. **Release** (`release.yml`) - triggered when the `release-prep` PR is merged. Creates the release tag, reconstructs release notes from merged PR titles, and uploads the wasm asset. +1. `release-assistant.yml` - every PR to `main` needs `major`/`minor`/`patch` or `skip-release`. +2. `gen-release-pr.yml` - after Build on `main`, aggregates labeled PRs, computes the version, regenerates README/quickstart with the wasm sha, opens a `release-prep` PR. +3. `release.yml` - on merge of `release-prep`, tags, builds release notes from PR titles, uploads the wasm. --- -## ๐Ÿ“– External References - -- [SQLC Documentation](https://docs.sqlc.dev/) -- [Npgsql](https://www.npgsql.org/) -- [MySqlConnector](https://mysqlconnector.net/) -- [Dapper](https://github.com/DapperLib/Dapper) -- [NodaTime](https://nodatime.org/) - ---- +## 6. Known issues and existing layering violations + +Do not copy these patterns; fix them only in a dedicated change (they may change generated output). +- `DbDriver.NullableTypes` hardcodes Npgsql types (`NpgsqlPoint`, `NpgsqlCidr`, ...) - should be added by `NpgsqlDriver`. +- `NpgsqlDriver.ColumnMappings` `string` / `byte[]` include MySQL type names (`longtext`, `tinyblob`, ...). +- `JsonElement` / `object` reader lambdas hardcode `reader` instead of `Variable.Reader.AsVarName()`. +- `EnumDbDriver` is declared in the global namespace. +- `CommonGen.ConstructDapperParamsDict` enum branch is dead: `Enums` is keyed by schema, not enum name, and the + `ToEnumString()` it would emit does not exist. +- SQLite `:copyfrom` ignores overrides and writer functions (uses `p.Column.NotNull` directly). +- MySQL `:copyfrom` writes a fixed `input.csv` in the process working directory (not concurrency-safe). +- `GetIdColumnType` (`:execlastid` return type) is a heuristic: column `id`, else first column containing `id`, else the first column. +- Not supported: PostgreSQL `time with time zone`; MySQL geometry types; array columns get no `NpgsqlDbType` override (TODO in `DbDriver.GetColumnDbTypeOverride`). diff --git a/Drivers/AGENTS.md b/Drivers/AGENTS.md new file mode 100644 index 00000000..7138263f --- /dev/null +++ b/Drivers/AGENTS.md @@ -0,0 +1,106 @@ +# Drivers/AGENTS.md + +This project owns **all database knowledge** and **all generated method bodies**. Read the root `AGENTS.md` first. + +## Layout +| File | Responsibility | +|----------------------------------------|----------------| +| `DbDriver.cs` | Abstract base: type resolution, nullability, overrides, parameter binding, readers, usings, package refs, hooks | +| `EnumDbDriver.cs` | Base for DBs with enums (Npgsql, MySQL): enum lookup, enum readers, enum extension classes | +| `NpgsqlDriver.cs`, `MySqlConnectorDriver.cs`, `SqliteDriver.cs` | One sealed class per DB: `ColumnMappings` + hook overrides + `I*` annotation implementations | +| `ColumnMapping.cs` | `ColumnMapping` record and the `ReaderFn` / `WriterFn` / `ConvertFunc` / `SqlMapperImplFunc` delegates | +| `QueryAnnotations.cs` | `IOne`, `IMany`, `IExec`, `IExecRows`, `IExecLastId`, `ICopyFrom` - the contract CodeGenerator calls | +| `Variable.cs` | Every identifier used inside generated code | +| `Generators/DeclareGen.cs` | DB-agnostic method body per annotation; calls driver hooks for anything DB-specific | +| `Generators/CommonGen.cs` | Shared fragments: parameter list, Dapper params dict, reader loop, dataclass instantiation, slice SQL transform | +| `Generators/CancellationGen.cs` | Every way a `CancellationToken` appears in generated code | + +## Rules +- A `*DeclareGen` must never branch on the concrete driver type. If a DB needs something different, add or use a + hook on `DbDriver` and override it in the driver. +- A driver must never emit a whole method for `:one/:many/:exec/:execrows/:execlastid`; it provides fragments via hooks. + Only `ICopyFrom.GetCopyFromImpl` returns a whole method body (it is too DB-specific to share). +- `DbDriver` must not know about any specific DB (see "Existing violations" in the root file). +- Generated identifiers come from `Variable` (`Variable.Reader.AsVarName()` -> `reader`). Add a new enum member + rather than writing a literal. +- Cancellation goes through `Cancellation.Argument()`, `TrailingArgument()`, `MethodParameter()`, + `TrailingMethodParameter()`, `WrapDapperArgs()`. With the option off, output must not change. +- Every generator handles four combinations: {driver, Dapper} x {no transaction, `this.Transaction`}, and both + `net*` and legacy (`Options.DotnetFramework.IsDotnetCore()` / `IsDotnetLegacy()`). + +## Hooks a driver can override +| Hook | Kind | Purpose | +|-----------------------------------------|----------|---------| +| `ColumnMappings` | abstract | DB type -> C# type table (see below) | +| `TransactionClassName` | abstract | e.g. `NpgsqlTransaction` | +| `TransformQueryText(query)` | abstract | rewrite sqlc SQL into the driver's parameter syntax (`$1`/`?` -> `@name`); return `""` to emit no `XxxSql` constant | +| `EstablishConnection(query)` | abstract | connection/data-source acquisition + optional open statement | +| `CreateSqlCommand(sqlConst)` | abstract | command creation, set text, prepare | +| `AddParametersToCommand(query)` | virtual | bind parameters (Npgsql adds `NpgsqlDbType` overrides) | +| `GetWriterFn(column, query)` | virtual | value conversion before binding (enums, sets, JSON, NodaTime, ...) | +| `GetColumnReader(column, ordinal, query)` | virtual | reader expression | +| `GetLastIdStatement(query)` | virtual | `:execlastid` tail; base = `ExecuteScalarAsync` + `ConvertFunc` of the id type | +| `GetCopyFromImpl(query, sqlConst)` | `ICopyFrom` | full `:copyfrom` body | +| `GetPackageReferences()` | virtual | csproj `PackageReference`s (base adds Dapper, NodaTime, System.Text.Json when needed) | +| `GetUsingDirectivesForQueries/Models/Utils()` | virtual | usings per generated file | +| `GetClassBaseTypes()`, `GetConstructorStatements()`, `GetAdditionalClassMembers()` | virtual | query-class skeleton (data source, `IDisposable`) | +| `GetMemberDeclarationsForUtils()`, `GetConfigureSqlMappings()` | virtual | `Utils.cs` members, Dapper type handlers | +| `EnumDbDriver.GetEnumType / EnumToCsharpDataType / EnumToModelName / GetEnumReader / GetEnumExtensionsMembers` | abstract/virtual | enum behaviour | + +## Type mappings (`ColumnMappings`) +`Dictionary`; each `ColumnMapping` has: +- `DbTypes`: `dbTypeName -> DbTypeInfo(Length?, DbTypeOverride?)`. `Length` restricts the match + (MySQL `tinyint` with length 1 -> `bool`). `DbTypeOverride` is the `NpgsqlDbType.X` passed when binding / binary copying. +- `ReaderFn(ordinal, dbType)`, `ReaderArrayFn` (arrays, Npgsql), `WriterFn(el, dbType, notNull, isDapper, isLegacy)`. +- `ConvertFunc` - converts `ExecuteScalarAsync` output; **required** if the type can be an `:execlastid` id. +- `UsingDirectives`, `SqlMapper` (Dapper registration line), `SqlMapperImpl` (Dapper `TypeHandler` class source). + +Outliers: +- **First match wins, in insertion order.** `bool` (`tinyint`, length 1) must stay before `short` (`tinyint`) in MySQL. +- DB type names are compared **lowercased**; `NpgsqlDriver`'s constructor also registers every type as `pg_catalog.`. +- Entries with empty `DbTypes` (`[]`) - `Instant` everywhere, SQLite `DateTime` and `bool` - are reachable only through + overrides. Their reader/writer receives the *underlying* DB type (`dbType`) to choose a conversion + (SQLite: `integer` -> unix seconds, `text` -> formatted string `yyyy-MM-dd HH:mm:ss`). +- Usings from `ColumnMapping.UsingDirectives` are collected only from types that appear in **query result columns** + (`GetUsingDirectivesForColumnMappings`), not from parameters or table-only columns. +- Dapper type handlers and `SqlMapper` registrations are emitted only for types that appear in a query (`TypeExistsInQueries`). + +### Nullability +- `AddNullableSuffixIfNeeded`: not-null -> no suffix. Nullable -> `?` if the type is in `NullableTypes`, or always on `net*`. +- So on **legacy** targets only types listed in `NullableTypes` get `?`. Every new **value-type** mapping must be added + to `NullableTypes`, otherwise nullable columns generate a non-nullable value type on `netstandard*`. +- `IsColumnNotNull` honours `overrides[].csharp_type.notNull`; always use it instead of `column.NotNull`. + +## Per-database specifics +**PostgreSQL (`NpgsqlDriver`)** +- Params: `$N` -> `@name` via regex. Connections from a lazy `NpgsqlDataSource`; query class is `IDisposable`. +- Enums: type may be `schema.enum` in `column.Type.Name` with an empty `Schema`; enum C# name = PascalCase(`schema.enum`) + (schema omitted for the default schema). Writes use generated `Stringify()`, reads `To()`. +- `:copyfrom`: SQL replaced by `COPY () FROM STDIN (FORMAT BINARY)`; rows written with `BeginBinaryImportAsync`. +- `:execlastid`: relies on a user-written `RETURNING`. +- `sqlc.slice` is not supported (sqlc limitation) - use array parameters. + +**MySQL (`MySqlConnectorDriver`)** +- Params: each `?` replaced in order. Lazy `MySqlDataSource` with `ConnectionReset = true`, `MinimumPoolSize >= 1`. +- `:execlastid`: `TransformQueryText` appends `; SELECT LAST_INSERT_ID()` (driver and Dapper alike). +- `:copyfrom`: the SQL constant is empty; generated code writes the batch with CsvHelper to `input.csv` in the + current working directory, then `MySqlBulkLoader` (needs `AllowLoadLocalInfile=true` and server `--local-infile=1`). + Nulls are written as `\N` via `Utils.NullToStringCsvConverter`; bool/byte/byte[]/SET have dedicated converters. +- Enums are all looked up in the default schema by `column.Type.Name`; C# name = PascalCase of that name + (sqlc names them `
_`). +- `SET` columns are detected heuristically: `column.Length > sum(enum value lengths)` -> `HashSet`. + +**SQLite (`SqliteDriver`)** +- Params: numbered `?N` replaced by number, otherwise `?` replaced in order. A new `SqliteConnection` per call; no `IDisposable`. +- Only `integer`, `real` (-> `decimal`), `text`, `blob` map natively; `DateTime`, `bool`, `Instant` come via overrides. +- `:copyfrom`: `Utils.TransformQueryForSqliteBatch` expands the `VALUES (...)` clause at runtime with suffixed parameters. + Does not apply writer functions or overrides. +- `:execlastid`: relies on a user-written `RETURNING`. + +## Adding a type mapping +1. Add/extend the `ColumnMappings` entry in the right driver (reader, array reader, writer, `ConvertFunc`, usings, Dapper handler as needed). +2. Value type? Add it to `NullableTypes` (for a DB-specific type, add it from the driver, not the base list). +3. Add a column to `examples/config//types/schema.sql` and use it in `types/query.sql` (normal + `:copyfrom` if supported). +4. Cover it in `end2end/EndToEndScaffold/Templates/Tests.cs` (see `end2end/AGENTS.md`). +5. Update the "Supported Data Types" table in `docs/0X_.md`. +6. `make sqlc-generate && make test-plugin`. diff --git a/docs/05_MySql.md b/docs/05_MySql.md index 6aef8aaa..8027246b 100644 --- a/docs/05_MySql.md +++ b/docs/05_MySql.md @@ -2,28 +2,14 @@
:execlastid - Implementation -The implementation differs if we're using `Dapper` or not. - -### Driver - MySqlConnector -The driver provides a `LastInsertedId` property to get the latest inserted id in the DB. -When accessing the property, it automatically performs the below query: - -```sql -SELECT LAST_INSERT_ID(); -``` - -That will work only when the id column is defined as `serial` or `bigserial`, and the generated method will always return -a `long` value. - -### Dapper -Since the `LastInsertedId` is DB specific and hence not available in Dapper, the `LAST_INSERT_ID` query is simply -appended to the original query like this: +The `LAST_INSERT_ID` query is appended to the original query, both with and without `Dapper`: ```sql INSERT INTO tab1 (field1, field2) VALUES ('a', 1); SELECT LAST_INSERT_ID(); ``` -The generated method will return `int` & `long` for `serial` & `bigserial` respectively. +The result is read as a scalar and converted to the C# type of the table's id column +(`int` for `int`/`integer`/`mediumint`, `long` for `bigint`/`serial`).
diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 00000000..9260a81e --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,31 @@ +# docs/AGENTS.md + +`README.md` at the repo root is **generated**: `cat docs/0*.md > README.md` (`docs/scripts/generate_all_docs.sh`). +Edit the files in `docs/`, never `README.md`. + +| File | How it is maintained | +|----------------------|----------------------| +| `01_Intro.md`, `03_Usage.md`, `04_Postgres.md`, `05_MySql.md`, `06_Sqlite.md`, `07_Benchmark.md`, `08_Contributing.md` | hand-written | +| `02_Quickstart.md` | generated by `docs/scripts/generate_quickstart.sh` (latest release tag + wasm sha256; downloads from GitHub) | +| `09_Examples.md` | generated from `sqlc.ci.yaml` by `docs/ExamplesDocGen` | + +## README is updated by the release flow +`gen-release-pr.yml` runs `generate_all_docs.sh` with the new version and wasm sha and opens the `release-prep` PR. +A manual `docs.yml` workflow (`workflow_dispatch`) does the same against the latest release and opens an +`update-docs` PR. Those two workflows are the only places `README.md` and `02_Quickstart.md` should change. In a feature PR: +- edit the hand-written `docs/*.md` files; +- if `sqlc.ci.yaml` changed, regenerate `09_Examples.md`: + `dotnet run --project docs/ExamplesDocGen/ExampleDocGen.csproj > docs/09_Examples.md`; +- do **not** regenerate `README.md` / `02_Quickstart.md` (it would pin the previous release). + +So `README.md` normally lags behind `docs/` until the next release - that is expected. + +## What to keep in sync with the code +| Code | Doc | +|---------------------------------------------------|-----| +| `PluginOptions/RawOptions.cs` (options, defaults) | options table in `03_Usage.md`, root `AGENTS.md` section 5 | +| Default driver / Dapper versions (`Default*Version` constants in `Drivers/`) | `03_Usage.md` | +| A driver's `ColumnMappings` | "Supported Data Types" table in `04_Postgres.md` / `05_MySql.md` / `06_Sqlite.md` | +| `:execlastid` / `:copyfrom` implementation | the implementation `
` sections in the same files | +| Annotation / macro support | tables in `03_Usage.md` | +| Build / test / WASM tooling | `08_Contributing.md` | diff --git a/end2end/AGENTS.md b/end2end/AGENTS.md new file mode 100644 index 00000000..32061b51 --- /dev/null +++ b/end2end/AGENTS.md @@ -0,0 +1,47 @@ +# end2end/AGENTS.md + +End-to-end tests run the **generated example projects** (`examples/*`) against real databases. +Read the root `AGENTS.md` first. + +## Projects +| Project | Target | What it is | +|-----------------------|-----------------|------------| +| `EndToEndScaffold` | `net10.0` | Console app that **generates** the test classes from templates. | +| `EndToEndTests` | `net10.0` | Tests for the `net*` examples (`NpgsqlExample`, `MySqlConnectorDapperExample`, ...). | +| `EndToEndTestsLegacy` | `net472` | Same tests for the `*LegacyExample` (`netstandard2.0`) projects. Runs only on Windows in CI (`.github/workflows/legacy-tests.yml`). | +| `EndToEndCommon` | `netstandard2.0`| Shared helpers: env loading, SQLite DB setup. Also used by `benchmark/`. | + +## How a tester class is built +Each tester (`NpgsqlTester`, `NpgsqlDapperTester`, `MySqlConnectorTester`, ... - 6 in total) is a `partial` class in two files: +- `.cs` - **hand-written**: the `QuerySql` instance and the `[TearDown]` that empties every table. +- `.generated.cs` - **generated, do not edit**: produced by `EndToEndScaffold` via `end2end/scripts/generate_tests.sh` + (`make generate-end2end-tests`). CI regenerates and fails on `git diff`. + +The same pair exists in both `EndToEndTests/` and `EndToEndTestsLegacy/`. The legacy hand-written files must stay +C# 7.3 / `net472` compatible (block-scoped namespaces, no target-typed `new`, no `!`). + +## Adding or changing a test +1. Write the test body in `EndToEndScaffold/Templates/Tests.cs` as a `KnownTestType -> TestImpl` entry + (`AnnotationTests`, `MacroTests`, `PostgresTests`, `MySqlTests`, `SqliteTests`). +2. Add the `KnownTestType` member and register it in the right set(s) in `EndToEndScaffold/Config.cs` + (`_postgresTestTypes`, `_mysqlTestTypes`, `_sqliteTestTypes`). A test type is emitted into both the driver + and Dapper tester of that DB, and into both the `net*` and legacy projects. +3. One template must compile for all four variants. Use the placeholders instead of branching: + - `#UnknownValue#` (`Consts.UnknownRecordValuePlaceholder`) -> `.Value` where `:one` returns a nullable + `record struct` (net*, non-Dapper), empty otherwise. + - `#UnknownNullableIndicator#` (`Consts.UnknownNullableIndicatorPlaceholder`) -> `?` on net*, empty on legacy. +4. New table? Add its truncate/delete call to the hand-written `[TearDown]` in **both** `EndToEndTests/.cs` + and `EndToEndTestsLegacy/.cs` (note: the legacy `NpgsqlTester` currently lacks + `TruncatePostgresNotNullTypesAsync`). +5. `make generate-end2end-tests`, commit the `*.generated.cs` files, then `make run-end2end-tests`. + +## Databases +- PostgreSQL and MySQL run in docker-compose. Their schemas are **baked into the images at build time** + (`postgres.Dockerfile`, `mysql.Dockerfile` copy `examples/config//{types,authors}/schema.sql` plus + `benchmark/config//schema.sql`). `end2end/scripts/run_tests.sh` rebuilds the images; if you start the + containers yourself after a schema change, use `docker-compose up --build`. +- SQLite is a file DB created by `GlobalSetup` from the schemas copied into the test output + (`authors.sqlite.schema.sql`, `types.sqlite.schema.sql`) and deleted afterwards. +- Connection strings come from the root `.env` (`POSTGRES_CONNECTION_STRING`, `MYSQL_CONNECTION_STRING`, + `SQLITE_CONNECTION_STRING`). The MySQL one needs `AllowLoadLocalInfile=true` for `:copyfrom`. +- The legacy workflow (Windows) loads the PostgreSQL schemas itself with `psql`; keep it in mind when adding schema files. diff --git a/unit-tests/RepositoryTests/ArchitectureTests.cs b/unit-tests/RepositoryTests/ArchitectureTests.cs new file mode 100644 index 00000000..d05fd3a9 --- /dev/null +++ b/unit-tests/RepositoryTests/ArchitectureTests.cs @@ -0,0 +1,69 @@ +using System.Xml.Linq; + +namespace RepositoryTests; + +/// +/// Guards the plugin's layering (see AGENTS.md, "Project dependency graph"). +/// Adding a project reference requires updating this map on purpose. +/// +public class ArchitectureTests +{ + private static readonly Dictionary AllowedProjectReferences = new() + { + ["GeneratedProtobuf/GeneratedProtobuf.csproj"] = [], + ["Extensions/Extensions.csproj"] = [], + ["PluginOptions/PluginOptions.csproj"] = ["GeneratedProtobuf"], + ["Drivers/Drivers.csproj"] = ["Extensions", "PluginOptions"], + ["CodeGenerator/CodeGenerator.csproj"] = ["Drivers", "GeneratedProtobuf", "PluginOptions"], + ["SqlcGenCsharp/SqlcGenCsharp.csproj"] = ["CodeGenerator"], + ["LocalRunner/LocalRunner.csproj"] = ["SqlcGenCsharp"], + ["WasmRunner/WasmRunner.csproj"] = ["SqlcGenCsharp"] + }; + + [Test] + [TestCaseSource(nameof(PluginProjects))] + public void TestPluginProjectReferencesFollowLayering(string csprojPath) + { + var fullPath = Path.Combine(FindRepositoryRoot(), csprojPath); + Assert.That(File.Exists(fullPath), $"{csprojPath} not found"); + + var actual = XDocument.Load(fullPath) + .Descendants("ProjectReference") + .Select(r => Path.GetFileNameWithoutExtension(r.Attribute("Include")!.Value.Replace('\\', '/'))) + .Order() + .ToArray(); + + Assert.That(actual, Is.EqualTo(AllowedProjectReferences[csprojPath].Order().ToArray()), + $"{csprojPath} references {string.Join(", ", actual)} - update AGENTS.md and this test if the layering should change"); + } + + private static readonly string[] ConcreteDrivers = ["NpgsqlDriver", "MySqlConnectorDriver", "SqliteDriver"]; + + // Only CodeGenerator.InstantiateDriver may know the concrete drivers; everything else goes through DbDriver. + [Test] + [TestCase("CodeGenerator", "CodeGenerator.cs")] + [TestCase("Drivers/Generators", null)] + public void TestConcreteDriversAreNotReferenced(string directory, string? allowedFile) + { + var offenders = Directory + .EnumerateFiles(Path.Combine(FindRepositoryRoot(), directory), "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") && + !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) + .Where(f => Path.GetFileName(f) != allowedFile) + .Where(f => ConcreteDrivers.Any(File.ReadAllText(f).Contains)) + .Select(Path.GetFileName) + .ToArray(); + + Assert.That(offenders, Is.Empty, $"{directory} must use DbDriver hooks, not concrete drivers"); + } + + private static IEnumerable PluginProjects() => AllowedProjectReferences.Keys; + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "sqlc-gen-csharp.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("repository root (sqlc-gen-csharp.sln) not found"); + } +} \ No newline at end of file