Search code examples
f#f#-datafsharp.data.sqlclient

F# filtering records with many option types


Okay, strange question here. I'm using FSharp.Data.SqlClient to get records from our Database. The records that it infers have several fields which are option types. I need to filter out the records where the ANY of the option types are None and create new records where the fields are known. The following is an example of what I am talking about. To solve this I created a filter function, recordFilter, which returns the type I want in the case that all of the types of Option<'T> contain a value and a None when they do not.

My question is whether it is possible to create a function which just automatically checks all of the Option<'T> fields in the record for having a value. I'm guessing this would require reflection of some kind to iterate through the fields of the record. I'm guessing this isn't possible but I wanted to throw this out there in case I'm wrong.

If this approach is the idiomatic way, then I would be happy to hear that. I just wanted to make sure that I was not missing out on some more elegant solution. What is possible with F# consistently surprises me.

My motivation is that I am dealing with records with dozens of fields which have a type of Option<'T>. It is annoying to have to write out a massive match...with statement as I do in this example. When it is only a few fields is fine, when it is 30+ fields, it is annoying.

type OptionRecord = {
    Id: int
    Attr1: int option
    Attr2: int option
    Attr3: int option
    Attr4: int option
    Attr5: int option
    Attr6: int option
}

type FilteredRecord = {
    Id: int
    Attr1: int
    Attr2: int
    Attr3: int
    Attr4: int
    Attr5: int
    Attr6: int
}

let optionRecords = [for i in 1..5 -> 
    {
        OptionRecord.Id = i
        Attr1 = Some i
        Attr2 = 
            match i % 2 = 0 with
            | true -> Some i
            | false -> None
        Attr3 = Some i
        Attr4 = Some i
        Attr5 = Some i
        Attr6 = Some i
    }]

let recordFilter (x:OptionRecord) =
    match x.Attr1, x.Attr2, x.Attr3, x.Attr4, x.Attr5, x.Attr6 with
    | Some attr1, Some attr2, Some attr3, Some attr4, Some attr5, Some attr6 ->
        Some {
            FilteredRecord.Id = x.Id
            Attr1 = attr1
            Attr2 = attr2
            Attr3 = attr3
            Attr4 = attr4
            Attr5 = attr5
            Attr6 = attr6
        }
    | _, _, _, _, _, _ -> None

let filteredRecords =
    optionRecords
    |> List.choose recordFilter

Solution

  • This can indeed be done with reflection. The namespace FSharp.Reflection contains some handy helpers for working specifically with F# types, not with .NET in general. The key points to consider are these:

    1. FSharpType.GetRecordFields returns a list of PropertyInfo objects for each record field.
    2. You can tell if a property is an option by comparing its type to typedefof<option>.
    3. None is represented as null at runtime.
    4. FSharpValue.GetUnionFields and FSharpValue.GetRecordFields return lists of union or record field values respectively.
    5. FSharpValue.MakeRecord creates a new record given list of its field values.

    Here is the code:

    open FSharp.Reflection
    
    /// Record with Option-typed fields
    type RM = { a: int option; b: string option; c: bool option }
    
    /// Record with same fields, but non-optional
    type R = { a: int; b: string; c: bool }
    
    /// Determines if the given property is of type option<_>
    let isOption (f: System.Reflection.PropertyInfo) = 
        f.PropertyType.IsGenericType && f.PropertyType.GetGenericTypeDefinition() = typedefof<option<_>>
    
    /// Returns an array of pairs (propertyInfo, value) for every field of the given record.
    let fieldsWithValues (r: 'a) =
        Array.zip (FSharpType.GetRecordFields typeof<'a>) (FSharpValue.GetRecordFields r)
    
    /// Determines if the given record has any option-type fields whose value is None.
    let anyNones (r: 'a) = 
        fieldsWithValues r |> Seq.exists (fun (f, value) -> isOption f && isNull value)
    
    /// Given two records, 'a and 'b, where 'a is expected to contain some option-typed
    /// fields, and 'b is expected to contain their non-option namesakes, creates a new
    /// record 'b with all non-None option values copied from 'a.
    let copyOptionFields (from: 'a) (to': 'b) : 'b =
        let bFields = FSharpValue.GetRecordFields to'
        let aFields = Array.zip (FSharpType.GetRecordFields typeof<'a>) (FSharpValue.GetRecordFields from)
        for idx, (f, value) in aFields |> Array.indexed do
            if isOption f && not (isNull value) then
                let _, values = FSharpValue.GetUnionFields( value, f.PropertyType )
                bFields.[idx] <- values.[0] // We know that this is a `Some` case, and it has only one value
    
        FSharpValue.MakeRecord( typeof<'b>, bFields ) :?> 'b
    

    Usage:

    > anyNones {RM.a = Some 42; b = Some "abc"; c = Some true} 
    val it : bool = false
    
    > anyNones {RM.a = Some 42; b = Some "abc"; c = None}
    val it : bool = true
    
    > let emptyR = {R.a = 0; b = ""; c = false}
    
    > copyOptionFields {RM.a = Some 42; b = Some "abc"; c = Some true} emptyR
    val it : R = {a = 42; b = "abc"; c = true;}
    
    > copyOptionFields {RM.a = None; b = Some "abc"; c = None} emptyR
    val it : R = {a = 0; b = "abc"; c = false;}
    

    NOTE: the above code does not perform any sanity checks (e.g. that 'a and 'b are indeed records, or that their fields are indeed namesakes and in the same order, etc.). I leave this as an exercise for the reader :-)

    NOTE 2: be careful with performance. Since this is reflection, it is slower and cannot be optimized at compile time.