修复bug

This commit is contained in:
LIm1tZz7 committed 2026-10-11 17:32:12 +08:00
1 parent fc9bbc20e4
commit e87c4ee6a8
90 files changed
+3828 -452

No files matched your search

+28 -5
View File
@@ -72,10 +72,12 @@ import ChuSQL.Interface.Link (
closeClient,
connectClient,
)
import ChuSQL.Interface.Normalization (NormalizationRequest, decodeNormalizationJSON)
import ChuSQL.Interface.Protocol (Grant (..), RoleView (..))
import ChuSQL.Interface.RateLimit (RateLimiter, rateLimitBlock, rateLimitClear, rateLimitRecord, setRateLimit)
import ChuSQL.Interface.Session (
Session,
analyzeNormalization,
authenticateSession,
catalog,
databases,
@@ -132,7 +134,7 @@ import qualified Data.Text.Encoding.Error as TEE
import qualified Data.Text.Lazy as TL
import Data.Time.Clock (getCurrentTime)
import qualified Data.Vector as V
import Network.HTTP.Types (Status, status204, status400, status401, status403, status404, status409, status413, status429, status500)
import Network.HTTP.Types (Status, status204, status400, status401, status403, status404, status409, status413, status429, status500, status503)
import Network.Wai (Application, Request (..))
import Text.Read (readMaybe)
import Web.Scotty
@@ -337,6 +339,7 @@ routes env = do
delete "/api/roles/:name/grants" (withDatabase env revokeRoleH)
post "/api/roles/:name/members" (withDatabase env addRoleMemberH)
delete "/api/roles/:name/members/:user" (withDatabase env removeRoleMemberH)
post "/api/normalization/analyze" (withDatabase env normalizationH)
post "/api/query" (withDatabase env queryH)
post "/api/query/rollback" (rollbackConsoleH env)
post "/api/edit/begin" (beginEditH env)
@@ -357,12 +360,13 @@ withDatabase env action = do
else do
editing <- header "X-ChuSQL-Edit"
req <- request
let snapshotRead = editing /= Nothing && requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"]
let snapshotRead = editing /= Nothing && ((requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"])
|| (requestMethod req == "POST" && pathInfo req == ["api", "normalization", "analyze"]))
console <- liftIO (readIORef (wsConsole ws))
caller <- consoleOwner
case console of
Just (owner, database) | not snapshotRead &&
(pathInfo req /= ["api", "query"] || caller /= owner || (not (T.null chosen) && chosen /= database)) ->
(pathInfo req `notElem` [["api", "query"], ["api", "normalization", "analyze"]] || caller /= owner || (not (T.null chosen) && chosen /= database)) ->
apiError "conflict" "another SQL console owns this connection transaction; commit or rollback there first"
_ -> pure ()
current <- liftIO (sessionDatabase (wsSession ws))
@@ -391,7 +395,8 @@ requireSession env = do
editHeader <- fmap TL.toStrict <$> header "X-ChuSQL-Edit"
req <- request
case editHeader of
Just editId | requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"] -> do
Just editId | (requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"])
|| (requestMethod req == "POST" && pathInfo req == ["api", "normalization", "analyze"]) -> do
active <- liftIO (readIORef (wsEdit ws))
database <- maybe "" (T.toLower . TL.toStrict) <$> header "X-ChuSQL-Database"
case active of
@@ -905,7 +910,7 @@ serverError message
(rawCode, rest) = T.breakOn ":" message
code = T.strip rawCode
detail = T.strip (T.drop 1 rest)
knownCodes = ["unauthorized", "forbidden", "not_found", "conflict", "bad_request", "no_database", "query_error", "storage_error", "too_large"]
knownCodes = ["unauthorized", "forbidden", "not_found", "conflict", "bad_request", "no_database", "query_error", "storage_error", "too_large", "too_busy", "timeout"]
-- | 取错误消息里的错误码
errorCodeOf :: Text -> Text
@@ -920,6 +925,8 @@ statusOf code = case code of
"conflict" -> status409
"too_many_attempts" -> status429
"too_large" -> status413
"too_busy" -> status503
"timeout" -> status503
"storage_error" -> status500
"internal" -> status500
_ -> status400
@@ -1901,3 +1908,19 @@ data QueryRequest = QueryRequest
instance FromJSON QueryRequest where
parseJSON = withObject "QueryRequest" $ \o -> QueryRequest <$> o .: "sql"
-- 使用现有连接视图转发只读范式请求。
normalizationH :: AppEnv -> ActionM ()
normalizationH env = do
ws <- requireSession env
rawBody <- body
case decodeNormalizationJSON rawBody of
Left message | BL.length rawBody > 65536 -> reply413
| otherwise -> reply400 "bad_request" message
Right value -> case A.fromJSON value :: A.Result NormalizationRequest of
A.Error message -> reply400 "bad_request" (T.pack message)
A.Success payload -> do
result <- liftIO (analyzeNormalization (wsSession ws) payload)
case result of
Left message -> serverError message
Right report -> json report
+21 -1
View File
@@ -21,7 +21,7 @@ function hasValidRows(body) {
async function request(url, init, database) {
const headers = new Headers(init && init.headers ? init.headers : undefined);
if (database) headers.set('X-ChuSQL-Database', database);
if (editing && editing.database === database && url.startsWith('/api/tables') && (!init || init.method === 'GET')) {
if (editing && editing.database === database && ((url.startsWith('/api/tables') && (!init || init.method === 'GET')) || url === '/api/normalization/analyze')) {
headers.set('X-ChuSQL-Edit', editing.id);
}
let response;
@@ -326,3 +326,23 @@ export async function loadUiSettings() {
export async function saveUiSettings(settings) {
await request('/api/ui-settings', jsonInit('PUT', settings));
}
// 在页面已有视图上请求只读范式分析。
export async function analyzeNormalization(database, table, declaration, expected) {
const payload = { api_version: 1, database, table };
if (expected) {
payload.expected_object = expected.object;
payload.expected_schema_fingerprint = expected.schema_fingerprint;
}
const init = jsonInit('POST', payload);
if (!editing && consoleTransaction?.database === database) {
init.headers['X-ChuSQL-Console'] = consoleTransaction.console;
}
// 原始声明直接嵌入,重复字段交给服务端明确拒绝。
init.body = `${JSON.stringify(payload).slice(0, -1)},"declaration":${typeof declaration === 'string' ? declaration : JSON.stringify(declaration)}}`;
const result = await request('/api/normalization/analyze', init, database);
if (!isObject(result) || result.api_version !== 1 || !Array.isArray(result.findings) || !Array.isArray(result.schema)) {
throw new Error('服务器返回的范式报告无法识别。');
}
return result;
}
+91 -2
View File
@@ -64,6 +64,8 @@ const state = {
dockHeight: '22vh',
};
let normalizationTarget = null;
const root = document.getElementById('root');
// ---------------------------------------------------------------- 小工具
@@ -210,6 +212,21 @@ function renderShell() {
<button type="button" class="primary" data-action="create-index">创建</button>
</footer>
</div>
</dialog>
<dialog id="dlg-normalization" class="dialog">
<div class="dialog-body">
<header class="dialog-header"><h2 id="normalization-title">范式分析</h2></header>
<div class="dialog-content">
<p>只读分析,以声明为条件。PRIMARY KEY、UNIQUE 和样本不会自动成为完整业务依赖;NULL 与重复行需要另行验证。</p>
<label class="field"><span>JSON 声明(完整性 false,原子性 null 表示未声明)</span><textarea id="normalization-declaration" rows="10"></textarea></label>
<p class="alert" id="normalization-error"></p>
<div id="normalization-result"></div>
</div>
<footer class="dialog-footer">
<button type="button" data-action="close-dialog">关闭</button>
<button type="button" class="primary" data-action="analyze-normalization" disabled>分析声明</button>
</footer>
</div>
</dialog>`;
renderAccount();
applyLayout();
@@ -282,7 +299,7 @@ function renderSidebar() {
<button class="mini danger" data-action="drop-table" data-table="${escapeHtml(table.name)}" title="删除表">✕</button>
</div>
${open ? `<ul class="column-list">${columns}</ul>${indexList}
<div class="tree-actions"><button class="mini" data-action="open-index-dialog" data-table="${escapeHtml(table.name)}">+ 索引</button></div>` : ''}
<div class="tree-actions">${state.database !== 'system' && !table.name.startsWith('__system_') ? `<button class="mini" data-action="open-normalization" data-table="${escapeHtml(table.name)}">范式分析</button>` : ''}<button class="mini" data-action="open-index-dialog" data-table="${escapeHtml(table.name)}">+ 索引</button></div>` : ''}
</li>`;
}).join('');
@@ -1383,7 +1400,7 @@ function startSplitterDrag(kind, event) {
// 这些动作都要求已经选中一个库(服务没有默认库)
const DATABASE_ACTIONS = new Set([
'open-table', 'demo-data', 'open-table-dialog', 'create-table',
'open-normalization', 'analyze-normalization', 'open-table', 'demo-data', 'open-table-dialog', 'create-table',
'open-index-dialog', 'create-index', 'drop-table', 'drop-column', 'drop-index',
'drop-database', 'commit', 'rollback', 'copy-sql',
]);
@@ -1413,7 +1430,14 @@ function onClick(event) {
}
switch (action) {
case 'open-normalization':
void openNormalization(target.dataset.table);
break;
case 'analyze-normalization':
void analyzeNormalizationDeclaration();
break;
case 'logout':
normalizationTarget = null;
void (async () => {
try {
await api.logout();
@@ -1790,3 +1814,68 @@ async function boot() {
}
void boot();
// 打开结构入口并绑定当前 schema 身份。
async function openNormalization(table) {
const declaration = { declared_keys: [], dependencies: [], dependencies_complete: false, atomic_attributes: null };
const target = { database: state.database, table, report: null };
normalizationTarget = target;
region('dlg-normalization').querySelector('[data-action="analyze-normalization"]').disabled = true;
region('normalization-title').textContent = `范式分析 · ${target.database}.${table}`;
region('normalization-declaration').value = JSON.stringify(declaration, null, 2);
region('normalization-result').textContent = '正在读取结构…';
region('normalization-error').textContent = '';
region('dlg-normalization').onclose = () => { normalizationTarget = null; };
region('dlg-normalization').showModal();
try {
target.report = await api.analyzeNormalization(target.database, table, declaration);
if (normalizationTarget === target) {
renderNormalization(target.report);
region('dlg-normalization').querySelector('[data-action="analyze-normalization"]').disabled = false;
}
} catch (error) {
if (normalizationTarget === target) {
region('normalization-result').textContent = '';
region('normalization-error').textContent = error.message;
}
}
}
// 提交显式声明并复核绑定的对象结构。
async function analyzeNormalizationDeclaration() {
const target = normalizationTarget;
if (!target?.report) return;
const button = region('dlg-normalization').querySelector('[data-action="analyze-normalization"]');
button.disabled = true;
region('normalization-error').textContent = '';
try {
const declaration = region('normalization-declaration').value;
const report = await api.analyzeNormalization(target.database, target.table, declaration, target.report);
if (normalizationTarget === target) {
target.report = report;
renderNormalization(report);
}
} catch (error) {
if (normalizationTarget === target) {
region('normalization-error').textContent = error.message;
if (error.code === 'conflict') {
target.report = null;
region('normalization-result').textContent = '对象或视图已变化,请关闭后重新打开范式分析。';
}
}
} finally {
button.disabled = !target.report;
}
}
// 展示条件结论、违反证据与一次分解。
function renderNormalization(report) {
const labels = { satisfied: '满足', violated: '违反', insufficient: '信息不足' };
const schema = report.schema.map((column) => `${column.name}: ${column.ty}${column.nullable ? '(可空)' : '(非空)'}`).join(',');
const findings = report.findings.map((finding) => `<li><strong>${escapeHtml(finding.normal_form.toUpperCase())}: ${escapeHtml(labels[finding.verdict] ?? finding.verdict)}</strong>
<p>${escapeHtml(finding.condition)}</p><pre>${escapeHtml(JSON.stringify(finding.witnesses, null, 2))}</pre></li>`).join('');
region('normalization-result').innerHTML = `<p>${escapeHtml(schema)}</p><p>视图:${escapeHtml(report.view)} · 结论以声明为条件</p>
<details><summary>结构与约束</summary><pre>${escapeHtml(JSON.stringify(report.schema, null, 2))}</pre></details>
<p>相对于声明的候选键:${escapeHtml(JSON.stringify(report.candidate_keys))}</p><ul>${findings}</ul>
<p>一次分解建议(无损标签不证明现有 SQL 数据):</p><pre>${escapeHtml(JSON.stringify(report.suggestions, null, 2))}</pre>`;
}
+96
View File
@@ -946,6 +946,102 @@ roleSpec env = describe "roles and grants" $ do
querySpec :: AppEnv -> Spec
querySpec env = describe "the SQL console" $ do
withApp (webApp env) $ do
it "analyzes explicit declarations for SELECT users and rejects stale schemas" $ do
admin <- adminHeaders
ensureDatabase admin testDatabaseName
ensureAccount admin "normalization_web_reader" "Reader-Secret-1234"
created <- testSql admin "CREATE TABLE normalization_web (id INT, course INT, name TEXT)"
expectStatusWith "normalization table" created 200
let declaration = object ["declared_keys" .= ([["id", "course"]] :: [[Text]]),
"dependencies" .= [object ["lhs" .= (["id"] :: [Text]), "rhs" .= (["name"] :: [Text])]],
"dependencies_complete" .= True, "atomic_attributes" .= True]
payload = object ["api_version" .= (1 :: Int), "database" .= testDatabaseName,
"table" .= ("normalization_web" :: Text), "declaration" .= declaration]
unauthorized <- postAs (withDb testDatabaseName []) "/api/normalization/analyze" (encode payload)
expectStatusWith "anonymous normalization" unauthorized 401
reader <- ordinaryHeaders "normalization_web_reader" "Reader-Secret-1234"
denied <- postAs (withDb testDatabaseName reader) "/api/normalization/analyze" (encode payload)
expectStatusWith "SELECT required" denied 403
granted <- testSql admin "GRANT SELECT ON normalization_web TO normalization_web_reader"
expectStatusWith "normalization SELECT" granted 200
readerAgain <- ordinaryHeaders "normalization_web_reader" "Reader-Secret-1234"
report <- postAs (withDb testDatabaseName readerAgain) "/api/normalization/analyze" (encode payload)
expectStatusWith "conditional report" report 200
check (at "conditional" (jsonBody report) `shouldBe` A.Bool True)
check (map (asText . at "verdict") (items (at "findings" (jsonBody report))) `shouldBe` ["satisfied", "violated", "violated", "violated"])
changed <- testSql admin "ALTER TABLE normalization_web ADD COLUMN extra INT"
expectStatusWith "change normalization schema" changed 200
let stale = object ["api_version" .= (1 :: Int), "database" .= testDatabaseName,
"table" .= ("normalization_web" :: Text), "declaration" .= declaration,
"expected_object" .= at "object" (jsonBody report), "expected_schema_fingerprint" .= at "schema_fingerprint" (jsonBody report)]
conflicted <- postAs (withDb testDatabaseName readerAgain) "/api/normalization/analyze" (encode stale)
expectStatusWith "schema changed" conflicted 409
it "uses a matching editing snapshot without creating a second transaction" $ do
admin <- adminHeaders
ensureDatabase admin testDatabaseName
created <- testSql admin "CREATE TABLE normalization_edit (id INT, value INT)"
expectStatusWith "normalization snapshot table" created 200
begun <- editRequest admin "/api/edit/begin" "normalization-owner" []
expectStatusWith "normalization edit begins" begun 200
changed <- testSql admin "ALTER TABLE normalization_edit ADD COLUMN extra INT"
expectStatusWith "live schema changes" changed 200
let payload = object ["api_version" .= (1 :: Int), "database" .= testDatabaseName,
"table" .= ("normalization_edit" :: Text), "declaration" .= object []]
snapshot <- postAs (("X-ChuSQL-Edit", "normalization-owner") : withDb testDatabaseName admin) "/api/normalization/analyze" (encode payload)
expectStatusWith "analysis on edit connection" snapshot 200
check (at "view" (jsonBody snapshot) `shouldBe` A.String "transaction_snapshot")
check (length (items (at "schema" (jsonBody snapshot))) `shouldBe` 2)
forged <- postAs (("X-ChuSQL-Edit", "another-page") : withDb testDatabaseName admin) "/api/normalization/analyze" (encode payload)
expectStatusWith "page identity mismatch" forged 409
rolledBack <- editRequest admin "/api/edit/rollback" "normalization-owner" []
expectStatusWith "release edit snapshot" rolledBack 200
it "binds normalization to the owning SQL console transaction" $ do
admin <- adminHeaders
ensureDatabase admin testDatabaseName
created <- testSql admin "CREATE TABLE normalization_console (id INT)"
expectStatusWith "console normalization table" created 200
let owner = ("X-ChuSQL-Console", "normalization-console") : admin
payload = object ["api_version" .= (1 :: Int), "database" .= testDatabaseName,
"table" .= ("normalization_console" :: Text), "declaration" .= object []]
begun <- testSql owner "BEGIN"
expectStatusWith "console begins" begun 200
snapshot <- postAs (withDb testDatabaseName owner) "/api/normalization/analyze" (encode payload)
expectStatusWith "owner console normalization" snapshot 200
check (at "view" (jsonBody snapshot) `shouldBe` A.String "transaction_snapshot")
denied <- postAs (withDb testDatabaseName admin) "/api/normalization/analyze" (encode payload)
expectStatusWith "unrelated console refused" denied 409
rolledBack <- testSql owner "ROLLBACK"
expectStatusWith "console ends" rolledBack 200
it "rejects duplicate declarations and oversized normalization bodies" $ do
admin <- adminHeaders
ensureDatabase admin testDatabaseName
duplicate <- postAs (withDb testDatabaseName admin) "/api/normalization/analyze"
"{\"api_version\":1,\"database\":\"test\",\"table\":\"users\",\"declaration\":{\"atomic_attributes\":true,\"atomic_attributes\":false}}"
expectStatusWith "duplicate declaration fields" duplicate 400
oversized <- postAs (withDb testDatabaseName admin) "/api/normalization/analyze" (BL.replicate 65537 32)
expectStatusWith "normalization request bound" oversized 413
it "exposes DISTINCT and self grants after grant option revocation" $ do
admin <- adminHeaders
ensureDatabase admin testDatabaseName
ensureAccount admin "http_option_reader" "Reader-Secret-1234"
created <- testSql admin "CREATE TABLE http_distinct_probe (id INT, value INT)"
expectStatusWith "distinct table" created 200
inserted <- testSql admin "INSERT INTO http_distinct_probe (id,value) VALUES (1,7),(2,7),(3,8)"
expectStatusWith "distinct rows" inserted 200
granted <- testSql admin "GRANT SELECT ON http_distinct_probe TO http_option_reader WITH GRANT OPTION"
expectStatusWith "grant option" granted 200
revoked <- testSql admin "REVOKE GRANT OPTION FOR SELECT ON http_distinct_probe FROM http_option_reader"
expectStatusWith "revoke only grant option" revoked 200
reader <- ordinaryHeaders "http_option_reader" "Reader-Secret-1234"
result <- testSql reader "SELECT DISTINCT value FROM http_distinct_probe ORDER BY 1 DESC LIMIT 2"
expectStatusWith "distinct output" result 200
check (at "rows" (jsonBody result) `shouldBe` A.toJSON ([[8], [7]] :: [[Int]]))
grants <- testSql reader "SHOW GRANTS"
expectStatusWith "own grants" grants 200
check (at "columns" (jsonBody grants) `shouldBe` A.toJSON (["identity", "privilege", "object", "grant_option"] :: [Text]))
denied <- testSql reader "GRANT SELECT ON http_distinct_probe TO admin"
expectStatusWith "no delegation" denied 403
it "returns typed compound values through HTTP and TCP" $ do
hs <- adminHeaders
ensureDatabase hs testDatabaseName
+50 -1
View File
@@ -12,7 +12,7 @@ globalThis.document = { getElementById: (id) => id === 'root' ? { addEventListen
let source = await readFile(new URL('../static/app.js', import.meta.url), 'utf8');
source = source.replaceAll("'./api.js'", JSON.stringify(new URL('../static/api.js', import.meta.url).href))
.replaceAll("'./core.js'", JSON.stringify(new URL('../static/core.js', import.meta.url).href))
.replace('void boot();', 'export { state, commitAll, rollbackAll, stageUpdate, removeStaged, ensureEdit, runSql };');
.replace('void boot();', 'export { state, commitAll, rollbackAll, stageUpdate, removeStaged, ensureEdit, runSql, renderNormalization };');
const app = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
let calls;
let commitResponse;
@@ -173,3 +173,52 @@ test('edits are locked while SQL releases the previous snapshot', async () => {
assert.equal(app.state.editBusy, false);
assert.equal(app.state.edit, null);
});
// 校验分析复用编辑视图并保留严格声明的原始字段。
test('normalization keeps snapshot ownership, exact identities and duplicate declaration fields', async () => {
await api.beginEdit('normalization-page', 'test');
const expected = { object: { database_id: '9007199254740993', object_id: '18446744073709551615' }, schema_fingerprint: 'schema' };
globalThis.fetch = async (url, init) => {
calls.push({ url, init });
return response({ api_version: 1, findings: [], schema: [] });
};
await api.analyzeNormalization('test', 'users', '{"atomic_attributes":true,"atomic_attributes":false}', expected);
const sent = calls.find((call) => call.url === '/api/normalization/analyze');
assert.equal(new Headers(sent.init.headers).get('X-ChuSQL-Edit'), 'normalization-page');
assert.equal(new Headers(sent.init.headers).get('X-ChuSQL-Database'), 'test');
assert.equal((sent.init.body.match(/"atomic_attributes"/g) ?? []).length, 2);
assert.deepEqual(JSON.parse(sent.init.body).expected_object, expected.object);
assert.equal(calls.filter((call) => call.url === '/api/edit/begin').length, 1);
api.forgetEdit();
globalThis.fetch = async (url, init) => {
calls.push({ url, init });
return response(url === '/api/query'
? { columns: [], rows: [], rowCount: 0, truncated: false, transaction: true }
: { api_version: 1, findings: [], schema: [] });
};
await api.query('BEGIN', 'test', 'normalization-console');
await api.analyzeNormalization('test', 'users', '{}', expected);
const fromConsole = calls.filter((call) => call.url === '/api/normalization/analyze').at(-1);
const begun = calls.filter((call) => call.url === '/api/query').at(-1);
assert.equal(new Headers(fromConsole.init.headers).get('X-ChuSQL-Console'), new Headers(begun.init.headers).get('X-ChuSQL-Console'));
assert.equal(new Headers(fromConsole.init.headers).get('X-ChuSQL-Edit'), null);
});
// 校验结构、违反证据和分解中的用户文字全部转义。
test('normalization escapes schema names, conditions, witnesses and suggestions', () => {
const result = { innerHTML: '' };
const original = document.getElementById;
document.getElementById = (id) => id === 'normalization-result' ? result : original(id);
try {
app.renderNormalization({ schema: [{ name: '<img src=x onerror=alert(1)>', ty: 'str', nullable: true }],
view: 'live_schema', candidate_keys: [['<script>']],
findings: [{ normal_form: 'bcnf', verdict: 'violated', condition: '<script>alert(1)</script>', witnesses: [{ lhs: ['<img>'], rhs: ['id'] }] }],
suggestions: [{ relations: [['<script>'], ['id']] }] });
assert.equal(result.innerHTML.includes('<img'), false);
assert.equal(result.innerHTML.includes('<script>'), false);
assert.equal(result.innerHTML.includes('&lt;img'), true);
assert.equal(result.innerHTML.includes('结构与约束'), true);
} finally {
document.getElementById = original;
}
});