@dialog-db/query
Version:
Datalog query engine inspired by Datomic
343 lines (302 loc) • 10.5 kB
JavaScript
import * as API from './plan.js'
import * as LRU from '../source/lru.js'
import * as JSON from '../json.js'
import { toDebugString } from '../debug.js'
import { indent } from '../data/string/format.js'
import * as Terms from '../terms.js'
import * as Constant from '../constant.js'
import * as Match from '../match.js'
/**
* @template {API.Proposition} [Match=API.Proposition]
*/
export class DeductiveRule {
/**
* @param {Match} match
* @param {API.Scope} scope
* @param {Record<string, API.Join>} disjuncts
* @param {number} cost
* @param {boolean} recurs
*/
constructor(match, scope, disjuncts, cost, recurs) {
this.match = match
this.scope = scope
this.disjuncts = disjuncts
this.cost = cost
this.recurs = recurs
}
get when() {
return this.disjuncts
}
toJSON() {
const { match, disjuncts } = this
const branches = Object.entries(disjuncts).map(([name, plan]) => [
name,
JSON.from(plan),
])
const when = Object.fromEntries(branches)
return branches.length === 1 && branches[0][1].length === 0 ?
{ match }
: { match, when }
}
toDebugString() {
const disjuncts = Object.entries(this.disjuncts)
const when = []
if (disjuncts.length === 1) {
const [[name, plan]] = disjuncts
when.push(indent(toDebugString(plan)))
} else {
when.push('{\n')
for (const [name, disjunct] of disjuncts) {
when.push(` ${name}: ${indent(toDebugString(disjunct))},\n`)
}
when.push('}')
}
return `{
match: ${indent(Terms.toDebugString(this.match))},
when: ${indent(when.join(''))}
}`
}
/**
* @param {API.EvaluationContext} context
* @returns {API.Task<API.MatchFrame[], Error>}
*/
*evaluate(context) {
/** @type {API.MatchFrame[]} */
const matches = []
// Run each branch and combine results
for (const plan of Object.values(this.disjuncts)) {
const bindings = yield* plan.evaluate(context)
matches.push(...bindings)
}
return matches
}
}
/**
* @template {API.Proposition} [Match=API.Proposition]
* @extends {API.RuleApplicationPlan<Match>}
*/
export class RuleApplication {
/**
* @param {Partial<API.RuleBindings<Match>>} match
* @param {API.RuleSyntax<Match>} rule
* @param {API.Cursor} references
* @param {API.MatchFrame} bindings
*/
constructor(match, rule, references, bindings) {
this.match = match
this.rule = rule
this.references = references
this.bindings = bindings
this.plan = rule.plan(this)
}
get cost() {
return this.plan.cost
}
/**
* @param {API.EvaluationContext} context
*/
*evaluate({ source, selection }) {
// Map identity -> actual match for deduplication
/** @type {Map<string, API.MatchFrame>} */
const matches = new Map()
for (const frame of selection) {
for (const input of this.read(frame)) {
// Create evaluation context for the main evaluation
/** @type {API.EvaluationContext} */
const context = {
source,
self: this.plan,
selection: [input],
recur: [], // Array for recursive steps
}
// First evaluate the base case
const base = yield* this.plan.evaluate(context)
// Process base results
for (const output of base) {
for (const match of this.write(frame, output)) {
const id = identifyMatch(match)
if (!matches.has(id)) {
matches.set(id, match)
}
}
}
// Track all contexts to handle transitive relationships correctly
// Map from each binding to all its ancestor contexts
const contextChains = new Map()
// Initialize the context chains with the original recursive steps
for (const [nextBinding, originalContext] of context.recur) {
contextChains.set(nextBinding, [originalContext])
}
// Process recursion using breadth-first evaluation
let { recur } = context
while (recur.length > 0) {
/** @type {API.EvaluationContext['recur']} */
const next = []
// Process all pending recursions for this iteration
for (const [nextBinding, origContext] of recur) {
// Check for tautology here - before recursively evaluating
// For patterns like Tautology(X) :- Person(X), Tautology(X)
// We need to detect when recursion would be unproductive
// Generate identifiers for both current context and the binding for recursion
const nextBindingId = identifyMatch(nextBinding)
const origContextId = identifyMatch(origContext)
// A tautology occurs when a rule refers to itself with exactly the same bindings
// This happens when recursion can't produce any new results
if (nextBindingId === origContextId) {
// This is a pure tautology - the recursive call would use the exact same bindings
// For pure tautologies, we add the current binding as a result
// This allows joins with other predicates (like Person) to work properly
for (const match of this.write(frame, nextBinding)) {
const id = identifyMatch(match)
if (!matches.has(id)) {
matches.set(id, match)
}
}
// Skip further recursion for this path as it would create an infinite loop
continue
}
// Create a context for this step's evaluation
/** @type {API.EvaluationContext} */
const recursiveContext = {
source,
self: this.plan,
selection: [nextBinding],
recur: [],
}
// Evaluate this step
const stepResults = yield* this.plan.evaluate(recursiveContext)
// Process direct results
for (const output of stepResults) {
for (const match of this.write(frame, output)) {
const id = identifyMatch(match)
if (!matches.has(id)) {
matches.set(id, match)
}
}
// Create transitive relationships with all ancestor contexts
const ancestorContexts = contextChains.get(nextBinding) || []
for (const context of ancestorContexts) {
for (const match of this.write(frame, context, output)) {
const id = identifyMatch(match)
if (!matches.has(id)) {
matches.set(id, match)
}
}
}
}
// Process new recursive steps
for (const [newBinding] of recursiveContext.recur) {
// Track context chains for transitive relationships
const ancestorContexts = contextChains.get(nextBinding) || []
const newContexts = [origContext, ...ancestorContexts]
contextChains.set(newBinding, newContexts)
// Add to next batch
next.push([newBinding, origContext])
}
}
recur = next
}
}
}
return [...matches.values()]
}
get terms() {
return /** @type {[string, API.Term][]} */ (Object.entries(this.match))
}
/**
* @param {API.MatchFrame} frame
*/
read(frame) {
const input = new Map()
// Copy any bound values from outer scope into rule scope
// based on the mapping defined in application.match
for (const [at, variable] of Object.entries(this.rule.match)) {
const term = /** @type {API.Term} */ (this.match[at])
const value = Match.get(frame, term)
if (value !== undefined) {
const { ok } = Match.set(input, variable, value)
if (!ok) {
return []
}
}
}
return [input]
}
/**
* @param {API.MatchFrame} frame
* @param {API.MatchFrame[]} outputs
*/
write(frame, ...outputs) {
const match = Match.clone(frame)
next: for (const [at, variable] of Object.entries(this.rule.match)) {
const term = /** @type {API.Term} */ (this.match[at])
// We do not want to override frame values just add new members
const current = Match.get(frame, term)
if (current === undefined) {
for (const output of outputs) {
const value = Match.get(output, variable)
if (value !== undefined) {
const result = Match.unify(match, term, value)
if (!result.ok) {
return []
} else {
continue next
}
}
}
// If none of the outputs contain the term we can not
// produce a valid frame so we return `[]`
return []
}
}
return [match]
}
toJSON() {
return {
match: this.match,
rule: JSON.from(this.plan),
}
}
/**
* @param {object} input
* @param {API.Querier} input.from
*/
*query({ from: source }) {
return yield* this.evaluate({
source: LRU.create(source),
self: this.plan,
selection: [new Map()],
recur: [], // Array for pairs of [nextBindings, originalContext]
})
// return new Selection(/** @type {Match} */ (this.match), frames)
// return Selector.select(/** @type {Selector} */ (selector), frames)
// return new Query(this.rule.match, frames)
}
toDebugString() {
const { match, plan } = this
return indent(`{
match: ${indent(Terms.toDebugString(/** @type {{}} */ (match)))},
rule: ${indent(toDebugString(plan))}
}`)
}
}
/**
* Generate a unique identifier for a match frame
* This is used for cycle detection in the fixed-point evaluation
*
* @param {API.MatchFrame} frame
* @param {string} [context=''] - Optional context to differentiate matches in different evaluation contexts
* @returns {string} A unique identifier for this frame
*/
const identifyMatch = (frame, context = '') => {
// A more reliable identifier that preserves the shape of objects
const mappedEntries = [...frame]
.map(([variable, value]) => {
const varStr = variable ? variable.toString() : 'undefined'
const valueStr =
value === undefined ? 'undefined' : Constant.toString(value)
return `${varStr}:${valueStr}`
})
.sort()
return context + '[' + mappedEntries.join(',') + ']'
}