308 lines
10 KiB
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;
|
|
}
|