0

I am creating a QueryBuilder, so I though that it w'd be amazing to serialize objects to tokens, and have an AST within the QueryBuilder. So I immediatly though about the Visitor Design Pattern.

Here's the classic OOP implementation that I imagine:

// ---------- VISITOR ----------
trait Visitor {
    fn visit_select(&mut self, s: &QueryKind);
    fn visit_column(&mut self, c: &ColumnRef);
    fn visit_from(&mut self, f: &FromClause);
}

// ---------- VISITABLE ----------
trait Visitable {
    fn accept(&self, v: &mut dyn Visitor);
}

// ---------- AST ----------
struct SelectAst<'a> {
    cols: Vec<ColumnRef<'a>>,
    table: &'a str,
    /* other Select related fields, like GROUP_BY, LIMIT, OFFSET... */
}

impl<'a> Visitable for SelectAst<'a> {
    fn accept(&self, v: &mut dyn Visitor) {
        v.visit_select(QueryKind::Select);
        for c in &self.cols {
            c.accept(v);
        }
        FromClause(self.table).accept(v);
    }
}

impl<'a> Visitable for ColumnRef<'a> {
    fn accept(&self, v: &mut dyn Visitor) {
        v.visit_column(self);
    }
}

// ---------- EMITTER ----------
struct SqlEmitter(Vec<String>);

impl Visitor for SqlEmitter {
    fn visit_select(&mut self, kind: &QueryKind) {
        self.0.push(kind.into());
    }
    fn visit_column(&mut self, c: &ColumnRef) {
        self.0.push(c.0.into());
    }
    fn visit_from(&mut self, f: &FromClause) {
        self.0.push(format!("FROM {}", f.0));
    }
}

But that means:

SqlEmitter has to implement all the methods on the Visitor contract. I mean, that's the design pattern goal, but there's lot of clauses on a SQL sentence to "serialize". That doesn't break the ISP principle?

Also, what I'd really like is to have some set of small traits that determine behaviour for every "Node" let's say,

// ---------- SMALL BEHAVIOUR TRAITS ----------
trait VisitKind<'a> { fn visit_kind(&self, out: &mut SqlEmitter<'a>); }
trait VisitColumns<'a> { fn visit_columns(&self, out: &mut SqlEmitter<'a>); }
trait VisitFrom<'a> { fn visit_from(&self, out: &mut SqlEmitter<'a>); }

// ---------- QUERY EMITTER ----------
pub struct SqlEmitter<'a> { pub tokens: Vec<SqlToken<'a>> }

impl<'a> SqlEmitter<'a> {
    pub fn kw(&mut self, k: &'a str) { self.tokens.push(SqlToken::Keyword(k)); }
    pub fn ident(&mut self, id: &'a str) { self.tokens.push(SqlToken::Ident(id)); }
}

// ---------- AST ----------
struct SelectAst<'a> {
    columns: Vec<&'a str>,
    table: &'a str,
}

// SelectAst defines ONLY what it needs:
impl<'a> VisitKind<'a> for SelectAst<'a> {
    fn visit_kind(&self, out: &mut SqlEmitter<'a>) {
        out.kw("SELECT");
    }
}

impl<'a> VisitColumns<'a> for SelectAst<'a> {
    fn visit_columns(&self, out: &mut SqlEmitter<'a>) {
        for (i, c) in self.columns.iter().enumerate() {
            if i > 0 { out.kw(","); }
            out.ident(c);
        }
    }
}

impl<'a> VisitFrom<'a> for SelectAst<'a> {
    fn visit_from(&self, out: &mut SqlEmitter<'a>) {
        out.kw("FROM");
        out.ident(self.table);
    }
}

impl<'a> SelectAst<'a> {
    pub fn accept(&self, out: &mut SqlEmitter<'a>) {
        self.visit_kind(out);
        self.visit_columns(out);
        self.visit_from(out);
    }
}

So, whenever I need a new SQL Clause to be rendered, I only need to:

- Create a new trait representing functionallity
- Implementing it for the concrete kinds of ASTs that care about it.

For example, the SELECT and DELETE SQL statements worries about having a FROM clause that determines which table will receive the operation, but the UPDATE doesn't have a FROM clause on it's syntax.

So my doubt is: Is this a kind of already known visitor design pattern? I am not having double-dispatch. I really like the functionality here, but now I make the concrete AST types responsible for writing the emission order (that's one thing that I like), but I need to modify them whenever I need to introduce a new operation which totally defeats the purpose of the Visitor.

Is any know way or pattern that gets the best of both worlds? I rode about some "modular" visitor design pattern variation, but I don't know if I am on the right track

1
  • In the OO world one would derive SqlEmitter from a common base that implements an empty visit for every operation. Then one re-implements things in SqlEmitter that make sense for SqlEmitter. This way, when a new operation is added, you only add it to the common base class (and implement it in the concrete class that actually has this operation). I guess in Rust you would have a common trait Visit that has default implementations for all operations (rather than a separate trait for each operation). When a new operation is added, you add a do-nothing implementation to trait Visit. Commented yesterday

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.