mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-08-16 19:22:19 +00:00
Reading the file where it lay never worked well enough. Two costs were structural rather than bugs: reads are serial, because the worker uses synchronous XHR, so a name search touching 390 pages waited 17 seconds to move 608 KB — roughly one request per result row, which no page size removes — and the first visitor after each deploy waited ~26 seconds for the CDN to fill its cache with a 288 MB object. The browser now downloads the whole database once and queries it in memory with sql.js. A dataset page is gated behind that: the gate states what it will cost, in transfer and in memory, and offers only the download, because there is nothing to show without it. Dropping the structures that existed to make range-request queries index-driven halved the file. name_word carried one row per word of every name, about 3.5 million of them, and with the partial score indexes it was more than half of what every visitor would now download. Measured on rebuilt databases: 2016 went 288.6 -> 142.5 MB (31 MB gzipped on the wire), 2017 237.7 -> 119.3 MB, both with row counts and audits unchanged. Queries on the result: an exam number is immediate, a name scans all 877,460 rows in about 240 ms. Alternatives were measured before choosing this. sqlite-wasm-http sizes files from a HEAD Content-Length with no override, so on a host that gzips it silently uses the compressed size. DuckDB-WASM ships 32-37 MB of WebAssembly before its Parquet extension, more than this whole download. Static pre-generated shards are the most robust option but cannot answer arbitrary SQL, and cannot stop early the way LIMIT does. The published name loses its chunk index, the byte budgets and the SQL consent modal go with the range reads that made them necessary, and the docs no longer describe a design the site does not use.
146 lines
4.8 KiB
Go
146 lines
4.8 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.
|
|
//
|
|
// One table and no secondary indexes. The browser downloads this file and
|
|
// queries it in memory, so an index buys a scan that already takes a few
|
|
// hundred milliseconds while costing tens of megabytes that every visitor
|
|
// pays for on the network.
|
|
//
|
|
// That is a reversal. An earlier design read the file over HTTP range
|
|
// requests, where any unindexed query pulls the whole table down, and it
|
|
// carried a name_word table — one row per word of every name, ~3.5 million of
|
|
// them — plus partial indexes on the score columns. Those made range-request
|
|
// queries seek instead of scan, and together they were more than half the
|
|
// published file.
|
|
//
|
|
// 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
|
|
);
|
|
`
|
|
|
|
// 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
|
|
}()
|