Improve the frontend UI (REPL, TUI, and the web UI)
This commit is contained in:
parent
5b52a45b81
commit
d1aed64194
@ -6,7 +6,7 @@ This file provides guidance to coding agents collaborating on this repository.
|
|||||||
|
|
||||||
Query Engine is an experimental Rust project for building query-engine
|
Query Engine is an experimental Rust project for building query-engine
|
||||||
components. The current implementation is centered on a chase-based reasoning
|
components. The current implementation is centered on a chase-based reasoning
|
||||||
core, lightweight interactive frontends, and an early relational/SQL scaffold.
|
core, interactive frontends, and an early relational/SQL scaffold.
|
||||||
|
|
||||||
Priorities, in order:
|
Priorities, in order:
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ Quick examples:
|
|||||||
- `inference.rs`: shared matching, negation filtering, and provenance-aware materialization helpers.
|
- `inference.rs`: shared matching, negation filtering, and provenance-aware materialization helpers.
|
||||||
- `stratification.rs`: stratification analysis for rules with negation.
|
- `stratification.rs`: stratification analysis for rules with negation.
|
||||||
- `union_find.rs`: equality merging support.
|
- `union_find.rs`: equality merging support.
|
||||||
- `src/frontend/`: lightweight interactive surface for scripts, REPL, local web UI, TUI (behind `tui` feature), and syntax highlighting.
|
- `src/frontend/`: interactive surface for scripts, REPL, local web UI, TUI (behind `tui` feature), and syntax highlighting.
|
||||||
- `src/relational/`: schemas, values, rows, and result sets for relational execution.
|
- `src/relational/`: schemas, values, rows, and result sets for relational execution.
|
||||||
- `src/catalog/`: predicate-to-table schema inference and catalog access.
|
- `src/catalog/`: predicate-to-table schema inference and catalog access.
|
||||||
- `src/io/`: CSV-based fact import and export.
|
- `src/io/`: CSV-based fact import and export.
|
||||||
@ -76,7 +76,7 @@ Quick examples:
|
|||||||
- The current SQL support is intentionally narrow: `SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT` over predicate-backed tables; equality and inequality predicates combined with `AND` and `OR`; comma-join style multi-table queries; table aliases; ordering by output-column names; integer and string literals; `COUNT`, `SUM`, `MIN`, `MAX`, and `AVG` aggregates with optional `GROUP BY`.
|
- The current SQL support is intentionally narrow: `SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT` over predicate-backed tables; equality and inequality predicates combined with `AND` and `OR`; comma-join style multi-table queries; table aliases; ordering by output-column names; integer and string literals; `COUNT`, `SUM`, `MIN`, `MAX`, and `AVG` aggregates with optional `GROUP BY`.
|
||||||
- Stable SQL column names come from explicit catalog registration or the frontend `schema ...` command, including for empty tables; otherwise the default names are positional such as `c0` and `c1`.
|
- Stable SQL column names come from explicit catalog registration or the frontend `schema ...` command, including for empty tables; otherwise the default names are positional such as `c0` and `c1`.
|
||||||
- Single-table SQL queries may use the table name as a qualifier when no alias is present.
|
- Single-table SQL queries may use the table name as a qualifier when no alias is present.
|
||||||
- Do not describe unsupported SQL features such as aggregates, grouping, or arbitrary expressions as implemented.
|
- Do not describe unsupported SQL features (such as subqueries, window functions, or arbitrary expressions) as implemented.
|
||||||
- The executor operates on the `DataSource` trait, not on `Instance` directly. `Instance` and `TableStore` are the two built-in implementations.
|
- The executor operates on the `DataSource` trait, not on `Instance` directly. `Instance` and `TableStore` are the two built-in implementations.
|
||||||
- Relational and SQL modules should build on explicit schemas and logical plans, not call frontend helpers directly.
|
- Relational and SQL modules should build on explicit schemas and logical plans, not call frontend helpers directly.
|
||||||
- If you add parser, planner, or executor layers, keep their responsibilities separate.
|
- If you add parser, planner, or executor layers, keep their responsibilities separate.
|
||||||
|
|||||||
18
README.md
18
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
An experimental Rust project for building query-engine components.
|
An experimental Rust project for building query-engine components.
|
||||||
|
|
||||||
Right now the repository is centered on a chase-based reasoning core, a small
|
Right now the repository is centered on a chase-based reasoning core, an
|
||||||
interactive frontend, and an early relational/SQL scaffold. The broader target
|
interactive frontend, and an early relational/SQL scaffold. The broader target
|
||||||
shape is a query engine with clearer front-end, planning, optimization, and
|
shape is a query engine with clearer front-end, planning, optimization, and
|
||||||
execution boundaries.
|
execution boundaries.
|
||||||
@ -15,8 +15,8 @@ execution boundaries.
|
|||||||
- Provenance-oriented explanations for derived answers
|
- Provenance-oriented explanations for derived answers
|
||||||
- Script, REPL, local web UI, and optional TUI for experimentation (all with syntax highlighting)
|
- Script, REPL, local web UI, and optional TUI for experimentation (all with syntax highlighting)
|
||||||
- Relational schema, catalog, logical-plan, and execution scaffolding
|
- Relational schema, catalog, logical-plan, and execution scaffolding
|
||||||
- Physical operator scaffolding with a small rule-based rewrite layer
|
- Physical operator scaffolding with a rule-based rewrite layer
|
||||||
- A minimal SQL slice for `SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT` queries over predicate-backed tables, including `COUNT`, `SUM`, `MIN`, `MAX`, and `AVG` aggregates
|
- A SQL slice for `SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT` queries over predicate-backed tables, including `COUNT`, `SUM`, `MIN`, `MAX`, and `AVG` aggregates
|
||||||
- Filter push-down across joins in the physical rewrite pass
|
- Filter push-down across joins in the physical rewrite pass
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
@ -28,7 +28,7 @@ The repository is currently organized around a few clear subsystems:
|
|||||||
- `src/frontend/`: REPL, script, GUI, and explanation rendering
|
- `src/frontend/`: REPL, script, GUI, and explanation rendering
|
||||||
- `src/relational/`: schemas, values, rows, and result sets
|
- `src/relational/`: schemas, values, rows, and result sets
|
||||||
- `src/catalog/`: predicate-backed table metadata
|
- `src/catalog/`: predicate-backed table metadata
|
||||||
- `src/sql/`: minimal SQL AST and parser
|
- `src/sql/`: SQL AST and parser
|
||||||
- `src/planner/`: logical plan structures and SQL-to-plan translation
|
- `src/planner/`: logical plan structures and SQL-to-plan translation
|
||||||
- `src/execution/`: execution for the current logical-plan subset, the `DataSource` trait, the `TableStore`, and a physical operator layer with rule-based rewrites
|
- `src/execution/`: execution for the current logical-plan subset, the `DataSource` trait, the `TableStore`, and a physical operator layer with rule-based rewrites
|
||||||
|
|
||||||
@ -102,16 +102,22 @@ cargo run -- script examples/scripts/sql_join.ech
|
|||||||
cargo run --features tui -- tui
|
cargo run --features tui -- tui
|
||||||
```
|
```
|
||||||
|
|
||||||
#### REPL language
|
#### REPL Language
|
||||||
|
|
||||||
```text
|
```text
|
||||||
fact Parent(alice, bob).
|
fact Parent(alice, bob).
|
||||||
rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).
|
rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).
|
||||||
|
rule Node(?X), NOT Connected(?X) -> Isolated(?X).
|
||||||
schema Parent(parent, child).
|
schema Parent(parent, child).
|
||||||
sql SELECT * FROM Parent;
|
sql SELECT * FROM Parent;
|
||||||
run.
|
run.
|
||||||
query Ancestor(?X, ?Y)?
|
query Ancestor(?X, ?Y)?
|
||||||
explain Ancestor(alice, carol)?
|
explain Ancestor(alice, carol)?
|
||||||
|
set chase skolem
|
||||||
|
set semi-naive on
|
||||||
|
load /path/to/csv/dir
|
||||||
|
save /path/to/csv/dir
|
||||||
|
source examples/scripts/ancestor.ech
|
||||||
show facts
|
show facts
|
||||||
show rules
|
show rules
|
||||||
reset
|
reset
|
||||||
@ -124,7 +130,7 @@ The repository now has a narrow SQL pipeline with:
|
|||||||
|
|
||||||
- predicate-backed catalog inference
|
- predicate-backed catalog inference
|
||||||
- relational schemas, rows, and values
|
- relational schemas, rows, and values
|
||||||
- SQL parsing for a small subset
|
- SQL parsing for the supported subset
|
||||||
- logical planning
|
- logical planning
|
||||||
- execution for filtering, ordering, limiting, and basic multi-table joins
|
- execution for filtering, ordering, limiting, and basic multi-table joins
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,12 @@ Available examples:
|
|||||||
|
|
||||||
- `ancestor.ech`: transitive closure over `Parent/2`
|
- `ancestor.ech`: transitive closure over `Parent/2`
|
||||||
- `employee_departments.ech`: existential rule that creates labeled nulls
|
- `employee_departments.ech`: existential rule that creates labeled nulls
|
||||||
|
- `negation.ech`: stratified negation-as-failure with `NOT` in rule bodies
|
||||||
- `same_team.ech`: conjunctive query with a self-join
|
- `same_team.ech`: conjunctive query with a self-join
|
||||||
|
- `skolem_chase.ech`: Skolem chase with deterministic labeled nulls
|
||||||
|
- `sql_aggregate.ech`: `GROUP BY` with `COUNT`, `SUM`, `MIN`, `MAX`, and `AVG`
|
||||||
- `sql_basic.ech`: named-column filtering in the SQL frontend
|
- `sql_basic.ech`: named-column filtering in the SQL frontend
|
||||||
|
- `sql_filter_ops.ech`: inequality, `OR`, `LIMIT`, and integer literals
|
||||||
- `sql_join.ech`: multi-table SQL join over predicate-backed tables
|
- `sql_join.ech`: multi-table SQL join over predicate-backed tables
|
||||||
- `sql_self_join.ech`: self-join with SQL table aliases
|
- `sql_self_join.ech`: self-join with SQL table aliases
|
||||||
- `sql_order_by.ech`: ordered SQL output with `ORDER BY`
|
- `sql_order_by.ech`: ordered SQL output with `ORDER BY`
|
||||||
|
|||||||
@ -205,12 +205,12 @@ pub fn chase_stratified(instance: Instance, rules: &[Rule], config: ChaseConfig)
|
|||||||
total_steps += result.steps;
|
total_steps += result.steps;
|
||||||
current_instance = result.instance;
|
current_instance = result.instance;
|
||||||
|
|
||||||
if let Some(error) = result.error {
|
if !result.terminated || result.error.is_some() {
|
||||||
return ChaseResult {
|
return ChaseResult {
|
||||||
instance: current_instance,
|
instance: current_instance,
|
||||||
steps: total_steps,
|
steps: total_steps,
|
||||||
terminated: false,
|
terminated: false,
|
||||||
error: Some(error),
|
error: result.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,6 +137,67 @@ struct PendingFact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn materialize(base_instance: Instance, rules: &[Rule]) -> MaterializedState {
|
pub fn materialize(base_instance: Instance, rules: &[Rule]) -> MaterializedState {
|
||||||
|
let has_negation = rules.iter().any(|r| r.has_negation());
|
||||||
|
if has_negation {
|
||||||
|
return materialize_stratified(base_instance, rules);
|
||||||
|
}
|
||||||
|
materialize_flat(base_instance, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialize_stratified(base_instance: Instance, rules: &[Rule]) -> MaterializedState {
|
||||||
|
use super::stratification::stratify;
|
||||||
|
|
||||||
|
let strata = match stratify(rules) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
// Unstratifiable: fall back to flat materialization.
|
||||||
|
return materialize_flat(base_instance, rules);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut instance = base_instance;
|
||||||
|
let mut provenance: HashMap<Atom, Derivation> = instance
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|fact| (fact, Derivation::Input))
|
||||||
|
.collect();
|
||||||
|
let mut total_steps = 0;
|
||||||
|
|
||||||
|
for stratum_indexes in &strata {
|
||||||
|
let stratum_rules: Vec<Rule> = stratum_indexes.iter().map(|&i| rules[i].clone()).collect();
|
||||||
|
|
||||||
|
let state = materialize_flat(instance, &stratum_rules);
|
||||||
|
total_steps += state.result.steps;
|
||||||
|
instance = state.result.instance;
|
||||||
|
for (atom, derivation) in state.provenance {
|
||||||
|
provenance.entry(atom).or_insert(derivation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.result.terminated {
|
||||||
|
return MaterializedState {
|
||||||
|
result: ChaseResult {
|
||||||
|
instance,
|
||||||
|
steps: total_steps,
|
||||||
|
terminated: false,
|
||||||
|
error: state.result.error,
|
||||||
|
},
|
||||||
|
provenance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterializedState {
|
||||||
|
result: ChaseResult {
|
||||||
|
instance,
|
||||||
|
steps: total_steps,
|
||||||
|
terminated: true,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
provenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialize_flat(base_instance: Instance, rules: &[Rule]) -> MaterializedState {
|
||||||
let mut instance = base_instance;
|
let mut instance = base_instance;
|
||||||
let mut provenance = instance
|
let mut provenance = instance
|
||||||
.iter()
|
.iter()
|
||||||
@ -289,10 +350,11 @@ impl MaterializedState {
|
|||||||
|
|
||||||
/// Filter a set of body-match substitutions against negated atoms.
|
/// Filter a set of body-match substitutions against negated atoms.
|
||||||
///
|
///
|
||||||
/// A substitution is removed if, after applying it to any negated atom, the
|
/// A substitution is removed if, after applying it to any negated atom, any
|
||||||
/// resulting ground atom exists in the instance. This implements
|
/// matching ground fact exists in the instance. When the substitution
|
||||||
/// negation-as-failure semantics: the negated atom must be absent for the
|
/// leaves unbound variables in a negated atom, the check uses pattern
|
||||||
/// rule to fire.
|
/// matching against the instance (existential semantics: the negated atom
|
||||||
|
/// blocks if any witness exists).
|
||||||
pub(crate) fn filter_negated(
|
pub(crate) fn filter_negated(
|
||||||
instance: &Instance,
|
instance: &Instance,
|
||||||
results: Vec<Substitution>,
|
results: Vec<Substitution>,
|
||||||
@ -305,8 +367,14 @@ pub(crate) fn filter_negated(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|subst| {
|
.filter(|subst| {
|
||||||
negated_body.iter().all(|atom| {
|
negated_body.iter().all(|atom| {
|
||||||
let ground = subst.apply_atom(atom);
|
let applied = subst.apply_atom(atom);
|
||||||
!instance.contains(&ground)
|
if applied.is_ground() {
|
||||||
|
!instance.contains(&applied)
|
||||||
|
} else {
|
||||||
|
// Unbound variables remain: check whether any matching
|
||||||
|
// fact exists via pattern matching.
|
||||||
|
instance.facts_matching_pattern(&applied).is_empty()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@ -441,3 +509,67 @@ fn term_null_id(term: &Term) -> Option<usize> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_negated_blocks_when_fact_present() {
|
||||||
|
let instance: Instance = vec![
|
||||||
|
Atom::new("A", vec![Term::constant("x")]),
|
||||||
|
Atom::new("B", vec![Term::constant("x")]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut subst = Substitution::new();
|
||||||
|
subst.bind("X".into(), Term::constant("x"));
|
||||||
|
|
||||||
|
let negated = vec![Atom::new("B", vec![Term::var("X")])];
|
||||||
|
let result = filter_negated(&instance, vec![subst], &negated);
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_negated_passes_when_fact_absent() {
|
||||||
|
let instance: Instance = vec![Atom::new("A", vec![Term::constant("x")])]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut subst = Substitution::new();
|
||||||
|
subst.bind("X".into(), Term::constant("x"));
|
||||||
|
|
||||||
|
let negated = vec![Atom::new("B", vec![Term::var("X")])];
|
||||||
|
let result = filter_negated(&instance, vec![subst], &negated);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_negated_handles_unbound_variables() {
|
||||||
|
// NOT Q(X, Y) where Y is not bound. Should block if any Q(x, _) exists.
|
||||||
|
let instance: Instance = vec![
|
||||||
|
Atom::new("A", vec![Term::constant("x")]),
|
||||||
|
Atom::new("Q", vec![Term::constant("x"), Term::constant("anything")]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut subst = Substitution::new();
|
||||||
|
subst.bind("X".into(), Term::constant("x"));
|
||||||
|
// Y is NOT bound
|
||||||
|
|
||||||
|
let negated = vec![Atom::new("Q", vec![Term::var("X"), Term::var("Y")])];
|
||||||
|
let result = filter_negated(&instance, vec![subst], &negated);
|
||||||
|
// Should be blocked because Q(x, anything) exists
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_negated_passes_empty_negated_body() {
|
||||||
|
let instance = Instance::new();
|
||||||
|
let subst = Substitution::new();
|
||||||
|
let result = filter_negated(&instance, vec![subst], &[]);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -47,6 +47,22 @@ impl Error for StratificationError {}
|
|||||||
/// dependencies) upward. Rules without negation all land in stratum 0 when
|
/// dependencies) upward. Rules without negation all land in stratum 0 when
|
||||||
/// there are no dependency chains through negation.
|
/// there are no dependency chains through negation.
|
||||||
pub fn stratify(rules: &[Rule]) -> Result<Vec<Vec<usize>>, StratificationError> {
|
pub fn stratify(rules: &[Rule]) -> Result<Vec<Vec<usize>>, StratificationError> {
|
||||||
|
// Reject rules that have negated body atoms but no positive body atoms.
|
||||||
|
// Such rules cannot anchor to the instance and are not supported by
|
||||||
|
// standard stratified evaluation.
|
||||||
|
for rule in rules {
|
||||||
|
if rule.body.is_empty() && !rule.negated_body.is_empty() {
|
||||||
|
let preds: Vec<String> = rule
|
||||||
|
.negated_body
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.predicate.clone())
|
||||||
|
.collect();
|
||||||
|
return Err(StratificationError {
|
||||||
|
cycle_predicates: preds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Collect all predicates.
|
// Collect all predicates.
|
||||||
let mut all_predicates: HashSet<String> = HashSet::new();
|
let mut all_predicates: HashSet<String> = HashSet::new();
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
@ -219,4 +235,18 @@ mod tests {
|
|||||||
let result = stratify(&rules);
|
let result = stratify(&rules);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_rule_with_only_negated_body() {
|
||||||
|
// NOT A(X) -> B(X) has no positive body atoms.
|
||||||
|
let rules = vec![
|
||||||
|
RuleBuilder::new()
|
||||||
|
.when_not("A", vec![Term::var("X")])
|
||||||
|
.then("B", vec![Term::var("X")])
|
||||||
|
.build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = stratify(&rules);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -485,4 +485,53 @@ mod tests {
|
|||||||
let result = instance.scan("Missing", &schema).unwrap();
|
let result = instance.scan("Missing", &schema).unwrap();
|
||||||
assert_eq!(result.rows().len(), 0);
|
assert_eq!(result.rows().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_from_term_parses_integer_constants() {
|
||||||
|
assert_eq!(
|
||||||
|
value_from_term(&Term::constant("42")).unwrap(),
|
||||||
|
Value::Integer(42)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value_from_term(&Term::constant("alice")).unwrap(),
|
||||||
|
Value::text("alice")
|
||||||
|
);
|
||||||
|
assert_eq!(value_from_term(&Term::Null(0)).unwrap(), Value::Null);
|
||||||
|
assert!(value_from_term(&Term::var("X")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregate_execution_count_and_sum() {
|
||||||
|
let schema = Schema::new(vec![
|
||||||
|
Field::new("dept", DataType::Text, false),
|
||||||
|
Field::new("salary", DataType::Integer, false),
|
||||||
|
]);
|
||||||
|
let rows = vec![
|
||||||
|
Row::new(vec![Value::text("eng"), Value::Integer(100)]),
|
||||||
|
Row::new(vec![Value::text("eng"), Value::Integer(200)]),
|
||||||
|
Row::new(vec![Value::text("sales"), Value::Integer(50)]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let aggregates = vec![
|
||||||
|
PlanAggregateExpr {
|
||||||
|
name: "__agg_0".into(),
|
||||||
|
func: AggregateFunc::Count,
|
||||||
|
arg: None,
|
||||||
|
},
|
||||||
|
PlanAggregateExpr {
|
||||||
|
name: "__agg_1".into(),
|
||||||
|
func: AggregateFunc::Sum,
|
||||||
|
arg: Some("salary".into()),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let result = compute_aggregate(&rows, &schema, &["dept".into()], &aggregates).unwrap();
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
// Each row: [dept, count, sum]
|
||||||
|
let eng = result
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.values()[0] == Value::text("eng"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(eng.values()[1], Value::Integer(2));
|
||||||
|
assert_eq!(eng.values()[2], Value::Integer(300));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,7 +118,7 @@ pub fn highlight_line(line: &str) -> Vec<HighlightToken> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variable (?X) — must be checked before the single-char operator set
|
// Variable (?X): must be checked before the single-char operator set
|
||||||
// since `?` also appears there for standalone query terminators.
|
// since `?` also appears there for standalone query terminators.
|
||||||
if ch == '?' && i + 1 < len && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_') {
|
if ch == '?' && i + 1 < len && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_') {
|
||||||
let start = i;
|
let start = i;
|
||||||
@ -171,7 +171,9 @@ fn classify_word(word: &str, is_first_word: bool) -> TokenKind {
|
|||||||
if is_first_word {
|
if is_first_word {
|
||||||
match upper.as_str() {
|
match upper.as_str() {
|
||||||
"FACT" | "RULE" | "RUN" | "QUERY" | "EXPLAIN" | "SHOW" | "RESET" | "HELP"
|
"FACT" | "RULE" | "RUN" | "QUERY" | "EXPLAIN" | "SHOW" | "RESET" | "HELP"
|
||||||
| "SCHEMA" | "SQL" => return TokenKind::Command,
|
| "SCHEMA" | "SQL" | "SET" | "LOAD" | "SAVE" | "SOURCE" => {
|
||||||
|
return TokenKind::Command;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -181,7 +183,7 @@ fn classify_word(word: &str, is_first_word: bool) -> TokenKind {
|
|||||||
"SELECT" | "FROM" | "WHERE" | "GROUP" | "BY" | "ORDER" | "LIMIT" | "AS" | "AND" | "OR"
|
"SELECT" | "FROM" | "WHERE" | "GROUP" | "BY" | "ORDER" | "LIMIT" | "AS" | "AND" | "OR"
|
||||||
| "ASC" | "DESC" | "NULL" | "NOT" | "ON" | "JOIN" | "INNER" | "LEFT" | "RIGHT"
|
| "ASC" | "DESC" | "NULL" | "NOT" | "ON" | "JOIN" | "INNER" | "LEFT" | "RIGHT"
|
||||||
| "OUTER" | "HAVING" | "DISTINCT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP"
|
| "OUTER" | "HAVING" | "DISTINCT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP"
|
||||||
| "TABLE" | "INTO" | "VALUES" | "SET" => {
|
| "TABLE" | "INTO" | "VALUES" => {
|
||||||
return TokenKind::SqlKeyword;
|
return TokenKind::SqlKeyword;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@ -16,6 +16,10 @@ pub enum Command {
|
|||||||
Explain(Vec<Atom>),
|
Explain(Vec<Atom>),
|
||||||
ShowFacts,
|
ShowFacts,
|
||||||
ShowRules,
|
ShowRules,
|
||||||
|
Set { key: String, value: String },
|
||||||
|
Load(String),
|
||||||
|
Save(String),
|
||||||
|
Source(String),
|
||||||
Reset,
|
Reset,
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
@ -150,6 +154,41 @@ pub fn parse_command(input: &str) -> Result<Command, String> {
|
|||||||
return Ok(Command::Explain(atoms));
|
return Ok(Command::Explain(atoms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = strip_keyword(trimmed, "set") {
|
||||||
|
let rest = rest.trim_end_matches('.');
|
||||||
|
let (key, value) = rest
|
||||||
|
.split_once(char::is_whitespace)
|
||||||
|
.ok_or_else(|| "set requires a key and value (e.g., `set chase skolem`)".to_string())?;
|
||||||
|
return Ok(Command::Set {
|
||||||
|
key: key.trim().to_ascii_lowercase(),
|
||||||
|
value: value.trim().to_ascii_lowercase(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = strip_keyword(trimmed, "load") {
|
||||||
|
let path = rest.trim().trim_end_matches('.');
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err("load requires a directory path".to_string());
|
||||||
|
}
|
||||||
|
return Ok(Command::Load(path.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = strip_keyword(trimmed, "save") {
|
||||||
|
let path = rest.trim().trim_end_matches('.');
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err("save requires a directory path".to_string());
|
||||||
|
}
|
||||||
|
return Ok(Command::Save(path.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = strip_keyword(trimmed, "source") {
|
||||||
|
let path = rest.trim().trim_end_matches('.');
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err("source requires a file path".to_string());
|
||||||
|
}
|
||||||
|
return Ok(Command::Source(path.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
Err("unknown command; try `help`".to_string())
|
Err("unknown command; try `help`".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,6 +202,14 @@ fn command_is_complete(input: &str) -> bool {
|
|||||||
|| trimmed.eq_ignore_ascii_case("show rules")
|
|| trimmed.eq_ignore_ascii_case("show rules")
|
||||||
|| trimmed.eq_ignore_ascii_case("reset")
|
|| trimmed.eq_ignore_ascii_case("reset")
|
||||||
|| trimmed.eq_ignore_ascii_case("help")
|
|| trimmed.eq_ignore_ascii_case("help")
|
||||||
|
|| starts_with_keyword(trimmed, "set")
|
||||||
|
|| starts_with_keyword(trimmed, "load")
|
||||||
|
|| starts_with_keyword(trimmed, "save")
|
||||||
|
|| starts_with_keyword(trimmed, "source")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn starts_with_keyword(input: &str, keyword: &str) -> bool {
|
||||||
|
strip_keyword(input, keyword).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strip_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
|
fn strip_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
|
||||||
@ -598,4 +645,19 @@ mod tests {
|
|||||||
let error = parse_script("help\nbogus\nrun.").unwrap_err();
|
let error = parse_script("help\nbogus\nrun.").unwrap_err();
|
||||||
assert!(error.contains("line 2"));
|
assert!(error.contains("line 2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rule_with_negation() {
|
||||||
|
let command = parse_command("rule Node(?X), NOT Connected(?X) -> Isolated(?X).").unwrap();
|
||||||
|
match command {
|
||||||
|
Command::Rule(rule) => {
|
||||||
|
assert_eq!(rule.body.len(), 1);
|
||||||
|
assert_eq!(rule.body[0].predicate, "Node");
|
||||||
|
assert_eq!(rule.negated_body.len(), 1);
|
||||||
|
assert_eq!(rule.negated_body[0].predicate, "Connected");
|
||||||
|
assert_eq!(rule.head.len(), 1);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected command: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::catalog::{CatalogError, PredicateCatalog};
|
use crate::catalog::{CatalogError, PredicateCatalog};
|
||||||
use crate::chase::{
|
use crate::chase::{
|
||||||
Atom, Instance, MaterializedState, Rule, Substitution, find_matches, materialize,
|
Atom, ChaseVariant, Instance, MaterializedState, Rule, Substitution, find_matches, materialize,
|
||||||
};
|
};
|
||||||
use crate::execution::execute;
|
use crate::execution::execute;
|
||||||
|
use crate::io::csv;
|
||||||
use crate::planner::sql::plan_select;
|
use crate::planner::sql::plan_select;
|
||||||
use crate::relational::{DataType, Field, ResultSet, Schema};
|
use crate::relational::{DataType, Field, ResultSet, Schema};
|
||||||
|
|
||||||
@ -20,6 +22,8 @@ pub struct Session {
|
|||||||
rules: Vec<Rule>,
|
rules: Vec<Rule>,
|
||||||
column_names: HashMap<String, Vec<String>>,
|
column_names: HashMap<String, Vec<String>>,
|
||||||
materialized: Option<MaterializedState>,
|
materialized: Option<MaterializedState>,
|
||||||
|
chase_variant: ChaseVariant,
|
||||||
|
semi_naive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
@ -79,6 +83,10 @@ impl Session {
|
|||||||
Command::Explain(query) => Ok(self.explain_query(&query)),
|
Command::Explain(query) => Ok(self.explain_query(&query)),
|
||||||
Command::ShowFacts => Ok(self.show_facts()),
|
Command::ShowFacts => Ok(self.show_facts()),
|
||||||
Command::ShowRules => Ok(self.show_rules()),
|
Command::ShowRules => Ok(self.show_rules()),
|
||||||
|
Command::Set { key, value } => self.apply_setting(&key, &value),
|
||||||
|
Command::Load(path) => self.load_csv(&path),
|
||||||
|
Command::Save(path) => self.save_csv(&path),
|
||||||
|
Command::Source(path) => self.run_source(&path),
|
||||||
Command::Reset => {
|
Command::Reset => {
|
||||||
*self = Self::default();
|
*self = Self::default();
|
||||||
Ok("Session reset.".to_string())
|
Ok("Session reset.".to_string())
|
||||||
@ -91,6 +99,76 @@ impl Session {
|
|||||||
*self = Self::default();
|
*self = Self::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_setting(&mut self, key: &str, value: &str) -> Result<String, String> {
|
||||||
|
match key {
|
||||||
|
"chase" => {
|
||||||
|
self.chase_variant = match value {
|
||||||
|
"restricted" => ChaseVariant::Restricted,
|
||||||
|
"standard" => ChaseVariant::Standard,
|
||||||
|
"oblivious" => ChaseVariant::Oblivious,
|
||||||
|
"skolem" => ChaseVariant::Skolem,
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown chase variant `{}`; expected restricted, standard, oblivious, or skolem",
|
||||||
|
value
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.materialized = None;
|
||||||
|
Ok(format!("Chase variant set to {}.", value))
|
||||||
|
}
|
||||||
|
"semi-naive" => {
|
||||||
|
self.semi_naive = match value {
|
||||||
|
"on" | "true" | "yes" | "1" => true,
|
||||||
|
"off" | "false" | "no" | "0" => false,
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"expected on or off for semi-naive, got `{}`",
|
||||||
|
value
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.materialized = None;
|
||||||
|
Ok(format!(
|
||||||
|
"Semi-naive evaluation {}.",
|
||||||
|
if self.semi_naive {
|
||||||
|
"enabled"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => Err(format!(
|
||||||
|
"unknown setting `{}`; try `set chase <variant>` or `set semi-naive on|off`",
|
||||||
|
key
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_csv(&mut self, path: &str) -> Result<String, String> {
|
||||||
|
let dir = Path::new(path);
|
||||||
|
let loaded = csv::load_directory(dir).map_err(|e| e.to_string())?;
|
||||||
|
let count = loaded.len();
|
||||||
|
for atom in loaded.iter().cloned() {
|
||||||
|
self.base_instance.add(atom);
|
||||||
|
}
|
||||||
|
self.materialized = None;
|
||||||
|
Ok(format!("Loaded {} fact(s) from {}.", count, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_csv(&self, path: &str) -> Result<String, String> {
|
||||||
|
let dir = Path::new(path);
|
||||||
|
let instance = self.active_instance();
|
||||||
|
csv::export_directory(instance, dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!("Exported {} fact(s) to {}.", instance.len(), path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_source(&mut self, path: &str) -> Result<String, String> {
|
||||||
|
let script =
|
||||||
|
std::fs::read_to_string(path).map_err(|e| format!("cannot read `{}`: {}", path, e))?;
|
||||||
|
self.execute_script(&script)
|
||||||
|
}
|
||||||
|
|
||||||
fn run_chase(&mut self) -> String {
|
fn run_chase(&mut self) -> String {
|
||||||
let state = materialize(self.base_instance.clone(), &self.rules);
|
let state = materialize(self.base_instance.clone(), &self.rules);
|
||||||
let message = if state.result.terminated {
|
let message = if state.result.terminated {
|
||||||
@ -239,18 +317,38 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn help_text() -> &'static str {
|
fn help_text() -> &'static str {
|
||||||
"Commands:
|
"\
|
||||||
fact Parent(alice, bob).
|
Facts and rules
|
||||||
rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).
|
fact Parent(alice, bob). Add a ground fact.
|
||||||
schema Parent(parent, child).
|
rule A(?X) -> B(?X). Add a derivation rule (TGD).
|
||||||
sql SELECT * FROM Parent;
|
rule A(?X), NOT B(?X) -> C(?X). Negation-as-failure in rule body.
|
||||||
run.
|
schema Parent(parent, child). Register column names for SQL.
|
||||||
query Ancestor(?X, ?Y)?
|
run. Run the chase to fixpoint.
|
||||||
explain Ancestor(alice, bob)?
|
|
||||||
show facts
|
Queries
|
||||||
show rules
|
query Ancestor(?X, ?Y)? Find all matching substitutions.
|
||||||
reset
|
query Parent(alice, bob)? Boolean: true or false.
|
||||||
help"
|
explain Ancestor(alice, carol)? Show derivation provenance.
|
||||||
|
|
||||||
|
SQL (ends with ;)
|
||||||
|
sql SELECT * FROM Parent;
|
||||||
|
sql SELECT dept, COUNT(*) FROM Emp GROUP BY dept;
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
set chase restricted Chase variant: restricted, standard,
|
||||||
|
oblivious, or skolem.
|
||||||
|
set semi-naive on Toggle semi-naive evaluation (on/off).
|
||||||
|
|
||||||
|
Data
|
||||||
|
load <directory> Import facts from CSV files.
|
||||||
|
save <directory> Export facts to CSV files.
|
||||||
|
source <path> Run a .ech script file.
|
||||||
|
|
||||||
|
Session
|
||||||
|
show facts List current facts.
|
||||||
|
show rules List current rules.
|
||||||
|
reset Clear all session state.
|
||||||
|
help Show this message."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sorted_render<'a, T>(items: impl Iterator<Item = &'a T>) -> Vec<String>
|
fn sorted_render<'a, T>(items: impl Iterator<Item = &'a T>) -> Vec<String>
|
||||||
@ -270,16 +368,15 @@ fn render_result_set(result: &ResultSet) -> String {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
let header = result
|
let col_count = result.schema().len();
|
||||||
|
let headers: Vec<String> = result
|
||||||
.schema()
|
.schema()
|
||||||
.fields()
|
.fields()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|field| field.name().to_string())
|
.map(|field| field.name().to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect();
|
||||||
.join(" | ");
|
|
||||||
lines.push(header);
|
|
||||||
|
|
||||||
let rows = result
|
let cell_rows: Vec<Vec<String>> = result
|
||||||
.rows()
|
.rows()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
@ -287,10 +384,40 @@ fn render_result_set(result: &ResultSet) -> String {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" | ")
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect();
|
||||||
lines.extend(rows);
|
|
||||||
|
// Compute column widths from headers and data.
|
||||||
|
let mut widths = vec![0usize; col_count];
|
||||||
|
for (i, header) in headers.iter().enumerate() {
|
||||||
|
widths[i] = widths[i].max(header.len());
|
||||||
|
}
|
||||||
|
for row in &cell_rows {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i < col_count {
|
||||||
|
widths[i] = widths[i].max(cell.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let format_row = |cells: &[String]| -> String {
|
||||||
|
let formatted = cells
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, cell)| {
|
||||||
|
let w = widths.get(i).copied().unwrap_or(0);
|
||||||
|
format!("{:<width$}", cell, width = w)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ");
|
||||||
|
// Trim trailing whitespace from the last column.
|
||||||
|
formatted.trim_end().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(format_row(&headers));
|
||||||
|
for row in &cell_rows {
|
||||||
|
lines.push(format_row(row));
|
||||||
|
}
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,6 +444,24 @@ fn format_substitution(subst: &Substitution, variables: &[String]) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Normalize a pipe-separated output by trimming whitespace around pipes
|
||||||
|
/// so that column-alignment padding does not break contains-checks.
|
||||||
|
fn normalize_pipes(s: &str) -> String {
|
||||||
|
s.lines()
|
||||||
|
.map(|line| {
|
||||||
|
if line.contains('|') {
|
||||||
|
line.split('|')
|
||||||
|
.map(str::trim)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ")
|
||||||
|
} else {
|
||||||
|
line.trim_end().to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_runs_chase_and_query() {
|
fn session_runs_chase_and_query() {
|
||||||
let mut session = Session::new();
|
let mut session = Session::new();
|
||||||
@ -391,11 +536,12 @@ mod tests {
|
|||||||
sql SELECT c0 AS parent_name, 'seed' AS label FROM Parent;",
|
sql SELECT c0 AS parent_name, 'seed' AS label FROM Parent;",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let normalized = normalize_pipes(&output);
|
||||||
|
|
||||||
assert!(output.contains("2 row(s)"));
|
assert!(normalized.contains("2 row(s)"));
|
||||||
assert!(output.contains("parent_name | label"));
|
assert!(normalized.contains("parent_name | label"));
|
||||||
assert!(output.contains("alice | seed"));
|
assert!(normalized.contains("alice | seed"));
|
||||||
assert!(output.contains("bob | seed"));
|
assert!(normalized.contains("bob | seed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -447,10 +593,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(output.contains("2 row(s)"));
|
let normalized = normalize_pipes(&output);
|
||||||
assert!(output.contains("Parent.parent | Ancestor.child"));
|
assert!(normalized.contains("2 row(s)"));
|
||||||
assert!(output.contains("alice | carol"));
|
assert!(normalized.contains("Parent.parent | Ancestor.child"));
|
||||||
assert!(output.contains("bob | dave"));
|
assert!(normalized.contains("alice | carol"));
|
||||||
|
assert!(normalized.contains("bob | dave"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -467,10 +614,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(output.contains("2 row(s)"));
|
let normalized = normalize_pipes(&output);
|
||||||
assert!(output.contains("p.parent | q.child"));
|
assert!(normalized.contains("2 row(s)"));
|
||||||
assert!(output.contains("alice | carol"));
|
assert!(normalized.contains("p.parent | q.child"));
|
||||||
assert!(output.contains("bob | dave"));
|
assert!(normalized.contains("alice | carol"));
|
||||||
|
assert!(normalized.contains("bob | dave"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -173,17 +173,39 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 22rem;
|
min-height: 22rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #fffdfa;
|
background: transparent;
|
||||||
color: var(--ink);
|
color: transparent;
|
||||||
|
caret-color: var(--ink);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@ -245,12 +267,15 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
<p>Minimal local workbench for rule-driven query experiments.</p>
|
<p>Minimal local workbench for rule-driven query experiments.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="editor-container">
|
||||||
|
<pre class="highlight-backdrop" id="backdrop" aria-hidden="true"></pre>
|
||||||
<textarea id="script">fact Parent(alice, bob).
|
<textarea id="script">fact Parent(alice, bob).
|
||||||
fact Parent(bob, carol).
|
fact Parent(bob, carol).
|
||||||
rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).
|
rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).
|
||||||
rule Ancestor(?X, ?Y), Parent(?Y, ?Z) -> Ancestor(?X, ?Z).
|
rule Ancestor(?X, ?Y), Parent(?Y, ?Z) -> Ancestor(?X, ?Z).
|
||||||
run.
|
run.
|
||||||
explain Ancestor(alice, carol)?</textarea>
|
explain Ancestor(alice, carol)?</textarea>
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="primary" id="execute">Execute</button>
|
<button class="primary" id="execute">Execute</button>
|
||||||
<button class="secondary" id="reset">Reset Session</button>
|
<button class="secondary" id="reset">Reset Session</button>
|
||||||
@ -266,6 +291,15 @@ explain Ancestor(alice, carol)?</textarea>
|
|||||||
<script>
|
<script>
|
||||||
const output = document.getElementById("output");
|
const output = document.getElementById("output");
|
||||||
const script = document.getElementById("script");
|
const script = document.getElementById("script");
|
||||||
|
const backdrop = document.getElementById("backdrop");
|
||||||
|
|
||||||
|
function syncBackdrop() {
|
||||||
|
backdrop.innerHTML = highlightBlock(script.value) + "\n";
|
||||||
|
backdrop.scrollTop = script.scrollTop;
|
||||||
|
}
|
||||||
|
script.addEventListener("input", syncBackdrop);
|
||||||
|
script.addEventListener("scroll", () => { backdrop.scrollTop = script.scrollTop; });
|
||||||
|
syncBackdrop();
|
||||||
|
|
||||||
const CMD_RE = /^(fact|rule|run|query|explain|show|reset|help|schema|sql)\b/i;
|
const CMD_RE = /^(fact|rule|run|query|explain|show|reset|help|schema|sql)\b/i;
|
||||||
const SQL_KW = /^(SELECT|FROM|WHERE|GROUP|BY|ORDER|LIMIT|AS|AND|OR|ASC|DESC|NULL|NOT|ON|JOIN|HAVING|DISTINCT)\b/i;
|
const SQL_KW = /^(SELECT|FROM|WHERE|GROUP|BY|ORDER|LIMIT|AS|AND|OR|ASC|DESC|NULL|NOT|ON|JOIN|HAVING|DISTINCT)\b/i;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user