Datalog.kt
src/jvmMain/kotlin/afield/data/query/datalog/Datalog.kt · 730 lines
package afield.data.query.datalog
import afield.ui.visualization.Table
import afield.ui.visualization.ToHTML
The facade over parser and engine: a Datalog holds a program, answers queries as DlRows, and reconstructs the proof of a derived fact.
Nothing here parses or evaluates. DatalogParser turns text into the DlUnit this file merges and DatalogEngine computes the model; what is left is the part a caller touches — holding the program, noticing when the cached model has gone stale, and sorting rows so that a query prints the same way twice, which set semantics on their own do not promise.
The exception is the top-down proof search behind why, which is here rather than in the engine
because it reads a finished model instead of building one. It walks rules backwards from a
derived fact, bounded by the round numbers the fixpoint recorded, and picks premises in the same
order the planner would. The opposite question, why a goal has no answers at all, is
DatalogWhyNot.kt.
import afield.data.query.datalog.* is in every Kotlin box's preamble, so Datalog() and the Dl types
are in scope with nothing to import.
class Datalog
Datalog for Field — a small deductive database you can point at your own data, or at the live box
graph (see DatalogBoxes.kt), or write directly in a box (see afield.data.query.datalog.DatalogExecution).
val d = Datalog()
d.add("""
edge(a, b). edge(b, c). edge(c, d).
path(X, Y) :- edge(X, Y).
path(X, Z) :- path(X, Y), edge(Y, Z).
""")
println(d.query("path(a, Z)").table())
println(d.why("path(a, d)"))
Facts can equally come from Kotlin, which is the point of having it here rather than shelling out to a real Datalog:
d.facts("emp", staff) { listOf(it.name, it.department, it.salary) }
d.query("dept(D), S = sum P : { emp(_, D, P) }")
The model is recomputed lazily and cached until the program changes; solve is cheap to call repeatedly, add invalidates.
class Datalog {
private val ruleList = ArrayList<DlRule>()
private val ruleKeys = HashSet<DlRule>()
private val edb = LinkedHashMap<String, DlRelation>()
private val queryList = ArrayList<DlQuery>()
private var cached: DlSolution? = null
private var queryCounter = 0
val rules: List<DlRule> get() = ruleList
val queries: List<DlQuery> get() = queryList
val facts: Map<String, DlRelation> get() = edb
Base relations exactly as given — before any rule has fired.
building
fun add(source: String): DlUnit = addParsed(DatalogParser.parse(source))
Parse source and merge it in. Ground facts become base relations, everything else a rule;
?- goals are recorded in queries but not run.
Re-adding a rule that is already present is a no-op, because a live box re-executes its whole
program every time you hit run and a program that doubled in size each time would be useless.
The returned DlUnit is what this call contributed, which is how the box language finds the
queries and #directives it should act on.
fun addParsed(unit: DlUnit): DlUnit
Merge an already-parsed unit. The box language parses first so it can rewrite a bare
non-ground atom — path(a, Z) typed on its own — into a query before it reaches addRule,
which would rightly reject it as a fact that is not ground.
fun addParsed(unit: DlUnit): DlUnit {
unit.rules.forEach { rule ->
if (rule.isFact && rule.head.terms.all { it is DlConst }) {
addFactRow(rule.head.pred, rule.head.terms.map { dlNormalize((it as DlConst).value) })
} else if (ruleKeys.add(rule)) {
ruleList.add(rule)
cached = null
}
}
queryList.addAll(unit.queries)
return unit
}
fun addRule(rule: DlRule): Datalog
Merge one rule (or ground fact).
fun addRule(rule: DlRule): Datalog {
addParsed(DlUnit(rules = listOf(rule)))
return this
}
fun fact(pred: String, vararg args: Any?): Datalog
Add one fact. Values go through dlNormalize, so fact("age", "ada", 36) stores a Long.
fun fact(pred: String, vararg args: Any?): Datalog {
addFactRow(pred, args.map { dlNormalize(it) })
return this
}
fun facts(pred: String, rows: Iterable<Iterable<Any?>>): Datalog
Add many rows at once.
fun facts(pred: String, rows: Iterable<Iterable<Any?>>): Datalog {
rows.forEach { row -> addFactRow(pred, row.map { dlNormalize(it) }) }
return this
}
fun <T> facts(pred: String, items: Iterable<T>, columns: (T) -> List<Any?>): Datalog {
Add many rows by projecting Kotlin objects into columns.
fun <T> facts(pred: String, items: Iterable<T>, columns: (T) -> List<Any?>): Datalog {
items.forEach { item -> addFactRow(pred, columns(item).map { dlNormalize(it) }) }
return this
}
private fun addFactRow(pred: String, row: List<Any?>) {
val relation = edb.getOrPut(pred) { DlRelation(pred, row.size) }
if (relation.arity != row.size) {
throw DlException("'$pred' has arity ${relation.arity}, but this fact has ${row.size} columns")
}
if (relation.add(row, 0)) cached = null
}
fun reset()
Forget everything — facts, rules, queries, and the cached model.
fun reset() {
ruleList.clear()
ruleKeys.clear()
edb.clear()
queryList.clear()
cached = null
}
fun clearFacts(pred: String)
Drop a single relation's base facts (rules that derive into it are untouched).
fun clearFacts(pred: String) {
if (edb.remove(pred) != null) cached = null
}
solving
fun solve(): DlSolution = cached ?: DatalogEngine(ruleList, edb).run().also { cached = it }
The saturated model. Cached until the program changes.
fun query(goal: String): DlRows
Evaluate a goal — a body conjunction, exactly as it would be written on the right of :-.
Rows come back sorted, because set semantics leave the order arbitrary and an arbitrary order that changes between runs is a bad thing to print next to a box.
fun query(goal: String): DlRows {
val body = DatalogParser.parseGoal(goal)
val vars = dlOutputVarsOf(body).filter { !it.isAnonymous }
val pred = "__query_${++queryCounter}"
val rule = DlRule(DlAtom(pred, vars), body, goal)
val solution = DatalogEngine(ruleList + rule, edb).run()
val rows = solution.rows(pred).sortedWith { a, b -> compareRows(a, b) }
return DlRows(goal, vars.map { it.name }, rows, solution.warnings)
}
fun ask(goal: String): Boolean = query(goal).rows.isNotEmpty()
True when the goal has at least one solution.
fun rowsOf(pred: String): List<DlRow>
All rows of one relation in the current model, sorted.
fun rowsOf(pred: String): List<DlRow> =
solve().rows(pred).sortedWith { a, b -> compareRows(a, b) }
private fun compareRows(a: DlRow, b: DlRow): Int {
for (i in 0 until minOf(a.size, b.size)) {
val c = dlCompareValues(a[i], b[i])
if (c != 0) return c
}
return a.size.compareTo(b.size)
}
provenance
fun why(goal: String): DlDerivation?
Why is this fact true? Returns a derivation tree, or null if the fact is not in the model.
The search is top-down over the finished model, bounded by the round numbers the fixpoint records: a premise must have been derived strictly earlier than its conclusion, so a proof can never lean on the thing it is proving, and the recursion always terminates. A goal with variables is answered about the first matching fact.
body — 57 lines, from line 189
fun why(goal: String): DlDerivation? {
val literals = DatalogParser.parseGoal(goal)
val atom = literals.singleOrNull() as? DlAtom
?: throw DlException("'#why' takes a single positive atom, e.g. '#why path(a, d)'")
if (atom.negated) throw DlException("'#why' cannot explain a negated goal")
val solution = solve()
val relation = solution[atom.pred] ?: return null
val ground = atom.terms.all { it is DlConst }
val target: DlRow = if (ground) {
atom.terms.map { dlNormalize((it as DlConst).value) }
} else {
val pattern = arrayOfNulls<Any?>(atom.arity)
var mask = 0
atom.terms.forEachIndexed { i, term ->
if (term is DlConst) {
pattern[i] = dlNormalize(term.value)
mask = mask or (1 shl i)
}
}
relation.lookup(pattern, mask).minWithOrNull { a, b -> compareRows(a, b) } ?: return null
}
if (!relation.contains(target)) return null
return derive(atom.pred, target, solution, IntArray(1) { MAX_PROOF_STEPS })
}
private fun factText(pred: String, row: DlRow): String =
if (row.isEmpty()) pred else pred + "(" + row.joinToString(", ") { dlSourceFormat(it) } + ")"
private fun derive(pred: String, row: DlRow, solution: DlSolution, budget: IntArray): DlDerivation? {
val relation = solution[pred] ?: return null
if (!relation.contains(row)) return null
val text = factText(pred, row)
val round = relation.roundOf(row)
if (round == 0) return DlDerivation(text, null, emptyList())
if (budget[0]-- <= 0) return DlDerivation(text, null, emptyList(), note = "proof search truncated")
for (rule in ruleList) {
if (rule.head.pred != pred || rule.head.arity != row.size) continue
val bindings = HashMap<String, Any?>()
var headMatches = true
rule.head.terms.forEachIndexed { i, term ->
if (!headMatches) return@forEachIndexed
when (term) {
is DlConst -> if (dlNormalize(term.value) != row[i]) headMatches = false
is DlVar -> {
val existing = bindings[term.name]
if (existing != null && existing != row[i]) headMatches = false else bindings[term.name] = row[i]
}
}
}
if (!headMatches) continue
val premises = satisfy(rule.body, bindings, solution, round, budget) ?: continue
return DlDerivation(text, rule, premises)
}
return DlDerivation(text, null, emptyList(), note = "derived in round $round; no proof reconstructed")
}
Find one assignment satisfying remaining under bindings, and the derivations of the
premises it used. The first solution enumerate reports, which is the whole of what a proof
needs: any one satisfying assignment justifies the conclusion.
private fun satisfy(
remaining: List<DlLiteral>,
bindings: Map<String, Any?>,
solution: DlSolution,
beforeRound: Int,
budget: IntArray,
): List<DlDerivation>? {
var found: List<DlDerivation>? = null
enumerate(remaining, bindings, solution, beforeRound, budget) { _, premises ->
found = premises
true
}
return found
}
private fun varsBound(arith: DlArith, bindings: Map<String, Any?>): Boolean {
var ok = true
fun walk(a: DlArith) {
when (a) {
is DlAValue -> (a.term as? DlVar)?.let { if (!bindings.containsKey(it.name)) ok = false }
is DlABinary -> { walk(a.left); walk(a.right) }
is DlAUnary -> walk(a.operand)
is DlACall -> a.args.forEach { walk(it) }
}
}
walk(arith)
return ok
}
Which of remaining to take next, mirroring the planner's ordering: evaluable tests first,
then bindings, then anti-joins, then scans, then aggregates. -1 when nothing is evaluable.
A literal that cannot be evaluated yet must score 99 — scoring an unevaluable comparison
below a positive atom made the search pick V > 5 before the cell(C, V) that binds V,
fail, and report a real fact as unproven.
Aggregates come last (as they do in DatalogEngine's planner) because an aggregate's group is only the right group once every variable the enclosing rule shares with the subgoal is bound. One aggregate whose subgoal reads another's target is pushed later still, so the two are taken in an order that can succeed rather than in source order.
private fun chooseLiteral(remaining: List<DlLiteral>, bindings: Map<String, Any?>): Int {
fun compareKind(literal: DlCompare): Int {
if (varsBound(literal.left, bindings) && varsBound(literal.right, bindings)) return 0
if (literal.op != "=") return 99
val leftVar = (literal.left as? DlAValue)?.term as? DlVar
val rightVar = (literal.right as? DlAValue)?.term as? DlVar
if (leftVar != null && !bindings.containsKey(leftVar.name) && varsBound(literal.right, bindings)) return 1
if (rightVar != null && !bindings.containsKey(rightVar.name) && varsBound(literal.left, bindings)) return 1
return 99
}
// A negated atom is evaluable once every variable is bound, or is anonymous — `!path(X, _)`
// asks whether *any* row matches, which is what the planner's DlArgWild means.
fun negatedReady(literal: DlAtom): Boolean = literal.terms.all { term ->
term is DlConst || bindings.containsKey((term as DlVar).name) || term.isAnonymous
}
fun aggregateKind(literal: DlAggregate): Int {
val others = remaining.filterIsInstance<DlAggregate>().filter { it !== literal }
val mine = dlVarsOf(literal.body).map { it.name }.toSet()
return if (others.any { it.target.name in mine }) 4 else 3
}
var chosen = -1
var chosenKind = 99
remaining.forEachIndexed { i, literal ->
val kind = when (literal) {
is DlCompare -> compareKind(literal)
is DlAtom -> if (!literal.negated) 2 else if (negatedReady(literal)) 0 else 99
is DlAggregate -> aggregateKind(literal)
}
if (kind < chosenKind) { chosen = i; chosenKind = kind }
}
return if (chosenKind >= 99) -1 else chosen
}
Report every assignment satisfying remaining under bindings, with the premises each one
used, until sink returns true to stop. Returns true when it was stopped.
The round bound is the invariant that makes all of this terminate: a candidate row is only
admitted when its round is strictly below beforeRound, and the engine buffers each round's
new facts until the round ends, so a proof can only ever descend. There is no occurs-check
anywhere and none is needed.
source — 131 lines, from line 339
private fun enumerate(
remaining: List<DlLiteral>,
bindings: Map<String, Any?>,
solution: DlSolution,
beforeRound: Int,
budget: IntArray,
sink: (Map<String, Any?>, List<DlDerivation>) -> Boolean,
): Boolean {
if (remaining.isEmpty()) return sink(bindings, emptyList())
if (budget[0]-- <= 0) return false
val chosen = chooseLiteral(remaining, bindings)
if (chosen < 0) return false
val literal = remaining[chosen]
val rest = remaining.toMutableList().also { it.removeAt(chosen) }
when (literal) {
is DlCompare -> {
// `X = expr` where X is still free is a binding here too.
val leftVar = (literal.left as? DlAValue)?.term as? DlVar
val rightVar = (literal.right as? DlAValue)?.term as? DlVar
if (literal.op == "=" && leftVar != null && !bindings.containsKey(leftVar.name) &&
varsBound(literal.right, bindings)
) {
val next = HashMap(bindings)
next[leftVar.name] = dlEvalArith(literal.right) { bindings[it] }
return enumerate(rest, next, solution, beforeRound, budget, sink)
}
if (literal.op == "=" && rightVar != null && !bindings.containsKey(rightVar.name) &&
varsBound(literal.left, bindings)
) {
val next = HashMap(bindings)
next[rightVar.name] = dlEvalArith(literal.left) { bindings[it] }
return enumerate(rest, next, solution, beforeRound, budget, sink)
}
if (!varsBound(literal.left, bindings) || !varsBound(literal.right, bindings)) return false
val ok = dlCompareOp(
literal.op,
dlEvalArith(literal.left) { bindings[it] },
dlEvalArith(literal.right) { bindings[it] },
)
return if (ok) enumerate(rest, bindings, solution, beforeRound, budget, sink) else false
}
is DlAtom -> {
if (literal.negated) {
// An unbound (anonymous) position is a "don't care", so this is a lookup on the
// bound positions only — `!edge(a, _)` must find edge(a, b) and fail.
val relation = solution[literal.pred]
val present = relation != null && negatedMatches(relation, literal, bindings)
if (present) return false
val witness = DlDerivation(
dlLiteralText(dlSubstitute(literal, bindings)),
null,
emptyList(),
note = "no such fact",
kind = DlPremiseKind.ABSENCE,
)
return enumerate(rest, bindings, solution, beforeRound, budget) { b, premises ->
sink(b, listOf(witness) + premises)
}
}
val relation = solution[literal.pred] ?: return false
val pattern = arrayOfNulls<Any?>(relation.arity)
var mask = 0
literal.terms.forEachIndexed { i, term ->
if (i >= relation.arity) return@forEachIndexed
val value = when (term) {
is DlConst -> dlNormalize(term.value)
is DlVar -> if (bindings.containsKey(term.name)) bindings[term.name] else return@forEachIndexed
}
pattern[i] = value
mask = mask or (1 shl i)
}
val candidates = relation.lookup(pattern, mask)
.filter { relation.roundOf(it) < beforeRound }
.sortedBy { relation.roundOf(it) }
for (candidate in candidates) {
val next = HashMap(bindings)
var ok = true
literal.terms.forEachIndexed { i, term ->
if (!ok) return@forEachIndexed
if (term is DlVar) {
val existing = next[term.name]
if (existing != null && existing != candidate[i]) ok = false else next[term.name] = candidate[i]
}
}
if (!ok) continue
val premise = derive(literal.pred, candidate, solution, budget) ?: continue
val stopped = enumerate(rest, next, solution, beforeRound, budget) { b, premises ->
sink(b, listOf(premise) + premises)
}
if (stopped) return true
}
return false
}
is DlAggregate -> {
val group = aggregateGroup(literal, bindings, solution, beforeRound, budget) ?: return false
val name = literal.target.name
val next = HashMap(bindings)
if (bindings.containsKey(name)) {
// The target came from the head. Recomputing the aggregate from the group and
// checking it against the value in the model is the whole point: a group that
// does not add up is not a proof, and refusing here falls back to the honest
// "no proof reconstructed" rather than showing a plausible wrong one.
if (!dlCompareOp("=", bindings[name], group.value)) return false
} else {
next[name] = group.value
}
return enumerate(rest, next, solution, beforeRound, budget) { b, premises ->
sink(b, listOf(group.node) + premises)
}
}
}
}
private fun negatedMatches(relation: DlRelation, literal: DlAtom, bindings: Map<String, Any?>): Boolean {
val pattern = arrayOfNulls<Any?>(relation.arity)
var mask = 0
literal.terms.forEachIndexed { i, term ->
if (i >= relation.arity) return@forEachIndexed
when (term) {
is DlConst -> { pattern[i] = dlNormalize(term.value); mask = mask or (1 shl i) }
is DlVar -> if (bindings.containsKey(term.name)) {
pattern[i] = bindings[term.name]
mask = mask or (1 shl i)
}
}
}
return relation.lookup(pattern, mask).isNotEmpty()
}
aggregate provenance
The reconstructed value of one aggregate, and the node that displays the group behind it.
private class DlGroup(val value: Any?, val node: DlDerivation)
Reconstruct the group behind one aggregate: every tuple that contributed, and the value they combine to.
This is what a proof through an aggregate means. count, sum, min and max are exactly
the constructs a reader cannot check by eye, and until now #why answered them with an
apology. A group plus the arithmetic that combined it is a real justification, not a guess:
the value is recomputed here by dlAggregateValue — the same function the fixpoint used — and
checked against the fact in the model by the caller.
Two details are load-bearing:
- The group is the one visible at the start of the round. The subgoal is enumerated under
the same
roundOf < beforeRoundbound as any other premise. An aggregate's dependencies are forced negative during stratification, so they always sit in a strictly earlier stratum, and rounds are globally monotone across strata — which is why every contributing tuple passes the bound, and why nothing derived in this round can sneak into its own proof. - The target must not filter the group. It is bound from the head by the time we get here, but the engine assigns it after the subgoal has run. Leaving it in the bindings would silently restrict the group to tuples that happen to equal the answer.
The number of tuples shown is capped at MAX_GROUP_MEMBERS; the value is always computed
over all of them, and for min/max the extreme tuple is kept and labelled even when it
falls outside the cap, because "which one was the max" is most of what the question meant.
source — 71 lines, from line 501
private fun aggregateGroup(
aggregate: DlAggregate,
bindings: Map<String, Any?>,
solution: DlSolution,
beforeRound: Int,
budget: IntArray,
): DlGroup? {
val outer = HashMap(bindings)
outer.remove(aggregate.target.name)
val contributions = ArrayList<Any?>()
val shown = ArrayList<Pair<Any?, DlDerivation>>()
var extreme: Pair<Any?, DlDerivation>? = null
var solutions = 0
enumerate(aggregate.body, outer, solution, beforeRound, budget) { bound, premises ->
solutions++
val over = aggregate.over?.let { arith -> dlEvalArith(arith) { bound[it] } }
if (aggregate.over != null) contributions.add(over)
val member = groupMember(aggregate, bound, premises, over)
if (shown.size < MAX_GROUP_MEMBERS) shown.add(over to member)
if (aggregate.op == "min" || aggregate.op == "max") {
val better = extreme?.let { current ->
val c = dlCompareValues(over, current.first)
if (aggregate.op == "min") c < 0 else c > 0
} ?: true
if (better) extreme = over to member
}
false
}
// A group cut short by the step budget would sum to the wrong number. Say nothing rather
// than say something false; the caller falls back to the "no proof reconstructed" note.
if (budget[0] <= 0) return null
val value = dlAggregateValue(aggregate.op, solutions, contributions) ?: return null
val members = ArrayList<DlDerivation>()
var shownCount = 0
shown.forEach { (contributed, node) ->
val extremeHere = (aggregate.op == "min" || aggregate.op == "max") &&
dlCompareOp("=", contributed, value)
members.add(if (extremeHere) tagged(node, "the ${aggregate.op}") else node)
shownCount++
}
// If the extreme fell outside the cap, show it anyway — it is the witness.
val extremeIsShown = shown.any { dlCompareOp("=", it.first, value) }
if (!extremeIsShown) extreme?.let { members.add(tagged(it.second, "the ${aggregate.op}")); shownCount++ }
if (solutions > shownCount) {
members.add(
DlDerivation(
"… ${solutions - shownCount} more tuple${if (solutions - shownCount == 1) "" else "s"}",
null,
emptyList(),
note = "in the group, not shown",
kind = DlPremiseKind.GROUP,
)
)
}
val plural = if (solutions == 1) "" else "s"
return DlGroup(
value,
DlDerivation(
dlLiteralText(dlSubstitute(aggregate, outer)),
null,
members,
note = "aggregate: ${aggregate.op} over $solutions tuple$plural = ${dlFormat(value)}",
kind = DlPremiseKind.AGGREGATE,
),
)
}
One solution of an aggregate's subgoal, as a proof node.
A single-atom subgoal — which is nearly all of them — contributes its atom directly, with the value it gave the aggregate appended. Wrapping it in a node whose text is the same atom read as the same fact twice, which is worse than no structure at all. A multi-atom subgoal does get a wrapper, because its atoms have to be held together to mean one tuple.
private fun groupMember(
aggregate: DlAggregate,
bound: Map<String, Any?>,
premises: List<DlDerivation>,
over: Any?,
): DlDerivation {
val contribution = aggregate.over?.let {
"contributes ${dlArithText(it)} = ${dlFormat(over)}"
}
val single = premises.singleOrNull()
if (single != null && single.kind == DlPremiseKind.DERIVED) {
return DlDerivation(
single.fact,
single.rule,
single.premises,
single.note,
DlPremiseKind.GROUP,
contribution,
)
}
return DlDerivation(
aggregate.body.joinToString(", ") { dlLiteralText(dlSubstitute(it, bound)) },
null,
premises,
note = "one solution of the subgoal",
kind = DlPremiseKind.GROUP,
contribution = contribution,
)
}
private fun tagged(node: DlDerivation, extra: String): DlDerivation = DlDerivation(
node.fact,
node.rule,
node.premises,
node.note,
node.kind,
listOfNotNull(node.contribution, extra).joinToString(", "),
)
rendering
fun program(): String
The program as source you could paste back in.
fun program(): String = buildString {
edb.values.forEach { relation ->
relation.rows().forEach { row ->
append(relation.name)
if (row.isNotEmpty()) append(row.joinToString(", ", "(", ")") { dlSourceFormat(it) })
append(".\n")
}
}
ruleList.forEach { append(it.toString()).append('\n') }
}
override fun toString(): String
override fun toString(): String =
"Datalog(${edb.values.sumOf { it.size }} facts, ${ruleList.size} rules)"
companion object
companion object {
const val MAX_PROOF_STEPS = 200_000
A ceiling on proof search, so #why on a pathological program answers rather than hangs.
const val MAX_GROUP_MEMBERS = 24
How many of an aggregate's contributing tuples a proof shows. The value is always
computed over the whole group; this only bounds the reading. A count over 40 000 rows is
a legitimate thing to ask why about, and 40 000 lines of proof is not an answer.
class DlRows
A query result: named columns and sorted rows.
A goal with no variables answers yes/no — rows is one empty row or none — which is why
table special-cases the zero-column shape rather than printing an empty frame.
class DlRows(
val goal: String,
val columns: List<String>,
val rows: List<DlRow>,
val warnings: List<String> = emptyList(),
) : Iterable<DlRow>, ToHTML {
override fun iterator(): Iterator<DlRow> = rows.iterator()
val size: Int get() = rows.size
val isEmpty: Boolean get() = rows.isEmpty()
fun value(): Any? = rows.firstOrNull()?.firstOrNull()
The single value of a single-column, single-row result — the common "just tell me" case.
fun column(name: String): List<Any?>
fun column(name: String): List<Any?> {
val i = columns.indexOf(name)
if (i < 0) throw DlException("no column '$name' in [${columns.joinToString(", ")}]")
return rows.map { it[i] }
}
fun asMaps(): List<Map<String, Any?>> = rows.map { row -> columns.zip(row).toMap() }
fun table(limit: Int = 200): String
A monospaced table, for a box's output area or a terminal.
fun table(limit: Int = 200): String {
if (columns.isEmpty()) return if (rows.isEmpty()) "no" else "yes"
if (rows.isEmpty()) return "(no rows)"
val shown = rows.take(limit)
val widths = columns.indices.map { i ->
maxOf(columns[i].length, shown.maxOf { dlFormat(it.getOrNull(i)).length })
}
return buildString {
columns.forEachIndexed { i, name -> append(name.padEnd(widths[i])); if (i < columns.size - 1) append(" ") }
append('\n')
widths.forEachIndexed { i, w -> append("-".repeat(w)); if (i < widths.size - 1) append(" ") }
shown.forEach { row ->
append('\n')
columns.indices.forEach { i ->
append(dlFormat(row.getOrNull(i)).padEnd(if (i == columns.size - 1) 0 else widths[i]))
if (i < columns.size - 1) append(" ")
}
}
if (rows.size > shown.size) append("\n… ${rows.size - shown.size} more rows")
}
}
override fun toString() = table()
override fun toHTML(): String
The same answer as table, as HTML — so display("main", d.query(…)) in a box shows a real
table rather than a wall of monospace.
A goal with no variables has no columns to draw, so it renders as the one word it means. afield.ui.visualization.Table does the drawing, which is what keeps a Datalog answer looking like every other table in Field rather than like Datalog.
override fun toHTML(): String {
if (columns.isEmpty()) {
val answer = if (rows.isEmpty()) "no" else "yes"
return Table(listOf("?- $goal"), title = null).also { it.showHeader = false }.row(answer).toHTML()
}
val table = Table(columns, title = "?- $goal (${rows.size} row${if (rows.size == 1) "" else "s"})")
table.maxRows = 24
columns.forEach { table.color(it, DL_VALUE_COLOUR) }
columns.firstOrNull()?.let { table.color(it, DL_KEY_COLOUR) }
if (warnings.isNotEmpty()) table.note = warnings.joinToString("; ")
return table.rows(rows.map { row -> row.map { dlFormat(it) } }).toHTML()
}
}
internal const val DL_KEY_COLOUR = "#8cf"
The accent the first column takes, matching afield.data.query.graph's path column.