https://github.com/jordanmarr/sqlhydra
SqlHydra is a suite of NuGet packages for working with databases in F# including code generation tools and query expressions.
https://github.com/jordanmarr/sqlhydra
fsharp orm typeprovider
Last synced: 4 months ago
JSON representation
SqlHydra is a suite of NuGet packages for working with databases in F# including code generation tools and query expressions.
- Host: GitHub
- URL: https://github.com/jordanmarr/sqlhydra
- Owner: JordanMarr
- License: mit
- Created: 2021-04-14T02:12:17.000Z (about 5 years ago)
- Default Branch: main
- Last Pushed: 2026-01-30T17:01:03.000Z (5 months ago)
- Last Synced: 2026-01-30T17:50:46.471Z (5 months ago)
- Topics: fsharp, orm, typeprovider
- Language: F#
- Homepage:
- Size: 26.8 MB
- Stars: 256
- Watchers: 6
- Forks: 28
- Open Issues: 6
-
Metadata Files:
- Readme: README.md
- Funding: .github/FUNDING.yml
- License: LICENSE
Awesome Lists containing this project
README
# SqlHydra
Type-safe SQL generation for F#. Generate types from your database, query with strongly-typed computation expressions.
[](https://www.nuget.org/packages/SqlHydra.Cli/)
[](https://www.nuget.org/packages/SqlHydra.Query/)
**Supported Databases:** SQL Server | PostgreSQL | SQLite | Oracle | MySQL
---
## Quick Start
**1. Install the CLI tool locally:**
```bash
dotnet new tool-manifest
dotnet tool install SqlHydra.Cli
```
**2. Generate types from your database:**
```bash
dotnet sqlhydra mssql # or: npgsql, sqlite, oracle, mysql
```
The wizard will prompt you for **connection string**, **output file**, and **namespace**.
**3. Install the query library:**
```bash
dotnet add package SqlHydra.Query
```
**4. 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:
```fsharp
let db = AdventureWorks.QueryContextFactory.Create(connStr, printfn "SQL: %O") // Optional SQL output logging
```
**5. Write your first query:**
```fsharp
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 `Task` and `Async` variants: `selectTask`/`selectAsync`, `insertTask`/`insertAsync`, `updateTask`/`updateAsync`, `deleteTask`/`deleteAsync`.
That's it! Your queries are now type-checked at compile time.
---
## What Gets Generated?
SqlHydra.Cli reads your database schema and generates:
- **F# record types** for each table (with Option types for nullable columns)
- **Table declarations** for use in queries
- **HydraReader** for efficiently reading query results
```fsharp
// 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 // table declaration for queries
```
---
SqlHydra.Cli Reference
### Installation
**Local Install (recommended):**
```bash
dotnet new tool-manifest
dotnet tool install SqlHydra.Cli
```
### Running the CLI
```bash
dotnet sqlhydra mssql # SQL Server
dotnet sqlhydra npgsql # PostgreSQL
dotnet sqlhydra sqlite # SQLite
dotnet sqlhydra oracle # Oracle
dotnet sqlhydra mysql # MySQL
```
- If no `.toml` config exists, a wizard will guide you through setup
- If a `.toml` config exists, it regenerates code using that config
- Generated `.fs` files are automatically added to your `.fsproj` as `Visible="false"`
### Configuration Wizard
The wizard prompts for:
1. **Connection String** - Used to query your database schema
2. **Output Filename** - e.g., `AdventureWorks.fs`
3. **Namespace** - e.g., `MyApp.AdventureWorks`
4. **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](https://github.com/JordanMarr/SqlHydra/wiki/TOML-Configuration).
### Auto-Regeneration (Build Event)
To regenerate on Rebuild in Debug mode:
```xml
```
### Multiple TOML Files
You can have multiple `.toml` files for different scenarios:
```bash
dotnet sqlhydra sqlite -t "shared.toml"
dotnet sqlhydra mssql -t "reporting.toml"
```
Useful for data migrations or generating types with different filters.
Select Queries
### Basic Select
```fsharp
let getProducts (db: QueryContextFactory) =
selectTask db {
for p in SalesLT.Product do
select p
}
```
### Where Clauses
```fsharp
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 |
```fsharp
// Filter where City starts with 'S'
let getCitiesStartingWithS (db: QueryContextFactory) =
selectTask db {
for a in SalesLT.Address do
where (a.City =% "S%")
select a
}
```
### Conditional Where (v3.0+)
Use `&&` to conditionally include/exclude where clauses:
```fsharp
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.
### Joins
```fsharp
// 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 `on` clauses, put the known (left) table on the left side of the `=`.
### Selecting Columns
```fsharp
// 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}"
}
```
### Aggregates
```fsharp
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., `minBy` on an empty result set), wrap in `Some`:
> ```fsharp
> select (minBy (Some p.ListPrice)) // Returns Option
> ```
### SQL Functions
SqlHydra.Query includes built-in SQL functions for each supported database provider. These can be used in both `select` and `where` clauses.
**Setup:**
```fsharp
// 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.LEN
```
**Use in select and where clauses:**
```fsharp
// 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
}
// 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:
- [SQL Server](src/SqlHydra.Query/SqlServerExtensions.fs)
- [PostgreSQL](src/SqlHydra.Query/NpgsqlExtensions.fs)
- [SQLite](src/SqlHydra.Query/SqliteExtensions.fs)
- [Oracle](src/SqlHydra.Query/OracleExtensions.fs)
- [MySQL](src/SqlHydra.Query/MySqlExtensions.fs)
**Define custom functions:**
You can easily define your own SQL function wrappers using the `sqlFn` helper:
```fsharp
// 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 `sqlFn` helper returns `Unchecked.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.
### Subqueries
```fsharp
// 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
}
```
### Other Operations
```fsharp
// 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
}
```
### Transforming Results (Important!)
The `select` clause only supports selecting columns/tables - **not** transformations like `.ToString()` or string interpolation.
**Correct:** Transform in `mapList`/`mapArray`/`mapSeq`:
```fsharp
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:
```fsharp
// DON'T DO THIS - will throw!
selectTask db {
for a in SalesLT.Address do
select ($"City: {a.City}")
}
```
Insert, Update, Delete
### Insert
```fsharp
// 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
```fsharp
// 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
}
```
### Upsert - SQL Server (`insertOrUpdateOnUnique`)
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 ](https://www.mssqltips.com/sqlservertip/3074/use-caution-with-sql-servers-merge-statement/).
#### How It Works
The generated SQL uses a `TRY/CATCH` pattern that:
1. Attempts the `INSERT`
2. If it fails with a duplicate key violation (error 2627 or 2601), falls back to an `UPDATE`
3. If the `UPDATE` affects 0 rows (due to a concurrent delete), retries the `INSERT`
```fsharp
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
u.Id // If key is matched, update columns in the tuple below:
(
u.Username,
u.Email,
u.UpdatedDate
)
}
```
### Upsert - PostgreSQL and SQLite (`onConflictDoUpdate`)
```fsharp
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
)
}
```
### Delete
```fsharp
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
}
```
Advanced Topics
### Sharing a QueryContext Transaction Across Multiple Operations
```fsharp
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()
}
```
### Custom SqlKata Operations
For operations not directly supported, use the `kata` operation:
```fsharp
select {
for c in main.Customer do
where (c.FirstName = "John")
kata (fun query ->
query.OrderByRaw("LastName COLLATE NOCASE")
)
}
```
### Custom SQL with HydraReader
```fsharp
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()
]
}
```
### SQL Server OUTPUT Clause
```fsharp
open SqlHydra.Query.SqlServerExtensions
let! (created, updated) =
insertTask db {
for p in dbo.Person do
entity person
output (p.CreateDate, p.UpdateDate)
}
```
Database-Specific Notes
### PostgreSQL
**Enum Types:** Postgres enums are generated as CLR enums. Register them with Npgsql:
```fsharp
let dataSource =
let builder = NpgsqlDataSourceBuilder("connection string")
builder.MapEnum("ext.mood") |> ignore
builder.Build()
```
**Arrays:** `text[]` and `integer[]` column types are supported.
### SQLite
SQLite uses type affinity. Use standard type aliases in your schema for proper .NET type mapping.
See: [SQLite Type Affinity](https://www.sqlite.org/datatype3.html#affinity_name_examples)
### SQL Server
If you get SSL certificate errors, append `;TrustServerCertificate=True` to your connection string.
(Fixed in `Microsoft.Data.SqlClient` v4.1.1+)
Supported Frameworks
- .NET 8, .NET 9, and .NET 10 are supported
- For .NET 5 support, use the older provider-specific tools (`SqlHydra.SqlServer`, etc.)
Contributing
- Uses VS Code Remote Containers for dev environment with test databases
- Or run `docker-compose` manually with your IDE
- See [Contributing Wiki](https://github.com/JordanMarr/SqlHydra/wiki/Contributing)
### Contributors
---
## Links
- [TOML Configuration Reference](https://github.com/JordanMarr/SqlHydra/wiki/TOML-Configuration)
- [Using HydraReader with other libraries](https://github.com/JordanMarr/SqlHydra/wiki/DataReaders)
- [SqlKata Documentation](https://sqlkata.com/)