diff --git a/src/NpgsqlFSharpParser/Parser.fs b/src/NpgsqlFSharpParser/Parser.fs index 962b133..23c62c5 100644 --- a/src/NpgsqlFSharpParser/Parser.fs +++ b/src/NpgsqlFSharpParser/Parser.fs @@ -42,6 +42,20 @@ let boolean : Parser = (text "true" |>> fun _ -> Expr.Boolean true) <|> (text "false" |>> fun _ -> Expr.Boolean false) +// Applies popen, then pchar repeatedly until pclose succeeds, +// returns the string in the middle +let manyCharsBetween popen pclose pchar = popen >>? manyCharsTill pchar pclose + +// Parses any string between popen and pclose +let anyStringBetween popen pclose = manyCharsBetween popen pclose anyChar + +// Parses any string between double quotes +let quotedString = skipChar '\'' |> anyStringBetween <| skipChar '\'' + +let stringLiteral : Parser = + quotedString + |>> Expr.StringLiteral + let commaSeparatedExprs = sepBy expr comma let selections = @@ -122,6 +136,12 @@ let optionalOrderingExpr = | Some exprs -> exprs | None -> [ ] +let optionalRetuningExpr = + optionalExpr (text "RETURNING " >>. selections) + |>> function + | Some exprs -> exprs + | None -> [ ] + let optionalDistinct = optional (attempt (text "DISTINCT ON") <|> attempt (text "DISTINCT")) @@ -154,7 +174,7 @@ let selectQuery = optionalHavingClause >>= fun havingExpr -> optionalOrderingExpr >>= fun orderingExprs -> optionalLimit >>= fun limitExpr -> - optionalOffset |>> fun offsetExpr -> + optionalOffset >>= fun offsetExpr -> let query = { SelectExpr.Default with Columns = selections @@ -167,7 +187,36 @@ let selectQuery = Limit = limitExpr Offset = offsetExpr } - Expr.Query (TopLevelExpr.Select query) + preturn (Expr.SelectQuery query) + +let deleteQuery = + text "DELETE FROM " >>. simpleIdentifier >>= fun tableName -> + optionalWhereClause >>= fun where -> + optionalRetuningExpr >>= fun returningExpr -> + let query = { + DeleteExpr.Default with + Table = tableName + Where = where + Returning = returningExpr + } + + preturn (Expr.DeleteQuery query) + +let insertQuery = + text "INSERT INTO " >>. simpleIdentifier >>= fun tableName -> + (parens (sepBy1 simpleIdentifier comma)) >>= fun columns -> + text "VALUES" >>= fun _ -> + (parens (sepBy1 expr comma)) >>= fun values -> + optionalRetuningExpr >>= fun returningExpr -> + let query = { + InsertExpr.Default with + Table = tableName + Columns = columns + Values = values + Returning = returningExpr + } + + preturn (Expr.InsertQuery query) opp.AddOperator(InfixOperator("AND", spaces, 7, Associativity.Left, fun left right -> Expr.And(left, right))) opp.AddOperator(InfixOperator("OR", notFollowedBy (text "DER BY"), 6, Associativity.Left, fun left right -> Expr.Or(left, right))) @@ -182,14 +231,17 @@ opp.AddOperator(PostfixOperator("IS NULL", spaces, 8, false, fun value -> Expr.E opp.AddOperator(PostfixOperator("IS NOT NULL", spaces, 8, false, fun value -> Expr.Not(Expr.Equals(Expr.Null, value)))) opp.TermParser <- choice [ + (attempt insertQuery) + (attempt deleteQuery) (attempt selectQuery) (attempt functionExpr) (text "(") >>. expr .>> (text ")") star - parameter - identifier integer boolean + stringLiteral + identifier + parameter ] let fullParser = (optional spaces) >>. expr .>> (optional spaces <|> (text ";" |>> fun _ -> ())) @@ -198,3 +250,8 @@ let parse (input: string) : Result = match run fullParser input with | Success(result,_,_) -> Result.Ok result | Failure(errMsg,_,_) -> Result.Error errMsg + +let parseUnsafe query = + match parse query with + | Result.Ok output -> output + | Result.Error errorMsg -> failwith errorMsg diff --git a/src/NpgsqlFSharpParser/Types.fs b/src/NpgsqlFSharpParser/Types.fs index c9881bc..c1a2a00 100644 --- a/src/NpgsqlFSharpParser/Types.fs +++ b/src/NpgsqlFSharpParser/Types.fs @@ -1,18 +1,12 @@ 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 + | StringLiteral of string | Integer of int | Float of float | Null @@ -27,7 +21,9 @@ type 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 + | SelectQuery of expr:SelectExpr + | DeleteQuery of expr:DeleteExpr + | InsertQuery of expr: InsertExpr type Ordering = | Asc of columnName:string @@ -69,20 +65,36 @@ type SelectExpr = { type UpdateExpr = { Table : string - Assignments : Map - ConflictResolution : Map + Where : Expr option + Assignments : (string * Expr) list + ConflictResolution : (string * Expr) list Returning : Expr list } type DeleteExpr = { Table : string Where : Expr option -} + Returning : Expr list +} with + static member Default = + { + Table = ""; + Where = None + Returning = [ ] + } type InsertExpr = { Table: string Columns : string list Values : Expr list - ConflictResolution : Map + ConflictResolution : (string * Expr) list Returning : Expr list -} +} with + static member Default = + { + Table = ""; + Columns = [ ] + Values = [ ] + ConflictResolution = [ ] + Returning = [ ] + } diff --git a/tests/NpgsqlFSharpAnalyzer.Tests/NpgsqlFSharpAnalyzer.Tests.fsproj b/tests/NpgsqlFSharpAnalyzer.Tests/NpgsqlFSharpAnalyzer.Tests.fsproj index 5ca511f..86282c4 100644 --- a/tests/NpgsqlFSharpAnalyzer.Tests/NpgsqlFSharpAnalyzer.Tests.fsproj +++ b/tests/NpgsqlFSharpAnalyzer.Tests/NpgsqlFSharpAnalyzer.Tests.fsproj @@ -6,7 +6,9 @@ true - + + + diff --git a/tests/NpgsqlFSharpAnalyzer.Tests/ParseDeleteTests.fs b/tests/NpgsqlFSharpAnalyzer.Tests/ParseDeleteTests.fs new file mode 100644 index 0000000..3b08812 --- /dev/null +++ b/tests/NpgsqlFSharpAnalyzer.Tests/ParseDeleteTests.fs @@ -0,0 +1,42 @@ +module ParserDeleteTests + +open Expecto +open NpgsqlFSharpParser + +let testDelete inputQuery expected = + test inputQuery { + match Parser.parse inputQuery with + | Ok (Expr.DeleteQuery query) -> + Expect.equal query expected "The query is parsed correctly" + | Ok somethingElse -> + failwithf "Unexpected delete statement %A" somethingElse + | Error errorMsg -> + failwith errorMsg + } + +let ftestDelete inputQuery expected = + ftest inputQuery { + match Parser.parse inputQuery with + | Ok (Expr.DeleteQuery query) -> + Expect.equal query expected "The query is parsed correctly" + | Ok somethingElse -> + failwithf "Unexpected delete statement %A" somethingElse + | Error errorMsg -> + failwith errorMsg + } + +[] +let deleteQueryTests = testList "Parse DELETE tests" [ + testDelete "DELETE FROM users WHERE last_login IS NULL" { + DeleteExpr.Default with + Table = "users" + Where = Some (Expr.Equals(Expr.Null, Expr.Ident "last_login")) + } + + testDelete "DELETE FROM users WHERE luck = 'bad' RETURNING *" { + DeleteExpr.Default with + Table = "users" + Where = Some (Expr.Equals(Expr.Ident "luck", Expr.StringLiteral "bad")) + Returning = [Expr.Star] + } +] diff --git a/tests/NpgsqlFSharpAnalyzer.Tests/ParseInsertTests.fs b/tests/NpgsqlFSharpAnalyzer.Tests/ParseInsertTests.fs new file mode 100644 index 0000000..f296b2a --- /dev/null +++ b/tests/NpgsqlFSharpAnalyzer.Tests/ParseInsertTests.fs @@ -0,0 +1,44 @@ +module ParseInsertTests + +open Expecto +open NpgsqlFSharpParser + +let testInsert inputQuery expected = + test inputQuery { + match Parser.parse inputQuery with + | Ok (Expr.InsertQuery query) -> + Expect.equal query expected "The query is parsed correctly" + | Ok somethingElse -> + failwithf "Unexpected insert statement %A" somethingElse + | Error errorMsg -> + failwith errorMsg + } + +let ftestInsert inputQuery expected = + ftest inputQuery { + match Parser.parse inputQuery with + | Ok (Expr.InsertQuery query) -> + Expect.equal query expected "The query is parsed correctly" + | Ok somethingElse -> + failwithf "Unexpected insert statement %A" somethingElse + | Error errorMsg -> + failwith errorMsg + } + +[] +let insertQueryTests = testList "Parse INSERT queries" [ + testInsert "INSERT INTO users (username, active) VALUES (@username, true)" { + InsertExpr.Default with + Table = "users" + Columns = ["username"; "active"] + Values = [ Expr.Parameter("@username"); Expr.Boolean true ] + } + + testInsert "INSERT INTO users (username, active) VALUES (@username, true) RETURNING *" { + InsertExpr.Default with + Table = "users" + Columns = ["username"; "active"] + Values = [ Expr.Parameter("@username"); Expr.Boolean true ] + Returning = [Expr.Star] + } +] diff --git a/tests/NpgsqlFSharpAnalyzer.Tests/ParserTests.fs b/tests/NpgsqlFSharpAnalyzer.Tests/ParseSelectTests.fs similarity index 76% rename from tests/NpgsqlFSharpAnalyzer.Tests/ParserTests.fs rename to tests/NpgsqlFSharpAnalyzer.Tests/ParseSelectTests.fs index 9cdee0f..a0474c6 100644 --- a/tests/NpgsqlFSharpAnalyzer.Tests/ParserTests.fs +++ b/tests/NpgsqlFSharpAnalyzer.Tests/ParseSelectTests.fs @@ -1,4 +1,4 @@ -module ParserTests +module ParseSelectTests open Expecto open NpgsqlFSharpParser @@ -6,7 +6,7 @@ open NpgsqlFSharpParser let testSelect inputQuery expected = test inputQuery { match Parser.parse inputQuery with - | Ok (Expr.Query (TopLevelExpr.Select query)) -> + | Ok (Expr.SelectQuery query) -> Expect.equal query expected "The query is parsed correctly" | Ok somethingElse -> failwithf "Unexpected select statement %A" somethingElse @@ -17,7 +17,7 @@ let testSelect inputQuery expected = let ftestSelect inputQuery expected = ftest inputQuery { match Parser.parse inputQuery with - | Ok (Expr.Query (TopLevelExpr.Select query)) -> + | Ok (Expr.SelectQuery query) -> Expect.equal query expected "The query is parsed correctly" | Ok somethingElse -> failwithf "Unexpected select statement %A" somethingElse @@ -26,7 +26,7 @@ let ftestSelect inputQuery expected = } [] -let parserTests = ftestList "Parser tests" [ +let selectQueryTests = testList "Parse SELECT tests" [ testSelect "SELECT NOW()" { SelectExpr.Default with @@ -126,12 +126,12 @@ let parserTests = ftestList "Parser tests" [ 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 { + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { SelectExpr.Default with Columns = [Expr.Ident "id"] From = Some (Expr.Ident "user_ids") Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) - }))) + })) } testSelect """ @@ -179,12 +179,12 @@ let parserTests = ftestList "Parser tests" [ Columns = [Expr.Ident "username"; Expr.Ident "email"] From = Some (Expr.Ident "users") Joins = [JoinExpr.InnerJoin("meters", Expr.Equals(Expr.Ident "meters.user_id", Expr.Ident "users.user_id"))] - Where = Some (Expr.In(Expr.Ident "user_id", Expr.Query(TopLevelExpr.Select { + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { SelectExpr.Default with Columns = [Expr.Ident "id"] From = Some (Expr.Ident "user_ids") Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) - }))) + })) } testSelect """ @@ -197,12 +197,12 @@ let parserTests = ftestList "Parser tests" [ Columns = [Expr.Ident "username"; Expr.Ident "email"] From = Some (Expr.Ident "users") Joins = [JoinExpr.InnerJoin("meters", Expr.Equals(Expr.Ident "meters.user_id", Expr.Ident "users.user_id"))] - Where = Some (Expr.In(Expr.Ident "user_id", Expr.Query(TopLevelExpr.Select { + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { SelectExpr.Default with Columns = [Expr.Ident "id"] From = Some (Expr.Ident "user_ids") Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) - }))) + })) } testSelect """ @@ -219,12 +219,12 @@ let parserTests = ftestList "Parser tests" [ JoinExpr.InnerJoin("meters", Expr.Equals(Expr.Ident "meters.user_id", Expr.Ident "users.user_id")) JoinExpr.LeftJoin("utilities", Expr.Equals(Expr.Ident "utilities.id", Expr.Ident "users.user_id")) ] - Where = Some (Expr.In(Expr.Ident "user_id", Expr.Query(TopLevelExpr.Select { + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { SelectExpr.Default with Columns = [Expr.Ident "id"] From = Some (Expr.Ident "user_ids") Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) - }))) + })) } testSelect """ @@ -244,12 +244,12 @@ let parserTests = ftestList "Parser tests" [ JoinExpr.InnerJoin("meters", Expr.Equals(Expr.Ident "meters.user_id", Expr.Ident "users.user_id")) JoinExpr.LeftJoin("utilities", Expr.Equals(Expr.Ident "utilities.id", Expr.Ident "users.user_id")) ] - Where = Some (Expr.In(Expr.Ident "user_id", Expr.Query(TopLevelExpr.Select { + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { SelectExpr.Default with Columns = [Expr.Ident "id"] From = Some (Expr.Ident "user_ids") Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) - }))) + })) } testSelect """ @@ -267,12 +267,12 @@ let parserTests = ftestList "Parser tests" [ JoinExpr.InnerJoin("meters", Expr.Equals(Expr.Ident "meters.user_id", Expr.Ident "users.user_id")) JoinExpr.LeftJoin("utilities", Expr.Equals(Expr.Ident "utilities.id", Expr.Ident "users.user_id")) ] - Where = Some (Expr.In(Expr.Ident "user_id", Expr.Query(TopLevelExpr.Select { + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { SelectExpr.Default with Columns = [Expr.Ident "id"] From = Some (Expr.Ident "user_ids") Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) - }))) + })) GroupBy = [Expr.Ident "user_id"; Expr.Ident "username"] } @@ -293,15 +293,80 @@ let parserTests = ftestList "Parser tests" [ JoinExpr.InnerJoin("meters", Expr.Equals(Expr.Ident "meters.user_id", Expr.Ident "users.user_id")) JoinExpr.LeftJoin("utilities", Expr.Equals(Expr.Ident "utilities.id", Expr.Ident "users.user_id")) ] - Where = Some (Expr.In(Expr.Ident "user_id", Expr.Query(TopLevelExpr.Select { + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { SelectExpr.Default with Columns = [Expr.Ident "id"] From = Some (Expr.Ident "user_ids") Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) - }))) + })) GroupBy = [Expr.Ident "user_id"; Expr.Ident "username"] Having = Some(Expr.GreaterThan(Expr.Function("SUM", [Expr.Ident "amount"]), Expr.Ident "users.salary")) } + + testSelect """ + SELECT username, email + FROM users + INNER JOIN meters ON meters.user_id = users.user_id + LEFT JOIN utilities ON utilities.id = users.user_id + WHERE user_id IN (SELECT id FROM user_ids WHERE id IS NOT NULL) + GROUP BY user_id, username + HAVING SUM(amount) > users.salary + LIMIT 20 + """ { + SelectExpr.Default with + Columns = [Expr.Ident "username"; Expr.Ident "email"] + From = Some (Expr.Ident "users") + Joins = [ + JoinExpr.InnerJoin("meters", Expr.Equals(Expr.Ident "meters.user_id", Expr.Ident "users.user_id")) + JoinExpr.LeftJoin("utilities", Expr.Equals(Expr.Ident "utilities.id", Expr.Ident "users.user_id")) + ] + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { + SelectExpr.Default with + Columns = [Expr.Ident "id"] + From = Some (Expr.Ident "user_ids") + Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) + })) + + GroupBy = [Expr.Ident "user_id"; Expr.Ident "username"] + + Having = Some(Expr.GreaterThan(Expr.Function("SUM", [Expr.Ident "amount"]), Expr.Ident "users.salary")) + + Limit = Some (Expr.Integer 20) + } + + testSelect """ + SELECT username, email + FROM users + INNER JOIN meters ON meters.user_id = users.user_id + LEFT JOIN utilities ON utilities.id = users.user_id + WHERE user_id IN (SELECT id FROM user_ids WHERE id IS NOT NULL) + GROUP BY user_id, username + HAVING SUM(amount) > users.salary + LIMIT 20 + OFFSET 100 + """ { + SelectExpr.Default with + Columns = [Expr.Ident "username"; Expr.Ident "email"] + From = Some (Expr.Ident "users") + Joins = [ + JoinExpr.InnerJoin("meters", Expr.Equals(Expr.Ident "meters.user_id", Expr.Ident "users.user_id")) + JoinExpr.LeftJoin("utilities", Expr.Equals(Expr.Ident "utilities.id", Expr.Ident "users.user_id")) + ] + Where = Some (Expr.In(Expr.Ident "user_id", Expr.SelectQuery { + SelectExpr.Default with + Columns = [Expr.Ident "id"] + From = Some (Expr.Ident "user_ids") + Where = Some(Expr.Not(Expr.Equals(Expr.Null, Expr.Ident "id"))) + })) + + GroupBy = [Expr.Ident "user_id"; Expr.Ident "username"] + + Having = Some(Expr.GreaterThan(Expr.Function("SUM", [Expr.Ident "amount"]), Expr.Ident "users.salary")) + + Limit = Some (Expr.Integer 20) + + Offset = Some (Expr.Integer 100) + } ]