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 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 close() => _f.close(); final _pages = {}; Future _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> tables() async { final out = []; 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> rows(SqliteTable t, {int offset = 0, int limit = 200}) async { if (t.withoutRowid) throw const SqliteFormatException('without rowid'); final out = []; 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 count(SqliteTable t) async { if (t.withoutRowid) return 0; var n = 0; final seen = {}; Future 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 _rows(int root, List columns) async* { final seen = {}; final stack = [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 _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 = {}; 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 _record(Uint8List r) { final (hsize, n0) = _varint(r, 0); var hp = n0; var dp = hsize; final out = []; 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 columns; final bool withoutRowid; const SqliteTable(this.name, this.rootPage, this.columns, {this.withoutRowid = false}); List get displayColumns => [for (final c in columns) c.replaceFirst('\u0000', '')]; } class SqliteRow { final int rowid; final List 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 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 = []; 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 = []; 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; }