mirror of
https://github.com/tursodatabase/limbo.git
synced 2025-08-04 18:18:03 +00:00
implement interaction plans
This commit is contained in:
parent
53ecedaceb
commit
7d4d803a13
5 changed files with 414 additions and 78 deletions
|
@ -2,6 +2,7 @@ use anarchist_readable_name_generator_lib::readable_name_custom;
|
|||
use rand::Rng;
|
||||
|
||||
pub mod query;
|
||||
pub mod plan;
|
||||
pub mod table;
|
||||
|
||||
pub trait Arbitrary {
|
||||
|
@ -12,10 +13,39 @@ pub trait ArbitraryFrom<T> {
|
|||
fn arbitrary_from<R: Rng>(rng: &mut R, t: &T) -> Self;
|
||||
}
|
||||
|
||||
pub(crate) fn frequency<'a, T, R: rand::Rng>(choices: Vec<(usize, Box<dyn FnOnce(&mut R) -> T + 'a>)>, rng: &mut R) -> T {
|
||||
let total = choices.iter().map(|(weight, _)| weight).sum::<usize>();
|
||||
let mut choice = rng.gen_range(0..total);
|
||||
|
||||
for (weight, f) in choices {
|
||||
if choice < weight {
|
||||
return f(rng);
|
||||
}
|
||||
choice -= weight;
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub(crate) fn one_of<T, R: rand::Rng>(choices: Vec<Box<dyn Fn(&mut R) -> T>>, rng: &mut R) -> T {
|
||||
let index = rng.gen_range(0..choices.len());
|
||||
choices[index](rng)
|
||||
}
|
||||
|
||||
pub(crate) fn pick<'a, T, R: rand::Rng>(choices: &'a Vec<T>, rng: &mut R) -> &'a T {
|
||||
let index = rng.gen_range(0..choices.len());
|
||||
&choices[index]
|
||||
}
|
||||
|
||||
pub(crate) fn pick_index<R: rand::Rng>(choices: usize, rng: &mut R) -> usize {
|
||||
rng.gen_range(0..choices)
|
||||
}
|
||||
|
||||
fn gen_random_text<T: Rng>(rng: &mut T) -> String {
|
||||
let big_text = rng.gen_ratio(1, 1000);
|
||||
if big_text {
|
||||
let max_size: u64 = 2 * 1024 * 1024 * 1024;
|
||||
// let max_size: u64 = 2 * 1024 * 1024 * 1024;
|
||||
let max_size: u64 = 2 * 1024; // todo: change this back to 2 * 1024 * 1024 * 1024
|
||||
let size = rng.gen_range(1024..max_size);
|
||||
let mut name = String::new();
|
||||
for i in 0..size {
|
||||
|
|
339
simulator/generation/plan.rs
Normal file
339
simulator/generation/plan.rs
Normal file
|
@ -0,0 +1,339 @@
|
|||
use std::{f32::consts::E, fmt::Display, os::macos::raw::stat, rc::Rc};
|
||||
|
||||
use limbo_core::{Connection, Result, RowResult};
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
query::{Create, Insert, Predicate, Query, Select},
|
||||
table::Value,
|
||||
},
|
||||
SimulatorEnv, SimulatorOpts,
|
||||
};
|
||||
|
||||
use crate::generation::{frequency, Arbitrary, ArbitraryFrom};
|
||||
|
||||
use super::{pick, pick_index};
|
||||
|
||||
pub(crate) type ResultSet = Vec<Vec<Value>>;
|
||||
|
||||
pub(crate) struct InteractionPlan {
|
||||
pub(crate) plan: Vec<Interaction>,
|
||||
pub(crate) stack: Vec<ResultSet>,
|
||||
}
|
||||
|
||||
impl Display for InteractionPlan {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
for interaction in &self.plan {
|
||||
match interaction {
|
||||
Interaction::Query(query) => write!(f, "{};\n", query)?,
|
||||
Interaction::Assertion(assertion) => write!(f, "-- ASSERT: {};\n", assertion.message)?,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InteractionStats {
|
||||
pub(crate) read_count: usize,
|
||||
pub(crate) write_count: usize,
|
||||
pub(crate) delete_count: usize,
|
||||
}
|
||||
|
||||
impl Display for InteractionStats {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Read: {}, Write: {}, Delete: {}",
|
||||
self.read_count, self.write_count, self.delete_count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum Interaction {
|
||||
Query(Query),
|
||||
Assertion(Assertion),
|
||||
}
|
||||
|
||||
impl Display for Interaction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Interaction::Query(query) => write!(f, "{}", query),
|
||||
Interaction::Assertion(assertion) => write!(f, "ASSERT: {}", assertion.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Assertion {
|
||||
pub(crate) func: Box<dyn Fn(&Vec<ResultSet>) -> bool>,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
pub(crate) struct Interactions(Vec<Interaction>);
|
||||
|
||||
impl Interactions {
|
||||
pub(crate) fn shadow(&self, env: &mut SimulatorEnv) {
|
||||
for interaction in &self.0 {
|
||||
match interaction {
|
||||
Interaction::Query(query) => match query {
|
||||
Query::Create(create) => {
|
||||
env.tables.push(create.table.clone());
|
||||
}
|
||||
Query::Insert(insert) => {
|
||||
let table = env
|
||||
.tables
|
||||
.iter_mut()
|
||||
.find(|t| t.name == insert.table)
|
||||
.unwrap();
|
||||
table.rows.push(insert.values.clone());
|
||||
}
|
||||
Query::Delete(_) => todo!(),
|
||||
Query::Select(_) => {}
|
||||
},
|
||||
Interaction::Assertion(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractionPlan {
|
||||
pub(crate) fn new() -> Self {
|
||||
InteractionPlan {
|
||||
plan: Vec::new(),
|
||||
stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, interaction: Interaction) {
|
||||
self.plan.push(interaction);
|
||||
}
|
||||
|
||||
pub(crate) fn stats(&self) -> InteractionStats {
|
||||
let mut read = 0;
|
||||
let mut write = 0;
|
||||
let mut delete = 0;
|
||||
|
||||
for interaction in &self.plan {
|
||||
match interaction {
|
||||
Interaction::Query(query) => match query {
|
||||
Query::Select(_) => read += 1,
|
||||
Query::Insert(_) => write += 1,
|
||||
Query::Delete(_) => delete += 1,
|
||||
Query::Create(_) => {}
|
||||
},
|
||||
Interaction::Assertion(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
InteractionStats {
|
||||
read_count: read,
|
||||
write_count: write,
|
||||
delete_count: delete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArbitraryFrom<SimulatorEnv> for InteractionPlan {
|
||||
fn arbitrary_from<R: rand::Rng>(rng: &mut R, env: &SimulatorEnv) -> Self {
|
||||
let mut plan = InteractionPlan::new();
|
||||
|
||||
let mut env = SimulatorEnv {
|
||||
opts: env.opts.clone(),
|
||||
tables: vec![],
|
||||
connections: vec![],
|
||||
io: env.io.clone(),
|
||||
db: env.db.clone(),
|
||||
rng: ChaCha8Rng::seed_from_u64(rng.next_u64()),
|
||||
};
|
||||
|
||||
let num_interactions = rng.gen_range(0..env.opts.max_interactions);
|
||||
|
||||
// First create at least one table
|
||||
let create_query = Create::arbitrary(rng);
|
||||
env.tables.push(create_query.table.clone());
|
||||
plan.push(Interaction::Query(Query::Create(create_query)));
|
||||
|
||||
while plan.plan.len() < num_interactions {
|
||||
log::debug!(
|
||||
"Generating interaction {}/{}",
|
||||
plan.plan.len(),
|
||||
num_interactions
|
||||
);
|
||||
let interactions = Interactions::arbitrary_from(rng, &(&env, plan.stats()));
|
||||
interactions.shadow(&mut env);
|
||||
|
||||
plan.plan.extend(interactions.0.into_iter());
|
||||
}
|
||||
|
||||
log::info!("Generated plan with {} interactions", plan.plan.len());
|
||||
plan
|
||||
}
|
||||
}
|
||||
|
||||
impl Interaction {
|
||||
pub(crate) fn execute_query(&self, conn: &mut Rc<Connection>) -> Result<ResultSet> {
|
||||
match self {
|
||||
Interaction::Query(query) => {
|
||||
let query_str = query.to_string();
|
||||
let rows = conn.query(&query_str);
|
||||
if rows.is_err() {
|
||||
let err = rows.err();
|
||||
log::error!(
|
||||
"Error running query '{}': {:?}",
|
||||
&query_str[0..query_str.len().min(4096)],
|
||||
err
|
||||
);
|
||||
return Err(err.unwrap());
|
||||
}
|
||||
let rows = rows.unwrap();
|
||||
assert!(rows.is_some());
|
||||
let mut rows = rows.unwrap();
|
||||
let mut out = Vec::new();
|
||||
while let Ok(row) = rows.next_row() {
|
||||
match row {
|
||||
RowResult::Row(row) => {
|
||||
let mut r = Vec::new();
|
||||
for el in &row.values {
|
||||
let v = match el {
|
||||
limbo_core::Value::Null => Value::Null,
|
||||
limbo_core::Value::Integer(i) => Value::Integer(*i),
|
||||
limbo_core::Value::Float(f) => Value::Float(*f),
|
||||
limbo_core::Value::Text(t) => Value::Text(t.to_string()),
|
||||
limbo_core::Value::Blob(b) => Value::Blob(b.to_vec()),
|
||||
};
|
||||
r.push(v);
|
||||
}
|
||||
|
||||
out.push(r);
|
||||
}
|
||||
RowResult::IO => {}
|
||||
RowResult::Done => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
Interaction::Assertion(_) => {
|
||||
unreachable!("unexpected: this function should only be called on queries")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn execute_assertion(&self, stack: &Vec<ResultSet>) -> Result<()> {
|
||||
match self {
|
||||
Interaction::Query(_) => {
|
||||
unreachable!("unexpected: this function should only be called on assertions")
|
||||
}
|
||||
Interaction::Assertion(assertion) => {
|
||||
if !assertion.func.as_ref()(stack) {
|
||||
return Err(limbo_core::LimboError::InternalError(
|
||||
assertion.message.clone(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn property_insert_select<R: rand::Rng>(rng: &mut R, env: &SimulatorEnv) -> Interactions {
|
||||
// Get a random table
|
||||
let table = pick(&env.tables, rng);
|
||||
// Pick a random column
|
||||
let column_index = pick_index(table.columns.len(), rng);
|
||||
let column = &table.columns[column_index].clone();
|
||||
// Generate a random value of the column type
|
||||
let value = Value::arbitrary_from(rng, &column.column_type);
|
||||
// Create a whole new row
|
||||
let mut row = Vec::new();
|
||||
for (i, column) in table.columns.iter().enumerate() {
|
||||
if i == column_index {
|
||||
row.push(value.clone());
|
||||
} else {
|
||||
let value = Value::arbitrary_from(rng, &column.column_type);
|
||||
row.push(value);
|
||||
}
|
||||
}
|
||||
// Insert the row
|
||||
let insert_query = Interaction::Query(Query::Insert(Insert {
|
||||
table: table.name.clone(),
|
||||
values: row.clone(),
|
||||
}));
|
||||
|
||||
// Select the row
|
||||
let select_query = Interaction::Query(Query::Select(Select {
|
||||
table: table.name.clone(),
|
||||
predicate: Predicate::Eq(column.name.clone(), value.clone()),
|
||||
}));
|
||||
|
||||
// Check that the row is there
|
||||
let assertion = Interaction::Assertion(Assertion {
|
||||
message: format!(
|
||||
"row [{:?}] not found in table {} after inserting ({} = {})",
|
||||
row.iter().map(|v| v.to_string()).collect::<Vec<String>>(),
|
||||
table.name,
|
||||
column.name,
|
||||
value,
|
||||
),
|
||||
func: Box::new(move |stack: &Vec<ResultSet>| {
|
||||
let rows = stack.last().unwrap();
|
||||
rows.iter().any(|r| r == &row)
|
||||
}),
|
||||
});
|
||||
|
||||
Interactions(vec![insert_query, select_query, assertion])
|
||||
}
|
||||
|
||||
fn create_table<R: rand::Rng>(rng: &mut R, env: &SimulatorEnv) -> Interactions {
|
||||
let create_query = Interaction::Query(Query::Create(Create::arbitrary(rng)));
|
||||
Interactions(vec![create_query])
|
||||
}
|
||||
|
||||
fn random_read<R: rand::Rng>(rng: &mut R, env: &SimulatorEnv) -> Interactions {
|
||||
let select_query = Interaction::Query(Query::Select(Select::arbitrary_from(rng, &env.tables)));
|
||||
Interactions(vec![select_query])
|
||||
}
|
||||
|
||||
fn random_write<R: rand::Rng>(rng: &mut R, env: &SimulatorEnv) -> Interactions {
|
||||
let table = pick(&env.tables, rng);
|
||||
let insert_query = Interaction::Query(Query::Insert(Insert::arbitrary_from(rng, table)));
|
||||
Interactions(vec![insert_query])
|
||||
}
|
||||
|
||||
impl ArbitraryFrom<(&SimulatorEnv, InteractionStats)> for Interactions {
|
||||
fn arbitrary_from<R: rand::Rng>(
|
||||
rng: &mut R,
|
||||
(env, stats): &(&SimulatorEnv, InteractionStats),
|
||||
) -> Self {
|
||||
let remaining_read =
|
||||
((((env.opts.max_interactions * env.opts.read_percent) as f64) / 100.0) as usize)
|
||||
.saturating_sub(stats.read_count);
|
||||
let remaining_write = ((((env.opts.max_interactions * env.opts.write_percent) as f64)
|
||||
/ 100.0) as usize)
|
||||
.saturating_sub(stats.write_count);
|
||||
|
||||
frequency(
|
||||
vec![
|
||||
(
|
||||
usize::min(remaining_read, remaining_write),
|
||||
Box::new(|rng: &mut R| property_insert_select(rng, env)),
|
||||
),
|
||||
(
|
||||
remaining_read,
|
||||
Box::new(|rng: &mut R| random_read(rng, env)),
|
||||
),
|
||||
(
|
||||
remaining_write,
|
||||
Box::new(|rng: &mut R| random_write(rng, env)),
|
||||
),
|
||||
(1, Box::new(|rng: &mut R| create_table(rng, env))),
|
||||
],
|
||||
rng,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ impl Arbitrary for Name {
|
|||
impl Arbitrary for Table {
|
||||
fn arbitrary<R: Rng>(rng: &mut R) -> Self {
|
||||
let name = Name::arbitrary(rng).0;
|
||||
let columns = (1..rng.gen_range(1..128))
|
||||
let columns = (1..=rng.gen_range(1..10))
|
||||
.map(|_| Column::arbitrary(rng))
|
||||
.collect();
|
||||
Table {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use generation::{Arbitrary, ArbitraryFrom};
|
||||
use generation::plan::{Interaction, ResultSet};
|
||||
use generation::{pick, pick_index, Arbitrary, ArbitraryFrom};
|
||||
use limbo_core::{Connection, Database, File, OpenFlags, PlatformIO, Result, RowResult, IO};
|
||||
use model::query::{Insert, Predicate, Query, Select};
|
||||
use model::table::{Column, Name, Table, Value};
|
||||
|
@ -6,6 +7,7 @@ use properties::{property_insert_select, property_select_all};
|
|||
use rand::prelude::*;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
use std::cell::RefCell;
|
||||
use std::io::Write;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
@ -29,13 +31,7 @@ enum SimConnection {
|
|||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
enum SimulatorMode {
|
||||
Random,
|
||||
Workload,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct SimulatorOpts {
|
||||
ticks: usize,
|
||||
max_connections: usize,
|
||||
|
@ -45,7 +41,7 @@ struct SimulatorOpts {
|
|||
read_percent: usize,
|
||||
write_percent: usize,
|
||||
delete_percent: usize,
|
||||
mode: SimulatorMode,
|
||||
max_interactions: usize,
|
||||
page_size: usize,
|
||||
}
|
||||
|
||||
|
@ -77,8 +73,8 @@ fn main() {
|
|||
read_percent,
|
||||
write_percent,
|
||||
delete_percent,
|
||||
mode: SimulatorMode::Workload,
|
||||
page_size: 4096, // TODO: randomize this too
|
||||
max_interactions: rng.gen_range(0..10000),
|
||||
};
|
||||
let io = Arc::new(SimulatorIO::new(seed, opts.page_size).unwrap());
|
||||
|
||||
|
@ -104,10 +100,20 @@ fn main() {
|
|||
|
||||
println!("Initial opts {:?}", env.opts);
|
||||
|
||||
for _ in 0..env.opts.ticks {
|
||||
let connection_index = env.rng.gen_range(0..env.opts.max_connections);
|
||||
log::info!("Generating database interaction plan...");
|
||||
let mut plan = generation::plan::InteractionPlan::arbitrary_from(&mut env.rng.clone(), &env);
|
||||
|
||||
log::info!("{}", plan.stats());
|
||||
|
||||
for interaction in &plan.plan {
|
||||
let connection_index = pick_index(env.connections.len(), &mut env.rng);
|
||||
let mut connection = env.connections[connection_index].clone();
|
||||
|
||||
if matches!(connection, SimConnection::Disconnected) {
|
||||
connection = SimConnection::Connected(env.db.connect());
|
||||
env.connections[connection_index] = connection.clone();
|
||||
}
|
||||
|
||||
match &mut connection {
|
||||
SimConnection::Connected(conn) => {
|
||||
let disconnect = env.rng.gen_ratio(1, 100);
|
||||
|
@ -116,10 +122,20 @@ fn main() {
|
|||
let _ = conn.close();
|
||||
env.connections[connection_index] = SimConnection::Disconnected;
|
||||
} else {
|
||||
match process_connection(&mut env, conn) {
|
||||
Ok(_) => {}
|
||||
match process_connection(conn, interaction, &mut plan.stack) {
|
||||
Ok(_) => {
|
||||
log::info!("connection {} processed", connection_index);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("error {}", err);
|
||||
log::debug!("db is at {:?}", path);
|
||||
// save the interaction plan
|
||||
let mut path = TempDir::new().unwrap().into_path();
|
||||
path.push("simulator.plan");
|
||||
let mut f = std::fs::File::create(path.clone()).unwrap();
|
||||
f.write(plan.to_string().as_bytes()).unwrap();
|
||||
log::debug!("plan saved at {:?}", path);
|
||||
log::debug!("seed was {}", seed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -130,73 +146,24 @@ fn main() {
|
|||
env.connections[connection_index] = SimConnection::Connected(env.db.connect());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
env.io.print_stats();
|
||||
}
|
||||
|
||||
fn process_connection(env: &mut SimulatorEnv, conn: &mut Rc<Connection>) -> Result<()> {
|
||||
if env.tables.is_empty() {
|
||||
maybe_add_table(env, conn)?;
|
||||
}
|
||||
|
||||
match env.opts.mode {
|
||||
SimulatorMode::Random => {
|
||||
match env.rng.gen_range(0..2) {
|
||||
// Randomly insert a value and check that the select result contains it.
|
||||
0 => property_insert_select(env, conn),
|
||||
// Check that the current state of the in-memory table is the same as the one in the
|
||||
// database.
|
||||
1 => property_select_all(env, conn),
|
||||
// Perform a random query, update the in-memory table with the result.
|
||||
2 => {
|
||||
let table_index = env.rng.gen_range(0..env.tables.len());
|
||||
let query = Query::arbitrary_from(&mut env.rng, &env.tables[table_index]);
|
||||
let rows = get_all_rows(env, conn, query.to_string().as_str())?;
|
||||
env.tables[table_index].rows = rows;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fn process_connection(conn: &mut Rc<Connection>, interaction: &Interaction, stack: &mut Vec<ResultSet>) -> Result<()> {
|
||||
match interaction {
|
||||
generation::plan::Interaction::Query(_) => {
|
||||
log::debug!("{}", interaction);
|
||||
let results = interaction.execute_query(conn)?;
|
||||
log::debug!("{:?}", results);
|
||||
stack.push(results);
|
||||
}
|
||||
SimulatorMode::Workload => {
|
||||
let picked = env.rng.gen_range(0..100);
|
||||
|
||||
if env.rng.gen_ratio(1, 100) {
|
||||
maybe_add_table(env, conn)?;
|
||||
}
|
||||
|
||||
if picked < env.opts.read_percent {
|
||||
let query = Select::arbitrary_from(&mut env.rng, &env.tables);
|
||||
let _ = get_all_rows(env, conn, Query::Select(query).to_string().as_str())?;
|
||||
} else if picked < env.opts.read_percent + env.opts.write_percent {
|
||||
let table_index = env.rng.gen_range(0..env.tables.len());
|
||||
let column_index = env.rng.gen_range(0..env.tables[table_index].columns.len());
|
||||
let column = &env.tables[table_index].columns[column_index].clone();
|
||||
let mut rng = env.rng.clone();
|
||||
let value = Value::arbitrary_from(&mut rng, &column.column_type);
|
||||
let mut row = Vec::new();
|
||||
for (i, column) in env.tables[table_index].columns.iter().enumerate() {
|
||||
if i == column_index {
|
||||
row.push(value.clone());
|
||||
} else {
|
||||
let value = Value::arbitrary_from(&mut rng, &column.column_type);
|
||||
row.push(value);
|
||||
}
|
||||
}
|
||||
let query = Query::Insert(Insert {
|
||||
table: env.tables[table_index].name.clone(),
|
||||
values: row.clone(),
|
||||
});
|
||||
let _ = get_all_rows(env, conn, query.to_string().as_str())?;
|
||||
env.tables[table_index].rows.push(row.clone());
|
||||
} else {
|
||||
let table_index = env.rng.gen_range(0..env.tables.len());
|
||||
let query = Query::Select(Select {
|
||||
table: env.tables[table_index].name.clone(),
|
||||
predicate: Predicate::And(Vec::new()),
|
||||
});
|
||||
let _ = get_all_rows(env, conn, query.to_string().as_str())?;
|
||||
}
|
||||
generation::plan::Interaction::Assertion(_) => {
|
||||
interaction.execute_assertion(stack)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ impl Deref for Name {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Table {
|
||||
pub(crate) rows: Vec<Vec<Value>>,
|
||||
pub(crate) name: String,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue