種類, …]。 */ final class Parser { // 別名(AS無し)や名前に使えない言葉 private const RESERVED = [ 'ADD', 'ALL', 'ALTER', 'AND', 'AS', 'BETWEEN', 'BY', 'CASE', 'CHECK', 'COLLATE', 'COMMIT', 'CONSTRAINT', 'CREATE', 'CROSS', 'DEFAULT', 'DELETE', 'DISTINCT', 'DROP', 'ELSE', 'END', 'ESCAPE', 'EXCEPT', 'EXISTS', 'FOREIGN', 'FROM', 'FULL', 'GLOB', 'GROUP', 'HAVING', 'IN', 'INDEX', 'INNER', 'INSERT', 'INTERSECT', 'INTO', 'IS', 'ISNULL', 'JOIN', 'LEFT', 'LIKE', 'LIMIT', 'NATURAL', 'NOT', 'NOTNULL', 'NULL', 'OFFSET', 'ON', 'OR', 'ORDER', 'OUTER', 'PRIMARY', 'REFERENCES', 'RETURNING', 'RIGHT', 'SELECT', 'SET', 'TABLE', 'THEN', 'TO', 'UNION', 'UNIQUE', 'UPDATE', 'USING', 'VALUES', 'WHEN', 'WHERE', 'WINDOW', 'WITH', 'REGEXP', 'MATCH', ]; private array $tok = []; private int $p = 0; private string $sql = ''; private int $nParam = 0; /** 名前付きのパラメータ(「:name」等) => 番号 */ private array $names = []; private int $lastNameStart = 0; /** * SQLを文に分ける * * @param string $sql * @return array 文の構文木の配列(各文の'sql'に元の文字列) * @throws DbException */ public function parseAll(string $sql): array { $this->sql = $sql; $this->tok = $this->lex($sql); $this->p = 0; $out = []; while (true) { while ($this->isOp(';')) $this->p++; if ($this->cur()['t'] === 'eof') break; $this->nParam = 0; $start = $this->cur()['s']; $this->names = []; $st = $this->statement(); $end = $this->tok[$this->p - 1]['e']; $st['sql'] = substr($sql, $start, $end - $start); $st['start'] = $start; $st['nParam'] = $this->nParam; $st['names'] = $this->names; $out[] = $st; if (!$this->isOp(';') && $this->cur()['t'] !== 'eof') $this->error(); } return $out; } /** 式だけ(DEFAULT・CHECK等の文字列から) */ public function parseExpr(string $sql): array { $this->sql = $sql; $this->tok = $this->lex($sql); $this->p = 0; $e = $this->expr(); if ($this->cur()['t'] !== 'eof') $this->error(); return $e; } /////////////// // 字句解析 private function lex(string $s): array { $out = []; $len = strlen($s); $i = 0; while ($i < $len) { $c = $s[$i]; if (ctype_space($c)) { $i++; continue; } if ($c === '-' && ($s[$i + 1] ?? '') === '-') { $j = strpos($s, "\n", $i); $i = $j === false ? $len : $j + 1; continue; } if ($c === '/' && ($s[$i + 1] ?? '') === '*') { $j = strpos($s, '*/', $i + 2); $i = $j === false ? $len : $j + 2; continue; } $st = $i; if ($c === "'") { $v = ''; $i++; while (true) { $j = strpos($s, "'", $i); if ($j === false) throw new DbException('閉じられていない文字列', 1, 'HY000'); $v .= substr($s, $i, $j - $i); if (($s[$j + 1] ?? '') === "'") { $v .= "'"; $i = $j + 2; continue; } $i = $j + 1; break; } $out[] = ['t' => 'str', 'v' => $v, 's' => $st, 'e' => $i]; continue; } if ($c === '"' || $c === '`' || $c === '[') { $close = $c === '[' ? ']' : $c; $v = ''; $i++; while (true) { $j = strpos($s, $close, $i); if ($j === false) throw new DbException('閉じられていない識別子', 1, 'HY000'); $v .= substr($s, $i, $j - $i); if ($close !== ']' && ($s[$j + 1] ?? '') === $close) { $v .= $close; $i = $j + 2; continue; } $i = $j + 1; break; } $out[] = ['t' => 'id', 'v' => $v, 'u' => '', 'q' => true, 's' => $st, 'e' => $i]; continue; } if (($c === 'x' || $c === 'X') && ($s[$i + 1] ?? '') === "'") { $j = strpos($s, "'", $i + 2); if ($j === false) throw new DbException('閉じられていないBLOB', 1, 'HY000'); $hex = substr($s, $i + 2, $j - $i - 2); if (strlen($hex) % 2 || !ctype_xdigit($hex) && $hex !== '') throw new DbException("不正なBLOB:{$hex}", 1, 'HY000'); $i = $j + 1; $out[] = ['t' => 'blob', 'v' => (string)hex2bin($hex), 's' => $st, 'e' => $i]; continue; } if (ctype_digit($c) || ($c === '.' && ctype_digit($s[$i + 1] ?? ''))) { if ($c === '0' && ($s[$i + 1] ?? '') !== '' && strtolower($s[$i + 1]) === 'x' && preg_match('/\G0[xX]([0-9a-fA-F]+)/', $s, $m, 0, $i)) { $i += strlen($m[0]); $hex = str_pad($m[1], 16, '0', STR_PAD_LEFT); if (strlen($hex) > 16) throw new DbException("16進数が大き過ぎます:{$m[0]}", 1, 'HY000'); $out[] = ['t' => 'num', 'v' => unpack('J', hex2bin($hex))[1], 's' => $st, 'e' => $i]; continue; } preg_match('/\G(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/', $s, $m, 0, $i); $i += strlen($m[0]); if (preg_match('/\G[A-Za-z_]/', $s, $mm, 0, $i)) throw new DbException("認識できないトークン:{$m[0]}".substr($s, $i, 1), 1, 'HY000'); $txt = $m[0]; if (ctype_digit($txt)) { $t2 = ltrim($txt, '0'); $v = $t2 === '' ? 0 : ((string)(int)$t2 === $t2 ? (int)$t2 : (float)$txt); } else { $v = (float)$txt; } $out[] = ['t' => 'num', 'v' => $v, 's' => $st, 'e' => $i]; continue; } if (ctype_alpha($c) || $c === '_' || ord($c) >= 0x80) { preg_match('/\G[A-Za-z0-9_$\x80-\xFF]+/', $s, $m, 0, $i); $i += strlen($m[0]); $out[] = ['t' => 'id', 'v' => $m[0], 'u' => strtoupper($m[0]), 'q' => false, 's' => $st, 'e' => $i]; continue; } if ($c === '?') { preg_match('/\G\?(\d*)/', $s, $m, 0, $i); $i += strlen($m[0]); $out[] = ['t' => 'param', 'v' => $m[1] === '' ? null : (int)$m[1], 's' => $st, 'e' => $i]; continue; } if (($c === ':' || $c === '@' || $c === '$') && preg_match('/\G[:@$][A-Za-z0-9_\x80-\xFF]+/', $s, $m, 0, $i)) { $i += strlen($m[0]); $out[] = ['t' => 'param', 'v' => $m[0], 's' => $st, 'e' => $i]; continue; } foreach (['->>', '||', '<<', '>>', '<=', '>=', '==', '!=', '<>', '->'] as $op) { if (substr($s, $i, strlen($op)) === $op) { $i += strlen($op); $out[] = ['t' => 'op', 'v' => $op, 's' => $st, 'e' => $i]; continue 2; } } if (str_contains('(),;.=<>+-*/%&|~', $c)) { $i++; $out[] = ['t' => 'op', 'v' => $c, 's' => $st, 'e' => $i]; continue; } throw new DbException("認識できない文字:{$c}", 1, 'HY000'); } $out[] = ['t' => 'eof', 'v' => '', 's' => $len, 'e' => $len]; return $out; } /////////////// // 補助 private function cur(): array { return $this->tok[$this->p]; } private function peek(int $n = 1): array { return $this->tok[min($this->p + $n, count($this->tok) - 1)]; } private function isKw(string ...$kw): bool { $t = $this->tok[$this->p]; return $t['t'] === 'id' && !$t['q'] && in_array($t['u'], $kw, true); } private function acceptKw(string ...$kw): ?string { if (!$this->isKw(...$kw)) return null; return $this->tok[$this->p++]['u']; } private function expectKw(string $kw): void { if (!$this->acceptKw($kw)) $this->error($kw); } private function isOp(string $op): bool { $t = $this->tok[$this->p]; return $t['t'] === 'op' && $t['v'] === $op; } private function acceptOp(string $op): bool { if (!$this->isOp($op)) return false; $this->p++; return true; } private function expectOp(string $op): void { if (!$this->acceptOp($op)) $this->error($op); } /** @return never */ private function error(string $expected = ''): never { $t = $this->cur(); $near = $t['t'] === 'eof' ? '最後' : '「'.substr($this->sql, $t['s'], max(1, min(30, $t['e'] - $t['s']))).'」'; throw new DbException("SQLの構文エラー:{$near}の近く".($expected !== '' ? "({$expected}が必要)" : ''), 1, '42000'); } /** 名前(識別子、予約語以外の言葉、文字列も可) */ private function name(bool $allowReserved = false): string { $t = $this->cur(); if ($t['t'] === 'id' && ($t['q'] || $allowReserved || !in_array($t['u'], self::RESERVED, true))) { $this->p++; return $t['v']; } if ($t['t'] === 'str') { $this->p++; return $t['v']; } $this->error('名前'); } private function isName(): bool { $t = $this->cur(); return ($t['t'] === 'id' && ($t['q'] || !in_array($t['u'], self::RESERVED, true))) || $t['t'] === 'str'; } /** 「schema.name」(schemaはmain・tempのみ) */ private function qualifiedName(): string { $this->lastNameStart = $this->cur()['s']; $n = $this->name(); if ($this->isOp('.') && $this->peek()['t'] === 'id') { if (!in_array(strtolower($n), ['main', 'temp'], true)) throw new DbException("不明なデータベース:{$n}", 1, 'HY000'); $this->p++; $this->lastNameStart = $this->cur()['s']; $n = $this->name(); } return $n; } /** 字句解析だけ(スキーマのSQLの書き換え用) */ public function tokens(string $sql): array { return $this->lex($sql); } private function ifNotExists(): bool { if ($this->isKw('IF') && $this->peek()['t'] === 'id' && $this->peek()['u'] === 'NOT') { $this->p += 2; $this->expectKw('EXISTS'); return true; } return false; } private function ifExists(): bool { if ($this->isKw('IF') && $this->peek()['t'] === 'id' && $this->peek()['u'] === 'EXISTS') { $this->p += 2; return true; } return false; } /////////////// // 文 private function statement(): array { if ($this->acceptKw('EXPLAIN')) throw new DbException('EXPLAINは未対応です', 1, 'HY000'); if ($this->isKw('SELECT', 'VALUES', 'WITH')) { $with = $this->isKw('WITH') ? $this->with() : []; if ($this->isKw('INSERT', 'REPLACE')) return $this->insert() + ['with' => $with]; if ($this->isKw('UPDATE')) return $this->update() + ['with' => $with]; if ($this->isKw('DELETE')) return $this->delete() + ['with' => $with]; $s = $this->select(false); $s['with'] = array_merge($with, $s['with']); return $s; } if ($this->isOp('(')) return $this->select(); if ($this->isKw('INSERT', 'REPLACE')) return $this->insert(); if ($this->isKw('UPDATE')) return $this->update(); if ($this->isKw('DELETE')) return $this->delete(); if ($this->acceptKw('CREATE')) return $this->create(); if ($this->acceptKw('DROP')) { $kind = $this->acceptKw('TABLE', 'INDEX', 'VIEW', 'TRIGGER') ?? $this->error('TABLE'); $if = $this->ifExists(); return ['t' => 'drop', 'kind' => strtolower($kind), 'name' => $this->qualifiedName(), 'ifExists' => $if]; } if ($this->acceptKw('ALTER')) return $this->alter(); if ($this->acceptKw('BEGIN')) { $mode = $this->acceptKw('DEFERRED', 'IMMEDIATE', 'EXCLUSIVE') ?? 'DEFERRED'; if ($this->acceptKw('TRANSACTION') && $this->isName()) $this->name(); return ['t' => 'begin', 'mode' => $mode]; } if ($this->acceptKw('COMMIT', 'END')) { $this->acceptKw('TRANSACTION'); return ['t' => 'commit']; } if ($this->acceptKw('ROLLBACK')) { $this->acceptKw('TRANSACTION'); if ($this->acceptKw('TO')) { $this->acceptKw('SAVEPOINT'); return ['t' => 'rollbackTo', 'name' => $this->name()]; } return ['t' => 'rollback']; } if ($this->acceptKw('SAVEPOINT')) return ['t' => 'savepoint', 'name' => $this->name()]; if ($this->acceptKw('RELEASE')) { $this->acceptKw('SAVEPOINT'); return ['t' => 'release', 'name' => $this->name()]; } if ($this->acceptKw('PRAGMA')) return $this->pragma(); if ($this->acceptKw('VACUUM', 'ANALYZE', 'REINDEX')) { while (!$this->isOp(';') && $this->cur()['t'] !== 'eof') $this->p++; return ['t' => 'noop']; } if ($this->isKw('ATTACH', 'DETACH')) throw new DbException('ATTACH・DETACHは未対応です', 1, 'HY000'); $this->error(); } private function pragma(): array { $name = strtolower($this->qualifiedName()); $arg = null; if ($this->acceptOp('=')) { $arg = $this->pragmaValue(); } else if ($this->acceptOp('(')) { $arg = $this->pragmaValue(); $this->expectOp(')'); } return ['t' => 'pragma', 'name' => $name, 'arg' => $arg]; } private function pragmaValue(): mixed { $t = $this->cur(); $neg = $this->acceptOp('-'); $t = $this->cur(); $this->p++; if ($t['t'] === 'num') return $neg ? -$t['v'] : $t['v']; if ($t['t'] === 'str' || $t['t'] === 'id') return $t['v']; $this->p--; $this->error('値'); } /** WITH名前(列) AS (SELECT…), … */ private function with(): array { $this->expectKw('WITH'); if ($this->acceptKw('RECURSIVE')) $recursive = true; $out = []; do { $name = $this->name(); $cols = null; if ($this->acceptOp('(')) { $cols = []; do $cols[] = $this->name(); while ($this->acceptOp(',')); $this->expectOp(')'); } $this->expectKw('AS'); if ($this->acceptKw('NOT')) $this->expectKw('MATERIALIZED'); else $this->acceptKw('MATERIALIZED'); $this->expectOp('('); $sel = $this->select(); $this->expectOp(')'); $out[strtolower($name)] = ['sel' => $sel, 'cols' => $cols, 'name' => $name, 'recursive' => !empty($recursive)]; } while ($this->acceptOp(',')); return $out; } /** * SELECT(複合も) * * @return array t、with、cores、ops、order、limit、offset */ private function select(bool $allowWith = true): array { $with = $allowWith && $this->isKw('WITH') ? $this->with() : []; $cores = [$this->selectCore()]; $ops = []; while ($op = $this->acceptKw('UNION', 'INTERSECT', 'EXCEPT')) { if ($op === 'UNION' && $this->acceptKw('ALL')) $op = 'UNION ALL'; $ops[] = $op; $cores[] = $this->selectCore(); } $order = []; if ($this->acceptKw('ORDER')) { $this->expectKw('BY'); $order = $this->orderList(); } $limit = $offset = null; if ($this->acceptKw('LIMIT')) { $limit = $this->expr(); if ($this->acceptKw('OFFSET')) { $offset = $this->expr(); } else if ($this->acceptOp(',')) { $offset = $limit; $limit = $this->expr(); } } return ['t' => 'select', 'with' => $with, 'cores' => $cores, 'ops' => $ops, 'order' => $order, 'limit' => $limit, 'offset' => $offset]; } private function orderList(): array { $out = []; do { $e = $this->expr(); $desc = $this->acceptKw('DESC') !== null; if (!$desc) $this->acceptKw('ASC'); $nulls = null; if ($this->acceptKw('NULLS')) $nulls = $this->acceptKw('FIRST', 'LAST') ?? $this->error('FIRST'); $out[] = ['e' => $e, 'desc' => $desc, 'nulls' => $nulls]; } while ($this->acceptOp(',')); return $out; } private function selectCore(): array { if ($this->acceptOp('(')) { // 括弧の中のSELECT(複合の一部) $s = $this->select(); $this->expectOp(')'); return ['sub' => $s]; } if ($this->acceptKw('VALUES')) { $rows = []; do { $this->expectOp('('); $row = []; do $row[] = $this->expr(); while ($this->acceptOp(',')); $this->expectOp(')'); $rows[] = $row; } while ($this->acceptOp(',')); return ['values' => $rows]; } $this->expectKw('SELECT'); $distinct = $this->acceptKw('DISTINCT') !== null; if (!$distinct) $this->acceptKw('ALL'); $cols = []; do { if ($this->acceptOp('*')) { $cols[] = ['star' => null]; continue; } if ($this->cur()['t'] === 'id' && $this->peek()['t'] === 'op' && $this->peek()['v'] === '.' && $this->peek(2)['t'] === 'op' && $this->peek(2)['v'] === '*') { $tbl = $this->cur()['v']; $this->p += 3; $cols[] = ['star' => $tbl]; continue; } $s = $this->cur()['s']; $e = $this->expr(); $text = substr($this->sql, $s, $this->tok[$this->p - 1]['e'] - $s); $as = null; if ($this->acceptKw('AS')) $as = $this->name(true); else if ($this->isName()) $as = $this->name(); $cols[] = ['e' => $e, 'as' => $as, 'text' => $text]; } while ($this->acceptOp(',')); $from = $this->acceptKw('FROM') ? $this->from() : []; $where = $this->acceptKw('WHERE') ? $this->expr() : null; $group = []; $having = null; if ($this->acceptKw('GROUP')) { $this->expectKw('BY'); do $group[] = $this->expr(); while ($this->acceptOp(',')); } if ($this->acceptKw('HAVING')) $having = $this->expr(); if ($this->isKw('WINDOW')) throw new DbException('ウィンドウ関数は未対応です', 1, 'HY000'); return ['distinct' => $distinct, 'cols' => $cols, 'from' => $from, 'where' => $where, 'group' => $group, 'having' => $having]; } /** FROM:テーブル・サブクエリと結合の配列 */ private function from(): array { $out = [$this->fromItem('inner')]; while (true) { if ($this->acceptOp(',')) { $out[] = $this->fromItem('inner'); continue; } $natural = $this->acceptKw('NATURAL') !== null; $kind = null; if ($this->acceptKw('LEFT')) { $this->acceptKw('OUTER'); $kind = 'left'; } else if ($this->acceptKw('INNER')) { $kind = 'inner'; } else if ($this->acceptKw('CROSS')) { $kind = 'inner'; } else if ($this->isKw('RIGHT', 'FULL')) { throw new DbException('RIGHT JOIN・FULL JOINは未対応です', 1, 'HY000'); } if ($kind === null && !$this->isKw('JOIN')) { if ($natural) $this->error('JOIN'); break; } $this->expectKw('JOIN'); $item = $this->fromItem($kind ?? 'inner'); $item['natural'] = $natural; if ($this->acceptKw('ON')) { $item['on'] = $this->expr(); } else if ($this->acceptKw('USING')) { $this->expectOp('('); $item['using'] = []; do $item['using'][] = $this->name(); while ($this->acceptOp(',')); $this->expectOp(')'); } $out[] = $item; } return $out; } private function fromItem(string $join): array { $item = ['join' => $join, 'on' => null, 'using' => null, 'natural' => false]; if ($this->acceptOp('(')) { if ($this->isKw('SELECT', 'VALUES', 'WITH')) { $item['sel'] = $this->select(); $this->expectOp(')'); } else { // (a JOIN b):サブクエリにする $from = $this->from(); $this->expectOp(')'); $item['sel'] = ['t' => 'select', 'with' => [], 'cores' => [['distinct' => false, 'cols' => [['star' => null]], 'from' => $from, 'where' => null, 'group' => [], 'having' => null]], 'ops' => [], 'order' => [], 'limit' => null, 'offset' => null]; } } else { $name = $this->qualifiedName(); if ($this->acceptOp('(')) throw new DbException("テーブル値関数は未対応です:{$name}", 1, 'HY000'); $item['table'] = $name; } $item['as'] = null; if ($this->acceptKw('AS')) $item['as'] = $this->name(); else if ($this->isName() && !$this->isKw('INDEXED')) $item['as'] = $this->name(); if ($this->acceptKw('INDEXED')) { $this->expectKw('BY'); $this->name(); } else if ($this->isKw('NOT') && $this->peek()['t'] === 'id' && $this->peek()['u'] === 'INDEXED') { $this->p += 2; } return $item; } private function conflictClause(): ?string { if ($this->acceptKw('OR')) return $this->acceptKw('ROLLBACK', 'ABORT', 'REPLACE', 'FAIL', 'IGNORE') ?? $this->error('REPLACE'); return null; } private function returning(): ?array { if (!$this->acceptKw('RETURNING')) return null; $cols = []; do { if ($this->acceptOp('*')) { $cols[] = ['star' => null]; continue; } $s = $this->cur()['s']; $e = $this->expr(); $text = substr($this->sql, $s, $this->tok[$this->p - 1]['e'] - $s); $as = null; if ($this->acceptKw('AS')) $as = $this->name(true); else if ($this->isName()) $as = $this->name(); $cols[] = ['e' => $e, 'as' => $as, 'text' => $text]; } while ($this->acceptOp(',')); return $cols; } private function insert(): array { $or = null; if ($this->acceptKw('REPLACE')) { $or = 'REPLACE'; } else { $this->expectKw('INSERT'); $or = $this->conflictClause(); } $this->expectKw('INTO'); $table = $this->qualifiedName(); $alias = null; if ($this->acceptKw('AS')) $alias = $this->name(); $cols = null; if ($this->acceptOp('(')) { $cols = []; do $cols[] = $this->name(); while ($this->acceptOp(',')); $this->expectOp(')'); } $sel = null; $default = false; if ($this->acceptKw('DEFAULT')) { $this->expectKw('VALUES'); $default = true; } else { $sel = $this->select(); } $upsert = []; while ($this->isKw('ON') && $this->peek()['t'] === 'id' && $this->peek()['u'] === 'CONFLICT') { $this->p += 2; $target = null; $twhere = null; if ($this->acceptOp('(')) { $target = []; do { $target[] = $this->name(); if ($this->acceptKw('COLLATE')) $this->name(); $this->acceptKw('ASC', 'DESC'); } while ($this->acceptOp(',')); $this->expectOp(')'); if ($this->acceptKw('WHERE')) $twhere = $this->expr(); } $this->expectKw('DO'); if ($this->acceptKw('NOTHING')) { $upsert[] = ['target' => $target, 'nothing' => true]; continue; } $this->expectKw('UPDATE'); $this->expectKw('SET'); $set = $this->setList(); $where = $this->acceptKw('WHERE') ? $this->expr() : null; $upsert[] = ['target' => $target, 'nothing' => false, 'set' => $set, 'where' => $where]; } return ['t' => 'insert', 'or' => $or, 'table' => $table, 'alias' => $alias, 'cols' => $cols, 'sel' => $sel, 'default' => $default, 'upsert' => $upsert, 'returning' => $this->returning(), 'with' => []]; } private function setList(): array { $set = []; do { if ($this->acceptOp('(')) { $names = []; do $names[] = $this->name(); while ($this->acceptOp(',')); $this->expectOp(')'); $this->expectOp('='); $this->expectOp('('); $vals = []; do $vals[] = $this->expr(); while ($this->acceptOp(',')); $this->expectOp(')'); if (count($names) !== count($vals)) throw new DbException('SETの列と値の数が違います', 1, 'HY000'); foreach ($names as $i => $n) $set[] = [$n, $vals[$i]]; continue; } $n = $this->name(); $this->expectOp('='); $set[] = [$n, $this->expr()]; } while ($this->acceptOp(',')); return $set; } private function update(): array { $this->expectKw('UPDATE'); $or = $this->conflictClause(); $table = $this->qualifiedName(); $alias = null; if ($this->acceptKw('AS')) $alias = $this->name(); else if ($this->isName() && !$this->isKw('SET', 'INDEXED')) $alias = $this->name(); if ($this->acceptKw('INDEXED')) { $this->expectKw('BY'); $this->name(); } $this->expectKw('SET'); $set = $this->setList(); $from = $this->acceptKw('FROM') ? $this->from() : []; $where = $this->acceptKw('WHERE') ? $this->expr() : null; return ['t' => 'update', 'or' => $or, 'table' => $table, 'alias' => $alias, 'set' => $set, 'from' => $from, 'where' => $where, 'returning' => $this->returning(), 'with' => []]; } private function delete(): array { $this->expectKw('DELETE'); $this->expectKw('FROM'); $table = $this->qualifiedName(); $alias = null; if ($this->acceptKw('AS')) $alias = $this->name(); else if ($this->isName() && !$this->isKw('INDEXED')) $alias = $this->name(); if ($this->acceptKw('INDEXED')) { $this->expectKw('BY'); $this->name(); } $where = $this->acceptKw('WHERE') ? $this->expr() : null; return ['t' => 'delete', 'table' => $table, 'alias' => $alias, 'where' => $where, 'returning' => $this->returning(), 'with' => []]; } /////////////// // CREATE・ALTER private function create(): array { $temp = $this->acceptKw('TEMP', 'TEMPORARY') !== null; $unique = $this->acceptKw('UNIQUE') !== null; if ($this->acceptKw('TABLE')) { if ($temp) throw new DbException('一時テーブルは未対応です', 1, 'HY000'); return $this->createTable(); } if ($this->acceptKw('INDEX')) return $this->createIndex($unique); if ($this->acceptKw('VIEW')) { $if = $this->ifNotExists(); $name = $this->qualifiedName(); $nameTok = ['s' => $this->lastNameStart]; $cols = null; if ($this->acceptOp('(')) { $cols = []; do $cols[] = $this->name(); while ($this->acceptOp(',')); $this->expectOp(')'); } $this->expectKw('AS'); $sel = $this->select(); return ['t' => 'createView', 'name' => $name, 'ifNotExists' => $if, 'cols' => $cols, 'sel' => $sel, 'nameStart' => $nameTok['s']]; } if ($this->acceptKw('TRIGGER')) throw new DbException('トリガーは未対応です', 1, 'HY000'); if ($this->acceptKw('VIRTUAL')) throw new DbException('仮想テーブルは未対応です', 1, 'HY000'); $this->error('TABLE'); } private function createTable(): array { $if = $this->ifNotExists(); $name = $this->qualifiedName(); $nameTok = ['s' => $this->lastNameStart]; if ($this->acceptKw('AS')) { return ['t' => 'createTable', 'name' => $name, 'ifNotExists' => $if, 'as' => $this->select(), 'nameStart' => $nameTok['s']]; } $def = $this->tableBody(); return ['t' => 'createTable', 'name' => $name, 'ifNotExists' => $if, 'def' => $def, 'nameStart' => $nameTok['s']]; } /** * テーブルの定義(括弧から最後まで) * * @return array cols(name、type、notNull、default、pk、autoinc、unique、collate、check、pkDesc)、pk、uniques、checks、withoutRowid、strict */ public function tableBody(): array { $this->expectOp('('); $cols = []; $pk = null; $uniques = []; $checks = []; $pkAuto = false; // 自動のインデックスの順番(sqlite_autoindex_テーブル_番号) $order = []; while (true) { if ($this->isKw('CONSTRAINT', 'PRIMARY', 'UNIQUE', 'CHECK', 'FOREIGN')) { if ($this->acceptKw('CONSTRAINT')) $this->name(); if ($this->acceptKw('PRIMARY')) { $this->expectKw('KEY'); $list = $this->indexedColumns(); if ($this->acceptKw('AUTOINCREMENT')) $pkAuto = true; $this->onConflict(); if ($pk !== null) throw new DbException("テーブルに主キーが複数有ります", 1, 'HY000'); $pk = $list; $order[] = ['pk', $list]; } else if ($this->acceptKw('UNIQUE')) { $list = $this->indexedColumns(); $uniques[] = $list; $order[] = ['unique', $list]; $this->onConflict(); } else if ($this->acceptKw('CHECK')) { $s = $this->cur()['s']; $this->expectOp('('); $checks[] = $this->expr(); $this->expectOp(')'); } else if ($this->acceptKw('FOREIGN')) { $this->expectKw('KEY'); $this->expectOp('('); do $this->name(); while ($this->acceptOp(',')); $this->expectOp(')'); $this->references(); } else { $this->error(); } } else { $c = $this->columnDef(); foreach ($c['cons'] as $k) $order[] = [$k, [['name' => $c['name'], 'desc' => $k === 'pk' && $c['pkDesc'], 'collate' => null]]]; $cols[] = $c; } if ($this->acceptOp(',')) continue; $this->expectOp(')'); break; } $withoutRowid = false; $strict = false; while (true) { if ($this->acceptKw('WITHOUT')) { if (!$this->isKw('ROWID')) $this->error('ROWID'); $this->p++; $withoutRowid = true; } else if ($this->acceptKw('STRICT')) { $strict = true; } else { break; } if (!$this->acceptOp(',')) break; } foreach ($cols as $c) { if ($c['pk']) { if ($pk !== null) throw new DbException("テーブルに主キーが複数有ります", 1, 'HY000'); $pk = [['name' => $c['name'], 'desc' => $c['pkDesc'], 'collate' => null]]; $pkAuto = $c['autoinc']; } } return ['cols' => $cols, 'pk' => $pk, 'pkAuto' => $pkAuto, 'uniques' => $uniques, 'checks' => $checks, 'withoutRowid' => $withoutRowid, 'strict' => $strict, 'order' => $order]; } private function onConflict(): void { if ($this->isKw('ON') && $this->peek()['t'] === 'id' && $this->peek()['u'] === 'CONFLICT') { $this->p += 2; $this->acceptKw('ROLLBACK', 'ABORT', 'FAIL', 'IGNORE', 'REPLACE') ?? $this->error('ABORT'); } } private function references(): void { $this->expectKw('REFERENCES'); $this->name(); if ($this->acceptOp('(')) { do $this->name(); while ($this->acceptOp(',')); $this->expectOp(')'); } while (true) { if ($this->acceptKw('ON')) { $this->acceptKw('DELETE', 'UPDATE') ?? $this->error('DELETE'); if ($this->acceptKw('SET')) $this->acceptKw('NULL', 'DEFAULT') ?? $this->error('NULL'); else if ($this->acceptKw('NO')) $this->expectKw('ACTION'); else $this->acceptKw('CASCADE', 'RESTRICT') ?? $this->error('CASCADE'); } else if ($this->acceptKw('MATCH')) { $this->name(); } else if ($this->isKw('NOT') && $this->peek()['u'] === 'DEFERRABLE' || $this->isKw('DEFERRABLE')) { $this->acceptKw('NOT'); $this->expectKw('DEFERRABLE'); if ($this->acceptKw('INITIALLY')) $this->acceptKw('DEFERRED', 'IMMEDIATE'); } else { break; } } } /** (列 [COLLATE x] [ASC|DESC], …) */ private function indexedColumns(): array { $this->expectOp('('); $out = []; do { $t = $this->cur(); $e = $this->expr(); $coll = null; if ($e['t'] === 'collate') { $coll = strtoupper($e['coll']); $e = $e['e']; } if ($e['t'] !== 'col' || $e['tbl'] !== null) throw new DbException('式のインデックスは未対応です', 1, 'HY000'); $desc = $this->acceptKw('DESC') !== null; if (!$desc) $this->acceptKw('ASC'); $out[] = ['name' => $e['name'], 'desc' => $desc, 'collate' => $coll]; } while ($this->acceptOp(',')); $this->expectOp(')'); return $out; } private function columnDef(): array { $c = ['name' => $this->name(), 'type' => '', 'notNull' => false, 'default' => null, 'pk' => false, 'pkDesc' => false, 'autoinc' => false, 'unique' => false, 'collate' => null, 'checks' => [], 'defaultText' => null, 'cons' => []]; // 型の名前 $words = []; while ($this->cur()['t'] === 'id' && !$this->cur()['q'] && !$this->isKw('CONSTRAINT', 'PRIMARY', 'NOT', 'NULL', 'UNIQUE', 'CHECK', 'DEFAULT', 'COLLATE', 'REFERENCES', 'GENERATED', 'AS')) { $words[] = $this->cur()['v']; $this->p++; } if (empty($words) && $this->cur()['t'] === 'id' && $this->cur()['q']) { $words[] = $this->cur()['v']; $this->p++; } $type = implode(' ', $words); if ($this->acceptOp('(')) { $args = []; do { $neg = $this->acceptOp('-') ? '-' : ($this->acceptOp('+') ? '' : ''); $t = $this->cur(); if ($t['t'] !== 'num') $this->error('数'); $this->p++; $args[] = $neg.substr($this->sql, $t['s'], $t['e'] - $t['s']); } while ($this->acceptOp(',')); $this->expectOp(')'); $type .= '('.implode(',', $args).')'; } $c['type'] = $type; while (true) { if ($this->acceptKw('CONSTRAINT')) $this->name(); if ($this->acceptKw('PRIMARY')) { $this->expectKw('KEY'); $c['pk'] = true; $c['cons'][] = 'pk'; $c['pkDesc'] = $this->acceptKw('DESC') !== null; if (!$c['pkDesc']) $this->acceptKw('ASC'); $this->onConflict(); if ($this->acceptKw('AUTOINCREMENT')) $c['autoinc'] = true; } else if ($this->acceptKw('NOT')) { $this->expectKw('NULL'); $c['notNull'] = true; $this->onConflict(); } else if ($this->acceptKw('NULL')) { $this->onConflict(); } else if ($this->acceptKw('UNIQUE')) { $c['unique'] = true; $c['cons'][] = 'unique'; $this->onConflict(); } else if ($this->acceptKw('CHECK')) { $this->expectOp('('); $c['checks'][] = $this->expr(); $this->expectOp(')'); } else if ($this->acceptKw('DEFAULT')) { $ds = $this->cur()['s']; if ($this->acceptOp('(')) { $c['default'] = $this->expr(); $this->expectOp(')'); } else { $neg = $this->acceptOp('-'); if (!$neg) $this->acceptOp('+'); $t = $this->cur(); if ($t['t'] === 'id' && !$t['q'] && in_array($t['u'], ['CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME', 'TRUE', 'FALSE', 'NULL'], true)) { $c['default'] = $this->primary(); } else if ($t['t'] === 'id') { // 識別子は文字列として $this->p++; $c['default'] = ['t' => 'lit', 'v' => $t['v']]; } else { $c['default'] = $this->primary(); } if ($neg) $c['default'] = ['t' => 'un', 'op' => '-', 'e' => $c['default']]; } $c['defaultText'] = substr($this->sql, $ds, $this->tok[$this->p - 1]['e'] - $ds); } else if ($this->acceptKw('COLLATE')) { $c['collate'] = strtoupper($this->name()); } else if ($this->isKw('REFERENCES')) { $this->references(); } else if ($this->isKw('GENERATED', 'AS')) { throw new DbException('生成列は未対応です', 1, 'HY000'); } else { break; } } return $c; } private function createIndex(bool $unique): array { $if = $this->ifNotExists(); $name = $this->qualifiedName(); $nameTok = ['s' => $this->lastNameStart]; $this->expectKw('ON'); $table = $this->name(); $cols = $this->indexedColumns(); if ($this->acceptKw('WHERE')) throw new DbException('部分インデックスは未対応です', 1, 'HY000'); return ['t' => 'createIndex', 'name' => $name, 'table' => $table, 'unique' => $unique, 'cols' => $cols, 'ifNotExists' => $if, 'nameStart' => $nameTok['s']]; } private function alter(): array { $this->expectKw('TABLE'); $table = $this->qualifiedName(); if ($this->acceptKw('RENAME')) { if ($this->acceptKw('TO')) return ['t' => 'alter', 'op' => 'rename', 'table' => $table, 'to' => $this->name()]; $this->acceptKw('COLUMN'); $from = $this->name(); $this->expectKw('TO'); return ['t' => 'alter', 'op' => 'renameColumn', 'table' => $table, 'from' => $from, 'to' => $this->name()]; } if ($this->acceptKw('ADD')) { $this->acceptKw('COLUMN'); $s = $this->cur()['s']; $col = $this->columnDef(); $text = substr($this->sql, $s, $this->tok[$this->p - 1]['e'] - $s); return ['t' => 'alter', 'op' => 'addColumn', 'table' => $table, 'col' => $col, 'text' => $text]; } if ($this->acceptKw('DROP')) { $this->acceptKw('COLUMN'); return ['t' => 'alter', 'op' => 'dropColumn', 'table' => $table, 'col' => $this->name()]; } $this->error('RENAME'); } /////////////// // 式 public function expr(): array { $l = $this->andExpr(); while ($this->acceptKw('OR')) $l = ['t' => 'bin', 'op' => 'OR', 'l' => $l, 'r' => $this->andExpr()]; return $l; } private function andExpr(): array { $l = $this->notExpr(); while ($this->acceptKw('AND')) $l = ['t' => 'bin', 'op' => 'AND', 'l' => $l, 'r' => $this->notExpr()]; return $l; } private function notExpr(): array { if ($this->acceptKw('NOT')) return ['t' => 'un', 'op' => 'NOT', 'e' => $this->notExpr()]; return $this->equality(); } private function equality(): array { $l = $this->comparison(); while (true) { if ($this->acceptOp('=') || $this->acceptOp('==')) { $l = ['t' => 'bin', 'op' => '=', 'l' => $l, 'r' => $this->comparison()]; } else if ($this->acceptOp('!=') || $this->acceptOp('<>')) { $l = ['t' => 'bin', 'op' => '!=', 'l' => $l, 'r' => $this->comparison()]; } else if ($this->acceptKw('IS')) { $not = $this->acceptKw('NOT') !== null; if ($this->acceptKw('DISTINCT')) { $this->expectKw('FROM'); $not = !$not; } $l = ['t' => 'is', 'not' => $not, 'l' => $l, 'r' => $this->comparison()]; } else if ($this->acceptKw('ISNULL')) { $l = ['t' => 'isnull', 'not' => false, 'e' => $l]; } else if ($this->acceptKw('NOTNULL')) { $l = ['t' => 'isnull', 'not' => true, 'e' => $l]; } else if ($this->isKw('NOT') && $this->peek()['t'] === 'id' && in_array($this->peek()['u'], ['NULL', 'IN', 'LIKE', 'GLOB', 'BETWEEN', 'REGEXP', 'MATCH'], true)) { $this->p++; if ($this->acceptKw('NULL')) { $l = ['t' => 'isnull', 'not' => true, 'e' => $l]; } else { $l = $this->postfixOp($l, true); } } else if ($this->isKw('IN', 'LIKE', 'GLOB', 'BETWEEN', 'REGEXP', 'MATCH')) { $l = $this->postfixOp($l, false); } else { return $l; } } } private function postfixOp(array $l, bool $not): array { if ($this->acceptKw('IN')) { if ($this->acceptOp('(')) { if ($this->isKw('SELECT', 'VALUES', 'WITH')) { $sel = $this->select(); $this->expectOp(')'); return ['t' => 'in', 'not' => $not, 'e' => $l, 'list' => null, 'sel' => $sel]; } $list = []; if (!$this->isOp(')')) { do $list[] = $this->expr(); while ($this->acceptOp(',')); } $this->expectOp(')'); return ['t' => 'in', 'not' => $not, 'e' => $l, 'list' => $list, 'sel' => null]; } $name = $this->qualifiedName(); return ['t' => 'in', 'not' => $not, 'e' => $l, 'list' => null, 'sel' => null, 'table' => $name]; } if ($this->acceptKw('BETWEEN')) { $lo = $this->comparison(); $this->expectKw('AND'); $hi = $this->comparison(); return ['t' => 'between', 'not' => $not, 'e' => $l, 'lo' => $lo, 'hi' => $hi]; } $op = $this->acceptKw('LIKE', 'GLOB', 'REGEXP', 'MATCH'); if ($op === 'REGEXP' || $op === 'MATCH') throw new DbException("{$op}は未対応です", 1, 'HY000'); $r = $this->comparison(); $esc = $this->acceptKw('ESCAPE') ? $this->comparison() : null; return ['t' => 'like', 'op' => $op, 'not' => $not, 'l' => $l, 'r' => $r, 'esc' => $esc]; } private function comparison(): array { $l = $this->bitwise(); while (true) { foreach (['<=', '>=', '<', '>'] as $op) { if ($this->acceptOp($op)) { $l = ['t' => 'bin', 'op' => $op, 'l' => $l, 'r' => $this->bitwise()]; continue 2; } } return $l; } } private function bitwise(): array { $l = $this->additive(); while (true) { foreach (['<<', '>>', '&', '|'] as $op) { if ($this->acceptOp($op)) { $l = ['t' => 'bin', 'op' => $op, 'l' => $l, 'r' => $this->additive()]; continue 2; } } return $l; } } private function additive(): array { $l = $this->multiplicative(); while (true) { if ($this->acceptOp('+')) $l = ['t' => 'bin', 'op' => '+', 'l' => $l, 'r' => $this->multiplicative()]; else if ($this->acceptOp('-')) $l = ['t' => 'bin', 'op' => '-', 'l' => $l, 'r' => $this->multiplicative()]; else return $l; } } private function multiplicative(): array { $l = $this->concat(); while (true) { foreach (['*', '/', '%'] as $op) { if ($this->acceptOp($op)) { $l = ['t' => 'bin', 'op' => $op, 'l' => $l, 'r' => $this->concat()]; continue 2; } } return $l; } } private function concat(): array { $l = $this->unary(); while (true) { if ($this->acceptOp('||')) $l = ['t' => 'bin', 'op' => '||', 'l' => $l, 'r' => $this->unary()]; else if ($this->acceptOp('->>')) $l = ['t' => 'fn', 'name' => 'json_extract_text', 'args' => [$l, $this->unary()], 'distinct' => false, 'star' => false]; else if ($this->acceptOp('->')) $l = ['t' => 'fn', 'name' => 'json_extract_json', 'args' => [$l, $this->unary()], 'distinct' => false, 'star' => false]; else return $l; } } private function unary(): array { if ($this->acceptOp('-')) { $e = $this->unary(); if ($e['t'] === 'lit' && (is_int($e['v']) || is_float($e['v']))) { // -9223372036854775808 if (is_float($e['v']) && $e['v'] === 9.2233720368547758e18) return ['t' => 'lit', 'v' => PHP_INT_MIN]; return ['t' => 'lit', 'v' => -$e['v']]; } return ['t' => 'un', 'op' => '-', 'e' => $e]; } if ($this->acceptOp('+')) return ['t' => 'un', 'op' => '+', 'e' => $this->unary()]; if ($this->acceptOp('~')) return ['t' => 'un', 'op' => '~', 'e' => $this->unary()]; $e = $this->primary(); while ($this->acceptKw('COLLATE')) $e = ['t' => 'collate', 'e' => $e, 'coll' => strtoupper($this->name())]; return $e; } private function primary(): array { $t = $this->cur(); switch ($t['t']) { case 'num': $this->p++; return ['t' => 'lit', 'v' => $t['v']]; case 'str': $this->p++; return ['t' => 'lit', 'v' => $t['v']]; case 'blob': $this->p++; return ['t' => 'lit', 'v' => new Blob($t['v'])]; case 'param': $this->p++; if ($t['v'] === null) { $i = ++$this->nParam; } else if (is_int($t['v'])) { $i = $t['v']; $this->nParam = max($this->nParam, $i); } else { // 名前付き:最初に出た順番で番号を付ける(SQLiteと同じ) $i = $this->names[$t['v']] ??= ++$this->nParam; } return ['t' => 'param', 'i' => $i]; case 'op': if ($t['v'] === '(') { $this->p++; if ($this->isKw('SELECT', 'VALUES', 'WITH')) { $sel = $this->select(); $this->expectOp(')'); return ['t' => 'sub', 'sel' => $sel]; } $e = $this->expr(); if ($this->isOp(',')) { $list = [$e]; while ($this->acceptOp(',')) $list[] = $this->expr(); $this->expectOp(')'); return ['t' => 'vector', 'list' => $list]; } $this->expectOp(')'); return $e; } $this->error(); case 'id': if (!$t['q']) { switch ($t['u']) { case 'NULL': $this->p++; return ['t' => 'lit', 'v' => null]; case 'TRUE': $this->p++; return ['t' => 'lit', 'v' => 1]; case 'FALSE': $this->p++; return ['t' => 'lit', 'v' => 0]; case 'CURRENT_TIMESTAMP': case 'CURRENT_DATE': case 'CURRENT_TIME': $this->p++; return ['t' => 'fn', 'name' => strtolower($t['u']), 'args' => [], 'distinct' => false, 'star' => false]; case 'CASE': return $this->caseExpr(); case 'CAST': $this->p++; $this->expectOp('('); $e = $this->expr(); $this->expectKw('AS'); $words = []; while ($this->cur()['t'] === 'id') { $words[] = $this->cur()['v']; $this->p++; } if ($this->acceptOp('(')) { while (!$this->acceptOp(')')) $this->p++; } $this->expectOp(')'); return ['t' => 'cast', 'e' => $e, 'type' => implode(' ', $words)]; case 'EXISTS': $this->p++; $this->expectOp('('); $sel = $this->select(); $this->expectOp(')'); return ['t' => 'exists', 'sel' => $sel]; case 'NOT': $this->error(); case 'RAISE': throw new DbException('RAISEは未対応です', 1, 'HY000'); } } $this->p++; // 関数 if ($this->isOp('(') && !$t['q']) { $this->p++; $name = strtolower($t['v']); $distinct = false; $star = false; $args = []; if ($this->acceptOp('*')) { $star = true; } else if (!$this->isOp(')')) { $distinct = $this->acceptKw('DISTINCT') !== null; do $args[] = $this->expr(); while ($this->acceptOp(',')); if ($this->acceptKw('ORDER')) throw new DbException('集約関数のORDER BYは未対応です', 1, 'HY000'); } $this->expectOp(')'); $filter = null; if ($this->acceptKw('FILTER')) { $this->expectOp('('); $this->expectKw('WHERE'); $filter = $this->expr(); $this->expectOp(')'); } if ($this->isKw('OVER')) throw new DbException('ウィンドウ関数は未対応です', 1, 'HY000'); return ['t' => 'fn', 'name' => $name, 'args' => $args, 'distinct' => $distinct, 'star' => $star, 'filter' => $filter]; } // 列(table.column) if ($this->isOp('.')) { $this->p++; $c = $this->cur(); if ($c['t'] !== 'id') $this->error('列'); $this->p++; if ($this->isOp('.')) { // schema.table.column $this->p++; $c2 = $this->cur(); if ($c2['t'] !== 'id') $this->error('列'); $this->p++; return ['t' => 'col', 'tbl' => $c['v'], 'name' => $c2['v']]; } return ['t' => 'col', 'tbl' => $t['v'], 'name' => $c['v']]; } if (!$t['q'] && in_array($t['u'], self::RESERVED, true)) { $this->p--; $this->error(); } // 「"文字列"」は列が無ければ文字列(SQLiteの互換) return ['t' => 'col', 'tbl' => null, 'name' => $t['v'], 'dq' => $t['q'] && ($this->sql[$t['s']] ?? '') === '"']; } $this->error(); } private function caseExpr(): array { $this->expectKw('CASE'); $base = $this->isKw('WHEN') ? null : $this->expr(); $when = []; while ($this->acceptKw('WHEN')) { $c = $this->expr(); $this->expectKw('THEN'); $when[] = [$c, $this->expr()]; } if (empty($when)) $this->error('WHEN'); $else = $this->acceptKw('ELSE') ? $this->expr() : null; $this->expectKw('END'); return ['t' => 'case', 'base' => $base, 'when' => $when, 'else' => $else]; } }