diff --git a/NpgsqlFSharpAnalyzer.sln b/NpgsqlFSharpAnalyzer.sln index a912a26..014c755 100644 --- a/NpgsqlFSharpAnalyzer.sln +++ b/NpgsqlFSharpAnalyzer.sln @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NpgsqlFSharpVs", "src\Npgsq EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "NpgsqlFSharpAnalyzer.Core", "src\NpgsqlFSharpAnalyzer.Core\NpgsqlFSharpAnalyzer.Core.fsproj", "{5964BB56-97B8-4FAE-9933-8113DB11438D}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NpgsqlFSharpParser", "src\NpgsqlFSharpParser\NpgsqlFSharpParser.fsproj", "{BC524F8E-6282-4E31-9A0E-29FCE38832E7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -103,6 +105,18 @@ Global {5964BB56-97B8-4FAE-9933-8113DB11438D}.Release|x64.Build.0 = Release|Any CPU {5964BB56-97B8-4FAE-9933-8113DB11438D}.Release|x86.ActiveCfg = Release|Any CPU {5964BB56-97B8-4FAE-9933-8113DB11438D}.Release|x86.Build.0 = Release|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Debug|x64.Build.0 = Debug|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Debug|x86.Build.0 = Debug|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Release|Any CPU.Build.0 = Release|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Release|x64.ActiveCfg = Release|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Release|x64.Build.0 = Release|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Release|x86.ActiveCfg = Release|Any CPU + {BC524F8E-6282-4E31-9A0E-29FCE38832E7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -114,6 +128,7 @@ Global {8FC9B600-2A16-4E0C-89D7-F61F5F315F3E} = {4CA3F686-DFAB-4AAF-8004-4158E18EF24E} {37577282-1289-40DB-AD3D-24499BD09DAE} = {C397A34C-84F1-49E7-AEBC-2F9F2B196216} {5964BB56-97B8-4FAE-9933-8113DB11438D} = {C397A34C-84F1-49E7-AEBC-2F9F2B196216} + {BC524F8E-6282-4E31-9A0E-29FCE38832E7} = {C397A34C-84F1-49E7-AEBC-2F9F2B196216} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BC821061-2FB3-4ABD-9FA1-044D4C59C475} diff --git a/src/NpgsqlFSharpParser/NpgsqlFSharpParser.fsproj b/src/NpgsqlFSharpParser/NpgsqlFSharpParser.fsproj new file mode 100644 index 0000000..775ef00 --- /dev/null +++ b/src/NpgsqlFSharpParser/NpgsqlFSharpParser.fsproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + + + + + + + + + + + + diff --git a/src/NpgsqlFSharpParser/Parser.fs b/src/NpgsqlFSharpParser/Parser.fs new file mode 100644 index 0000000..cee96df --- /dev/null +++ b/src/NpgsqlFSharpParser/Parser.fs @@ -0,0 +1,111 @@ +[] +module NpgsqlFSharpParser.Parser + +open FParsec + +let identifier : Parser = + let isIdentifierFirstChar token = isLetter token + let isIdentifierChar token = isLetter token || isDigit token || token = '.' || token = '_' + many1Satisfy2L isIdentifierFirstChar isIdentifierChar "identifier" .>> spaces + |>> Expr.Ident + +let parameter : Parser = + let isIdentifierFirstChar token = token = '@' + let isIdentifierChar token = isLetter token || isDigit token || token = '_' + many1Satisfy2L isIdentifierFirstChar isIdentifierChar "identifier" .>> spaces + |>> Expr.Parameter + +let text value : Parser = + (optional spaces) >>. pstringCI value .>> (optional spaces) + +let star : Parser = + text "*" |>> fun _ -> Expr.Star + +let opp = new OperatorPrecedenceParser() + +let expr = opp.ExpressionParser + +let parens parser = between (text "(") (text ")") parser + +let commaSeparatedExprs = sepBy expr (text ",") + +let selections = + (star |>> List.singleton) + <|> (attempt commaSeparatedExprs) + <|> (attempt (parens commaSeparatedExprs)) + +let functionExpr = + let isIdentifierFirstChar token = isLetter token + let isIdentifierChar token = isLetter token || isDigit token || token = '.' || token = '_' + many1Satisfy2L isIdentifierFirstChar isIdentifierChar "identifier" .>> spaces + >>= fun functionName -> + (parens commaSeparatedExprs) + |>> fun arguments -> Expr.Function(functionName, arguments) + +let optionalDistinct = + optional (attempt (text "DISTINCT ON") <|> attempt (text "DISTINCT")) + +let selectFromWhere = + text "SELECT" >>= fun _ -> + optionalDistinct >>= fun _ -> + selections >>= fun selections -> + text "FROM" >>= fun _ -> + identifier >>= fun tableName -> + text "WHERE" >>. expr |>> fun condition -> + let query = + { SelectExpr.Default with + Columns = selections + From = Some tableName + Where = Some condition } + + Expr.Query (TopLevelExpr.Select query) + +let selectFrom = + text "SELECT" >>= fun _ -> + optionalDistinct >>= fun _ -> + selections >>= fun selections -> + text "FROM" >>= fun _ -> + identifier |>> fun tableName -> + let query = + { SelectExpr.Default with + Columns = selections + From = Some tableName } + + Expr.Query (TopLevelExpr.Select query) + +let primitiveSelect = + text "SELECT" >>= fun _ -> + optionalDistinct >>= fun _ -> + selections |>> fun selections -> + let query = + { SelectExpr.Default with + Columns = selections } + + Expr.Query (TopLevelExpr.Select query) + +opp.AddOperator(InfixOperator("AND", spaces, 7, Associativity.Left, fun left right -> Expr.And(left, right))) +opp.AddOperator(InfixOperator("OR", spaces, 7, Associativity.Left, fun left right -> Expr.Or(left, right))) +opp.AddOperator(InfixOperator("IN", spaces, 8, Associativity.Left, fun left right -> Expr.In(left, right))) +opp.AddOperator(InfixOperator(">", spaces, 9, Associativity.Left, fun left right -> Expr.GreaterThan(left, right))) +opp.AddOperator(InfixOperator("<", spaces, 9, Associativity.Left, fun left right -> Expr.LessThan(left, right))) +opp.AddOperator(InfixOperator("<=", spaces, 9, Associativity.Left, fun left right -> Expr.LessThanOrEqual(left, right))) +opp.AddOperator(InfixOperator(">=", spaces, 9, Associativity.Left, fun left right -> Expr.GreaterThanOrEqual(left, right))) +opp.AddOperator(InfixOperator("=", spaces, 9, Associativity.Left, fun left right -> Expr.Equals(left, right))) +opp.AddOperator(PostfixOperator("IS NULL", spaces, 8, false, fun value -> Expr.Equals(Expr.Null, value))) +opp.AddOperator(PostfixOperator("IS NOT NULL", spaces, 8, false, fun value -> Expr.Not(Expr.Equals(Expr.Null, value)))) + +opp.TermParser <- choice [ + star + (text "(") >>. expr .>> (text ")") + (attempt selectFromWhere <|> attempt selectFrom <|> attempt primitiveSelect) + (attempt functionExpr) + identifier + parameter +] + +let fullParser = (optional spaces) >>. expr .>> (optional spaces <|> (text ";" |>> fun _ -> ())) + +let parse (input: string) : Result = + match run fullParser input with + | Success(result,_,_) -> Result.Ok result + | Failure(errMsg,_,_) -> Result.Error errMsg diff --git a/src/NpgsqlFSharpParser/Types.fs b/src/NpgsqlFSharpParser/Types.fs new file mode 100644 index 0000000..bb58efa --- /dev/null +++ b/src/NpgsqlFSharpParser/Types.fs @@ -0,0 +1,82 @@ +namespace rec NpgsqlFSharpParser + +[] +type TopLevelExpr = + | Select of SelectExpr + | Insert of InsertExpr + | Delete of DeleteExpr + | Update of UpdateExpr + +[] +type Expr = + | Star + | Ident of string + | Parameter of string + | Boolean of bool + | Integer of int + | Float of float + | Null + | Function of name:string * arguments:Expr list + | And of left:Expr * right:Expr + | Or of left:Expr * right:Expr + | In of left:Expr * right:Expr + | Not of expr:Expr + | Equals of left:Expr * right:Expr + | GreaterThan of left:Expr * right:Expr + | LessThan of left:Expr * right:Expr + | GreaterThanOrEqual of left:Expr * right:Expr + | LessThanOrEqual of left:Expr * right:Expr + | Between of value:Expr * leftBound:Expr * rightBound:Expr + | Query of expr:TopLevelExpr + +type Ordering = { + Column : string + ASC : bool + DESC : bool + NullFirst : bool + NullLast : bool +} + +type JoinExpr = + | Join of tableName:string + | LeftOuterJoin of tableName:string + | RightOuterJoin of tableName:string + | FullOuterJoin of tableName:string + +type SelectExpr = { + Columns : Expr list + From : Expr option + Joins : JoinExpr list + Where : Expr option + OrderBy : Ordering list + GroupBy : Expr list +} + with + static member Default = { + Columns = [ ] + From = None + Where = None + OrderBy = [ ] + GroupBy = [ ] + Joins = [ ] + } + +type UpdateExpr = { + Table : string + Assignments : Map + ConflictResolution : Map + Returning : Expr list +} + +type DeleteExpr = { + Table : string + Where : Expr option +} + +type InsertExpr = { + Table: string + Columns : string list + Values : Expr list + ConflictResolution : Map + Returning : Expr list +} diff --git a/tests/NpgsqlFSharpAnalyzer.Tests/NpgsqlFSharpAnalyzer.Tests.fsproj b/tests/NpgsqlFSharpAnalyzer.Tests/NpgsqlFSharpAnalyzer.Tests.fsproj index 5348b79..5ca511f 100644 --- a/tests/NpgsqlFSharpAnalyzer.Tests/NpgsqlFSharpAnalyzer.Tests.fsproj +++ b/tests/NpgsqlFSharpAnalyzer.Tests/NpgsqlFSharpAnalyzer.Tests.fsproj @@ -6,6 +6,7 @@ true + @@ -14,6 +15,7 @@ + diff --git a/tests/NpgsqlFSharpAnalyzer.Tests/ParserTests.fs b/tests/NpgsqlFSharpAnalyzer.Tests/ParserTests.fs new file mode 100644 index 0000000..1ea3f7c --- /dev/null +++ b/tests/NpgsqlFSharpAnalyzer.Tests/ParserTests.fs @@ -0,0 +1,112 @@ +module ParserTests + +open Expecto +open NpgsqlFSharpParser + +let testSelect query expected = + test query { + match Parser.parse query with + | Ok (Expr.Query (TopLevelExpr.Select query)) -> + Expect.equal query expected "The query is parsed correctly" + | Ok somethingElse -> + failwithf "Unexpected select statement %A" somethingElse + | Error errorMsg -> + failwith errorMsg + } + +let ftestSelect query expected = + ftest query { + match Parser.parse query with + | Ok (Expr.Query (TopLevelExpr.Select query)) -> + Expect.equal query expected "The query is parsed correctly" + | Ok somethingElse -> + failwithf "Unexpected select statement %A" somethingElse + | Error errorMsg -> + failwith errorMsg + } + +[] +let parserTests = testList "Parser tests" [ + + testSelect "SELECT NOW()" { + SelectExpr.Default with + Columns = [Expr.Function("NOW", [])] + } + + testSelect "SELECT NOW();" { + SelectExpr.Default with + Columns = [Expr.Function("NOW", [])] + } + + testSelect "SELECT username, user_id FROM users" { + SelectExpr.Default with + Columns = [Expr.Ident "username"; Expr.Ident "user_id"] + From = Some (Expr.Ident "users") + } + + testSelect "SELECT DISTINCT username, user_id FROM users" { + SelectExpr.Default with + Columns = [Expr.Ident "username"; Expr.Ident "user_id"] + From = Some (Expr.Ident "users") + } + + testSelect "SELECT DISTINCT ON (username, user_id) FROM users" { + SelectExpr.Default with + Columns = [Expr.Ident "username"; Expr.Ident "user_id"] + From = Some (Expr.Ident "users") + } + + testSelect "SELECT COUNT(*) FROM users" { + SelectExpr.Default with + Columns = [Expr.Function("COUNT", [Expr.Star]) ] + From = Some (Expr.Ident "users") + } + + testSelect "SELECT COUNT(*) FROM users WHERE last_login > @login_date" { + SelectExpr.Default with + Columns = [Expr.Function("COUNT", [Expr.Star]) ] + From = Some (Expr.Ident "users") + Where = Some (Expr.GreaterThan(Expr.Ident "last_login", Expr.Parameter "@login_date")) + } + + testSelect "SELECT COUNT(*) FROM users WHERE last_login > NOW();" { + SelectExpr.Default with + Columns = [Expr.Function("COUNT", [Expr.Star]) ] + From = Some (Expr.Ident "users") + Where = Some (Expr.GreaterThan(Expr.Ident "last_login", Expr.Function("NOW", [ ]))) + } + + testSelect "SELECT * FROM users WHERE user_id = @user_id" { + SelectExpr.Default with + Columns = [Expr.Star] + From = Some (Expr.Ident "users") + Where = Some (Expr.Equals(Expr.Ident "user_id", Expr.Parameter "@user_id")) + } + + testSelect """ + SELECT value, timestamp + FROM meters + WHERE timestamp >= @from AND timestamp < @to + """ { + SelectExpr.Default with + Columns = [Expr.Ident "value"; Expr.Ident "timestamp"] + From = Some (Expr.Ident "meters") + Where = Some (Expr.And(Expr.GreaterThanOrEqual(Expr.Ident "timestamp", Expr.Parameter "@from"), Expr.LessThan(Expr.Ident "timestamp", Expr.Parameter "@to"))) + } + + testSelect """ + SELECT username, email + FROM users + WHERE user_id IN (SELECT id FROM user_ids WHERE id IS NOT NULL) + """ { + SelectExpr.Default with + Columns = [Expr.Ident "username"; Expr.Ident "email"] + From = Some (Expr.Ident "users") + Where = Some (Expr.In(Expr.Ident "user_id", Expr.Query(TopLevelExpr.Select { + SelectExpr.Default with + Columns = [Expr.Ident "id"] + From = Some (Expr.Ident "user_ids") + Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) + }))) + } +]