SqlHydra

repository·main·Indexed 42 days ago

https://github.com/jordanmarr/sqlhydra

A tool for F# developers to achieve type-safe SQL generation. It provides a CLI to generate F# types from existing database schemas and a strongly-typed DSL for writing queries via computation expressions. Supports mssql, npgsql (PostgreSQL), sqlite, oracle, and mysql providers.

Tokens
7.7K
Snippets
36
Records
40
Agent score
4%

What's inside SqlHydra

  1. What SqlHydra generates from your database schema

    main

    SqlHydra.Cli reads your database schema and generates an F# file containing:

    • F# record types: One for each table. Nullable columns are automatically mapped to Option types.
    • Table declarations: Used to reference tables within selectTask blocks.
    • QueryContextFactory: A factory with a static Create(connectionString: string) method to initialize your database connection.

    Example of generated code:

    module SalesLT =
        type Product =
            { ProductID: int
              Name: string
              ListPrice: decimal
              Color: string option }  // nullable columns become Option
    
    let Product = table<Product>  // table declaration for queries
    module SalesLT =
        type Product =
            { ProductID: int
              Name: string
              ListPrice: decimal
              Color: string option }
    
    let Product = table<Product>
  2. Use Left-Joined Views for cleaner Left Joins

    main

    If left_joined_views = true is enabled in your .toml config, SqlHydra generates a LeftJoined module for each schema. This module contains records where every column is in its nullable form (e.g., string option).

    This allows you to join without wrapping the entire record in an Option, making the on clause much cleaner:

    for o in Sales.SalesOrderHeader do
    // No .Value or Some required in the ON clause
    leftJoin d in Sales.LeftJoined.SalesOrderDetail on (o.SalesOrderID = d.SalesOrderID)
    where (d.OrderQty = Some 2s)
    select (o, d)

    To convert a left-joined view back into a full record Option, use the generated ToOption() extension method:

    let orders = rows |> Seq.map (fun (o, d) -> o, d.ToOption())
    leftJoin d in Sales.LeftJoined.SalesOrderDetail on (o.SalesOrderID = d.SalesOrderID)
    where (d.OrderQty = Some 2s)
    select (o, d)
  3. Compose multiple type mapping extensions

    main
    Multiple extensions can be used together; they compose in order, where each extension wraps the previous one. To ensure the chain continues correctly, an extension should call baseTryFind ctx for any types it does not explicitly handle. This allows the next extension in the chain, or the built-in SqlHydra mappings, to process the type.
  4. Use TypeMappingContext for custom type mapping

    main

    When implementing a type mapping extension, you receive a TypeMappingContext object. This object provides full schema metadata for the column currently being mapped, allowing you to make decisions based on more than just the provider type name. You can inspect the table name, column name, schema, or other metadata.

    Available metadata fields in TypeMappingContext:

    • Table: Contains TableSchema (catalog, schema, name, type, and all columns).
    • Column: Contains ColumnSchema (name, type, nullability, precision, scale, etc.).
    type TypeMappingContext =
        {
            Table: TableSchema   // Table catalog, schema, name, type, and all columns
            Column: ColumnSchema  // Column name, type, nullability, precision, scale, etc.
        }
  5. Use Common Table Expressions (CTEs)

    main

    A CTE creates a named WITH source from a select query. The resulting source can be queried like a table or used in joins.

    • Use cte<'T> "name" query when the CTE's row shape matches the inner select's type.
    • Use cteFrom<'T> "name" query when the CTE's row shape differs from the inner select (e.g., when using computed columns).
    let dallasAddresses =
        select {
            for a in SalesLT.Address do
            where (a.City = "Dallas")
        }
    
    let dallas = cte<SalesLT.Address> "dallas_addresses" dallasAddresses
    
    let getDallasAddressIds (db: QueryContextFactory) = 
        selectTask db {
            for a in dallas do
            select a.AddressID
        }
  6. Handle PostgreSQL database-owned columns with Write Records

    main

    For tables with columns that the database manages (e.g., GENERATED ALWAYS AS IDENTITY or STORED columns), SqlHydra generates a secondary record type named {table}_write (implementing IWriteOf<{table}>). This record excludes the read-only columns to prevent insertion errors.

    To use these in queries:

    • Use writeEntity to tell the query to use the write-optimized record instead of the full read record.
    • For updates, use writeEntity { toWrite row with ... } to map a read record back to its write counterpart.
    • For upserts (on conflict), use the ...Write twin of the command (e.g., doUpdateWrite) which accepts a lambda operating on the write record.
    // UPDATE: Using a read record to update only allowed columns
    updateTask db {
        for i in sales.invoice do
        entity { row with price = 20m }   // 'row' is the read record; 'id' and 'tax' are not sent
        where (i.id = row.id)
    }
    
    // INSERT: Using the write record directly
    insertTask db {
        into sales.invoice
        writeEntity { seq = 7; price = 10m }
    }
    
    // UPSERT: Using the lambda-based write twin for PostgreSQL
    insertTask db {
        for i in sales.invoice do
        writeEntity { seq = 7; price = 10m }
        onConflict i.seq
        doUpdateWrite (fun w -> w.price)   // 'w' is the sales.invoice_write record
    }
  7. Perform PostgreSQL Lateral Joins

    main

    To perform lateral joins in PostgreSQL, use lateralJoin from SqlHydra.Query.NpgsqlExtensions.

    1. Use correlate inside the subquery to reference tables from the outer query.
    2. Use lateralCol<'T> "alias" "column" in the outer select to access columns from the joined subquery.
    open SqlHydra.Query.NpgsqlExtensions
    
    let latestDetail =
        select {
            for d in sales.salesorderdetail do
            correlate o in sales.salesorderheader
            where (d.salesorderid = o.salesorderid)
            orderByDescending d.modifieddate
            select d.orderqty
            take 1
        }
    
    selectTask db {
        for o in sales.salesorderheader do
        lateralJoin latestDetail "latest"
        select (o.salesorderid, lateralCol<int16> "latest" "orderqty")
    }
  8. Perform Upserts (Insert or Update)

    main

    SqlHydra supports atomic upsert operations across different database providers:

    SQL Server

    Use insertOrUpdateOnUnique from SqlHydra.Query.SqlServerExtensions. This uses a TRY/CATCH pattern to avoid the pitfalls of the SQL MERGE statement. You must provide the unique key and the tuple of columns to update.

    PostgreSQL and SQLite

    Use onConflictDoUpdate from SqlHydra.Query.NpgsqlExtensions or SqlHydra.Query.SqliteExtensions.

    // SQL Server Upsert
    insertTask db {
        for u in dbo.Users do
        entity { Id = user.Id; Username = user.Username; ... }
        insertOrUpdateOnUnique
            u.Id
            (u.Username, u.Email, u.UpdatedDate)
    }
    
    // PostgreSQL Upsert
    insertTask db {
        for u in dbo.Users do
        entity { ... }
        onConflictDoUpdate
            u.Id
            (u.Username, u.Email, u.UpdatedDate)
    }
  9. Perform Insert, Update, and Delete operations

    main

    SqlHydra provides specialized tasks for data modification:

    • Insert: Use insertTask with entity for single rows or entities for multiple. Use getId to retrieve identity columns.
    • Insert From Select: Use fromSelect to perform an INSERT INTO ... SELECT ... without round-tripping data through the application.
    • Update: Use updateTask with set to modify specific fields or entity to update an entire object. Use excludeColumn to prevent updating specific fields like IDs.
    • Delete: Use deleteTask with where to remove specific rows. Use deleteAll to remove all rows (requires explicit opt-in).
    // Insert with identity
    let! newId =
        insertTask db {
            for e in dbo.ErrorLog do
            entity { ErrorLogID = 0; ErrorMessage = "Test" }
            getId e.ErrorLogID
        }
    
    // Update specific fields
    updateTask db {
        for e in dbo.ErrorLog do
        set e.ErrorMessage "Updated message"
        where (e.ErrorLogID = 1)
    }
  10. Register NuGet package extensions

    main

    Type mapping extensions can be distributed as NuGet packages. To use one, add the package as a PackageReference in your project and then register its package ID in your TOML configuration under [extensions].type_mappings. SqlHydra will resolve the assembly from your project's build output and load all IExtendTypeMapping implementations found within it.

    [extensions]
    type_mappings = ["SqlHydra.Query.Pgvector"]
  11. Transform results using mapList/mapArray/mapSeq

    main

    The select clause in SqlHydra is strictly for selecting columns or tables. Do not perform transformations (like string interpolation or .ToString()) inside the select block, as this will throw a runtime error. Instead, perform transformations in the mapList, mapArray, or mapSeq blocks after the selection.

    // Correct way to transform
    selectTask db {
        for a in SalesLT.Address do
        select (a.City, a.StateProvince) into (city, state)
        mapList $"City: {city}, State: {state}"
    }
  12. Use HydraReader with other libraries

    main
    If you need to integrate the generated data readers with other libraries or custom data access layers, refer to the HydraReader documentation on the project wiki for integration patterns and compatibility details.