///|
priv struct QueryContext {
source : QuerySource
realized : @realization.RealizedLedger
mut aggregate_row : BqlRow?
mut error : String?
running : @booking.Inventory
mut opens : Map[String, @ast.Entry]?
mut closes : Map[String, @ast.Entry]?
mut prices : Map[String, Array[(@ast.Date, @decimal.Decimal)]]?
}
///|
fn QueryContext::new(
realized : @realization.RealizedLedger,
source : QuerySource,
) -> QueryContext {
{
source,
realized,
aggregate_row: None,
error: None,
running: @booking.Inventory::new(),
opens: None,
closes: None,
prices: None,
}
}
///|
fn ensure_account_index(ctx : QueryContext) -> Unit {
if ctx.opens is Some(_) {
return
}
let opens : Map[String, @ast.Entry] = Map([])
let closes : Map[String, @ast.Entry] = Map([])
for entry in ctx.realized.entries {
match entry.directive {
DOpen(o) => opens.set(o.account, entry)
DClose(c) => closes.set(c.account, entry)
_ => ()
}
}
ctx.opens = Some(opens)
ctx.closes = Some(closes)
}
///|
fn entry_metadata(entry : @ast.Entry) -> QueryValue {
let fields = source_metadata(query_location(entry), entry.meta)
if entry.inferred_tolerances is Some(values) {
fields.push(
("__tolerances__", QDict(values.map(fn(p) { (p.0, QNumber(p.1)) }))),
)
}
QDict(fields)
}
///|
fn directive_value(entry : @ast.Entry) -> QueryValue {
let fields : Array[(String, QueryValue)] = [
("meta", entry_metadata(entry)),
("date", QDate(entry.date)),
]
let name = match entry.directive {
DOpen(o) => {
fields.append([
("account", QText(o.account)),
(
"currencies",
if o.currencies.is_empty() {
QNull
} else {
QList(o.currencies.map(fn(s) { QText(s) }))
},
),
(
"booking",
match o.booking {
Some(b) => QEnum("Booking", b)
None => QNull
},
),
])
"Open"
}
DClose(c) => {
fields.push(("account", QText(c.account)))
"Close"
}
DCommodity(c) => {
fields.push(("currency", QText(c.currency)))
"Commodity"
}
DEvent(e) => {
fields.append([
("type", QText(e.event_type)),
("description", QText(e.description)),
])
"Event"
}
DPrice(p) => {
fields.append([
("currency", QText(p.currency)),
("amount", QAmount(p.amount)),
])
"Price"
}
DNote(n) => {
fields.append([
("account", QText(n.account)),
("comment", QText(n.comment)),
("tags", QFrozenSet(n.tags)),
("links", QFrozenSet(n.links)),
])
"Note"
}
DDocument(d) => {
fields.append([
("account", QText(d.account)),
("filename", QText(document_filename(entry, d))),
("tags", QFrozenSet(d.tags)),
("links", QFrozenSet(d.links)),
])
"Document"
}
DPad(p) => {
fields.append([
("account", QText(p.account)),
("source_account", QText(p.source_account)),
])
"Pad"
}
DBalance(b) => {
fields.append([
("account", QText(b.account)),
("amount", QAmount(b.amount)),
(
"tolerance",
match b.tolerance {
Some(n) => QNumber(n)
None => QNull
},
),
(
"diff_amount",
match entry.balance_discrepancy {
Some(a) => QAmount(a)
None => QNull
},
),
])
"Balance"
}
DQuery(q) => {
fields.append([("name", QText(q.name)), ("query_string", QText(q.query))])
"Query"
}
DTransaction(t) => {
fields.append([
("flag", QText(t.flag)),
(
"payee",
match t.payee {
Some(s) => QText(s)
None => QNull
},
),
("narration", QText(t.narration)),
("tags", QFrozenSet(t.tags)),
("links", QFrozenSet(t.links)),
(
"postings",
QList(booked_postings(t.postings).map(posting_value_record)),
),
])
"Transaction"
}
DCustom(c) => {
fields.append([
("type", QText(c.custom_type)),
("values", QList(c.values.map(custom_value_record))),
])
"Custom"
}
}
QRecord(name, fields)
}
///|
fn posting_price(p : @ast.Posting) -> QueryValue {
match p.price {
Some(PerUnit(a)) => QAmount(a)
Some(Total(a)) =>
match p.units {
Some(u) =>
if u.number.is_zero() {
QAmount(@ast.Amount::new(@decimal.Decimal::zero(), a.currency))
} else {
match a.number.div(u.number.abs()) {
Some(n) => QAmount(@ast.Amount::new(n, a.currency))
None => QNull
}
}
None => QNull
}
None => QNull
}
}
///|
fn posting_value_record(p : @ast.Posting) -> QueryValue {
let cost = match (p.cost, p.units) {
(Some(_), Some(units)) =>
match posting_cost(p, units) {
Some(c) =>
QRecord("Cost", [
("number", QNumber(c.number)),
("currency", QText(c.currency)),
(
"date",
match c.date {
Some(d) => QDate(d)
None => QNull
},
),
(
"label",
match c.label {
Some(s) => QText(s)
None => QNull
},
),
])
None => QNull
}
_ => QNull
}
QRecord("Posting", [
("account", QText(p.account)),
(
"units",
match p.units {
Some(a) => QAmount(a)
None => QNull
},
),
("cost", cost),
("price", posting_price(p)),
(
"flag",
match p.flag {
Some(s) => QText(s)
None => QNull
},
),
("meta", posting_metadata(p)),
])
}
///|
fn custom_value_record(value : @ast.CustomValue) -> QueryValue {
let (v, t) = match value {
CString(s) | CCurrency(s) | CTag(s) => (QText(s), QPythonType("str"))
CAccount(s) => (QText(s), QText(""))
CNumber(n) => (QNumber(n), QPythonType("decimal.Decimal"))
CBool(b) => (QBool(b), QPythonType("bool"))
CDate(d) => (QDate(d), QPythonType("datetime.date"))
CAmount(a) => (QAmount(a), QPythonType("beancount.core.amount.Amount"))
}
QRecord("ValueType", [("value", v), ("dtype", t)])
}
///|
fn hash_scalar(value : QueryValue) -> String {
match value {
QNull => "None"
QBool(b) => if b { "True" } else { "False" }
QDict(_) | QRecord(_, _) | QList(_) => python_repr(value)
_ => value.to_text()
}
}
///|
fn directive_identity(value : QueryValue) -> String {
match value {
QRecord(_, fields) => {
let out = StringBuilder()
for (_, field) in fields {
match field {
QList(values) => {
let hashes = values.map(fn(v) {
match v {
QRecord(_, _) => directive_identity(v)
_ => query_md5(hash_scalar(v))
}
})
hashes.sort_by(fn(a, b) { a.lexical_compare(b) })
out.write_string(hashes.join(""))
}
QSet(values) | QFrozenSet(values) => {
let hashes = values.map(fn(v) { query_md5(v) })
hashes.sort_by(fn(a, b) { a.lexical_compare(b) })
out.write_string(hashes.join(""))
}
_ => out.write_string(hash_scalar(field))
}
}
query_md5(out.to_string())
}
_ => query_md5(hash_scalar(value))
}
}
///|
fn document_filename(entry : @ast.Entry, document : @ast.Document) -> String {
let file = @path.Path(document.filename)
if file.is_absolute() {
file.normalize().to_string()
} else {
@path.Path(entry.loc.file).dirname().join(file).normalize().to_string()
}
}
///|
fn query_error(ctx : QueryContext, message : String) -> Unit {
if ctx.error is None {
ctx.error = Some(message)
}
}
///|
fn running_balance(ctx : QueryContext, row : BqlRow) -> QueryValue {
if row.extra is Some(extra) && extra.running is Some(inventory) {
return QInventory(inventory)
}
ctx.running.add(row.position)
let snapshot = ctx.running.copy()
let extra = match row.extra {
Some(extra) => extra
None => {
let extra = new_row_extra(None, None, None)
row.extra = Some(extra)
extra
}
}
extra.running = Some(snapshot)
QInventory(snapshot)
}
///|
fn origin_expr(expr : Expr) -> Bool {
match expr {
ECol(
"entry"
| "id"
| "meta"
| "filename"
| "lineno"
| "location"
| "posting_flag"
| "price"
) => true
ECall("meta" | "entry_meta" | "any_meta", _) => true
_ => false
}
}
///|
fn expr_any(expr : Expr, predicate : (Expr) -> Bool) -> Bool {
if predicate(expr) {
return true
}
match expr {
EPredicate(c) => cond_any(c, predicate)
ECall(_, args) => args.any(fn(e) { expr_any(e, predicate) })
EBinary(a, _, b) => expr_any(a, predicate) || expr_any(b, predicate)
_ => false
}
}
///|
fn cond_any(cond : Cond, predicate : (Expr) -> Bool) -> Bool {
match cond {
CTrue => false
CNot(c) => cond_any(c, predicate)
CAnd(a, b) | COr(a, b) => cond_any(a, predicate) || cond_any(b, predicate)
CCmp(a, _, b) => expr_any(a, predicate) || expr_any(b, predicate)
CIsNull(e, _) | CTruthy(e) | CRegex(e, _, _) => expr_any(e, predicate)
}
}
///|
fn query_any(query : SelectQuery, predicate : (Expr) -> Bool) -> Bool {
query.projections.any(fn(e) { expr_any(e, predicate) }) ||
query.groups.any(fn(e) { expr_any(e, predicate) }) ||
query.orders.any(fn(o) { expr_any(o.expr, predicate) }) ||
cond_any(query.filter, predicate) ||
cond_any(query.having, predicate)
}
///|
fn query_location(entry : @ast.Entry) -> @ast.Loc {
entry.query_origin.unwrap_or(entry.loc)
}
///|
fn dict_get(value : QueryValue, key : String) -> QueryValue? {
match value {
QDict(fields) => {
for (name, v) in fields {
if name == key {
return Some(v)
}
}
None
}
_ => None
}
}
///|
fn record_metadata(ctx : QueryContext, value : QueryValue) -> QueryValue {
if value is QRecord(_, fields) {
for (name, field) in fields {
if name == "meta" {
return field
}
}
}
query_error(ctx, "query.oracle_type_error: AttributeError directive metadata")
QNull
}
///|
fn representation_expr(expr : Expr) -> Bool {
match expr {
ECol("id" | "meta" | "entry" | "open" | "close") => true
ECall(
"str"
| "repr"
| "meta"
| "entry_meta"
| "any_meta"
| "open_meta"
| "commodity_meta"
| "currency_meta",
_
) => true
_ => false
}
}
///|
fn structural_value(value : QueryValue) -> Bool {
match value {
QDict(_) | QRecord(_, _) | QList(_) | QEnum(_, _) | QPythonType(_) => true
_ => false
}
}
///|
fn typed_compare(ctx : QueryContext, a : QueryValue, b : QueryValue) -> Int32? {
if a is QText(a) && b is QText(b) {
return Some(python_text_compare(a, b))
}
let sequence = match (a, b) {
(QList(a), QList(b)) => Some((a, b))
(QRecord(_, a), QRecord(_, b)) =>
Some((a.map(fn(p) { p.1 }), b.map(fn(p) { p.1 })))
_ => None
}
if sequence is Some((a, b)) {
for i in 0.. RowExtra {
{ entry, source, posting, running: None, cells: None, }
}
///|
fn row_entry(row : BqlRow) -> @ast.Entry? {
match row.extra {
Some(extra) => extra.entry
None => None
}
}
///|
fn row_posting(row : BqlRow) -> @ast.Posting? {
match row.extra {
Some(extra) => extra.posting
None => None
}
}
///|
fn posting_metadata(posting : @ast.Posting) -> QueryValue {
if posting.loc.file == "" && posting.loc.line == 0 && posting.meta.is_empty() {
QNull
} else {
QDict(source_metadata(posting.loc, posting.meta))
}
}
///|
fn booked_postings(postings : Array[@ast.Posting]) -> Array[@ast.Posting] {
if postings.length() < 3 {
return postings
}
let currency = fn(p : @ast.Posting) {
if p.units is Some(a) {
if posting_cost(p, a) is Some(c) {
c.currency
} else if posting_price(p) is QAmount(price) {
price.currency
} else {
a.currency
}
} else {
""
}
}
let currencies : Array[String] = []
for p in postings {
let c = currency(p)
if !currencies.contains(c) {
currencies.push(c)
}
}
if currencies.length() < 2 {
return postings
}
let result = postings.copy()
result
.mut_view()
.stable_sort_by(fn(a, b) {
currencies
.search(currency(a))
.unwrap()
.compare(currencies.search(currency(b)).unwrap())
})
result
}
///|
fn constant_query_error(expr : Expr) -> String? {
let pure = [
"abs", "bool", "coalesce", "date", "date_add", "date_bin", "date_diff", "date_part",
"date_trunc", "day", "decimal", "empty", "findfirst", "filter_currency", "getitem",
"grep", "grepn", "int", "interval", "joinstr", "leaf", "length", "lower", "maxwidth",
"month", "neg", "number", "parent", "parse_date", "quarter", "repr", "root",
"round", "safediv", "splitcomp", "str", "subst", "substr", "today", "upper",
"weekday", "year", "yearmonth",
]
if expr_any(expr, fn(e) {
match e {
ECol(_) | EStar => true
ECall(name, _) => !pure.contains(name)
_ => false
}
}) ||
!expr_any(expr, fn(e) { e is ECall(_, _) || e is EBinary(_, _, _) }) {
return None
}
let errors : Array[String] = []
expression_type(expr, SrcPostings, errors) |> ignore
if !errors.is_empty() {
return None
}
let ledger : @realization.RealizedLedger = {
entries: [],
at: None,
options: @ast.Options::new(),
inventories: Map([]),
accounts: Map([]),
prices: @realization.PriceDb::new(),
}
let ctx = QueryContext::new(ledger, SrcPostings)
eval_expr(ctx, expr, relation_rows([], [[]])[0]) |> ignore
ctx.error
}