diff --git a/src/treatment/queries.mjs b/src/treatment/queries.mjs index 3671cc1..882ccf2 100644 --- a/src/treatment/queries.mjs +++ b/src/treatment/queries.mjs @@ -95,18 +95,37 @@ export function listLeads(db, filtro = {}) { const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc']; const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t; - const rows = db.prepare( + + // Pagina por id primeiro, sem as subconsultas de numero compartilhado: em + // ordenacoes que so casam parcialmente com o indice (ex.: prioridade:asc), + // o sort do SQLite materializa o bloco empatado inteiro antes do + // LIMIT/OFFSET, o que reavaliaria as subconsultas para o bloco inteiro (ate + // dezenas de milhares de linhas) em vez de so a pagina. Selecionando so o id + // aqui, o custo desse sort volta a ser o mesmo de antes da funcionalidade de + // numero compartilhado existir. + const pageIds = db.prepare( + `SELECT leads.id AS id + FROM leads + ${whereSql} + ORDER BY ${orderBy} + LIMIT ? OFFSET ?` + ).all(...params, pageSize, (page - 1) * pageSize).map((row) => row.id); + + if (pageIds.length === 0) return { total, page, pageSize, rows: [] }; + + const placeholders = pageIds.map(() => '?').join(', '); + const rowsById = new Map(db.prepare( `SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label, interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao, prioridade, produto_principal, telefone_valido, origem, exported_at, captured_at, segments.affinity AS afinidade, ${sharedNumberColumnsSql('leads.whatsapp')} FROM leads LEFT JOIN segments ON segments.name = leads.segmento - ${whereSql} - ORDER BY ${orderBy} - LIMIT ? OFFSET ?` - ).all(...params, pageSize, (page - 1) * pageSize) - .map((lead) => withSharedNumberState(withWhatsAppState(db, lead))); + WHERE leads.id IN (${placeholders})` + ).all(...pageIds) + .map((lead) => [lead.id, withSharedNumberState(withWhatsAppState(db, lead))])); + + const rows = pageIds.map((id) => rowsById.get(id)); return { total, page, pageSize, rows }; } diff --git a/tests/personal-backend.test.mjs b/tests/personal-backend.test.mjs index 3fc4bf9..2bfcc19 100644 --- a/tests/personal-backend.test.mjs +++ b/tests/personal-backend.test.mjs @@ -277,13 +277,36 @@ test('estado de numero compartilhado sai por linha, sem varrer a tabela inteira' ); assert.ok(elapsedMs < 1_000, `listagem paginada levou ${elapsedMs.toFixed(1)}ms`); - // Prova estrutural na consulta real: a subconsulta correlacionada procura os - // irmaos pelo indice do telefone, e a pagina e limitada no SQL. - assert.ok(trace.sql, 'consulta real de listagem nao foi observada'); - assert.match(trace.sql, /LIMIT \? OFFSET \?/); + // Prova estrutural na consulta real de hidratacao (a que traz as colunas de + // numero compartilhado): ela busca so os ids ja paginados (nao faz o + // LIMIT/OFFSET nem o sort ela mesma — isso acontece antes, na consulta so-id + // — e a subconsulta correlacionada procura os irmaos pelo indice do + // telefone. + assert.ok(trace.sql, 'consulta de hidratacao nao foi observada'); + assert.match(trace.sql, /WHERE leads\.id IN \(/); const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args) .map((row) => row.detail).join('\n'); assert.match(plan, /sharing USING [A-Z ]*INDEX idx_leads_whatsapp/i, plan); assert.doesNotMatch(plan, /SCAN sharing/i, plan); + + // A ordenacao padrao (prioridade:asc, score:desc, cidade:asc, empresa:asc) + // so casa parcialmente com o indice: os 60 mil leads de "ruido" empatam em + // prioridade=4/score=0, um bloco unico que o sort do SQLite materializa + // inteiro antes de aplicar LIMIT/OFFSET. A pagina 1 (os 3 leads especiais, + // prioridade=1) nao entra nesse bloco e por isso nao prova nada sobre ele; + // a pagina 2 cai direto nele. Se as subconsultas de numero compartilhado + // fossem reavaliadas para o bloco inteiro durante o sort (em vez de so para + // a pagina, apos paginar por id), essa consulta ficaria uma ordem de + // grandeza mais lenta aqui. + const page2StartedAt = performance.now(); + const page2 = listLeads(db, { pageSize: 3, page: 2 }).rows; + const page2ElapsedMs = performance.now() - page2StartedAt; + assert.equal(page2.length, 3); + assert.ok(page2.every((row) => row.empresa.startsWith('Ruido')), page2.map((r) => r.empresa).join(',')); + assert.ok( + page2ElapsedMs < elapsedMs * 5 + 200, + `pagina 2 (dentro do bloco de 60 mil empatados) levou ${page2ElapsedMs.toFixed(1)}ms ` + + `vs ${elapsedMs.toFixed(1)}ms na pagina 1 — sugere reavaliacao das subconsultas para o bloco inteiro` + ); db.close(); });