fsprojects / Rezoom.SQL

Statically typechecks a common SQL dialect and translates it to various RDBMS backends
MIT License
669 stars 25 forks source link

Reusable types #10

Open Risord opened 7 years ago

Risord commented 7 years ago

Is there way / plans to create types reusable?

If types have somewhat big complexity and there is lot of queries which have different filter / order logic but result data is identical you still have to duplicate mapping.

Minimal example:

type private getItemsQuery = SQL<"""
    select *
    from Item i
""">

type private getActiveItemsQuery = SQL<"""
    select *
    from Item i
    where i.IsActive = true
""">

//How to make this available for both queries?
let mapItem (dbRow : ???.Row) =
    (dbRow.ItemId, dbRow.DataField)

let getItems () =
    use context = new ConnectionContext()
    getItemsQuery.Command().Execute(context)
    result |> Seq.map mapItem

let getActiveItems () =
    use context = new ConnectionContext()
    getActiveItemsQuery.Command().Execute(context)
    result |> Seq.map mapItem
rspeele commented 7 years ago

Unfortunately there's no way to have them share the same generated type.

What I'd like to do to support this in the future is let you specify paths to assemblies in rzsql.json. These would be called "row interface assemblies" or something like that, and the type provider would load up the interfaces from those assemblies and automatically add interface implementations for all the ones matched by a provided row type.

So in this case you might have something like this in MyRowInterfaceAssembly.dll, which should be built before MyAssemblyWithRezoomSQLQueries.dll:

type IItem =
    abstract ItemId : int
    abstract DataField : string

Then both getItemsQuery.Row and getActiveItemsQuery.Row could automatically implement that interface because they have the necessary columns, and you can write mapItem to take an IItem instead of a concrete row type.

I've held off on doing this because ultimately, I want it to be smart enough to do stuff like automatically map Id : int to Id : ItemId where ItemId is a wrapper type around an int. But, I suppose it wouldn't hurt to introduce the feature in a bare-bones state where it'll only auto-implement the interface when column types match exactly.

Current workarounds

Right now there are a couple things you can do to make this less painful.

Inline functions

Write a helper to convert to your domain type with F# inline functions. This is especially useful if you have some wrapper types in your domain model that RZSQL doesn't know about anyway, like type UserId = UserId of int (highly recommended to avoid mixing up identifiers).

type ItemId = ItemId of int
type Item =
    {    Id : ItemId
         Name : string
    }
let inline itemFromRow x =
    {   Id = ItemId (^a : (member get_Id : unit -> int)(x))
        Name = (^a : (member get_Name : unit -> string)(x))
    }

Then you can use itemFromRow on the different row types returned from your different queries. The downside, of course, is that those inline constrained property invocations are ugly to read and write. You can mitigate it somewhat by moving them into their own functions which can be reused for common column names like Id, Name, etc., but it's still not great.

Configurable queries

Where possible, use fewer queries but make them configurable with parameters. E.g. for the minimal example, you could use:

select *
from Item i
where @activeFilter is null or i.IsActive = @activeFilter

And pass None for activeFilter to implement getItems, Some true to implement getActiveItems.

Risord commented 7 years ago

Making identifiers to it's own types is quite clearly "the right way" and it's great that it's on your concern list.

Auto generated interface implementations sounds good way to work with this. Although this makes me hope that F# should have better support for general purpose compile time programming.

I think it should be also considered that should interfaces must be explicitly expressed like: type itemQuery = SQL<"""[query]""", MyPrecompiledModule.IItem> It may help with implementation and functionality would be much less magical. Also if interface is not actually valid I think it would be easier to produce much better error messages.