Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
476 changes: 222 additions & 254 deletions AGENTS.md

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions Drivers/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/<Annotation>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<string csharpType, ColumnMapping>`; 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.<type>`.
- 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<Enum>()`.
- `:copyfrom`: SQL replaced by `COPY <table> (<cols>) 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 `<table>_<column>`).
- `SET` columns are detected heuristically: `column.Length > sum(enum value lengths)` -> `HashSet<Enum>`.

**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/<db>/types/schema.sql` and use it in `types/query.sql` (normal + `:copyfrom` if supported).
4. Cover it in `end2end/EndToEndScaffold/Templates/<Db>Tests.cs` (see `end2end/AGENTS.md`).
5. Update the "Supported Data Types" table in `docs/0X_<Db>.md`.
6. `make sqlc-generate && make test-plugin`.
20 changes: 3 additions & 17 deletions docs/05_MySql.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,14 @@
<details>
<summary>:execlastid - Implementation</summary>

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`).

</details>

Expand Down
31 changes: 31 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 `<details>` sections in the same files |
| Annotation / macro support | tables in `03_Usage.md` |
| Build / test / WASM tooling | `08_Contributing.md` |
47 changes: 47 additions & 0 deletions end2end/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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:
- `<Tester>.cs` - **hand-written**: the `QuerySql` instance and the `[TearDown]` that empties every table.
- `<Tester>.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/<Area>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/<Tester>.cs`
and `EndToEndTestsLegacy/<Tester>.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/<db>/{types,authors}/schema.sql` plus
`benchmark/config/<db>/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.
69 changes: 69 additions & 0 deletions unit-tests/RepositoryTests/ArchitectureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Xml.Linq;

namespace RepositoryTests;

/// <summary>
/// Guards the plugin's layering (see AGENTS.md, "Project dependency graph").
/// Adding a project reference requires updating this map on purpose.
/// </summary>
public class ArchitectureTests
{
private static readonly Dictionary<string, string[]> 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<string> 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");
}
}
Loading