JJ_MKVMaker/lib/core/sqlite_reader.dart

308 lines
10 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
/// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite 파일 읽기 전용 보기 - 새 패키지 · 네이티브 라이브러리 없이 파일 형식
/// (sqlite.org/fileformat2.html) 을 직접 읽는다. 쓰지 않는다 (파일을 열 때도 읽기 전용).
/// - 읽는 것: 머리 · sqlite_schema · 테이블 b-tree (안쪽 · 잎 페이지) · 넘친 페이지 · 레코드 (NULL · 정수 · 실수 · 글 · BLOB)
/// - 못 읽는 것: WITHOUT ROWID 테이블 (인덱스 b-tree) · 쓰는 중인 WAL 파일의 내용 (-wal 에만 있는 바뀐 것은 보이지 않음)
class SqliteFile {
final RandomAccessFile _f;
final int pageSize, usable, pageCount;
/// 1 UTF-8 · 2 UTF-16LE · 3 UTF-16BE
final int encoding;
SqliteFile._(this._f, this.pageSize, this.usable, this.pageCount, this.encoding);
/// 열기 (읽기 전용). SQLite 파일이 아니면 [SqliteFormatException]
static Future<SqliteFile> open(String path) async {
final f = await File(path).open();
try {
final h = await f.read(100);
if (h.length < 100 || ascii.decode(h.sublist(0, 15), allowInvalid: true) != 'SQLite format 3') {
throw const SqliteFormatException('not sqlite');
}
var ps = (h[16] << 8) | h[17];
if (ps == 1) ps = 65536;
if (ps < 512 || ps & (ps - 1) != 0) throw const SqliteFormatException('page size');
final len = await f.length();
final enc = _be(h, 56, 4);
return SqliteFile._(f, ps, ps - h[20], len ~/ ps, enc == 0 ? 1 : enc);
} catch (_) {
await f.close();
rethrow;
}
}
Future<void> close() => _f.close();
final _pages = <int, Uint8List>{};
Future<Uint8List> _page(int n) async {
if (n < 1 || n > pageCount) throw SqliteFormatException('page $n');
final hit = _pages[n];
if (hit != null) return hit;
await _f.setPosition((n - 1) * pageSize);
final b = await _f.read(pageSize);
if (_pages.length > 256) _pages.remove(_pages.keys.first);
return _pages[n] = b;
}
/// sqlite_schema 의 테이블 (이름 · 루트 페이지 · 열 이름)
Future<List<SqliteTable>> tables() async {
final out = <SqliteTable>[];
await for (final r in _rows(1, const [])) {
if (r.values.length < 5 || r.values[0] != 'table') continue;
final name = '${r.values[1]}';
final sql = '${r.values[4] ?? ''}';
final root = r.values[3];
if (root is! int) continue;
out.add(SqliteTable(name, root, columnsOf(sql), withoutRowid: RegExp(r'without\s+rowid\s*$', caseSensitive: false).hasMatch(sql.trim())));
}
return out;
}
/// [t] 의 행을 [offset] 부터 [limit] 개 (rowid 순)
Future<List<SqliteRow>> rows(SqliteTable t, {int offset = 0, int limit = 200}) async {
if (t.withoutRowid) throw const SqliteFormatException('without rowid');
final out = <SqliteRow>[];
var i = 0;
await for (final r in _rows(t.rootPage, t.columns)) {
if (i++ < offset) continue;
out.add(r);
if (out.length >= limit) break;
}
return out;
}
/// [t] 의 행 수 (페이지를 모두 훑음)
Future<int> count(SqliteTable t) async {
if (t.withoutRowid) return 0;
var n = 0;
final seen = <int>{};
Future<void> walk(int page) async {
if (!seen.add(page)) throw const SqliteFormatException('loop');
final b = await _page(page);
final o = page == 1 ? 100 : 0;
final cells = _be(b, o + 3, 2);
if (b[o] == 0x0D) {
n += cells;
} else if (b[o] == 0x05) {
for (var i = 0; i < cells; i++) {
await walk(_be(b, _be(b, o + 12 + i * 2, 2), 4));
}
await walk(_be(b, o + 8, 4));
}
}
await walk(t.rootPage);
return n;
}
/// 테이블 b-tree 를 rowid 순으로 (안쪽 페이지는 왼쪽 자식들 → 오른쪽 끝 자식)
Stream<SqliteRow> _rows(int root, List<String> columns) async* {
final seen = <int>{};
final stack = <int>[root];
// 깊이 우선 - 차례를 지키려고 자식을 거꾸로 쌓는다
while (stack.isNotEmpty) {
final page = stack.removeLast();
if (!seen.add(page)) throw const SqliteFormatException('loop');
final b = await _page(page);
final o = page == 1 ? 100 : 0;
final type = b[o];
final cells = _be(b, o + 3, 2);
if (type == 0x05) {
final kids = [for (var i = 0; i < cells; i++) _be(b, _be(b, o + 12 + i * 2, 2), 4), _be(b, o + 8, 4)];
stack.addAll(kids.reversed);
} else if (type == 0x0D) {
for (var i = 0; i < cells; i++) {
var p = _be(b, o + 8 + i * 2, 2);
final (size, n1) = _varint(b, p);
p += n1;
final (rowid, n2) = _varint(b, p);
p += n2;
final payload = await _payload(b, p, size);
final values = _record(payload);
// INTEGER PRIMARY KEY 열은 레코드에 NULL 로 두고 rowid 를 쓴다
final ipk = columns.indexWhere((c) => c.startsWith('\u0000'));
if (ipk >= 0 && ipk < values.length && values[ipk] == null) values[ipk] = rowid;
yield SqliteRow(rowid, values);
}
} else {
throw SqliteFormatException('page type $type');
}
}
}
/// 셀의 내용 - 이 페이지에 든 만큼 + 넘친 페이지들
Future<Uint8List> _payload(Uint8List b, int p, int size) async {
final u = usable;
final x = u - 35;
if (size <= x) return b.sublist(p, p + size);
final m = ((u - 12) * 32 ~/ 255) - 23;
final k = m + ((size - m) % (u - 4));
final local = k <= x ? k : m;
final out = BytesBuilder(copy: false)..add(b.sublist(p, p + local));
var next = _be(b, p + local, 4);
var left = size - local;
final seen = <int>{};
while (left > 0 && next != 0) {
if (!seen.add(next)) throw const SqliteFormatException('overflow loop');
final ob = await _page(next);
final take = left < u - 4 ? left : u - 4;
out.add(ob.sublist(4, 4 + take));
left -= take;
next = _be(ob, 0, 4);
}
return out.takeBytes();
}
List<Object?> _record(Uint8List r) {
final (hsize, n0) = _varint(r, 0);
var hp = n0;
var dp = hsize;
final out = <Object?>[];
while (hp < hsize) {
final (t, n) = _varint(r, hp);
hp += n;
switch (t) {
case 0:
out.add(null);
case >= 1 && <= 6:
final len = const [0, 1, 2, 3, 4, 6, 8][t];
out.add(_signed(r, dp, len));
dp += len;
case 7:
out.add(ByteData.sublistView(r, dp, dp + 8).getFloat64(0));
dp += 8;
case 8:
out.add(0);
case 9:
out.add(1);
default:
if (t >= 12 && t.isEven) {
final len = (t - 12) ~/ 2;
out.add(r.sublist(dp, dp + len));
dp += len;
} else if (t >= 13) {
final len = (t - 13) ~/ 2;
out.add(_text(r.sublist(dp, dp + len)));
dp += len;
} else {
out.add(null);
}
}
}
return out;
}
String _text(Uint8List b) {
if (encoding == 1) return utf8.decode(b, allowMalformed: true);
final le = encoding == 2;
return String.fromCharCodes([for (var i = 0; i + 1 < b.length; i += 2) le ? b[i] | (b[i + 1] << 8) : (b[i] << 8) | b[i + 1]]);
}
}
class SqliteTable {
final String name;
final int rootPage;
/// 열 이름 (INTEGER PRIMARY KEY 열은 앞에 \u0000 표시 - [displayColumns] 로 보임)
final List<String> columns;
final bool withoutRowid;
const SqliteTable(this.name, this.rootPage, this.columns, {this.withoutRowid = false});
List<String> get displayColumns => [for (final c in columns) c.replaceFirst('\u0000', '')];
}
class SqliteRow {
final int rowid;
final List<Object?> values;
const SqliteRow(this.rowid, this.values);
}
class SqliteFormatException implements Exception {
final String message;
const SqliteFormatException(this.message);
@override
String toString() => 'SqliteFormatException: $message';
}
int _be(Uint8List b, int o, int n) {
var v = 0;
for (var i = 0; i < n; i++) {
v = (v << 8) | b[o + i];
}
return v;
}
int _signed(Uint8List b, int o, int n) {
var v = _be(b, o, n);
final bits = n * 8;
if (bits < 64 && (v & (1 << (bits - 1))) != 0) v -= 1 << bits;
return v;
}
/// SQLite varint (1~9 바이트, 큰 쪽 먼저) → (값, 바이트 수)
(int, int) _varint(Uint8List b, int o) {
var v = 0;
for (var i = 0; i < 8; i++) {
final c = b[o + i];
v = (v << 7) | (c & 0x7F);
if (c < 0x80) return (v, i + 1);
}
return ((v << 8) | b[o + 8], 9);
}
/// CREATE TABLE 의 열 이름 (제약 줄은 빼고). INTEGER PRIMARY KEY 열은 앞에 \u0000
List<String> columnsOf(String sql) {
final open = sql.indexOf('(');
final close = sql.lastIndexOf(')');
if (open < 0 || close <= open) return const [];
final body = sql.substring(open + 1, close);
final parts = <String>[];
var depth = 0;
var quote = '';
var cur = StringBuffer();
for (final ch in body.split('')) {
if (quote.isNotEmpty) {
cur.write(ch);
if (ch == quote) quote = '';
continue;
}
if (ch == '"' || ch == "'" || ch == '`' || ch == '[') {
quote = ch == '[' ? ']' : ch;
cur.write(ch);
} else if (ch == '(') {
depth++;
cur.write(ch);
} else if (ch == ')') {
depth--;
cur.write(ch);
} else if (ch == ',' && depth == 0) {
parts.add(cur.toString());
cur = StringBuffer();
} else {
cur.write(ch);
}
}
parts.add(cur.toString());
final out = <String>[];
for (final raw in parts) {
final s = raw.trim();
if (s.isEmpty) continue;
if (RegExp(r'^(constraint|primary|unique|check|foreign)\b', caseSensitive: false).hasMatch(s)) continue;
final m = RegExp(r'^("([^"]|"")*"|`[^`]*`|\[[^\]]*\]|\S+)').firstMatch(s);
if (m == null) continue;
var name = m[0]!;
if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('`') && name.endsWith('`')) || (name.startsWith('[') && name.endsWith(']'))) {
name = name.substring(1, name.length - 1).replaceAll('""', '"');
}
final ipk = RegExp(r'\binteger\s+primary\s+key\b', caseSensitive: false).hasMatch(s) &&
!RegExp(r'\bdesc\b', caseSensitive: false).hasMatch(s);
out.add(ipk ? '\u0000$name' : name);
}
return out;
}