LEVIATHAN v962456e · 962456eee1

Metaprogramming

Method calls in a reified lambda, and like / ilike

The six method calls that may appear in a reified lambda, and the exact matching rules of string.like and string.ilike.

since 0.1.0-alpha.1linuxwindowswasm

Description

A method call is reified as expr::Call(name, receiver, arguments), but only for these calls. The list is fixed, and growing it is a language change, not something a library can do.

receiver type method arguments
string like 1
string ilike 1
string startsWith 1
string endsWith 1
string contains 1
Array<T> for any T contains 1

The receiver is identified by its checked type. A call that type-checks but is not in the table, such as u.name.toUpper(), is rejected with cannot reify non-whitelisted call 'toUpper', and the error also lists the allowed calls.

like and ilike are ordinary string methods that any program can call. In a reified lambda they have the same meaning, and a consumer that translates the tree to SQL maps them to LIKE patterns, so this page defines them exactly.

Allowed calls become Call nodes

class User {
    string name;
    new User(string n) { name = n; }
}

string dump(expr::Node n) {
    match (n) {
        expr::Bin => {
            expr::Bin b = n;
            return "Bin(${b.op}, ${dump(b.l)}, ${dump(b.r)})";
        }
        expr::Call => {
            expr::Call c = n;
            string arg = "";
            for (expr::Node a in c.args) {
                arg = dump(a);
            }
            return "Call(${c.name}, ${dump(c.recv)}, ${arg})";
        }
        expr::Field => {
            string p = n.path.joinToString(".");
            return "Field(${p})";
        }
        expr::Bind => { return "Bind(${n.slot})"; }
        expr::Lit => { return "Lit"; }
        else => { return "?"; }
    }
}

void show(expr::Expr<(User) => bool> e) {
    console.writeln(dump(e.tree));
}

show((u) => u.name.like("A%"));
show((u) => u.name.ilike("a%") && u.name.startsWith("A"));
show((u) => u.name.endsWith("a") || u.name.contains("d"));
Call(like, Field(name), Lit)
Bin(&&, Call(ilike, Field(name), Lit), Call(startsWith, Field(name), Lit))
Bin(||, Call(endsWith, Field(name), Lit), Call(contains, Field(name), Lit))

Rules

Matching rules of like and ilike. The pattern is matched against the whole string, as in SQL LIKE, and matching is done on bytes.

  • % matches any run of bytes, including none.
  • _ matches exactly one byte. A UTF-8 character that takes two bytes needs two _.
  • \ makes the next byte of the pattern literal, so \%, \_ and \\ match a percent sign, an underscore and a backslash. A \ that is the last byte of the pattern matches a literal backslash at the end of the text.
  • The empty pattern matches only the empty string. A run of % at the end is the same as one.
  • like compares bytes exactly, so it is case sensitive.
  • ilike treats the ASCII letters A to Z and a to z as equal in the text and in the pattern, including the byte that follows a \. Everything outside ASCII is compared exactly: ilike never folds a non-ASCII letter.

Examples

like and ilike

void row(string text, string pattern) {
    console.writeln("'${text}' ~ '${pattern}': like=${text.like(pattern)} ilike=${text.ilike(pattern)}");
}

row("hello", "h%");
row("hello", "%llo");
row("hello", "h_llo");
row("hello", "h_lo");
row("Hello", "h%O");
row("", "%");
row("", "");
row("hello", "");
row("50%", "50\\%");
row("505", "50\\%");
row("a_b", "a\\_b");
row("axb", "a\\_b");
row("a\\", "a\\");
row("hello", "hello%%%");
'hello' ~ 'h%': like=true ilike=true
'hello' ~ '%llo': like=true ilike=true
'hello' ~ 'h_llo': like=true ilike=true
'hello' ~ 'h_lo': like=false ilike=false
'Hello' ~ 'h%O': like=false ilike=true
'' ~ '%': like=true ilike=true
'' ~ '': like=true ilike=true
'hello' ~ '': like=false ilike=false
'50%' ~ '50\%': like=true ilike=true
'505' ~ '50\%': like=false ilike=false
'a_b' ~ 'a\_b': like=true ilike=true
'axb' ~ 'a\_b': like=false ilike=false
'a\' ~ 'a\': like=true ilike=true
'hello' ~ 'hello%%%': like=true ilike=true

Matching is by byte, which matters for text outside ASCII:

Bytes, not characters

console.writeln("héllo".like("h_llo"));
console.writeln("héllo".like("h__llo"));
console.writeln("HÉllo".ilike("hÉllo"));
console.writeln("HÉllo".ilike("héllo"));
false
true
true
false

A generic method can accept a reified predicate that uses an allowed call, so a typed query builder works:

A generic query builder

class User {
    string name;
    new User(string n) { name = n; }
}

class Query<E> {
    Array<string> filters = [];
    Query<E> where(expr::Expr<(E) => bool> p) {
        expr::Node t = p.tree;
        match (t) {
            expr::Call => {
                expr::Call c = t;
                filters = filters.add(c.name);
            }
            else => { filters = filters.add("other"); }
        }
        return this;
    }
}

Query<User> q = Query();
q.where((u) => u.name.like("A%")).where((u) => u.name.endsWith("z"));
console.writeln(q.filters.joinToString(", "));
like, endsWith
class User { string name; }

// error: cannot reify non-whitelisted call 'toUpper'
// note: reifiable calls: string.like/1, string.ilike/1, string.startsWith/1, string.endsWith/1, string.contains/1, Array.contains/1
expr::Expr<(User) => bool> e = (u) => u.name.toUpper() == "A";
not run — shows a compile error

See also

  • Expression reification — lambdas as data — A lambda literal in a position typed expr::Expr<F> compiles to an ordinary closure plus a walkable tree of its checked body, which is what query builders translate to other languages.
  • The reifiable subset — The expressions that may appear in a reified lambda and the tree node each one becomes.
  • Reification errors — The compile errors a reified lambda can produce, what each means, and how to fix it.
  • like — Match the whole string against an SQL LIKE pattern.
  • ilike — Match the whole string against an SQL LIKE pattern, ignoring ASCII letter case.