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")))
+ })))
+ }
+]