Files
troupe-standup/wizard-work/null/minisql/minisql.py
T

1235 lines
41 KiB
Python

#!/usr/bin/env python3
"""
minisql.py -- a from-scratch SQL SELECT engine. Stdlib only, no third-party imports.
Wizard: null (lane: Codeforge -- hard coding and agentic reasoning).
Scope: exactly the grammar the differential harness `diff_sqlite.py` generates, and
nothing else. Everything outside it raises SqlError.
SELECT [DISTINCT] <items> FROM <table> [[AS] alias]
[[INNER|LEFT [OUTER]] JOIN <table> [[AS] alias] ON <expr>]*
[WHERE <expr>]
[GROUP BY <expr>, ...]
[HAVING <expr>]
[ORDER BY <expr> [ASC|DESC], ...]
[LIMIT <n> [OFFSET <m>]]
items : * | alias.* | <expr> [[AS] alias]
expr : literals (int, real, 'text', NULL, TRUE, FALSE)
| column refs (name | alias.name)
| unary - + NOT ; binary + - * /
| = <> != < <= > >= (three-valued)
| IS [NOT] NULL | [NOT] IN (list) | [NOT] BETWEEN a AND b | [NOT] LIKE 'pat'
| AND OR | ( expr )
| CASE WHEN c THEN e [...] [ELSE e] END (searched CASE only)
| COUNT(*) COUNT(x) SUM(x) AVG(x) MIN(x) MAX(x)
Semantics implemented deliberately to match SQLite 3.x:
* NULL propagation, three-valued AND/OR/NOT; WHERE keeps rows whose value is true.
* storage-class ordering NULL < INTEGER/REAL < TEXT, and the affinity rule: if one
operand has INTEGER/REAL/NUMERIC affinity and the other has TEXT/BLOB/no affinity,
numeric affinity is applied to the other operand.
* integer division truncates toward zero and stays INTEGER; division by zero -> NULL.
* SUM of no non-NULL values -> NULL (not 0); COUNT(x) ignores NULLs; AVG -> REAL.
* LIKE is case-insensitive for ASCII only, `%` = any run, `_` = one char.
* ASC sorts NULLs first, DESC last; comparison is by storage class then value.
The module is written so each semantic decision above lives in its own named function
(compare_values, sort_key, arith_div, agg_sum, agg_count, distinct_key, join_relation,
tri/truthy), because diff_sqlite.py mutates them one at a time to prove its own teeth.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, replace
from typing import Optional
__all__ = [
"SqlError", "parse_query", "execute", "run", "Table",
"compare_values", "sort_key", "arith_div", "agg_sum", "agg_count",
"distinct_key", "tri", "truthy", "like_match",
]
class SqlError(Exception):
"""Raised for anything outside the supported subset."""
# --------------------------------------------------------------------------
# tokenizer
# --------------------------------------------------------------------------
_KEYWORDS = {
"SELECT", "DISTINCT", "FROM", "JOIN", "INNER", "LEFT", "OUTER", "ON", "WHERE",
"GROUP", "BY", "HAVING", "ORDER", "ASC", "DESC", "LIMIT", "OFFSET", "AS",
"AND", "OR", "NOT", "IS", "NULL", "IN", "BETWEEN", "LIKE", "CASE", "WHEN",
"THEN", "ELSE", "END", "TRUE", "FALSE",
}
_AGGS = {"COUNT", "SUM", "AVG", "MIN", "MAX"}
_TOKEN_RE = re.compile(
r"""
(?P<ws>\s+)
| (?P<comment>--[^\n]*)
| (?P<str>'(?:[^']|'')*')
| (?P<qident>"(?:[^"]|"")*")
| (?P<num>\d+\.\d*(?:[eE][-+]?\d+)?|\.\d+(?:[eE][-+]?\d+)?|\d+(?:[eE][-+]?\d+)?)
| (?P<ident>[A-Za-z_][A-Za-z_0-9]*)
| (?P<op><=|>=|<>|!=|\|\||[-+*/%(),.=<>;])
""",
re.VERBOSE,
)
@dataclass
class _Tok:
kind: str # 'kw' | 'ident' | 'num' | 'str' | 'op' | 'end'
text: str # canonical text (keywords uppercased, idents lowercased)
value: object = None
def tokenize(sql: str) -> list:
toks, i, n = [], 0, len(sql)
while i < n:
m = _TOKEN_RE.match(sql, i)
if not m:
raise SqlError("cannot tokenize at offset %d: %r" % (i, sql[i:i + 12]))
i = m.end()
kind = m.lastgroup
text = m.group()
if kind in ("ws", "comment"):
continue
if kind == "str":
toks.append(_Tok("str", text, text[1:-1].replace("''", "'")))
elif kind == "qident":
toks.append(_Tok("ident", text[1:-1].replace('""', '"').lower()))
elif kind == "num":
if "." in text or "e" in text or "E" in text:
toks.append(_Tok("num", text, float(text)))
else:
toks.append(_Tok("num", text, int(text)))
elif kind == "ident":
up = text.upper()
if up in _KEYWORDS:
toks.append(_Tok("kw", up))
else:
toks.append(_Tok("ident", text.lower()))
else:
toks.append(_Tok("op", text))
toks.append(_Tok("end", ""))
return toks
# --------------------------------------------------------------------------
# parser
# --------------------------------------------------------------------------
# AST nodes are plain tuples; the first element names the node.
class _Parser:
def __init__(self, toks):
self.t = toks
self.i = 0
# -- helpers ----------------------------------------------------------
def peek(self, k=0):
return self.t[min(self.i + k, len(self.t) - 1)]
def next(self):
tok = self.t[self.i]
self.i += 1
return tok
def at_kw(self, *names):
tok = self.peek()
return tok.kind == "kw" and tok.text in names
def at_op(self, *ops):
tok = self.peek()
return tok.kind == "op" and tok.text in ops
def eat_kw(self, name):
if not self.at_kw(name):
raise SqlError("expected %s, got %r" % (name, self.peek().text))
return self.next()
def eat_op(self, op):
if not self.at_op(op):
raise SqlError("expected %r, got %r" % (op, self.peek().text))
return self.next()
def eat_ident(self):
tok = self.peek()
if tok.kind != "ident":
raise SqlError("expected identifier, got %r" % tok.text)
return self.next().text
# -- statements -------------------------------------------------------
def parse_query(self):
self.eat_kw("SELECT")
distinct = False
if self.at_kw("DISTINCT"):
self.next()
distinct = True
items = [self.parse_item()]
while self.at_op(","):
self.next()
items.append(self.parse_item())
if not self.at_kw("FROM"):
# SELECT without FROM: one row, no columns. Constant-expression queries only.
if self.peek().kind != "end":
raise SqlError("a SELECT without FROM takes no clauses")
return {
"distinct": distinct, "items": items,
"from": (None, None), "joins": [], "where": None, "group_by": [],
"having": None, "order_by": [], "limit": None, "offset": None,
}
self.next()
from_tbl, from_alias = self.parse_table_ref()
joins = []
while True:
if self.at_kw("JOIN"):
self.next()
kind = "inner"
elif self.at_kw("INNER"):
self.next()
self.eat_kw("JOIN")
kind = "inner"
elif self.at_kw("LEFT"):
self.next()
if self.at_kw("OUTER"):
self.next()
self.eat_kw("JOIN")
kind = "left"
else:
break
tbl, alias = self.parse_table_ref()
self.eat_kw("ON")
on = self.parse_expr()
joins.append((kind, tbl, alias, on))
where = None
if self.at_kw("WHERE"):
self.next()
where = self.parse_expr()
group_by = []
if self.at_kw("GROUP"):
self.next()
self.eat_kw("BY")
group_by = [self.parse_expr()]
while self.at_op(","):
self.next()
group_by.append(self.parse_expr())
having = None
if self.at_kw("HAVING"):
self.next()
having = self.parse_expr()
order_by = []
if self.at_kw("ORDER"):
self.next()
self.eat_kw("BY")
while True:
e = self.parse_expr()
desc = False
if self.at_kw("ASC"):
self.next()
elif self.at_kw("DESC"):
self.next()
desc = True
order_by.append((e, desc))
if self.at_op(","):
self.next()
continue
break
limit = offset = None
if self.at_kw("LIMIT"):
self.next()
limit = self.parse_signed_int()
if self.at_kw("OFFSET"):
self.next()
offset = self.parse_signed_int()
if self.peek().kind != "end":
raise SqlError("trailing input: %r" % self.peek().text)
return {
"distinct": distinct, "items": items,
"from": (from_tbl, from_alias), "joins": joins,
"where": where, "group_by": group_by, "having": having,
"order_by": order_by, "limit": limit, "offset": offset,
}
def parse_signed_int(self):
neg = False
if self.at_op("-"):
self.next()
neg = True
tok = self.next()
if tok.kind != "num" or not isinstance(tok.value, int):
raise SqlError("expected integer, got %r" % tok.text)
return -tok.value if neg else tok.value
def parse_table_ref(self):
name = self.eat_ident()
alias = name
if self.at_kw("AS"):
self.next()
alias = self.eat_ident()
elif self.peek().kind == "ident":
alias = self.eat_ident()
return name, alias
def parse_item(self):
if self.at_op("*"):
self.next()
return ("star", None, None)
if self.peek().kind == "ident" and self.peek(1).kind == "op" and self.peek(1).text == "." \
and self.peek(2).kind == "op" and self.peek(2).text == "*":
tbl = self.next().text
self.next()
self.next()
return ("star", tbl, None)
e = self.parse_expr()
alias = None
if self.at_kw("AS"):
self.next()
alias = self.eat_ident()
elif self.peek().kind == "ident":
alias = self.eat_ident()
return ("item", e, alias)
# -- expressions ------------------------------------------------------
def parse_expr(self):
return self.parse_or()
def parse_or(self):
left = self.parse_and()
while self.at_kw("OR"):
self.next()
left = ("or", left, self.parse_and())
return left
def parse_and(self):
left = self.parse_not()
while self.at_kw("AND"):
self.next()
left = ("and", left, self.parse_not())
return left
def parse_not(self):
if self.at_kw("NOT"):
self.next()
return ("not", self.parse_not())
return self.parse_predicate()
def parse_predicate(self):
left = self.parse_additive()
while True:
if self.at_op("=", "<>", "!=", "<", "<=", ">", ">="):
op = self.next().text
right = self.parse_additive()
left = ("cmp", op, left, right)
elif self.at_kw("IS"):
self.next()
neg = False
if self.at_kw("NOT"):
self.next()
neg = True
self.eat_kw("NULL")
left = ("isnull", left, neg)
elif self.at_kw("IN") or (self.at_kw("NOT") and self.peek(1).kind == "kw"
and self.peek(1).text == "IN"):
neg = False
if self.at_kw("NOT"):
self.next()
neg = True
self.eat_kw("IN")
self.eat_op("(")
items = []
if not self.at_op(")"):
items.append(self.parse_expr())
while self.at_op(","):
self.next()
items.append(self.parse_expr())
self.eat_op(")")
left = ("in", left, items, neg)
elif self.at_kw("BETWEEN") or (self.at_kw("NOT") and self.peek(1).kind == "kw"
and self.peek(1).text == "BETWEEN"):
neg = False
if self.at_kw("NOT"):
self.next()
neg = True
self.eat_kw("BETWEEN")
lo = self.parse_additive()
self.eat_kw("AND")
hi = self.parse_additive()
left = ("between", left, lo, hi, neg)
elif self.at_kw("LIKE") or (self.at_kw("NOT") and self.peek(1).kind == "kw"
and self.peek(1).text == "LIKE"):
neg = False
if self.at_kw("NOT"):
self.next()
neg = True
self.eat_kw("LIKE")
left = ("like", left, self.parse_additive(), neg)
else:
return left
def parse_additive(self):
left = self.parse_multiplicative()
while self.at_op("+", "-"):
op = self.next().text
left = ("binop", op, left, self.parse_multiplicative())
return left
def parse_multiplicative(self):
left = self.parse_unary()
while self.at_op("*", "/", "%"):
op = self.next().text
if op == "%":
raise SqlError("`%` is out of scope")
left = ("binop", op, left, self.parse_unary())
return left
def parse_unary(self):
if self.at_op("-"):
self.next()
return ("unary", "-", self.parse_unary())
if self.at_op("+"):
self.next()
return ("unary", "+", self.parse_unary())
return self.parse_primary()
def parse_primary(self):
tok = self.peek()
if tok.kind == "op" and tok.text == "(":
self.next()
e = self.parse_expr()
self.eat_op(")")
return e
if tok.kind == "num":
self.next()
return ("lit", tok.value)
if tok.kind == "str":
self.next()
return ("lit", tok.value)
if tok.kind == "kw" and tok.text == "NULL":
self.next()
return ("lit", None)
if tok.kind == "kw" and tok.text in ("TRUE", "FALSE"):
self.next()
return ("lit", 1 if tok.text == "TRUE" else 0)
if tok.kind == "kw" and tok.text == "CASE":
return self.parse_case()
if tok.kind == "ident":
if tok.text.upper() in _AGGS and self.peek(1).kind == "op" and self.peek(1).text == "(":
return self.parse_aggregate()
name = self.next().text
if self.at_op("."):
self.next()
col = self.eat_ident()
return ("col", name, col)
return ("col", None, name)
raise SqlError("unexpected token %r" % tok.text)
def parse_aggregate(self):
fname = self.next().text.upper()
self.eat_op("(")
if self.at_op("*"):
self.next()
self.eat_op(")")
if fname != "COUNT":
raise SqlError("%s(*) is not a function" % fname)
return ("agg", "COUNT", None)
arg = self.parse_expr()
self.eat_op(")")
return ("agg", fname, arg)
def parse_case(self):
self.eat_kw("CASE")
if not self.at_kw("WHEN"):
raise SqlError("only searched CASE is in scope")
whens = []
while self.at_kw("WHEN"):
self.next()
cond = self.parse_expr()
self.eat_kw("THEN")
val = self.parse_expr()
whens.append((cond, val))
els = None
if self.at_kw("ELSE"):
self.next()
els = self.parse_expr()
self.eat_kw("END")
return ("case", whens, els)
def parse_query(sql: str) -> dict:
return _Parser(tokenize(sql)).parse_query()
# --------------------------------------------------------------------------
# semantics -- each decision below is a separate function on purpose
# --------------------------------------------------------------------------
_NUM_AFF = {"INTEGER", "REAL", "NUMERIC"}
def class_rank(v):
"""SQLite storage-class order: NULL < numbers < text < blob."""
if v is None:
return 0
if isinstance(v, (int, float)) and not isinstance(v, bool):
return 1
if isinstance(v, str):
return 2
raise SqlError("unsupported value type %r" % (type(v),))
_NUM_PREFIX_RE = re.compile(r"[ \t]*[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?")
def numeric_prefix(v):
"""SQLite's arithmetic coercion of text: the longest numeric *prefix*, else integer 0.
Measured against sqlite3 3.53.1: `'5abc' + 0` -> 5 (integer), `'5.5' + 0` -> 5.5 (real),
`'abc' + 0` -> 0 (integer), `'' + 0` -> 0, `' 5 ' + 0` -> 5. Note this is a *prefix*
parse and is NOT the same rule as affinity conversion, which needs the whole string.
"""
if isinstance(v, str):
m = _NUM_PREFIX_RE.match(v)
if not m:
return 0
text = m.group()
if "." in text or "e" in text or "E" in text:
return float(text)
return int(text)
return v
def apply_numeric_affinity(v):
"""SQLite numeric affinity: a text value that looks wholly numeric becomes a number."""
if isinstance(v, str):
s = v.strip()
if not s:
return v
try:
return int(s)
except ValueError:
pass
try:
return float(s)
except ValueError:
return v
return v
def apply_text_affinity(v):
"""SQLite's TEXT affinity conversion: a number compared against a TEXT-affinity column
becomes its text form. Measured: `s = 10` is TRUE for s='10' while `s = 10.0` is FALSE
(the real converts to '10.0'), so a float must render with SQLite's own shortest form."""
if v is None or isinstance(v, str):
return v
if isinstance(v, bool):
return str(int(v))
if isinstance(v, int):
return str(v)
if isinstance(v, float):
if v == int(v) and abs(v) < 1e16:
return "%.1f" % v
return repr(v)
raise SqlError("cannot apply TEXT affinity to %r" % (type(v),))
def compare_values(a, b, aff_a=None, aff_b=None):
"""-1/0/1, or None when either side is NULL. Applies SQLite's two affinity rules first:
* one side INTEGER/REAL/NUMERIC affinity, the other TEXT/BLOB/none -> numeric affinity
is applied to the other side;
* one side TEXT affinity, the other none -> TEXT affinity is applied to the other side.
"""
if a is None or b is None:
return None
if aff_a in _NUM_AFF and aff_b not in _NUM_AFF:
b = apply_numeric_affinity(b)
elif aff_b in _NUM_AFF and aff_a not in _NUM_AFF:
a = apply_numeric_affinity(a)
elif aff_a == "TEXT" and aff_b is None:
b = apply_text_affinity(b)
elif aff_b == "TEXT" and aff_a is None:
a = apply_text_affinity(a)
ra, rb = class_rank(a), class_rank(b)
if ra != rb:
return -1 if ra < rb else 1
if a == b:
return 0
return -1 if a < b else 1
def sort_key(v):
"""Ordering key for ORDER BY / MIN / MAX: class rank then value (NULLs first ascending)."""
r = class_rank(v)
return (r, 0 if v is None else v)
def tri(v):
"""Truth value of an expression as 1/0/None (three-valued)."""
if v is None:
return None
if isinstance(v, bool):
return 1 if v else 0
if isinstance(v, (int, float)):
return 1 if v != 0 else 0
if isinstance(v, str):
n = apply_numeric_affinity(v)
if isinstance(n, (int, float)) and not isinstance(n, bool):
return 1 if n != 0 else 0
return 0
raise SqlError("cannot test truth of %r" % (type(v),))
def truthy(v):
return tri(v) == 1
def arith_div(a, b):
"""SQLite `/`: integer when both are integers, truncating toward zero; x/0 -> NULL."""
if a is None or b is None:
return None
a, b = numeric_prefix(a), numeric_prefix(b)
if b == 0:
return None
if isinstance(a, int) and isinstance(b, int):
q = abs(a) // abs(b)
return -q if (a < 0) != (b < 0) else q
return a / b
def arith(op, a, b):
if a is None or b is None:
return None
a, b = numeric_prefix(a), numeric_prefix(b)
if op == "/":
return arith_div(a, b)
if op == "+":
return a + b
if op == "-":
return a - b
if op == "*":
return a * b
raise SqlError("unknown operator %r" % op)
def agg_numeric(v):
"""Aggregate coercion: numbers pass, text becomes numeric only if the WHOLE string is
numeric; otherwise it counts as REAL 0.0. Measured: `SUM(s)` over {'10', NULL, 'abc'}
is 10.0 REAL (so 'abc' contributed 0.0 and made the total real), not 10 INTEGER."""
if isinstance(v, str):
n = apply_numeric_affinity(v)
return n if isinstance(n, (int, float)) else 0.0
return v
def agg_count(values, n_rows):
"""COUNT(expr): number of non-NULL values."""
return sum(1 for v in values if v is not None)
def agg_sum(values):
"""SUM: NULL when there is no non-NULL value; INTEGER when every value is an integer."""
vals = [agg_numeric(v) for v in values if v is not None]
if not vals:
return None
if all(isinstance(v, int) for v in vals):
return sum(vals)
return float(sum(vals))
def agg_avg(values):
vals = [agg_numeric(v) for v in values if v is not None]
if not vals:
return None
return float(sum(vals)) / len(vals)
def agg_minmax(values, want_max):
vals = [v for v in values if v is not None]
if not vals:
return None
best = vals[0]
for v in vals[1:]:
c = compare_values(v, best)
if (c > 0) if want_max else (c < 0):
best = v
return best
_LIKE_CACHE = {}
def like_match(value, pattern):
"""SQLite LIKE: ASCII-case-insensitive, `%` = any run, `_` = exactly one character."""
if value is None or pattern is None:
return None
if not isinstance(value, str):
value = str(value)
rx = _LIKE_CACHE.get(pattern)
if rx is None:
out = ["(?s)\\A"]
for ch in pattern:
if ch == "%":
out.append(".*")
elif ch == "_":
out.append(".")
else:
out.append(re.escape(ch))
out.append("\\Z")
rx = re.compile("".join(out), re.IGNORECASE | re.ASCII)
_LIKE_CACHE[pattern] = rx
return 1 if rx.match(value) else 0
def distinct_key(row):
"""Equality for DISTINCT / GROUP BY: numbers unify (5 == 5.0), text and NULL by identity."""
key = []
for v in row:
if v is None:
key.append((0, None))
elif isinstance(v, (int, float)) and not isinstance(v, bool):
key.append((1, float(v)))
elif isinstance(v, str):
key.append((2, v))
else:
raise SqlError("unsupported value in DISTINCT: %r" % (type(v),))
return tuple(key)
def join_relation(left, right, kind, on_eval):
"""Nested-loop join. `kind` is 'inner' or 'left'; unmatched left rows get NULL right side."""
out = []
n_right_cols = len(right.cols)
for lrow in left.rows:
matched = False
for rrow in right.rows:
combined = lrow + rrow
if truthy(on_eval(combined)):
matched = True
out.append(combined)
if kind == "left" and not matched:
out.append(lrow + [None] * n_right_cols)
return Relation(left.cols + right.cols, out)
# --------------------------------------------------------------------------
# relations / evaluation
# --------------------------------------------------------------------------
@dataclass
class Table:
cols: list # [(name, declared_type)]
rows: list # [[value, ...], ...]
@dataclass
class Relation:
cols: list # [(table_alias, column_name), ...]
rows: list # [] is used for the index-only scaffold built before a join
index: Optional[dict] = None # name -> [idx]; set by _build_index
@dataclass
class _Ctx:
rel: Relation
values: list
group: Optional[list] = None # list of value-lists when in an aggregate context
index: Optional[dict] = None # name -> [idx] built once per relation
def _build_index(rel):
idx = {}
for i, (tbl, col) in enumerate(rel.cols):
idx.setdefault(col, []).append(i)
idx.setdefault(tbl + "." + col, [i])
return idx
def _resolve(ctx, tbl, col):
key = col if tbl is None else tbl + "." + col
hits = ctx.index.get(key)
if not hits:
raise SqlError("no such column: %s" % key)
if len(hits) > 1:
raise SqlError("ambiguous column: %s" % key)
return hits[0]
def affinity_of(node):
"""Only a bare column reference carries affinity in this engine."""
if node[0] == "col":
return node[3] if len(node) > 3 else None
return None
def has_aggregate(node):
k = node[0]
if k == "agg":
return True
for sub in node[1:]:
if isinstance(sub, tuple) and sub and isinstance(sub[0], str):
if has_aggregate(sub):
return True
elif isinstance(sub, list):
for x in sub:
if isinstance(x, tuple) and x and isinstance(x[0], str) and has_aggregate(x):
return True
if isinstance(x, tuple) and x and isinstance(x[0], tuple):
for y in x:
if isinstance(y, tuple) and y and isinstance(y[0], str) and has_aggregate(y):
return True
return False
def eval_expr(node, ctx):
k = node[0]
if k == "lit":
return node[1]
if k == "col":
tbl, col = node[1], node[2]
i = _resolve(ctx, tbl, col)
return ctx.values[i]
if k == "star":
raise SqlError("* is only valid in the select list")
if k == "unary":
v = eval_expr(node[2], ctx)
if v is None:
return None
if node[1] == "+":
return v
if isinstance(v, str):
raise SqlError("unary - on text is out of scope")
return -v
if k == "binop":
return arith(node[1], eval_expr(node[2], ctx), eval_expr(node[3], ctx))
if k == "cmp":
return _eval_cmp(node, ctx)
if k == "isnull":
v = eval_expr(node[1], ctx)
return 1 if ((v is None) != bool(node[2])) else 0
if k == "in":
return _eval_in(node, ctx)
if k == "between":
lo = eval_expr(node[2], ctx)
hi = eval_expr(node[3], ctx)
aff = affinity_of(node[1])
ge = _cmp_result(eval_expr(node[1], ctx), lo, aff, affinity_of(node[2]), ">=")
le = _cmp_result(eval_expr(node[1], ctx), hi, aff, affinity_of(node[3]), "<=")
r = _tri_and(ge, le)
return _tri_not(r) if node[4] else r
if k == "like":
r = like_match(eval_expr(node[1], ctx), eval_expr(node[2], ctx))
return _tri_not(r) if node[3] else r
if k == "and":
return _tri_and(eval_expr(node[1], ctx), eval_expr(node[2], ctx))
if k == "or":
return _tri_or(eval_expr(node[1], ctx), eval_expr(node[2], ctx))
if k == "not":
return _tri_not(eval_expr(node[1], ctx))
if k == "case":
for cond, val in node[1]:
if truthy(eval_expr(cond, ctx)):
return eval_expr(val, ctx)
return None if node[2] is None else eval_expr(node[2], ctx)
if k == "agg":
return _eval_agg(node, ctx)
raise SqlError("cannot evaluate node %r" % (k,))
def _cmp_result(a, b, aff_a, aff_b, op):
c = compare_values(a, b, aff_a, aff_b)
if c is None:
return None
if op == "=":
return 1 if c == 0 else 0
if op in ("<>", "!="):
return 1 if c != 0 else 0
if op == "<":
return 1 if c < 0 else 0
if op == "<=":
return 1 if c <= 0 else 0
if op == ">":
return 1 if c > 0 else 0
if op == ">=":
return 1 if c >= 0 else 0
raise SqlError("unknown comparison %r" % op)
def _eval_cmp(node, ctx):
return _cmp_result(
eval_expr(node[2], ctx), eval_expr(node[3], ctx),
affinity_of(node[2]), affinity_of(node[3]), node[1],
)
def _eval_in(node, ctx):
"""SQLite IN: a match is TRUE; otherwise NULL if any list element is NULL; else FALSE.
`NOT IN` is the three-valued negation of that, so `3 NOT IN (1,2,NULL)` is NULL."""
target = eval_expr(node[1], ctx)
aff = affinity_of(node[1])
saw_null = False
for item in node[2]:
v = eval_expr(item, ctx)
c = compare_values(target, v, aff, affinity_of(item))
if c is None:
saw_null = True
elif c == 0:
return 0 if node[3] else 1
r = None if saw_null else 0
return _tri_not(r) if node[3] else r
def _tri_and(a, b):
ta, tb = tri(a), tri(b)
if ta == 0 or tb == 0:
return 0
if ta is None or tb is None:
return None
return 1
def _tri_or(a, b):
ta, tb = tri(a), tri(b)
if ta == 1 or tb == 1:
return 1
if ta is None or tb is None:
return None
return 0
def _tri_not(a):
ta = tri(a)
return None if ta is None else 1 - ta
def _eval_agg(node, ctx):
fname, arg = node[1], node[2]
if ctx.group is None:
raise SqlError("aggregate %s outside an aggregate context" % fname)
if fname == "COUNT" and arg is None:
return len(ctx.group)
values = []
for rv in ctx.group:
values.append(eval_expr(arg, replace(ctx, values=rv, group=None)))
if fname == "COUNT":
return agg_count(values, len(ctx.group))
if fname == "SUM":
return agg_sum(values)
if fname == "AVG":
return agg_avg(values)
if fname == "MIN":
return agg_minmax(values, False)
if fname == "MAX":
return agg_minmax(values, True)
raise SqlError("unknown aggregate %s" % fname)
# --------------------------------------------------------------------------
# query execution
# --------------------------------------------------------------------------
def _build_from(q, tables):
tname, alias = q["from"]
if tname is None:
return Relation([], [[]]) # SELECT without FROM: exactly one empty row
if tname not in tables:
raise SqlError("no such table: %s" % tname)
decl = {c: ty for t in tables.values() for c, ty in t.cols}
t = tables[tname]
rel = Relation([(alias, c) for c, _ in t.cols], [list(r) for r in t.rows])
rel.index = _build_index(rel)
for kind, jtname, jalias, on in q["joins"]:
if jtname not in tables:
raise SqlError("no such table: %s" % jtname)
jt = tables[jtname]
right = Relation([(jalias, c) for c, _ in jt.cols], [list(r) for r in jt.rows])
right.index = _build_index(right)
combined_cols = rel.cols + right.cols
combined_index = _build_index(Relation(combined_cols, []))
# the ON clause gets the declared affinities too: `text_col <> 20` means `<> '20'`
col_aff = {i: decl.get(c) for i, (_a, c) in enumerate(combined_cols)}
def on_eval(combined, _on=on, _cols=combined_cols, _idx=combined_index, _aff=col_aff):
ctx = _Ctx(Relation(_cols, []), combined, None, _idx)
return eval_expr(_tag(_on, ctx, _aff), ctx)
rel = join_relation(rel, right, kind, on_eval)
rel.index = _build_index(rel)
return rel
def execute(sql_or_query, tables):
"""Run a query against {name: Table} and return (column_names, rows)."""
q = sql_or_query if isinstance(sql_or_query, dict) else parse_query(sql_or_query)
rel = _build_from(q, tables)
# affinity lookup for column refs (position -> declared type)
decl = {}
for t in tables.values():
for cname, ctype in t.cols:
decl[cname] = ctype
col_aff = {i: decl.get(c) for i, (_a, c) in enumerate(rel.cols)}
rows = rel.rows
if q["where"] is not None:
kept = []
for rv in rows:
ctx = _Ctx(rel, rv, None, rel.index)
if truthy(_eval_with_affinity(q["where"], ctx, col_aff)):
kept.append(rv)
rows = kept
agg_context = bool(q["group_by"]) \
or any(it[0] == "item" and has_aggregate(it[1]) for it in q["items"]) \
or (q["having"] is not None and has_aggregate(q["having"])) \
or any(has_aggregate(e) for e, _ in q["order_by"])
out_names, out_exprs = _expand_items(q["items"], rel)
# SQLite resolves a select-list alias inside GROUP BY / HAVING / ORDER BY; substitute it
# with the aliased expression so `HAVING n > 1` means `HAVING COUNT(*) > 1`.
alias_expr = {}
for (nm, _e), e in zip(zip(out_names, out_exprs), out_exprs):
if nm:
alias_expr.setdefault(nm, e)
def sub_alias(node):
if node[0] == "col" and node[1] is None and node[2] in alias_expr:
return alias_expr[node[2]]
out = [node[0]]
for s in node[1:]:
if isinstance(s, tuple) and s and isinstance(s[0], str):
out.append(sub_alias(s))
elif isinstance(s, list):
out.append([sub_alias(x) if (isinstance(x, tuple) and x and isinstance(x[0], str))
else x for x in s])
else:
out.append(s)
return tuple(out)
group_by = [sub_alias(e) for e in q["group_by"]]
having = sub_alias(q["having"]) if q["having"] is not None else None
produced = [] # (values, ctx)
if agg_context:
if group_by:
groups = []
seen = {}
for rv in rows:
ctx = _Ctx(rel, rv, None, rel.index)
key = distinct_key([_eval_with_affinity(e, ctx, col_aff) for e in group_by])
if key in seen:
groups[seen[key]][1].append(rv)
else:
seen[key] = len(groups)
groups.append((rv, [rv]))
else:
# a bare aggregate over zero rows still yields exactly one row (COUNT 0 / SUM NULL)
groups = [(rows[0] if rows else [], rows)]
for first, grp in groups:
ctx = _Ctx(rel, first, grp, rel.index)
if having is not None and not truthy(_eval_with_affinity(having, ctx, col_aff)):
continue
vals = [_eval_with_affinity(e, ctx, col_aff) for e in out_exprs]
produced.append((vals, ctx))
else:
for rv in rows:
ctx = _Ctx(rel, rv, None, rel.index)
vals = [_eval_with_affinity(e, ctx, col_aff) for e in out_exprs]
produced.append((vals, ctx))
if q["distinct"]:
seen, uniq = set(), []
for vals, ctx in produced:
k = distinct_key(vals)
if k not in seen:
seen.add(k)
uniq.append((vals, ctx))
produced = uniq
if q["order_by"]:
alias_pos = {}
for i, (nm, _e) in enumerate(zip(out_names, out_exprs)):
if nm:
alias_pos.setdefault(nm, i)
keys = []
for vals, ctx in produced:
row_key = []
for e, desc in q["order_by"]:
e = sub_alias(e)
if e[0] == "col" and e[1] is None and e[2] in alias_pos:
v = vals[alias_pos[e[2]]]
else:
v = _eval_with_affinity(e, ctx, col_aff)
row_key.append((sort_key(v), desc))
keys.append(row_key)
order = sorted(range(len(produced)),
key=lambda i: _order_tuple(keys[i]))
produced = [produced[i] for i in order]
if q["limit"] is not None:
start = q["offset"] or 0
produced = produced[start:start + q["limit"]] if q["limit"] >= 0 else produced[start:]
elif q["offset"]:
produced = produced[q["offset"]:]
return out_names, [v for v, _ in produced]
class _Desc:
__slots__ = ("v",)
def __init__(self, v):
self.v = v
def __lt__(self, other):
return other.v < self.v
def __eq__(self, other):
return other.v == self.v
def _order_tuple(row_key):
return tuple(_Desc(k) if desc else k for k, desc in row_key)
def _eval_with_affinity(node, ctx, col_aff):
return eval_expr(_tag(node, ctx, col_aff), ctx)
def _tag(node, ctx, col_aff):
"""Recursively attach declared affinities to bare column refs (see affinity_of)."""
k = node[0]
if k == "col":
i = _resolve(ctx, node[1], node[2])
return ("col", node[1], node[2], col_aff.get(i))
if k == "agg":
return node
out = [k]
for sub in node[1:]:
if isinstance(sub, tuple) and sub and isinstance(sub[0], str):
out.append(_tag(sub, ctx, col_aff))
elif isinstance(sub, list):
new = []
for x in sub:
if isinstance(x, tuple) and x and isinstance(x[0], str):
new.append(_tag(x, ctx, col_aff))
elif isinstance(x, tuple) and x and isinstance(x[0], tuple):
new.append(tuple(_tag(y, ctx, col_aff) if isinstance(y, tuple)
else y for y in x))
else:
new.append(x)
out.append(new)
else:
out.append(sub)
return tuple(out)
def _expand_items(items, rel):
names, exprs = [], []
for it in items:
if it[0] == "star":
tbl = it[1]
for (_alias, col) in rel.cols:
if tbl is None or _alias == tbl:
names.append(col)
exprs.append(("col", _alias, col))
continue
e, alias = it[1], it[2]
if alias:
names.append(alias)
elif e[0] == "col":
names.append(e[2])
else:
names.append(None)
exprs.append(e)
return names, exprs
def run(sql, tables):
"""Convenience: execute SQL text against {name: Table}."""
return execute(sql, tables)
def table_from(cols, rows):
return Table([(c, t) for c, t in cols], [list(r) for r in rows])
# --------------------------------------------------------------------------
# selftest -- the differential harness in diff_sqlite.py is the real teeth
# --------------------------------------------------------------------------
_SELFTEST = [
("SELECT 1 + 2 * 3", [], [[7]]),
("SELECT 7 / 2", [], [[3]]),
("SELECT -7 / 2", [], [[-3]]),
("SELECT 7.0 / 2", [], [[3.5]]),
("SELECT 1 / 0", [], [[None]]),
("SELECT NULL = NULL", [], [[None]]),
("SELECT 1 IS NULL", [], [[0]]),
("SELECT NULL IS NULL", [], [[1]]),
("SELECT 'a' < 5", [], [[0]]),
("SELECT 5 < 'a'", [], [[1]]),
("SELECT '5abc' + 0", [], [[5]]),
("SELECT 'a' + 0", [], [[0]]),
("SELECT '5.5' + 0", [], [[5.5]]),
("SELECT 'abc' LIKE 'A%'", [], [[1]]),
("SELECT 'abc' LIKE 'a_c'", [], [[1]]),
("SELECT 'abc' NOT LIKE 'b%'", [], [[1]]),
("SELECT 2 IN (1, 2, NULL)", [], [[1]]),
("SELECT 3 IN (1, 2, NULL)", [], [[None]]),
("SELECT 3 NOT IN (1, 2, NULL)", [], [[None]]),
("SELECT 3 NOT IN (1, 2)", [], [[1]]),
("SELECT 2 BETWEEN 1 AND 3", [], [[1]]),
("SELECT CASE WHEN 1 = 2 THEN 'a' ELSE 'b' END", [], [['b']]),
]
def _selftest():
tables = {
"t": table_from([("id", "INTEGER"), ("s", "TEXT")], [[1, "a"], [2, None], [3, "c"]]),
}
fails = 0
for sql, _t, want in _SELFTEST:
got = execute(sql, tables)[1]
if got != want:
fails += 1
print("SELFTEST FAIL: %s -> %r (want %r)" % (sql, got, want))
extra = [
("SELECT COUNT(*), COUNT(s), SUM(id) FROM t", [[3, 2, 6]]),
("SELECT SUM(s) FROM t", [[0.0]]), # text coerces to REAL 0.0
("SELECT SUM(id) FROM t WHERE id > 99", [[None]]),
("SELECT * FROM t ORDER BY s", [[2, None], [1, "a"], [3, "c"]]),
("SELECT s, COUNT(*) AS n FROM t GROUP BY s ORDER BY n, s",
[[None, 1], ["a", 1], ["c", 1]]),
("SELECT s, COUNT(*) AS n FROM t GROUP BY s HAVING n > 1", []),
("SELECT id FROM t WHERE s IS NULL", [[2]]),
("SELECT id FROM t ORDER BY id DESC LIMIT 2", [[3], [2]]),
("SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1", [[2], [3]]),
("SELECT id FROM t ORDER BY id LIMIT -1 OFFSET 1", [[2], [3]]),
]
for sql, want in extra:
try:
got = execute(sql, tables)[1]
except SqlError as exc:
got = "SqlError: %s" % exc
if got != want:
fails += 1
print("SELFTEST FAIL: %s -> %r (want %r)" % (sql, got, want))
print("selftest: %d/%d" % (len(_SELFTEST) + len(extra) - fails, len(_SELFTEST) + len(extra)))
return 0 if fails == 0 else 1
if __name__ == "__main__":
if "--selftest" in sys.argv:
sys.exit(_selftest())
print(__doc__.strip().splitlines()[1])
print("usage: minisql.py --selftest (the differential battery is diff_sqlite.py)")