Type-safe SQL generation for F#. Generate types from your database, query with strongly-typed computation expressions.
Supported Databases: SQL Server | PostgreSQL | SQLite | Oracle | MySQL
1. Install the CLI tool locally:
dotnet new tool-manifest
dotnet tool install --local SqlHydra.Cli2. Generate types from your database:
dotnet sqlhydra mssql # or: npgsql, sqlite, oracle, mysqlThe wizard will prompt you for connection string, output file, and namespace.
3. Install the query library:
dotnet add package SqlHydra.Query4. Configure Query Context:
SqlHydra.Cli now generates a DB‑specific QueryContextFactory for each generated database (perfect for DI injection).
Use it to create a strongly‑typed query context:
let db = AdventureWorks.QueryContextFactory.Create(connStr, printfn "SQL: %O") // Optional SQL output logging5. Write your first query:
open SqlHydra.Query
open AdventureWorks
// Query with full type safety
let getProducts minPrice =
selectTask db {
for p in SalesLT.Product do
where (p.ListPrice > minPrice)
orderBy p.Name
select p
}Note: All query builders have both
TaskandAsyncvariants:selectTask/selectAsync,insertTask/insertAsync,updateTask/updateAsync,deleteTask/deleteAsync.
That's it! Your queries are now type-checked at compile time.
SqlHydra.Cli reads your database schema and adds a generated file to your project that contains:
- F# record types for each table (with
Optiontypes for nullable columns) - Table declarations for use in queries
QueryContextFactorywith a staticCreate(connectionString: string)method.
// Generated from your database schema:
module SalesLT =
type Product =
{ ProductID: int
Name: string
ListPrice: decimal
Color: string option } // nullable columns become Option
let Product = table<Product> // table declaration for queriesLocal Install (recommended):
dotnet new tool-manifest
dotnet tool install SqlHydra.Clidotnet sqlhydra mssql # SQL Server
dotnet sqlhydra npgsql # PostgreSQL
dotnet sqlhydra sqlite # SQLite
dotnet sqlhydra oracle # Oracle
dotnet sqlhydra mysql # MySQL- If no
.tomlconfig exists, a wizard will guide you through setup - If a
.tomlconfig exists, it regenerates code using that config - Generated
.fsfiles are automatically added to your.fsprojasVisible="false"
The wizard prompts for:
- Connection String - Used to query your database schema
- Output Filename - e.g.,
AdventureWorks.fs - Namespace - e.g.,
MyApp.AdventureWorks - Use Case:
- SqlHydra.Query integration (default) - Generates everything needed for SqlHydra.Query
- Other data library - Just the record types (for Dapper.FSharp, Donald, etc.)
- Standalone - Record types + HydraReader (no SqlHydra.Query metadata)
For advanced configuration, see the TOML Configuration Reference.
To regenerate on Rebuild in Debug mode:
<Target Name="SqlHydra" BeforeTargets="Clean" Condition="'$(Configuration)' == 'Debug'">
<Exec Command="dotnet sqlhydra mssql" />
</Target>You can have multiple .toml files for different scenarios:
dotnet sqlhydra sqlite -t "shared.toml"
dotnet sqlhydra mssql -t "reporting.toml"Useful for data migrations or generating types with different filters.
let getProducts (db: QueryContextFactory) =
selectTask db {
for p in SalesLT.Product do
select p
}let getExpensiveProducts (db: QueryContextFactory) minPrice =
selectTask db {
for p in SalesLT.Product do
where (p.ListPrice > minPrice)
select p
}Where operators:
| Operator | Function | Description |
|---|---|---|
|=| |
isIn |
Column IN list |
|<>| |
isNotIn |
Column NOT IN list |
=% |
like |
LIKE pattern |
<>% |
notLike |
NOT LIKE pattern |
= None |
isNullValue |
IS NULL |
<> None |
isNotNullValue |
IS NOT NULL |
// Filter where City starts with 'S'
let getCitiesStartingWithS (db: QueryContextFactory) =
selectTask db {
for a in SalesLT.Address do
where (a.City =% "S%")
select a
}Use && to conditionally include/exclude where clauses:
let getAddresses (db: QueryContextFactory) (cityFilter: string option) (zipFilter: string option) =
selectTask db {
for a in Person.Address do
where (
(cityFilter.IsSome && a.City = cityFilter.Value) &&
(zipFilter.IsSome && a.PostalCode = zipFilter.Value)
)
}If cityFilter.IsSome is false, that clause is excluded from the query.
// Inner join
let getProductsWithCategory (db: QueryContextFactory) =
selectTask db {
for p in SalesLT.Product do
join c in SalesLT.ProductCategory on (p.ProductCategoryID.Value = c.ProductCategoryID)
select (p, c.Name)
take 10
}
// Left join (joined table becomes Option).
// You can use `|> Option.map` to select specifc left joined columns.
let getCustomerAddresses (db: QueryContextFactory) =
selectTask db {
for c in SalesLT.Customer do
leftJoin a in SalesLT.Address on (c.AddressID = a.Value.AddressID)
select (
c.Email,
a |> Option.map _.State
) into selected
mapList (
let email, stateMaybe = selected
let state = stateMaybe |> Option.defaultValue "N/A"
$"Customer: {email}, State: {state}"
)
}
// Improved join syntax with `join'` and `leftJoin'` lets you use full predicates in `on'` clauses.
// * Makes multi-column joins much cleaner (no need for tuple comparison).
// * Allows full predicates (e.g., AND/OR) in join conditions.
// * Optional cheeky usage of `;` if you want `on'` on the same line!
selectTask db {
for o in Sales.SalesOrderHeader do
join' d in Sales.SalesOrderDetail; on' (o.ID = d.OrderID && o.Status = "Completed")
select o
}
Note: In join
onclauses, put the known (left) table on the left side of the=.
// Select specific columns
let getCityStates (db: QueryContextFactory) =
selectTask db {
for a in SalesLT.Address do
select (a.City, a.StateProvince)
}
// Transform results with mapList
let getCityLabels (db: QueryContextFactory) =
selectTask db {
for a in SalesLT.Address do
select (a.City, a.StateProvince) into (city, state)
mapList $"City: {city}, State: {state}"
}let getCategoriesWithHighPrices (db: QueryContextFactory) =
selectTask db {
for p in SalesLT.Product do
where (p.ProductCategoryID <> None)
groupBy p.ProductCategoryID
having (avgBy p.ListPrice > 500M)
select (p.ProductCategoryID, avgBy p.ListPrice)
}
// Count
let getCustomerCount (db: QueryContextFactory) =
selectTask db {
for c in SalesLT.Customer do
count
}Aggregate functions: countBy, sumBy, minBy, maxBy, avgBy
Warning: If an aggregate might return NULL (e.g.,
minByon an empty result set), wrap inSome:select (minBy (Some p.ListPrice)) // Returns Option
SqlHydra.Query includes built-in SQL functions for each supported database provider. These can be used in both select and where clauses.
Setup:
// Import the extension module for your database provider:
open SqlHydra.Query.SqlServerExtensions // SQL Server
open SqlHydra.Query.NpgsqlExtensions // PostgreSQL
open SqlHydra.Query.SqliteExtensions // SQLite
open SqlHydra.Query.OracleExtensions // Oracle
open SqlHydra.Query.MySqlExtensions // MySQL
open type SqlFn // Optional: allows unqualified access, e.g. LEN vs SqlFn.LENUse in select and where clauses:
// String functions
selectTask db {
for p in Person.Person do
where (LEN(p.FirstName) > 3)
select (p.FirstName, LEN(p.FirstName), UPPER(p.FirstName))
}
// Generates: SELECT ... WHERE LEN([p].[FirstName]) > 3
// Null handling - ISNULL accepts Option<'T> and returns unwrapped 'T
selectTask db {
for p in Person.Person do
select (ISNULL(p.MiddleName, "N/A")) // Option<string> -> string
}
// Date functions
selectTask db {
for o in Sales.SalesOrderHeader do
where (YEAR(o.OrderDate) = 2024)
select (o.OrderDate, YEAR(o.OrderDate), MONTH(o.OrderDate))
}
// Compare two functions
selectTask db {
for p in Person.Person do
where (LEN(p.FirstName) < LEN(p.LastName))
select (p.FirstName, p.LastName)
}Built-in functions include string functions (LEN, UPPER, SUBSTRING, etc.), null handling (ISNULL/COALESCE with overloads for Option<'T> and Nullable<'T>), numeric functions (ABS, ROUND, etc.), and date/time functions (GETDATE, YEAR, MONTH, etc.).
See the full list for each provider:
Define custom functions:
You can easily define your own SQL function wrappers using the sqlFn helper:
// Define a wrapper - the function name becomes the SQL function name
let SOUNDEX (s: string) : string = sqlFn
let DIFFERENCE (s1: string, s2: string) : int = sqlFn
// Use in queries
selectTask db {
for p in Person.Person do
where (SOUNDEX(p.LastName) = SOUNDEX("Smith"))
select p.LastName
}Note: The
sqlFnhelper returnsUnchecked.defaultof<'Return>- the function is never executed at runtime. The expression visitor translates the function name and arguments to SQL. If you use an invalid function name, you'll get a database error at runtime.
// Subquery returning multiple values
let top5Categories =
select {
for p in SalesLT.Product do
groupBy p.ProductCategoryID
orderByDescending (avgBy p.ListPrice)
select p.ProductCategoryID
take 5
}
let getTopCategoryNames (db: QueryContextFactory) =
selectTask db {
for c in SalesLT.ProductCategory do
where (Some c.ProductCategoryID |=| subqueryMany top5Categories)
select c.Name
}
// Subquery returning single value
let avgPrice =
select {
for p in SalesLT.Product do
select (avgBy p.ListPrice)
}
let getAboveAverageProducts (db: QueryContextFactory) =
selectTask db {
for p in SalesLT.Product do
where (p.ListPrice > subqueryOne avgPrice)
select p
}// Ordering
selectTask db {
for p in SalesLT.Product do
orderBy p.Name
thenByDescending p.ListPrice
select p
}
// Conditional ordering with ^^
let getAddresses (db: QueryContextFactory) (sortByCity: bool) =
selectTask db {
for a in Person.Address do
orderBy (sortByCity ^^ a.City)
select a
}
// Pagination
selectTask db {
for p in SalesLT.Product do
skip 10
take 20
select p
}
// Distinct
selectTask db {
for c in SalesLT.Customer do
select (c.FirstName, c.LastName)
distinct
}
// Get single/optional result
selectTask db {
for p in SalesLT.Product do
where (p.ProductID = 123)
select p
tryHead // Returns Option
}The select clause only supports selecting columns/tables - not transformations like .ToString() or string interpolation.
Correct: Transform in mapList/mapArray/mapSeq:
selectTask db {
for a in SalesLT.Address do
select (a.City, a.StateProvince) into (city, state)
mapList $"City: {city}, State: {state}"
}Incorrect: Transforming in select throws at runtime:
// DON'T DO THIS - will throw!
selectTask db {
for a in SalesLT.Address do
select ($"City: {a.City}")
}// Simple insert
let! rowsInserted =
insertTask db {
into dbo.Person
entity { ID = Guid.NewGuid(); FirstName = "John"; LastName = "Doe" }
}
// Insert with identity column
let! newId =
insertTask db {
for e in dbo.ErrorLog do
entity { ErrorLogID = 0; ErrorMessage = "Test"; (* ... *) }
getId e.ErrorLogID // Returns the generated ID
}
// Multiple inserts
match items |> AtLeastOne.tryCreate with
| Some items ->
insertTask db {
into dbo.Product
entities items
}
| None ->
printfn "Nothing to insert"// Update specific fields
updateTask db {
for e in dbo.ErrorLog do
set e.ErrorMessage "Updated message"
set e.ErrorNumber 500
where (e.ErrorLogID = 1)
}
// Update entire entity
updateTask db {
for e in dbo.ErrorLog do
entity errorLog
excludeColumn e.ErrorLogID // Don't update the ID
where (e.ErrorLogID = errorLog.ErrorLogID)
}
// Update all rows (requires explicit opt-in)
updateTask db {
for c in Sales.Customer do
set c.AccountNumber "123"
updateAll
}SqlHydra.Query v3.5+ supports insert-or-update (upsert) for SQL Server via the new insertOrUpdateOnUnique custom operation. This allows you to atomically insert a row or update it if a row with the same unique key already exists.
The goal was to provide a built-in upsert capability for SQL Server that is analogous to the onConflictDoUpdate style upsert extensions already available for SQLite and PostgreSQL queries. A key design decision was to avoid using SQL Server's MERGE statement in order to sidestep its well-known footguns .
The generated SQL uses a TRY/CATCH pattern that:
- Attempts the
INSERT - If it fails with a duplicate key violation (error 2627 or 2601), falls back to an
UPDATE - If the
UPDATEaffects 0 rows (due to a concurrent delete), retries theINSERT
open SqlHydra.Query.SqlServerExtensions
let saveUser (user: Domain.User) =
let utcNow = System.DateTime.UtcNow
insertTask db {
for u in dbo.Users do
entity {
Id = user.Id
Username = user.Username
Email = user.Email
CreatedDate = utcNow
UpdatedDate = utcNow
}
insertOrUpdateOnUnique
// Match on unique key (supports tuple for composite keys):
u.Id
// If unique key is matched, update columns in the tuple below:
(
u.Username,
u.Email,
u.UpdatedDate
)
}open SqlHydra.Query.NpgsqlExtensions
// open SqlHydra.Query.SqliteExtensions
let saveUser (user: Domain.User) =
let utcNow = System.DateTime.UtcNow
insertTask db {
for u in dbo.Users do
entity {
Id = user.Id
Username = user.Username
Email = user.Email
CreatedDate = utcNow
UpdatedDate = utcNow
}
onConflictDoUpdate
u.Id // If key is matched, update columns in the tuple below:
(
u.Username,
u.Email,
u.UpdatedDate
)
}deleteTask db {
for e in dbo.ErrorLog do
where (e.ErrorLogID = 5)
}
// Delete all rows (requires explicit opt-in)
deleteTask db {
for c in Sales.Customer do
deleteAll
}let completeOrder (db: QueryContextFactory) orderId = task {
use! shared = db.CreateContextAsync()
shared.BeginTransaction()
// Update status for order
do! updateTask shared {
for o in dbo.Orders do
set o.Status "Complete"
where (o.Id = orderId)
} : Task
// Write to audit log
do! insertTask shared {
into dbo.AuditLog
entity { Message = $"Completed order {orderId}"; Timestamp = DateTime.UtcNow }
} : Task
shared.CommitTransaction()
}For operations not directly supported, use the kata operation:
select {
for c in main.Customer do
where (c.FirstName = "John")
kata (fun query ->
query.OrderByRaw("LastName COLLATE NOCASE")
)
}let getTop10Products (db: QueryContextFactory) (conn: SqlConnection) = task {
let sql = "SELECT TOP 10 * FROM Product"
use cmd = new SqlCommand(sql, conn)
use! reader = cmd.ExecuteReaderAsync()
let hydra = HydraReader(reader)
return [
while reader.Read() do
hydra.``dbo.Product``.Read()
]
}open SqlHydra.Query.SqlServerExtensions
let! (created, updated) =
insertTask db {
for p in dbo.Person do
entity person
output (p.CreateDate, p.UpdateDate)
}Enum Types: Postgres enums are generated as CLR enums. Register them with Npgsql:
let dataSource =
let builder = NpgsqlDataSourceBuilder("connection string")
builder.MapEnum<ext.mood>("ext.mood") |> ignore
builder.Build()Arrays: text[] and integer[] column types are supported.
SQLite uses type affinity. Use standard type aliases in your schema for proper .NET type mapping. See: SQLite Type Affinity
If you get SSL certificate errors, append ;TrustServerCertificate=True to your connection string.
(Fixed in Microsoft.Data.SqlClient v4.1.1+)
SqlHydra supports type mapping extensions via the IExtendTypeMapping interface in SqlHydra.Domain. This lets you add custom database-to-CLR type mappings that SqlHydra doesn't handle out of the box.
When SqlHydra.Cli runs, it automatically scans your target project's build output for types implementing IExtendTypeMapping. Your extension wraps the built-in type mapping function, giving you a chance to handle custom types before falling back to the default behavior.
Add a class implementing IExtendTypeMapping anywhere in your project:
open SqlHydra.Domain
type MyCustomMapping() =
interface IExtendTypeMapping with
member _.Extend(baseTryFind) =
fun (ctx: TypeMappingContext) ->
match ctx.Column.ProviderTypeName.ToLower() with
| "vector" ->
Some {
TypeMapping.ColumnTypeAlias = "vector"
TypeMapping.ClrType = "Pgvector.Vector"
TypeMapping.DbType = System.Data.DbType.Object
TypeMapping.ProviderDbType = Some "Vector"
}
| _ -> baseTryFind ctxThat's it -- no configuration needed. The next time you run dotnet sqlhydra, your extension will be discovered automatically from your project's build output.
Note: Make sure your project is built before running
sqlhydraso the extension can be discovered.
Your extension receives a TypeMappingContext with full schema metadata for the column being mapped:
type TypeMappingContext =
{
Table: TableSchema // Table catalog, schema, name, type, and all columns
Column: ColumnSchema // Column name, type, nullability, precision, scale, etc.
}This lets you make mapping decisions based on the table name, column name, schema, or any other metadata -- not just the provider type name.
If a type mapping extension is published as a NuGet package (e.g. SqlHydra.Query.PgVector), add it as a PackageReference in your project and then register it in your TOML configuration:
[extensions]
type_mappings = ["SqlHydra.Query.PgVector"]SqlHydra will resolve the assembly from your project's build output and load any IExtendTypeMapping implementations it finds.
Multiple extensions compose in order -- each wraps the previous one. An extension should call baseTryFind ctx for any types it doesn't handle, allowing the next extension (or the built-in mappings) to take over.
- .NET 8, .NET 9, and .NET 10 are supported
- For .NET 5 support, use the older provider-specific tools (
SqlHydra.SqlServer, etc.)
- Uses VS Code Remote Containers for dev environment with test databases
- Or run
docker-composemanually with your IDE - See Contributing Wiki
|
💻 |
💻 |
💻 |
💻 |
💻 |
|
💻 |
💻 |
💻 |
💻 |
💻 |