From fd7234bb2f1bee50ee07ee0a19c16ed472ff9819 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:11:28 +0100 Subject: [PATCH] feat(database): bring an SObject's rows read and written together Query rows and DML rows are separate limits, so an SObject both read and written was split across two bars and whole in neither. rowBudgets now rolls the two budgets up per SObject, and the section shows them as one bar: hue for which way an object leans, hover for the split. Held out of the Database view until the section scrolling work. --- .../database/components/DatabaseRowBudget.ts | 45 ++++++++++++++- .../__tests__/DatabaseRowBudget.test.ts | 35 +++++++++++- .../services/__tests__/rowBudget.test.ts | 57 +++++++++++++++++++ .../features/database/services/rowBudget.ts | 43 ++++++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) diff --git a/log-viewer/src/features/database/components/DatabaseRowBudget.ts b/log-viewer/src/features/database/components/DatabaseRowBudget.ts index 54fa9b55..5aab622e 100644 --- a/log-viewer/src/features/database/components/DatabaseRowBudget.ts +++ b/log-viewer/src/features/database/components/DatabaseRowBudget.ts @@ -20,6 +20,7 @@ import { inspectorSectionStyles } from '../../../styles/inspectorSection.styles. import { NO_STATEMENTS } from '../services/databaseOverview.js'; import { rowBudgets, + type ObjectRows, type RowBudget, type RowBudgetKind, type RowCount, @@ -28,9 +29,11 @@ import { import { kindColors } from './DatabaseOverview.js'; import { governorTier } from './GovernorSummary.js'; -/** More than a namespace bar: single-hue shades in a wide card, not a dock legend. */ +/** More than a namespace bar: shades in a wide card, not a dock legend. */ const MAX_OBJECTS = 8; +const OBJECTS_LABEL = 'Query and DML rows by SObject'; + const KIND_LABEL: Record = { SOQL: 'Query rows', DML: 'DML rows' }; /** @@ -63,12 +66,16 @@ export class DatabaseRowBudget extends LitElement { padding-top: var(--lana-space-sm); } + .budget__head, + .objects__head { + padding-bottom: var(--lana-space-2xs); + font-size: var(--lana-text-sm); + } + .budget__head { display: flex; align-items: baseline; gap: var(--lana-space-sm); - padding-bottom: var(--lana-space-2xs); - font-size: var(--lana-text-sm); } .budget__figure { @@ -114,6 +121,7 @@ export class DatabaseRowBudget extends LitElement { const shown = budgets.budgets.filter((budget) => (budget.used ?? budget.observed) > 0); return html` ${shown.map((budget) => this._budget(budget, colors[budget.kind]))} + ${budgets.objects.length > 0 ? this._objects(budgets.objects, colors) : ''} ${ shown.some(overLimit) ? html`

@@ -135,6 +143,37 @@ export class DatabaseRowBudget extends LitElement { `; } + /** + * Every SObject once, read beside written. The two limits are separate bars, so + * an object on both sides is only whole here, and its hue says which way it leans. + */ + private _objects( + objects: readonly ObjectRows[], + colors: Record, + ): TemplateResult { + const segments = segmentsWithTail( + objects, + (object, index) => ({ + label: object.sObject, + value: object.rows, + color: shade(colors[object.rowsWritten > object.rowsRead ? 'DML' : 'SOQL'], index), + detail: `${formatInteger(object.rowsRead)} read · ${formatInteger(object.rowsWritten)} written`, + }), + MAX_OBJECTS, + ); + return html` +

+

${OBJECTS_LABEL}

+ +
+ `; + } + /** One statement count, against its limit where the log names one. */ private _count(count: RowCount): TemplateResult { const limit = count.limit > 0 ? `/${formatInteger(count.limit)}` : ''; diff --git a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts index 4731b6a5..ff832b3d 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts @@ -41,6 +41,11 @@ const full = (): RowBudgets => ({ groups: [{ sObject: 'Case', rows: 300, statements: 1 }], }, ], + objects: [ + { sObject: 'Contact', rowsRead: 40_000, rowsWritten: 0, rows: 40_000 }, + { sObject: 'Account', rowsRead: 5_000, rowsWritten: 0, rows: 5_000 }, + { sObject: 'Case', rowsRead: 0, rowsWritten: 300, rows: 300 }, + ], counts: [ { label: 'SOQL', used: 62, limit: 100 }, { label: 'DML', used: 14, limit: 150 }, @@ -79,6 +84,9 @@ const texts = (element: Element, selector: string) => const bars = (element: Element) => [...(element.shadowRoot?.querySelectorAll('stacked-time-bar') ?? [])] as StackedTimeBar[]; +const barOf = (element: Element, label: string) => + bars(element).find((bar) => bar.getAttribute('label') === label); + beforeEach(() => { document.body.replaceChildren(); budgets = full(); @@ -97,7 +105,12 @@ describe('database-rows', () => { it('measures the bar against the limit, not against the rows it holds', async () => { const element = await mount(); - expect(bars(element).map((bar) => [bar.format(1_000), bar.total])).toEqual([ + expect( + ['Query rows', 'DML rows'].map((label) => { + const bar = barOf(element, label); + return [bar?.format(1_000), bar?.total]; + }), + ).toEqual([ ['1,000', 50_000], ['1,000', 10_000], ]); @@ -186,6 +199,26 @@ describe('database-rows', () => { ]); }); + it('brings the two limits together per SObject, read beside written', async () => { + const objects = barOf(await mount(), 'Query and DML rows by SObject'); + + expect( + objects?.segments.map((segment) => [segment.label, segment.value, segment.detail]), + ).toEqual([ + ['Contact', 40_000, '40,000 read · 0 written'], + ['Account', 5_000, '5,000 read · 0 written'], + ['Case', 300, '0 read · 300 written'], + ]); + // Rows against no one limit, so the bar measures itself. + expect(objects?.total).toBe(0); + }); + + it('leaves the SObject split out when the service brings nothing together', async () => { + budgets = { ...full(), objects: [] }; + + expect(texts(await mount(), '.objects__head')).toEqual([]); + }); + it('says so when the log records no database statements', async () => { budgets = { ...full(), statements: 0 }; diff --git a/log-viewer/src/features/database/services/__tests__/rowBudget.test.ts b/log-viewer/src/features/database/services/__tests__/rowBudget.test.ts index a14b745a..c49342fa 100644 --- a/log-viewer/src/features/database/services/__tests__/rowBudget.test.ts +++ b/log-viewer/src/features/database/services/__tests__/rowBudget.test.ts @@ -185,4 +185,61 @@ describe('rowBudgets', () => { { sObject: UNKNOWN_OBJECT, rows: 10, statements: 1 }, ]); }); + + it('brings an SObject read and written together, biggest total first', () => { + overview = overviewOf([ + statement({ rows: 100, maxRows: 100 }), + statement({ rows: 40, kind: 'DML', sObject: 'Account', repeats: 3 }), + statement({ rows: 60, maxRows: 60, sObject: 'Contact' }), + ]); + + expect(rowBudgets(logWith(1)).objects).toEqual([ + { sObject: 'Account', rowsRead: 100, rowsWritten: 40, rows: 140 }, + { sObject: 'Contact', rowsRead: 60, rowsWritten: 0, rows: 60 }, + ]); + }); + + it('leaves the split out while one limit holds every row, which is its own bar', () => { + overview = overviewOf([statement({ rows: 100, maxRows: 100 })]); + + expect(rowBudgets(logWith(1)).objects).toEqual([]); + }); + + it('brings one SObject together when the two sides name it in a different case', () => { + overview = overviewOf([ + statement({ rows: 100, maxRows: 100, sObject: 'account' }), + statement({ rows: 5, kind: 'DML', sObject: 'Account' }), + ]); + + expect(rowBudgets(logWith(1)).objects).toEqual([ + { sObject: 'account', rowsRead: 100, rowsWritten: 5, rows: 105 }, + ]); + }); + + it('leaves the unknown label out of the split, a bucket of objects and not one', () => { + overview = overviewOf([ + statement({ rows: 100, sObject: null }), + statement({ rows: 5, kind: 'DML', sObject: null }), + statement({ rows: 10, maxRows: 10 }), + statement({ rows: 2, kind: 'DML', sObject: 'Case' }), + ]); + + expect(rowBudgets(logWith(1)).objects).toEqual([ + { sObject: 'Account', rowsRead: 10, rowsWritten: 0, rows: 10 }, + { sObject: 'Case', rowsRead: 0, rowsWritten: 2, rows: 2 }, + ]); + }); + + it('leaves a search out of the SObject split, which holds rows against no total', () => { + overview = overviewOf([ + statement({ rows: 30, maxRows: 30, kind: 'SOSL', sObject: 'Lead' }), + statement({ rows: 10, maxRows: 10 }), + statement({ rows: 5, kind: 'DML', sObject: 'Case' }), + ]); + + expect(rowBudgets(logWith(1)).objects).toEqual([ + { sObject: 'Account', rowsRead: 10, rowsWritten: 0, rows: 10 }, + { sObject: 'Case', rowsRead: 0, rowsWritten: 5, rows: 5 }, + ]); + }); }); diff --git a/log-viewer/src/features/database/services/rowBudget.ts b/log-viewer/src/features/database/services/rowBudget.ts index ab6d60a8..c9070d0d 100644 --- a/log-viewer/src/features/database/services/rowBudget.ts +++ b/log-viewer/src/features/database/services/rowBudget.ts @@ -36,6 +36,15 @@ export interface RowBudget { groups: RowGroup[]; } +/** One SObject's rows, read beside written, across every statement that touched it. */ +export interface ObjectRows { + sObject: string; + rowsRead: number; + rowsWritten: number; + /** The two sides summed: the length the segment is drawn at. */ + rows: number; +} + /** A statement count against its own limit, for the counts line. */ export interface RowCount { label: string; @@ -46,6 +55,8 @@ export interface RowCount { /** Everything the Row budget section reads. */ export interface RowBudgets { budgets: RowBudget[]; + /** Every SObject once, read beside written, biggest first. Empty unless both limits hold rows. */ + objects: ObjectRows[]; counts: RowCount[]; /** The most rows one SOSL query returned, against its per-query cap. */ worstSearch: { rows: number; limit: number } | null; @@ -95,6 +106,8 @@ function build(log: ApexLog): RowBudgets { budgetOf('SOQL', soql, limits.queryRows, hasLimits ? limits.queryRows.used : null), budgetOf('DML', dml, limits.dmlRows, hasLimits ? limits.dmlRows.used : null), ], + // With one limit holding every row the roll-up says nothing the budget does not. + objects: soql.size > 0 && dml.size > 0 ? objectRows(soql, dml) : [], counts: [ count('SOQL', limits.soqlQueries, overview.time.soql.statements, hasLimits), count('DML', limits.dmlStatements, overview.time.dml.statements, hasLimits), @@ -118,6 +131,36 @@ function addRows(groups: Map, statement: DatabaseStatement): v } } +/** + * The two budgets rolled up per SObject. An object both read and written appears + * in each, and only here do its two halves meet. The two sides name the object + * from separate sources, so they can disagree on case; the unknown label is a + * bucket of objects, not one object, so it holds no roll-up. + */ +function objectRows(soql: Map, dml: Map): ObjectRows[] { + const merged = new Map(); + const addSide = (groups: Map, side: 'rowsRead' | 'rowsWritten'): void => { + for (const group of groups.values()) { + if (group.sObject === UNKNOWN_OBJECT) { + continue; + } + const key = group.sObject.toLowerCase(); + const object = merged.get(key) ?? { + sObject: group.sObject, + rowsRead: 0, + rowsWritten: 0, + rows: 0, + }; + object[side] += group.rows; + object.rows += group.rows; + merged.set(key, object); + } + }; + addSide(soql, 'rowsRead'); + addSide(dml, 'rowsWritten'); + return [...merged.values()].sort((a, b) => b.rows - a.rows); +} + /** The governor's count where the log holds one, else what the tree showed. */ function count( label: string,