mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-09-02 06:21:04 +00:00
The browser downloaded 45 MB of gzipped SQLite before it could answer anything. Now sql.js-httpvfs asks for the pages a query touches and the databases ship uncompressed as <id>.sqlite3 — a byte range of a gzip stream is not a byte range of a database. That only works if every query the site issues is index-driven, and measured against the real 2016 file, most were not: so_bao_danh = ? SEARCH via PK ~20 KB ho_ten_ascii LIKE '%x%' SCAN 127 MB ho_ten_ascii LIKE 'x%' SCAN 127 MB COUNT(*) covering index scan 20 MB ORDER BY toan DESC LIMIT 10 SCAN + temp b-tree 127 MB Prefix LIKE scans because SQLite's LIKE optimisation needs a NOCASE index; a range comparison does use the index. So the schema changed to suit the access pattern rather than the search changing to suit the schema. name_word holds one row per word of each name, WITHOUT ROWID so the table is the index, carrying ho_ten_ascii so a multi-word query is resolved inside a single b-tree. name_word_freq says which word of a query is rarest — the vocabulary is 4,397 words across 2.87M entries, so "buu loc" seeks on 287 entries rather than walking the 300,000 that "thi" would. Searching by any word of a name survives, at a few hundred KB a query. idx_ho_ten and idx_ho_ten_ascii are gone: no plan could use either. Partial indexes on toan, khtn and khxh cost 12 MB and keep the SQL presets off a full scan. The footer's candidate count now comes from datasets.json instead of COUNT(*). 2016 grows 223.5 MB to 288.6 MB, 2017 162.7 MB to 237.7 MB, and the site is 528 MB against the 1 GB GitHub Pages limit. Row counts are unchanged. The SQL tab is the one place a user can still write a query that reads the whole table, so it asks before it opens, runs under a byte budget that stops a runaway query, and shows what each query actually fetched. Verified: row counts through the assembler guards, every app query index-driven under EXPLAIN QUERY PLAN, and GitHub Pages returning 206 with a correct Content-Range. Not verified in a browser — this machine has none — and the library refuses to open a file the host compresses, so the deployed response headers need a look.
188 lines
6.4 KiB
Go
188 lines
6.4 KiB
Go
// Package schema is the single source of truth for the SQL shape of every
|
|
// dataset.
|
|
//
|
|
// Every dataset writes into the same 22-column student table. Columns a dataset has no data for bind NULL.
|
|
//
|
|
// Column provenance:
|
|
//
|
|
// ten_cum_thi, gioi_tinh -> 2016 only
|
|
// khtn, khxh, gdcd -> 2017 only
|
|
// everything else -> both, all six languages included
|
|
//
|
|
// The DDL, the INSERT and the subject regexes belong here and nowhere else. One
|
|
// copy per dataset is what let the 2016 and 2017 schemas drift apart; the
|
|
// per-dataset configs carry only parse rules.
|
|
package schema
|
|
|
|
import "regexp"
|
|
|
|
// DDL is executed verbatim after the output database is (re)created.
|
|
//
|
|
// Every index here is chosen for a database read over HTTP range requests,
|
|
// where an unindexed query downloads the table. The rules that follow from
|
|
// that:
|
|
//
|
|
// - No index on ho_ten or ho_ten_ascii. Neither substring nor prefix LIKE can
|
|
// use one (SQLite's LIKE optimisation needs a NOCASE index or
|
|
// case_sensitive_like), so both scanned the whole table. name_word replaces
|
|
// them.
|
|
// - idx_ten_cum_thi is partial, so it holds zero entries on the 2017 dataset
|
|
// — where the column is always NULL — while serving the 2016 cluster
|
|
// grouping. Partial indexes are SQLite-specific.
|
|
//
|
|
// This text is frozen: it decides the shape of every database the parser
|
|
// produces. TestDDLIsFrozen holds an independent copy so any edit has to be
|
|
// deliberate.
|
|
const DDL = `
|
|
CREATE TABLE student (
|
|
so_bao_danh TEXT PRIMARY KEY,
|
|
ho_ten TEXT NOT NULL,
|
|
ho_ten_ascii TEXT NOT NULL,
|
|
ngay_sinh TEXT,
|
|
ten_cum_thi TEXT,
|
|
gioi_tinh TEXT,
|
|
toan REAL,
|
|
ngu_van REAL,
|
|
vat_ly REAL,
|
|
hoa_hoc REAL,
|
|
sinh_hoc REAL,
|
|
khtn REAL,
|
|
lich_su REAL,
|
|
dia_ly REAL,
|
|
gdcd REAL,
|
|
khxh REAL,
|
|
tieng_anh REAL,
|
|
tieng_phap REAL,
|
|
tieng_nga REAL,
|
|
tieng_duc REAL,
|
|
tieng_nhat REAL,
|
|
tieng_trung REAL
|
|
);
|
|
CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi) WHERE ten_cum_thi IS NOT NULL;
|
|
|
|
CREATE TABLE name_word (
|
|
word TEXT NOT NULL,
|
|
so_bao_danh TEXT NOT NULL,
|
|
ho_ten_ascii TEXT NOT NULL,
|
|
PRIMARY KEY (word, so_bao_danh)
|
|
) WITHOUT ROWID;
|
|
|
|
CREATE TABLE name_word_freq (
|
|
word TEXT PRIMARY KEY,
|
|
n INTEGER NOT NULL
|
|
) WITHOUT ROWID;
|
|
`
|
|
|
|
// PostLoadSQL runs once the student rows are in, before VACUUM.
|
|
//
|
|
// The frequency table is what lets the site pick which word of a query to seek
|
|
// on: the vocabulary is about 4,400 words and the rarest word of a real query
|
|
// matches a few hundred rows, so seeking on it and filtering the rest inside
|
|
// name_word keeps a search to a few hundred kilobytes.
|
|
//
|
|
// The three score indexes are partial for the same reason idx_ten_cum_thi is:
|
|
// each covers only the exam year that has the column, and each costs about
|
|
// 4 MB. They exist so the SQL presets that rank by these columns seek instead
|
|
// of scanning 127 MB.
|
|
const PostLoadSQL = `
|
|
INSERT INTO name_word_freq (word, n)
|
|
SELECT word, COUNT(*) FROM name_word GROUP BY word;
|
|
CREATE INDEX idx_toan ON student(toan) WHERE toan IS NOT NULL;
|
|
CREATE INDEX idx_khtn ON student(khtn) WHERE khtn IS NOT NULL;
|
|
CREATE INDEX idx_khxh ON student(khxh) WHERE khxh IS NOT NULL;
|
|
`
|
|
|
|
// NameWordInsertSQL adds one row per distinct word of a candidate's ASCII name.
|
|
//
|
|
// ho_ten_ascii is carried along deliberately: a query with several words seeks
|
|
// on the rarest one and filters the others against this copy, so the whole
|
|
// match happens inside one b-tree and only the surviving rows are read from
|
|
// student.
|
|
const NameWordInsertSQL = `
|
|
INSERT OR IGNORE INTO name_word (word, so_bao_danh, ho_ten_ascii) VALUES (?, ?, ?)
|
|
`
|
|
|
|
// IdentityFields are the identity columns, in INSERT parameter order.
|
|
var IdentityFields = []string{
|
|
"so_bao_danh",
|
|
"ho_ten",
|
|
"ho_ten_ascii",
|
|
"ngay_sinh",
|
|
"ten_cum_thi",
|
|
"gioi_tinh",
|
|
}
|
|
|
|
// ScoreFields are the subject columns, in INSERT parameter order. Bound NULL
|
|
// when a row has no score for that subject.
|
|
var ScoreFields = []string{
|
|
"toan",
|
|
"ngu_van",
|
|
"vat_ly",
|
|
"hoa_hoc",
|
|
"sinh_hoc",
|
|
"khtn",
|
|
"lich_su",
|
|
"dia_ly",
|
|
"gdcd",
|
|
"khxh",
|
|
"tieng_anh",
|
|
"tieng_phap",
|
|
"tieng_nga",
|
|
"tieng_duc",
|
|
"tieng_nhat",
|
|
"tieng_trung",
|
|
}
|
|
|
|
// ParamCount is the total bound parameters per row.
|
|
const ParamCount = 22
|
|
|
|
// InsertSQL is a positional INSERT matching IdentityFields then ScoreFields.
|
|
//
|
|
// OR REPLACE is a behavioural contract, not an optimisation: a repeated SBD
|
|
// overwrites the earlier row rather than aborting the transaction, so the last
|
|
// file to supply a duplicate wins.
|
|
const InsertSQL = `
|
|
INSERT OR REPLACE INTO student
|
|
(so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh, ten_cum_thi, gioi_tinh,
|
|
toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
|
|
lich_su, dia_ly, gdcd, khxh,
|
|
tieng_anh, tieng_phap, tieng_nga, tieng_duc, tieng_nhat, tieng_trung)
|
|
VALUES
|
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
|
|
// scorePatternSources holds the regex per subject, applied to the DIEM_THI cell
|
|
// text. The literals contain Vietnamese subject names exactly as they appear in
|
|
// the source files — copy them, never retype them.
|
|
//
|
|
// Every pattern runs against every dataset. A subject absent from a given exam
|
|
// year simply never matches and stays NULL: 2016 files contain no "KHTN:",
|
|
// "KHXH:" or "GDCD:" tokens, since those combined papers did not exist yet.
|
|
var scorePatternSources = map[string]string{
|
|
"toan": `Toán:\s*(\d+(?:\.\d+)?)`,
|
|
"ngu_van": `Ngữ văn:\s*(\d+(?:\.\d+)?)`,
|
|
"vat_ly": `Vật lí:\s*(\d+(?:\.\d+)?)`,
|
|
"hoa_hoc": `Hóa học:\s*(\d+(?:\.\d+)?)`,
|
|
"sinh_hoc": `Sinh học:\s*(\d+(?:\.\d+)?)`,
|
|
"khtn": `KHTN:\s*(\d+(?:\.\d+)?)`,
|
|
"lich_su": `Lịch sử:\s*(\d+(?:\.\d+)?)`,
|
|
"dia_ly": `Địa lí:\s*(\d+(?:\.\d+)?)`,
|
|
"gdcd": `GDCD:\s*(\d+(?:\.\d+)?)`,
|
|
"khxh": `KHXH:\s*(\d+(?:\.\d+)?)`,
|
|
"tieng_anh": `Tiếng Anh:\s*(\d+(?:\.\d+)?)`,
|
|
"tieng_phap": `Tiếng Pháp:\s*(\d+(?:\.\d+)?)`,
|
|
"tieng_nga": `Tiếng Nga:\s*(\d+(?:\.\d+)?)`,
|
|
"tieng_duc": `Tiếng Đức:\s*(\d+(?:\.\d+)?)`,
|
|
"tieng_nhat": `Tiếng Nhật:\s*(\d+(?:\.\d+)?)`,
|
|
"tieng_trung": `Tiếng Trung:\s*(\d+(?:\.\d+)?)`,
|
|
}
|
|
|
|
// ScorePatterns holds the subject regexes, compiled once at package init.
|
|
var ScorePatterns = func() map[string]*regexp.Regexp {
|
|
out := make(map[string]*regexp.Regexp, len(scorePatternSources))
|
|
for field, src := range scorePatternSources {
|
|
out[field] = regexp.MustCompile(src)
|
|
}
|
|
return out
|
|
}()
|