diff --git a/TransactionQL.Application/TransactionQL.Application.fsproj b/TransactionQL.Application/TransactionQL.Application.fsproj index 7877175..3f3d858 100644 --- a/TransactionQL.Application/TransactionQL.Application.fsproj +++ b/TransactionQL.Application/TransactionQL.Application.fsproj @@ -4,7 +4,7 @@ net8.0 true TransactionQL.CsharpApi - 2.1.3 + 2.2.0 diff --git a/TransactionQL.Console/TransactionQL.Console.fsproj b/TransactionQL.Console/TransactionQL.Console.fsproj index c5943c1..88de186 100644 --- a/TransactionQL.Console/TransactionQL.Console.fsproj +++ b/TransactionQL.Console/TransactionQL.Console.fsproj @@ -6,7 +6,7 @@ ./nupkg Exe net8.0 - 2.1.3 + 2.2.0 diff --git a/TransactionQL.DesktopApp/TransactionQL.DesktopApp.csproj b/TransactionQL.DesktopApp/TransactionQL.DesktopApp.csproj index 3f6e8dc..6d827d7 100644 --- a/TransactionQL.DesktopApp/TransactionQL.DesktopApp.csproj +++ b/TransactionQL.DesktopApp/TransactionQL.DesktopApp.csproj @@ -7,7 +7,7 @@ app.manifest true Assets\lion.ico - 2.1.3 + 2.2.0 diff --git a/TransactionQL.Input/TransactionQL.Input.fsproj b/TransactionQL.Input/TransactionQL.Input.fsproj index 52428c9..49f1e68 100644 --- a/TransactionQL.Input/TransactionQL.Input.fsproj +++ b/TransactionQL.Input/TransactionQL.Input.fsproj @@ -2,7 +2,7 @@ net8.0 - 2.1.3 + 2.2.0 diff --git a/TransactionQL.Parser.Tests/QLInterpreterTests.fs b/TransactionQL.Parser.Tests/QLInterpreterTests.fs index 35b150f..352f117 100644 --- a/TransactionQL.Parser.Tests/QLInterpreterTests.fs +++ b/TransactionQL.Parser.Tests/QLInterpreterTests.fs @@ -232,6 +232,32 @@ let ``Inference: number column - notequalto`` () = Assert.True(evalFilter' (env', Filter(Column "Amount", NotEqualTo, (Number 2.0)))) +[] +let ``Payee: words`` () = + let payeeParts = Interpolation [ Word "American"; Word "Express"] + let payee = evalPayee env payeeParts + Assert.Equal("American Express", payee) + +[] +let ``Payee: variable`` () = + let payeeParts = Interpolation [ ColumnToken (Column "Name") ] + let env' = { env with Row = Map.ofList [ ("Name", "American Express") ]} + let payee = evalPayee env' payeeParts + Assert.Equal("American Express", payee) + +[] +let ``Payee: variable (undefined)`` () = + let payeeParts = Interpolation [ ColumnToken (Column "Name") ] + let payee = evalPayee env payeeParts + Assert.Equal("@Name", payee) + +[] +let ``Payee: interpolation`` () = + let payeeParts = Interpolation [ Word "Monthly:"; ColumnToken (Column "Name") ] + let env' = { env with Row = Map.ofList [ ("Name", "American Express") ]} + let payee = evalPayee env' payeeParts + Assert.Equal("Monthly: American Express", payee) + [] let ``Posting lines: No amount`` () = let env' = @@ -326,7 +352,7 @@ let ``Posting: updates remainder between lines`` () = let ``Query: given a matching row, a posting is generated`` () = let ql = Query( - Payee "a payee", + Word "a payee", [ Filter(Column "Amount", GreaterThan, Number 0.00) ], Posting( None, @@ -351,7 +377,7 @@ let ``Query: given a matching row, a posting is generated`` () = let ``Query: given a row that does not match, no posting is generated`` () = let ql = Query( - Payee "a payee", + Word "a payee", [ Filter(Column "Amount", GreaterThan, Number 0.00) ], Posting( None, @@ -369,7 +395,7 @@ let ``Query: given a row that does not match, no posting is generated`` () = let ``Queries: multiple matching queries only applies the first match`` () = let queries = [ Query( - Payee "first payee", + Word "first payee", [ Filter(Column "Amount", GreaterThan, Number 0.00) ], Posting( None, @@ -382,7 +408,7 @@ let ``Queries: multiple matching queries only applies the first match`` () = ) ) Query( - Payee "second payee", + Word "second payee", [ Filter(Column "Amount", GreaterThan, Number 0.00) ], Posting( None, @@ -411,7 +437,7 @@ let ``Queries: multiple matching queries only applies the first match`` () = let ``Queries: multiple queries only applies the match`` () = let queries = [ Query( - Payee "first payee", + Word "first payee", [ Filter(Column "Amount", LessThan, Number 0.00) ], Posting( None, @@ -424,7 +450,7 @@ let ``Queries: multiple queries only applies the match`` () = ) ) Query( - Payee "second payee", + Word "second payee", [ Filter(Column "Amount", GreaterThan, Number 0.00) ], Posting( None, @@ -459,7 +485,7 @@ let ``Queries: no matches`` () = let ``Queries: notes are added to the comments`` () = let queries = [ Query( - Payee "second payee", + Word "second payee", [ Filter(Column "Amount", GreaterThan, Number 0.00) ], Posting( "this is a note" |> Some, @@ -486,7 +512,7 @@ let ``Queries: notes are added to the comments`` () = let ``Queries: tags are added to the posting line`` () = let queries = [ Query( - Payee "second payee", + Word "second payee", [ Filter(Column "Amount", GreaterThan, Number 0.00) ], Posting( "this is a note" |> Some, diff --git a/TransactionQL.Parser.Tests/QLParserTests.fs b/TransactionQL.Parser.Tests/QLParserTests.fs index 0e17461..1dd0843 100644 --- a/TransactionQL.Parser.Tests/QLParserTests.fs +++ b/TransactionQL.Parser.Tests/QLParserTests.fs @@ -181,27 +181,54 @@ let ``Filters: or groups`` () = [] let ``Payee: # `` () = let payee = "Some long string" - test QLParser.qpayee $"# %s{payee}" (Payee payee) + let words = payee.Split () |> (Array.map Word) |> List.ofArray + test QLParser.qpayee $"# %s{payee}\n" (Interpolation words) [] -let ``Query: `` () = - let query = - """# Full description test - Creditor = "NL" - Amount >= 50.00 - - posting { - Assets:TestAccount EUR (total / 2) - Assets:TestSavings EUR (remainder) - Expenses:Development - } - """ +let ``Payee: # `` () = + let variable = "Name" + test QLParser.qpayee $"# @%s{variable}\n" (Interpolation [ColumnToken (Column variable)]) + +[] +let ``Payee: # `` () = + let variable = "Name" + test QLParser.qpayee $"# Some @%s{variable} string\n" (Interpolation [ (Word "Some"); (ColumnToken (Column variable)); (Word "string") ]) +[] +let ``Payee: # `` () = + let variable = "Name" + test + QLParser.qpayee + $"# Some & Sons (@%s{variable}) string\n" + (Interpolation [ + Word "Some" + Word "" + Word "&" + Word "Sons" + Word "(" + ColumnToken (Column "Name") + Word ")" + Word "string" + ]) + +[] +let ``Query: `` () = + let query = [ + "# Full description @Creditor" + " Creditor = \"NL\"" + " Amount >= 50.00" + "" + " posting {" + " Assets:TestAccount EUR (total / 2)" + " Assets:TestSavings EUR (remainder)" + " Expenses:Development" + " }" + ] test QLParser.qquery - query + (String.concat Environment.NewLine query) (Query( - Payee "Full description test", + Interpolation [ Word "Full"; Word "description"; ColumnToken (Column "Creditor") ], [ Filter(Column "Creditor", EqualTo, String "NL") Filter(Column "Amount", GreaterThanOrEqualTo, Number 50.0) ], Posting( @@ -220,38 +247,38 @@ let ``Query: `` () = [] let ``Queries: multiple queries`` () = - let queries = - """# First query - Creditor = "NL" - - posting { - Test:Account - } - - # Second query - Creditor = "BE" - - A = 5.0 - or B = 2.0 - - C = 1.0 - - posting { - Assets:Checking - } - """ + let queries = [ + "# First query" + " Creditor = \"NL\"" + "" + " posting {" + " Test:Account" + " }" + "" + "# Second query" + " Creditor = \"BE\"" + "" + " A = 5.0" + " or B = 2.0" + "" + " C = 1.0" + "" + " posting {" + " Assets:Checking" + " }" + ] test QLParser.qprogram - queries + (String.concat Environment.NewLine queries) ([ Query( - Payee "First query", + Interpolation [ Word "First"; Word "query" ], [ Filter(Column "Creditor", EqualTo, String "NL") ], Posting(None, [ trx (Account [ "Test"; "Account" ], None) ]) ) Query( - Payee "Second query", + Interpolation [ Word "Second"; Word "query" ], [ Filter(Column "Creditor", EqualTo, String "BE") OrGroup [ Filter(Column "A", EqualTo, Number 5.0) diff --git a/TransactionQL.Parser/AST.fs b/TransactionQL.Parser/AST.fs index 5d15c78..d6ac57e 100644 --- a/TransactionQL.Parser/AST.fs +++ b/TransactionQL.Parser/AST.fs @@ -45,6 +45,9 @@ module AST = | Filter of Column * FilterOperator * FilterAtom | OrGroup of Filter list - type Payee = Payee of string + type Payee = + | Word of string + | ColumnToken of Column + | Interpolation of Payee list type Query = Query of Payee * Filter list * Posting diff --git a/TransactionQL.Parser/Interpretation.fs b/TransactionQL.Parser/Interpretation.fs index 2fef8b2..3470ae0 100644 --- a/TransactionQL.Parser/Interpretation.fs +++ b/TransactionQL.Parser/Interpretation.fs @@ -25,3 +25,8 @@ module Interpretation = Interpretation(newEnv, folder currentResult newResult)) seed list + + let map + (f: ('a -> 'b)) + (Interpretation(e, r): Interpretation<'a>) + : Interpretation<'b> = Interpretation(e, f(r)) diff --git a/TransactionQL.Parser/QLInterpreter.fs b/TransactionQL.Parser/QLInterpreter.fs index f69c678..34c9d72 100644 --- a/TransactionQL.Parser/QLInterpreter.fs +++ b/TransactionQL.Parser/QLInterpreter.fs @@ -38,7 +38,6 @@ module QLInterpreter = eval' expr |> fun n -> Interpretation(env, n) - let generatePostingLine env ({ Account = accounts @@ -135,7 +134,17 @@ module QLInterpreter = | OrGroup filters -> Interpretation.fold evalFilter (||) (Interpretation(env, false)) filters - let evalQuery env (Query(Payee payee, filters, posting)) = + let rec evalPayee env payee = + match payee with + | Word p -> p + | ColumnToken (Column col) -> + Map.tryFind col env.Row + |> Option.defaultValue $"@{col}" + | Interpolation xs -> + List.map (evalPayee env) xs + |> (String.concat " ") + + let evalQuery env (Query(payee, filters, posting)) = let (Interpretation(envFilter, isMatch)) = Interpretation.fold evalFilter (&&) (Interpretation(env, true)) filters @@ -153,8 +162,8 @@ module QLInterpreter = let date = System.DateTime.ParseExact(Map.find "Date" env.Row, env.DateFormat, CultureInfo.InvariantCulture) - // TODO: (20240818) Check if Payee contains variables (for example 'Recipient', 'Name' or 'Receiver') - let header = Header(date, payee) + let payeeString = evalPayee env payee + let header = Header(date, payeeString) let comments = [ if Map.containsKey "Description" envFilter.Row then @@ -178,7 +187,6 @@ module QLInterpreter = Comments = newComments } ) - let rec evalProgram env queries = match queries with | (q :: qs) -> diff --git a/TransactionQL.Parser/QLParser.fs b/TransactionQL.Parser/QLParser.fs index 9408e63..b7a1c32 100644 --- a/TransactionQL.Parser/QLParser.fs +++ b/TransactionQL.Parser/QLParser.fs @@ -132,15 +132,22 @@ module QLParser = orGroup + /// Skip zero or more literal spaces + let pspace = skipMany (pchar ' ') + + let qcolumnToken = pchar '@' >>. qcolumnIdentifier |>> ColumnToken + let qpayee: Parser = - let isNewline = fun c -> List.contains c [ "\n"; "\r" ] - (pchar '#' .>> spaces1) >>. manySatisfy (not << isNewline << string) |>> Payee + let isWhitespaceOrVar = fun c -> List.contains c [ "\n"; "\r"; "\t"; " "; "@" ] + let ptext = manySatisfy (not << isWhitespaceOrVar << string) + let payeeParts = ((either qcolumnToken (ptext |>> Word)) .>>? pspace) + let pinterpolation = (many1Till payeeParts newline) |>> Interpolation + (pchar '#' .>> spaces1) >>. pinterpolation let qquery = - let payee = qpayee .>> newline let filters = many (between spaces spaces1 qfilter) let posting = between spaces spaces qposting - pipe3 payee filters posting (curry3 Query) + pipe3 qpayee filters posting (curry3 Query) let qprogram = many (qquery .>> spaces) diff --git a/TransactionQL.Parser/TransactionQL.Parser.fsproj b/TransactionQL.Parser/TransactionQL.Parser.fsproj index 0585831..c2f033a 100644 --- a/TransactionQL.Parser/TransactionQL.Parser.fsproj +++ b/TransactionQL.Parser/TransactionQL.Parser.fsproj @@ -2,7 +2,7 @@ net8.0 - 2.1.3 + 2.2.0 diff --git a/readme.md b/readme.md index cb3d4e8..3e17d62 100644 --- a/readme.md +++ b/readme.md @@ -65,7 +65,11 @@ filter, it will generate a posting. Filters consist of three parts: ### Payee line Every filter starts with a payee line. This line will be used on the generated -posting. +posting. Variables can be used by prefixing them with the `@`-symbol. +For example, `# Rent (@Name)`. If the variable does not exist, the text is +printed as is. + +Individual `@`-symbols are not supported. ### Conditions