mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(query): clamp SQL rows and stabilize response stats
Clamp CLI SQL output after execution so user-provided LIMIT clauses cannot bypass the requested row cap. Add a deterministic message id tie-breaker for response-time statistics.
This commit is contained in:
1 parent
8fd3f397e0
commit
73553c4c2c
4 files changed
+48
-8
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { responseStatsMessagesSql } from './commands-stats'
|
||||
|
||||
describe('responseStatsMessagesSql', () => {
|
||||
it('orders same-second messages by id for deterministic response gaps', () => {
|
||||
const sql = responseStatsMessagesSql()
|
||||
|
||||
assert.match(sql, /SELECT\s+msg\.id,/i)
|
||||
assert.match(sql, /ORDER BY msg\.ts ASC, msg\.id ASC/i)
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,14 @@ function toTimeFilter(time: ReturnType<typeof parseTimeOptions>): { startTs: num
|
||||
return { startTs: time.startTs ?? 0, endTs: time.endTs ?? Math.floor(Date.now() / 1000) }
|
||||
}
|
||||
|
||||
export function responseStatsMessagesSql(): string {
|
||||
return `SELECT msg.id, msg.sender_id, COALESCE(m.group_nickname, m.account_name) AS name, msg.ts
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE msg.type = 0 AND msg.ts >= @startTs
|
||||
ORDER BY msg.ts ASC, msg.id ASC`
|
||||
}
|
||||
|
||||
export function registerStatsCommands(program: Command): void {
|
||||
const statsCmd = program
|
||||
.command('stats')
|
||||
@@ -259,11 +267,7 @@ export function registerStatsCommands(program: Command): void {
|
||||
const top = parseLimit(options.top, 10, 100, '--top')
|
||||
const rows = executeParameterizedSql<{ sender_id: number; name: string; ts: number }>(
|
||||
ctx.db,
|
||||
`SELECT msg.sender_id, COALESCE(m.group_nickname, m.account_name) AS name, msg.ts
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE msg.type = 0 AND msg.ts >= @startTs
|
||||
ORDER BY msg.ts ASC`,
|
||||
responseStatsMessagesSql(),
|
||||
{ startTs: time.startTs ?? 0 }
|
||||
)
|
||||
const items = computeResponseTimeStats(
|
||||
|
||||
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
assertSqlPrivacyAllowed,
|
||||
assertSqlResultPrivacyAllowed,
|
||||
clampSqlRows,
|
||||
parseSqlRowLimit,
|
||||
topicsAgentText,
|
||||
type TopicListItem,
|
||||
@@ -58,6 +59,16 @@ describe('parseSqlRowLimit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampSqlRows', () => {
|
||||
it('clamps SQL rows even when the executed SQL already had a LIMIT', () => {
|
||||
const rows = [{ id: 1 }, { id: 2 }, { id: 3 }]
|
||||
const result = clampSqlRows(rows, 2, false)
|
||||
|
||||
assert.deepEqual(result.rows, [{ id: 1 }, { id: 2 }])
|
||||
assert.equal(result.truncated, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertSqlPrivacyAllowed', () => {
|
||||
it('requires raw mode before SQL can read message content', () => {
|
||||
assert.throws(
|
||||
|
||||
@@ -24,6 +24,17 @@ export function parseSqlRowLimit(value: string | undefined): number {
|
||||
return parseLimit(value, 100, 1000, '--limit', 1)
|
||||
}
|
||||
|
||||
export function clampSqlRows(
|
||||
rows: Record<string, unknown>[],
|
||||
maxRows: number,
|
||||
alreadyTruncated: boolean
|
||||
): { rows: Record<string, unknown>[]; truncated: boolean } {
|
||||
if (maxRows <= 0 || rows.length <= maxRows) {
|
||||
return { rows, truncated: alreadyTruncated }
|
||||
}
|
||||
return { rows: rows.slice(0, maxRows), truncated: true }
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
@@ -335,7 +346,8 @@ export function registerSqlCommands(program: Command): void {
|
||||
}
|
||||
assertSqlResultPrivacyAllowed(result.columns, !!options.raw)
|
||||
|
||||
let rows = result.rows
|
||||
const clamped = clampSqlRows(result.rows, limit, result.truncated)
|
||||
let rows = clamped.rows
|
||||
let removed = 0
|
||||
if (!options.raw) {
|
||||
const rules = enabledRules(ctx)
|
||||
@@ -362,7 +374,7 @@ export function registerSqlCommands(program: Command): void {
|
||||
}
|
||||
|
||||
return {
|
||||
data: { columns: result.columns, rows, rowCount: rows.length, truncated: result.truncated },
|
||||
data: { columns: result.columns, rows, rowCount: rows.length, truncated: clamped.truncated },
|
||||
meta: {
|
||||
session: ctx.session,
|
||||
...(options.raw ? { preprocess: { raw: true } } : {}),
|
||||
@@ -372,7 +384,7 @@ export function registerSqlCommands(program: Command): void {
|
||||
rows.length === 0
|
||||
? 'No results.'
|
||||
: rows.map((r) => JSON.stringify(r)).join('\n') +
|
||||
`\n${rows.length} row(s)${result.truncated ? ' (truncated)' : ''}`,
|
||||
`\n${rows.length} row(s)${clamped.truncated ? ' (truncated)' : ''}`,
|
||||
}
|
||||
} finally {
|
||||
ctx.close()
|
||||
|
||||
Reference in new issue
Block a user