Skip to content
Merged
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,17 @@ let orders = rows |> Seq.map (fun (o, d) -> o, d.ToOption()) // d.ToOption() :

`ToOption()` is generated as an extension member, so it is available whenever the generated namespace is `open`ed (which query code already requires).

The classic `leftJoin ... on (o.Id = d.Value.Id)` form (whole record as `Option`) still works; both forms can be mixed per join site, so queries can be migrated one at a time.
The predicate-style `leftJoin'` works with left-views too. Its `on'` clause sees the joined row as the view, so lift the outer column with `Some` instead of reaching into the inner one with `.Value`:

```fsharp
selectTask db {
for o in Sales.SalesOrderHeader do
leftJoin' d in Sales.LeftJoined.SalesOrderDetail; on' (Some o.SalesOrderID = d.SalesOrderID && d.OrderQty > Some 5s)
select (o, d)
}
```

The classic `leftJoin ... on (o.Id = d.Value.Id)` and `leftJoin' ... on' (o.Id = d.Value.Id)` forms (whole record as `Option`) still work; both forms can be mixed per join site, so queries can be migrated one at a time.

### Selecting Columns

Expand Down
15 changes: 0 additions & 15 deletions src/SqlHydra.Cli/Npgsql/NpgsqlDataTypes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,6 @@ let supportedTypeMappings isLegacy = // https://www.npgsql.org/doc/types/basic.h
"name", "string", DbType.String, Some (nameof NpgsqlDbType.Name), None
"(internal) char", "char", DbType.String, Some (nameof NpgsqlDbType.InternalChar), Some NpgsqlDbType.InternalChar
// skipped unsupported types

// Handle Materialized View Column Mappings
// https://www.postgresql.org/docs/current/datatype.html#DATATYPE-TABLE
"int8", "int64", DbType.Int64, Some (nameof NpgsqlDbType.Bigint), Some NpgsqlDbType.Bigint
"bool", "bool", DbType.Boolean, Some (nameof NpgsqlDbType.Boolean), Some NpgsqlDbType.Boolean
"char", "string", DbType.String, Some (nameof NpgsqlDbType.Char), Some NpgsqlDbType.Char
"varchar", "string", DbType.String, Some (nameof NpgsqlDbType.Varchar), Some NpgsqlDbType.Varchar
"float8", "double", DbType.Double, Some (nameof NpgsqlDbType.Double), Some NpgsqlDbType.Double
"int", "int", DbType.Int32, Some (nameof NpgsqlDbType.Integer), Some NpgsqlDbType.Integer
"int4", "int", DbType.Int32, Some (nameof NpgsqlDbType.Integer), Some NpgsqlDbType.Integer
"decimal", "decimal", DbType.Decimal, Some (nameof NpgsqlDbType.Numeric), Some NpgsqlDbType.Numeric
"float4", "float", DbType.Single, Some (nameof NpgsqlDbType.Real), Some NpgsqlDbType.Real
"int2", "int16", DbType.Int16, Some (nameof NpgsqlDbType.Smallint), Some NpgsqlDbType.Smallint
"timetz", "System.DateTime", DbType.DateTime, Some (nameof NpgsqlDbType.TimeTz), Some NpgsqlDbType.TimeTz
"timestamptz", "System.DateTime", DbType.DateTime, Some (nameof NpgsqlDbType.TimestampTz), Some NpgsqlDbType.TimestampTz
]
/// Programmatically add array mappings (where ArrayType is Some)
|> List.collect (fun (columnTypeAlias, clrType, dbType, providerDbType, arrayBaseType) ->
Expand Down
329 changes: 77 additions & 252 deletions src/SqlHydra.Cli/Npgsql/NpgsqlSchemaProvider.fs

Large diffs are not rendered by default.

51 changes: 23 additions & 28 deletions src/SqlHydra.Cli/SchemaFilters.fs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ open GlobExpressions
open SqlHydra.Domain
open Spectre.Console

/// True when the path matches any of the glob patterns.
let matchesAny (patterns: string list) =
let globs = patterns |> List.map Glob
fun (path: string) -> globs |> List.exists (fun g -> g.IsMatch path)

/// Applies glob include and exclude patterns to filter schemas and tables.
let inline filterTables (filters: Filters) (tables: 'Table seq when 'Table : (member Schema: string) and 'Table : (member Name: string)) =
let isTableFilter (filter: string) = not (filter.Contains ".")
Expand All @@ -15,20 +20,16 @@ let inline filterTables (filters: Filters) (tables: 'Table seq when 'Table : (me
tables
| _ ->
let getPath (tbl: 'Table) = $"{tbl.Schema}/{tbl.Name}"
let tablesByPath = tables |> Seq.map (fun t -> getPath t, t) |> Map.ofSeq
let tablePaths = tablesByPath |> Map.toList |> List.map fst

let getMatchingTablePaths =
List.map Glob
>> List.collect (fun pattern -> tablePaths |> List.filter pattern.IsMatch)
>> List.distinct
>> Set.ofList

let includedPaths = includeFilters |> getMatchingTablePaths
let excludedPaths = excludeFilters |> getMatchingTablePaths

let filteredPaths = includedPaths - excludedPaths
let filteredTables = filteredPaths |> Seq.map (fun path -> tablesByPath.[path]) |> Seq.toList
let isIncluded = matchesAny includeFilters
let isExcluded = matchesAny excludeFilters

// No table-level includes means include all (e.g. excludes only).
let filteredTables =
tables
|> Seq.filter (fun tbl ->
let path = getPath tbl
(includeFilters.IsEmpty || isIncluded path) && not (isExcluded path))
|> Seq.toList

AnsiConsole.MarkupLineInterpolated($"[blue]-[/] Filters:")
AnsiConsole.MarkupLineInterpolated($" [blue]-[/] Include: [green][{filters.Includes}][/]")
Expand All @@ -48,18 +49,12 @@ let inline filterColumns (filters: Filters) (schema: string) (table: string) (co
columns
| _ ->
let getPath (col: 'Column) = $"{schema}/{table}.{col.Name}"
let columnsByPath = columns |> Seq.map (fun c -> getPath c, c) |> Map.ofSeq
let columnPaths = columnsByPath |> Map.toList |> List.map fst

let getMatchingColumnPaths =
List.map Glob
>> List.collect (fun pattern -> columnPaths |> List.filter pattern.IsMatch)
>> List.distinct
>> Set.ofList
let isIncluded = matchesAny includeFilters
let isExcluded = matchesAny excludeFilters

let includedPaths = includeFilters |> getMatchingColumnPaths
let excludedPaths = excludeFilters |> getMatchingColumnPaths

let filteredPaths = includedPaths - excludedPaths
let filteredColumns = filteredPaths |> Seq.map (fun path -> columnsByPath.[path]) |> Seq.toList
filteredColumns
// No column-level includes means include all, so `include = [ "*" ]` with a column
// exclude keeps every other column. Filtering in place preserves column order.
columns
|> Seq.filter (fun col ->
let path = getPath col
(includeFilters.IsEmpty || isIncluded path) && not (isExcluded path))
63 changes: 47 additions & 16 deletions src/SqlHydra.Query/LinqExpressionVisitors.fs
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,17 @@ module NormalizedPatterns =
/// A constant value or an evaluable expression.
/// Delegates to compileAndEvaluateExpression for non-constant evaluable expressions.
let (|NValue|_|) (nexp: NormalizedExpression) =
let rec isQueryVariable nexp =
match nexp with
| NParameter _ -> true
| NMemberAccess(inner, _) -> isQueryVariable inner
| _ -> false

match nexp with
| NConstant(v, _) -> Some v
// `Some o.Col` lifts a column to compare with a left-view's option column; it is a
// column (see NProperty), not a value, and evaluating it would throw.
| NMethodCall(call, [ arg ]) when call.Method.Name = "Some" && isQueryVariable arg -> None
| NMethodCall(call, _) when not (isSqlHydraFunction call.Method) ->
compileAndEvaluateExpression (call :> Expression) |> Some
| NMemberAccess(NConstant _, m) ->
Expand Down Expand Up @@ -607,6 +616,17 @@ let private renderObjAsLiteral (v: obj) =
renderStringLiteral (dto.ToString("yyyy-MM-dd HH:mm:sszzz", inv))
| _ -> formatNumericLiteral v (v.GetType())

/// Marks a qualified dotted name (`alias.Column`, `schema.Table.Column`) for the emitter's
/// `QuoteRawFragment`. A structured clause (`Compare`, `IsNull`, ...) quotes its column slot
/// itself; a raw fragment is emitted almost verbatim, so a column bound for one is marked.
let markQualified (fqCol: string) =
fqCol.Split('.')
|> Array.map (fun segment -> $"{{%s{segment}}}")
|> String.concat "."

/// `alias.Column`, marked for `QuoteRawFragment`.
let markColumn (alias: string) (mem: MemberInfo) = markQualified $"%s{alias}.%s{mem.Name}"

/// Converts a SQL function MethodCall expression to a SQL fragment string.
/// Also renders argument expressions when called recursively as the entry point for
/// general select-fragment compilation (caseWhen, castAs, etc.).
Expand Down Expand Up @@ -683,11 +703,11 @@ let rec visitSqlFn (qualifyColumn: string -> MemberInfo -> string) (exp: Express
|> List.map (fun (c, v) -> $"WHEN {c} THEN {v}")
|> String.concat " "
$"CASE {whens} ELSE {renderExpr m.Arguments.[1]} END"
// Lateral subquery column reference: lateralCol "alias" "col" → "alias"."col"
// Lateral subquery column reference: lateralCol "alias" "col" → {alias}.{col}, quoted by the emitter
| MethodCall m when m.Method.Name = nameof lateralCol && m.Arguments.Count = 2 ->
let alias = compileAndEval m.Arguments.[0] :?> string
let column = compileAndEval m.Arguments.[1] :?> string
$"\"{alias}\".\"{column}\""
markQualified $"{alias}.{column}"
// inlineValue marker: emit the wrapped value as a SQL literal.
| MethodCall m when m.Method.Name = nameof inlineValue && m.Arguments.Count = 1 ->
renderObjAsLiteral (compileAndEval m.Arguments.[0])
Expand Down Expand Up @@ -720,12 +740,17 @@ let rec visitSqlFn (qualifyColumn: string -> MemberInfo -> string) (exp: Express
| _ ->
notImplMsg $"Expected a method call expression but got: {exp.NodeType}"

/// Delegates to existing visitSqlFn by extracting the original MethodCallExpression.
/// Wraps a builder-supplied `qualifyColumn` so its output is marked for `QuoteRawFragment`.
let markingQualifier (qualifyColumn: string -> MemberInfo -> string) =
fun alias (mem: MemberInfo) -> markQualified (qualifyColumn alias mem)

/// Renders a SQL function call to a raw fragment, marking the columns `qualifyColumn` returns.
let nVisitSqlFn (qualifyColumn: string -> MemberInfo -> string) (nexp: NormalizedExpression) : string =
match nexp with
| NMethodCall(m, _) -> visitSqlFn qualifyColumn (m :> Expression)
| NMethodCall(m, _) -> visitSqlFn (markingQualifier qualifyColumn) (m :> Expression)
| _ -> notImplMsg $"Expected NMethodCall for SQL function"


/// `x = None` (a null) is `x IS NULL`, since `= NULL` never matches; `x = Some v` binds v.
/// A column binds a parameter typed from its member; a rendered fragment binds a raw one.
let private compareColumnToValue (fqCol: string) (column: MemberInfo) (op: ComparisonOp) (value: obj) =
Expand All @@ -746,6 +771,8 @@ let visitWhere<'T> (tables: TableMapping seq) (filter: Expression<Func<'T, bool>
| NProperty (p, ext) when tables |> Seq.exists (fun tbl -> tbl.IsInTable p) -> Some (p, ext)
| _ -> None

let qualifyForRaw = markingQualifier qualifyColumn

/// Evaluate a NormalizedExpression to a runtime value.
let nEvaluate (nexp: NormalizedExpression) =
match nexp with
Expand Down Expand Up @@ -1023,13 +1050,13 @@ let visitWhere<'T> (tables: TableMapping seq) (filter: Expression<Func<'T, bool>
| NMethodCall (m, _), NColumn (p, _) when isSqlHydraFunction m.Method ->
let sqlFragment = nVisitSqlFn qualifyColumn left
let alias = visitAlias p.Expression
let fqCol = qualifyColumn alias p.Member
let fqCol = qualifyForRaw alias p.Member
RawWhere($"{sqlFragment} {comparison} {fqCol}", [||])

// Column compared to SQL function
| NColumn (p, _), NMethodCall (m, _) when isSqlHydraFunction m.Method ->
let alias = visitAlias p.Expression
let fqCol = qualifyColumn alias p.Member
let fqCol = qualifyForRaw alias p.Member
let sqlFragment = nVisitSqlFn qualifyColumn right
RawWhere($"{fqCol} {comparison} {sqlFragment}", [||])

Expand Down Expand Up @@ -1134,6 +1161,8 @@ let visitHaving<'T> (tables: TableMapping seq) (filter: Expression<Func<'T, bool
| NProperty (p, ext) when tables |> Seq.exists (fun tbl -> tbl.IsInTable p) -> Some (p, ext)
| _ -> None

let qualifyForRaw = markingQualifier qualifyColumn

let rec visit (nexp: NormalizedExpression) : WhereClause =
match nexp with
| NNot operand ->
Expand Down Expand Up @@ -1223,14 +1252,14 @@ let visitHaving<'T> (tables: TableMapping seq) (filter: Expression<Func<'T, bool
| NAggregateColumn (aggType, (p1, _)), NColumn (p2, _) ->
let lt =
let alias = visitAlias p1.Expression
qualifyColumn alias p1.Member
qualifyForRaw alias p1.Member
let rt =
let alias = visitAlias p2.Expression
qualifyColumn alias p2.Member
qualifyForRaw alias p2.Member
RawWhere($"{renderAggregate aggType lt} {comparison} {rt}", [||])
| NAggregateColumn (aggType, (p, _)), NValue value ->
let alias = visitAlias p.Expression
compareFragmentToValue (renderAggregate aggType (qualifyColumn alias p.Member)) op value
compareFragmentToValue (renderAggregate aggType (qualifyForRaw alias p.Member)) op value
| NColumn (p1, _), NColumn (p2, _) ->
let lt =
let alias = visitAlias p1.Expression
Expand Down Expand Up @@ -1298,7 +1327,7 @@ let visitOrderByPropertySelector<'T, 'Prop> (propertySelector: Expression<Func<'
// parameters; columns are qualified; InfixOperators registrations rewrite to infix.
| NMethodCall(m, _) ->
let parms = ResizeArray<obj>()
let qualifyColumn alias (mem: MemberInfo) = $"\"{alias}\".\"{mem.Name}\""
let qualifyColumn = markColumn
let rec render (e: Expression) : string =
match e with
| :? UnaryExpression as u when u.NodeType = ExpressionType.Convert ->
Expand Down Expand Up @@ -1400,6 +1429,8 @@ let visitJoinPredicate<'T> (tables: TableMapping seq) (predicate: Expression<Fun
| NProperty (p, ext) when tables |> Seq.exists (fun tbl -> tbl.IsInTable p) -> Some (p, ext)
| _ -> None

let qualifyForRaw = markingQualifier qualifyColumn

let rec visit (nexp: NormalizedExpression) : WhereClause =
match nexp with
| NBinaryAnd(left, right) ->
Expand All @@ -1418,12 +1449,12 @@ let visitJoinPredicate<'T> (tables: TableMapping seq) (predicate: Expression<Fun
// below, which compile-and-eval whichever side is not a column.
| NColumn (p, _), NMethodCall (m, _) when isSqlHydraFunction m.Method ->
let alias = visitAlias p.Expression
let fqCol = qualifyColumn alias p.Member
let fqCol = qualifyForRaw alias p.Member
RawWhere($"{fqCol} {comparison} {nVisitSqlFn qualifyColumn right}", [||])

| NMethodCall (m, _), NColumn (p, _) when isSqlHydraFunction m.Method ->
let alias = visitAlias p.Expression
let fqCol = qualifyColumn alias p.Member
let fqCol = qualifyForRaw alias p.Member
RawWhere($"{nVisitSqlFn qualifyColumn left} {comparison} {fqCol}", [||])

| NMethodCall (m, _), NValue value when isSqlHydraFunction m.Method ->
Expand Down Expand Up @@ -1574,7 +1605,7 @@ let visitJoinPredicate<'T> (tables: TableMapping seq) (predicate: Expression<Fun
/// InfixOperators rewrites apply.
let private renderSelectExpression (exp: Expression) : string * obj[] =
let parms = ResizeArray<obj>()
let qualifyColumn alias (mem: MemberInfo) = $"{{{alias}}}.{{{mem.Name}}}"
let qualifyColumn = markColumn
let rec render (e: Expression) : string =
match e with
| :? UnaryExpression as u when u.NodeType = ExpressionType.Convert ->
Expand Down Expand Up @@ -1687,15 +1718,15 @@ let visitSelect<'T, 'Prop> (propertySelector: Expression<Func<'T, 'Prop>>) =
| _ -> notImplMsg $"Unsupported Option.map lambda body: {mapLam.Body.NodeType}"
| None -> notImplMsg $"Could not extract mapping lambda from Option.map expression"
else
let qualifyCol alias (mem: MemberInfo) = $"{{%s{alias}}}.{{%s{mem.Name}}}"
let qualifyCol = markColumn
let sqlFragment = visitSqlFn qualifyCol (m :> Expression)
[ SelectedExpression sqlFragment ]
| NAggregateColumn (aggType, (p, _)) ->
let alias = visitAlias p.Expression
let fqCol = $"{{%s{alias}}}.{{%s{p.Member.Name}}}"
let fqCol = markColumn alias p.Member
[ SelectedExpression (renderAggregate aggType fqCol) ]
| NMethodCall(m, _) ->
let qualifyCol alias (mem: MemberInfo) = $"{{%s{alias}}}.{{%s{mem.Name}}}"
let qualifyCol = markColumn
let sqlFragment = visitSqlFn qualifyCol (m :> Expression)
[ SelectedExpression sqlFragment ]
| NNew(newExpr, args) ->
Expand Down
Loading
Loading