From bb4bf27bf36fb5830a69175b605e4db326e9a12c Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:40:33 +0100 Subject: [PATCH 1/3] feat(database): governor-limit visibility, SOSL table, redesigned tab (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface which SOQL/DML consume governor limits and add first-class SOSL support. SOQL/DML: - Derive and show the queried SObject (Object column, default) so custom metadata (__mdt) is visible โ€” those queries may be free (they don't count toward the SOQL limit unless they select a long text area field or run in a Flow, which the log can't confirm). - Per-section reconciliation: Found (in log) vs Counted (CUMULATIVE_LIMIT_USAGE) with a delta, since per-query certainty isn't possible from the log alone. - Group by Object / Namespace / Caller Namespace on both tables. - Parser: structured DMLBeginLine.sObjectType (from the Type: field). SOSL (new): - getSOSLLines, searchable SOSLView, SOSL_VIEWS + database.sosl setting, and cross-table find routing (SOQL/DML/SOSL). Rows metered per query vs the 2,000-row cap; the section meters total vs the derived 20 x 2,000 ceiling. Redesigned Database tab: - Governor overview strip (DML/SOQL/SOSL + query/DML rows) with used/limit gauges. - Ordered DML -> SOQL -> SOSL sections, each an accent-headed collapsible panel (empty types auto-collapse) with inline tracked-vs-consumed metric stats on a shared left inset; 'limit n/a' when cumulative limits aren't logged. - limits.ts documents the governor numbers + doc URL for future reference. --- CHANGELOG.md | 6 + .../__tests__/EventMetadata.test.ts | 18 +- apex-log-parser/src/LogEvents.ts | 6 + apex-log-parser/src/index.ts | 1 + lana/package.json | 17 +- lana/src/commands/LogView.ts | 1 + lana/src/workspace/AppConfig.ts | 2 + log-viewer/src/__tests__/Database.test.ts | 18 + .../features/call-tree/utils/GovernorCost.ts | 5 +- .../features/database/components/DMLView.ts | 47 +- .../database/components/DatabaseMetricCard.ts | 194 +++++ .../database/components/DatabaseSection.ts | 127 ++- .../database/components/DatabaseView.ts | 301 ++++++- .../database/components/GovernorSummary.ts | 166 ++++ .../features/database/components/SOQLView.ts | 61 +- .../features/database/components/SOSLView.ts | 754 ++++++++++++++++++ log-viewer/src/features/database/limits.ts | 22 + .../features/database/services/Database.ts | 27 +- .../__tests__/sobjectClassification.test.ts | 55 ++ .../services/sobjectClassification.ts | 42 + log-viewer/src/features/settings/Settings.ts | 1 + log-viewer/src/tabulator/ColumnViews.ts | 18 +- .../tabulator/__tests__/ColumnViews.test.ts | 14 +- 23 files changed, 1812 insertions(+), 91 deletions(-) create mode 100644 log-viewer/src/features/database/components/DatabaseMetricCard.ts create mode 100644 log-viewer/src/features/database/components/GovernorSummary.ts create mode 100644 log-viewer/src/features/database/components/SOSLView.ts create mode 100644 log-viewer/src/features/database/limits.ts create mode 100644 log-viewer/src/features/database/services/__tests__/sobjectClassification.test.ts create mode 100644 log-viewer/src/features/database/services/sobjectClassification.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8723600e..70fac272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- ๐Ÿ—„๏ธ **Database tab: governor-limit visibility + SOSL**. ([#162]) + - ๐Ÿ“ **Limit gauge strip**: SOQL, SOSL, DML, query/DML rows, CPU and heap shown as `used / limit`, colored as they approach the limit. + - ๐Ÿงฎ **Found vs Counted**: each section reconciles statements found in the log against the governor-counted total, flagging queries that didn't consume the limit (e.g. custom metadata). + - ๐Ÿท๏ธ **Object column** (SOQL/DML): the queried SObject, with grouping by object. Custom metadata (`__mdt`) is visible via its suffix โ€” a reminder those queries may be free (they don't count toward the SOQL limit unless they select a long text area field or run in a Flow). + - ๐Ÿ”Ž **SOSL table**: a dedicated, searchable table with column views, grouping and CSV export. - ๐Ÿ”ด **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828]) - ๐Ÿงฉ **Call Tree, Analysis and Database tables** gain configurable columns and views. ([#298]) - ๐Ÿ—‚๏ธ **Column views**: switch each table between column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`); edit a view by showing/hiding columns from the **Columns** toolbar button or the header right-click menu, with inline **reset** to restore defaults; choices persist per view. @@ -511,6 +516,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea [#848]: https://github.com/certinia/debug-log-analyzer/issues/848 [#827]: https://github.com/certinia/debug-log-analyzer/issues/827 [#298]: https://github.com/certinia/debug-log-analyzer/issues/298 +[#162]: https://github.com/certinia/debug-log-analyzer/issues/162 [#32]: https://github.com/certinia/debug-log-analyzer/issues/32 diff --git a/apex-log-parser/__tests__/EventMetadata.test.ts b/apex-log-parser/__tests__/EventMetadata.test.ts index 98a6ae5d..2d4dd76e 100644 --- a/apex-log-parser/__tests__/EventMetadata.test.ts +++ b/apex-log-parser/__tests__/EventMetadata.test.ts @@ -1,8 +1,8 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { describe, expect } from '@jest/globals'; -import { getLogEventClass, parse } from '../src/index.js'; +import { describe, expect, it } from '@jest/globals'; +import { DMLBeginLine, getLogEventClass, parse } from '../src/index.js'; describe('Event debugLevel and debugCategory', () => { describe('new events are parsed (not null from getLogEventClass)', () => { @@ -89,4 +89,18 @@ describe('Event debugLevel and debugCategory', () => { expect(line!.debugLevel).toBe(expectedLevel); }); }); + + describe('DMLBeginLine.sObjectType', () => { + it.each([ + ['15:20:52.222 (100)|DML_BEGIN|[1]|Op:Insert|Type:Account|Rows:1', 'Account'], + [ + '15:20:52.222 (100)|DML_BEGIN|[1]|Op:Update|Type:ns2__MyObject__c|Rows:1', + 'ns2__MyObject__c', + ], + ])('parses the target object from %s', (logLine, expected) => { + const line = parse(logLine).children[0]; + expect(line).toBeInstanceOf(DMLBeginLine); + expect((line as DMLBeginLine).sObjectType).toBe(expected); + }); + }); }); diff --git a/apex-log-parser/src/LogEvents.ts b/apex-log-parser/src/LogEvents.ts index adf7ea2b..c58d7524 100644 --- a/apex-log-parser/src/LogEvents.ts +++ b/apex-log-parser/src/LogEvents.ts @@ -962,11 +962,17 @@ export class DMLBeginLine extends DurationLogEvent { total: 1, }; namespace = 'default'; + /** The SObject the DML targets (e.g. `Account`), from the `Type:` field. Null if absent. */ + sObjectType: string | null = null; constructor(parser: ApexLogParser, parts: string[]) { super(parser, parts, ['DML_END'], LOG_CATEGORY.DML, 'free'); this.lineNumber = this.parseLineNumber(parts[2]); this.text = 'DML ' + parts[3] + ' ' + parts[4]; + const typePart = parts[4]; + if (typePart?.startsWith('Type:')) { + this.sObjectType = typePart.slice('Type:'.length); + } const rowCountString = parts[5]; this.dmlRowCount.total = this.dmlRowCount.self = rowCountString ? parseRows(rowCountString) : 0; } diff --git a/apex-log-parser/src/index.ts b/apex-log-parser/src/index.ts index 498f7b71..0e44d939 100644 --- a/apex-log-parser/src/index.ts +++ b/apex-log-parser/src/index.ts @@ -38,6 +38,7 @@ export { MethodEntryLine, SOQLExecuteBeginLine, SOQLExecuteExplainLine, + SOSLExecuteBeginLine, parseObjectNamespace, parseRows, parseVfNamespace, diff --git a/lana/package.json b/lana/package.json index 9a2ae2d6..6a86bf8f 100644 --- a/lana/package.json +++ b/lana/package.json @@ -354,7 +354,8 @@ "enum": [ "General", "Performance", - "Query Plan" + "Query Plan", + "Limits" ], "markdownDescription": "The column view applied to the Database SOQL table. Default: `General`", "order": 0 @@ -365,10 +366,22 @@ "default": "General", "enum": [ "General", - "Timing" + "Timing", + "Limits" ], "markdownDescription": "The column view applied to the Database DML table. Default: `General`", "order": 2 + }, + "lana.database.sosl.columnView": { + "title": "SOSL column view", + "type": "string", + "default": "General", + "enum": [ + "General", + "Timing" + ], + "markdownDescription": "The column view applied to the Database SOSL table. Default: `General`", + "order": 4 } } } diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index 0d3afc20..3a497423 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -113,6 +113,7 @@ export class LogView { config.callTree.columnOverrides = overrides['callTree.columnOverrides'] ?? {}; config.database.soql.columnOverrides = overrides['database.soql.columnOverrides'] ?? {}; config.database.dml.columnOverrides = overrides['database.dml.columnOverrides'] ?? {}; + config.database.sosl.columnOverrides = overrides['database.sosl.columnOverrides'] ?? {}; panel.webview.postMessage({ requestId, cmd: 'getConfig', diff --git a/lana/src/workspace/AppConfig.ts b/lana/src/workspace/AppConfig.ts index 1c353ff3..554c5de4 100644 --- a/lana/src/workspace/AppConfig.ts +++ b/lana/src/workspace/AppConfig.ts @@ -41,6 +41,7 @@ interface Config { database: { soql: { columnView: string; columnOverrides: Record }; dml: { columnView: string; columnOverrides: Record }; + sosl: { columnView: string; columnOverrides: Record }; }; } @@ -75,6 +76,7 @@ export const COLUMN_OVERRIDE_SECTIONS = [ 'callTree.columnOverrides', 'database.soql.columnOverrides', 'database.dml.columnOverrides', + 'database.sosl.columnOverrides', ] as const; type ColumnOverrides = Record; diff --git a/log-viewer/src/__tests__/Database.test.ts b/log-viewer/src/__tests__/Database.test.ts index e107c2db..533d0ff4 100644 --- a/log-viewer/src/__tests__/Database.test.ts +++ b/log-viewer/src/__tests__/Database.test.ts @@ -26,6 +26,24 @@ describe('Analyse database tests', () => { const firstDML = result.getDMLLines()[0]; expect(firstDML?.text).toEqual('DML Op:Insert Type:codaCompany__c'); + expect(firstDML?.sObjectType).toEqual('codaCompany__c'); + }); + + it('collects SOSL statements', async () => { + const log = + '09:18:22.6 (6574780)|EXECUTION_STARTED\n' + + '09:18:22.6 (6586704)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + + '17:33:36.2 (1672655920)|SOSL_EXECUTE_BEGIN|[12]|FIND :searchQuery RETURNING Account(Id, Name)\n' + + '17:33:36.2 (1678684460)|SOSL_EXECUTE_END|[12]|Rows:5\n' + + '09:18:22.6 (7300000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + + '09:18:22.6 (7400000)|EXECUTION_FINISHED\n'; + + const apexLog = parse(log); + const result = await DatabaseAccess.create(apexLog); + + const soslLines = result.getSOSLLines(); + expect(soslLines.length).toEqual(1); + expect(soslLines[0]?.soslRowCount.self).toEqual(5); }); it('resolves stack by eventIndex when timestamps are duplicated', async () => { diff --git a/log-viewer/src/features/call-tree/utils/GovernorCost.ts b/log-viewer/src/features/call-tree/utils/GovernorCost.ts index 23426c9f..18622a9c 100644 --- a/log-viewer/src/features/call-tree/utils/GovernorCost.ts +++ b/log-viewer/src/features/call-tree/utils/GovernorCost.ts @@ -43,8 +43,9 @@ interface CostMetric { * Metrics that are attributed per call-tree node (and therefore comparable to * their limit per path). CPU time and callouts are intentionally excluded โ€” * they are only tracked globally, not per node. SOSL rows are also excluded: - * they have no governor limit (only SOSL *queries* is limited, to 20) and do - * not count against the SOQL query-rows limit. + * they have no per-transaction limit to accumulate against (the 2,000-row cap + * is per query) and don't count against the SOQL query-rows limit; only SOSL + * *queries* is a transaction total (limited to 20). */ const COST_METRICS: CostMetric[] = [ { label: 'SOQL', used: (r) => r.soqlCount.total, limit: (l) => l.soqlQueries.limit }, diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index fb83a727..e73ef012 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -13,7 +13,6 @@ import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenge import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { isVisible } from '../../../core/utility/Util.js'; import { getSettings, updateSetting } from '../../settings/Settings.js'; -import { DatabaseAccess } from '../services/Database.js'; import { applyColumnView, buildColumnMenuItems, @@ -46,13 +45,13 @@ import '../../../components/CallStack.js'; import '../../../components/ContextMenu.js'; import type { ContextMenu } from '../../../components/ContextMenu.js'; import '../../../components/datagrid-filter-bar.js'; -import './DatabaseSection.js'; /** The DML column is always shown in the DML table. */ const ALWAYS_VISIBLE = ['dml']; const groupLabelsToFields = new Map([ ['DML', 'dml'], + ['Object', 'objectType'], ['Namespace', 'namespace'], ['Caller Namespace', 'callerNamespace'], ['None', ''], @@ -69,8 +68,9 @@ export class DMLView extends LitElement { @property() oldIndex: number = 0; - @state() - dmlLines: DMLBeginLine[] = []; + /** DML lines to display; supplied by the parent DatabaseView. */ + @property({ attribute: false }) + lines: DMLBeginLine[] = []; dmlTable: Tabulator | null = null; holder: HTMLElement | null = null; @@ -119,8 +119,7 @@ export class DMLView extends LitElement { updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && - changedProperties.has('timelineRoot') && - !changedProperties.get('timelineRoot') + (changedProperties.has('lines') || changedProperties.has('timelineRoot')) ) { this._appendTableWhenVisible(); } @@ -158,8 +157,6 @@ export class DMLView extends LitElement { const dmlSkeleton = !this.timelineRoot ? html`` : ``; return html` - - DML + Object + Namespace Caller Namespace None @@ -234,7 +233,9 @@ export class DMLView extends LitElement { private _setColumnView(id: string) { this.columnView = id; - if (this.dmlTable) { + // Only apply once the table is laid out; otherwise tableBuilt โ†’ _initTableColumns + // applies the current view (redraw on an unrendered table throws). + if (this.dmlTable?.element?.clientHeight) { applyColumnView(this.dmlTable, this._columnViewFields(id), ALWAYS_VISIBLE); } } @@ -366,13 +367,9 @@ export class DMLView extends LitElement { return; } - isVisible(this).then(async (isVisible) => { - const treeRoot = this.timelineRoot; + isVisible(this).then((isVisible) => { const tableWrapper = this._dmlTableWrapper; - if (tableWrapper && treeRoot && isVisible) { - const dbAccess = await DatabaseAccess.create(treeRoot); - this.dmlLines = dbAccess.getDMLLines(); - + if (tableWrapper && this.timelineRoot && isVisible) { Tabulator.registerModule(Object.values(CommonModules)); Tabulator.registerModule([ RowKeyboardNavigation, @@ -382,7 +379,7 @@ export class DMLView extends LitElement { GroupChildIndent, GroupSort, ]); - this._renderDMLTable(tableWrapper, this.dmlLines); + this._renderDMLTable(tableWrapper, this.lines); } }); } @@ -448,6 +445,7 @@ export class DMLView extends LitElement { dmlData.push({ id: ++nextRowId, dml: dml.text, + objectType: dml.sObjectType, namespace: dml.namespace, callerNamespace: getCallerNamespace(dml), rowCount: dml.dmlRowCount.self, @@ -548,6 +546,22 @@ export class DMLView extends LitElement { }, headerFilterLiveFilter: false, }, + { + title: 'Object', + field: 'objectType', + sorter: 'string', + width: 150, + visible: false, + headerFilter: 'list', + headerFilterFunc: 'in', + headerFilterParams: { + valuesLookup: 'all', + clearable: true, + multiselect: true, + }, + headerFilterLiveFilter: false, + formatter: (cell) => (cell.getValue() as string | null) ?? 'โ€”', + }, { title: 'Namespace', field: 'namespace', @@ -743,6 +757,7 @@ type VSCodeSaveFile = { interface DMLRow { id: number; dml?: string; + objectType?: string | null; namespace?: string; callerNamespace?: string; rowCount?: number; diff --git a/log-viewer/src/features/database/components/DatabaseMetricCard.ts b/log-viewer/src/features/database/components/DatabaseMetricCard.ts new file mode 100644 index 00000000..04b8351a --- /dev/null +++ b/log-viewer/src/features/database/components/DatabaseMetricCard.ts @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LitElement, css, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { globalStyles } from '../../../styles/global.styles.js'; + +const integer = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); + +/** A single database limit metric: what the tables tracked vs what the limit is. */ +export interface DatabaseMetric { + /** Metric name, e.g. `Statements`, `Rows`, `Searches`. */ + label: string; + /** Count seen in the log and shown in the table. */ + found: number; + /** The governor-consumed total, or `null` when cumulative limits weren't logged. */ + used: number | null; + /** The governor limit (0 when none applies / unknown). */ + limit: number; + /** + * Overrides the default seen-vs-used reconciliation tooltip. Set when the limit + * is a derived ceiling that needs explaining (e.g. SOSL rows: 2,000/query ร— 20). + */ + note?: string; +} + +/** Fill tier: safe under 80%, warning from 80%, danger at or over 100%. */ +function tier(percent: number): 'safe' | 'warn' | 'danger' { + if (percent >= 100) { + return 'danger'; + } + return percent >= 80 ? 'warn' : 'safe'; +} + +/** + * One database limit metric as a small stacked stat: a `LABEL N seen ยท used / + * limit` line over a thin full-width bar. Shares the section header's language + * (no box or fill). Two states: + * - known โ†’ `used / limit` + proportional bar (tooltip: {@link DatabaseMetric.note} + * if set, else the seen-vs-used reconciliation); + * - unknown โ†’ `limit n/a` + empty bar, whole stat muted (never a guess). + */ +@customElement('database-metric-card') +export class DatabaseMetricCard extends LitElement { + @property({ attribute: false }) + metric: DatabaseMetric | null = null; + + static styles = [ + globalStyles, + css` + :host { + display: inline-flex; + } + + .stat { + display: inline-flex; + flex-direction: column; + gap: 4px; + min-width: 8rem; + } + + /* Unknown limit (no cumulative usage in the log): de-emphasised so an + empty bar reads as "no data", not "0%". */ + .stat.unknown { + opacity: 0.6; + } + + .stat__line { + display: inline-flex; + align-items: baseline; + gap: 6px; + white-space: nowrap; + font-size: 0.85rem; + } + + .stat__label { + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + } + + .stat__seen { + font-family: var(--vscode-editor-font-family, monospace); + font-variant-numeric: tabular-nums; + } + + .stat__used { + font-family: var(--vscode-editor-font-family, monospace); + font-variant-numeric: tabular-nums; + font-size: 0.8rem; + color: var(--vscode-descriptionForeground); + } + + .stat__used.na { + font-style: italic; + } + + .stat__sep { + color: var(--vscode-descriptionForeground); + } + + .stat__track { + display: block; + width: 100%; + height: 3px; + border-radius: 2px; + background: var(--vscode-editorWidget-border, var(--vscode-panel-border)); + overflow: hidden; + } + + .stat__fill { + display: block; + height: 100%; + border-radius: 2px; + transition: width 150ms ease; + } + + .stat__fill--safe { + background: var(--vscode-charts-green, #388a34); + } + .stat__fill--warn { + background: var(--vscode-charts-yellow, var(--vscode-editorWarning-foreground)); + } + .stat__fill--danger { + background: var(--vscode-errorForeground, #f14c4c); + } + + @media (prefers-reduced-motion: reduce) { + .stat__fill { + transition: none; + } + } + `, + ]; + + render() { + const metric = this.metric; + if (!metric) { + return nothing; + } + + const unknown = metric.used === null && metric.limit <= 0; + return html` + + ${metric.label} + ${integer.format(metric.found)} seen + ยท + ${this._renderConsumed(metric)} + + ${this._renderBar(metric)} + `; + } + + private _renderConsumed(metric: DatabaseMetric) { + if (metric.used === null) { + return html`limit n/a`; + } + + const delta = metric.found - metric.used; + const title = + metric.note ?? + (delta > 0 + ? `${delta} seen but not counted toward the limit (e.g. custom metadata).` + : delta < 0 + ? `${-delta} counted by Salesforce but not emitted as log lines (e.g. managed-package internals).` + : 'Every statement seen counted toward the limit.'); + return html`${integer.format(metric.used)} / ${integer.format(metric.limit)} used`; + } + + private _renderBar(metric: DatabaseMetric) { + // The track always renders โ€” even when the limit is unknown, where it stays + // empty โ€” so every metric block is the same height and their text lines up. + const hasFill = metric.used !== null && metric.limit > 0; + const percent = hasFill && metric.used !== null ? (metric.used / metric.limit) * 100 : 0; + return html`${ + hasFill + ? html`` + : nothing + }`; + } +} diff --git a/log-viewer/src/features/database/components/DatabaseSection.ts b/log-viewer/src/features/database/components/DatabaseSection.ts index a87bdd50..37800328 100644 --- a/log-viewer/src/features/database/components/DatabaseSection.ts +++ b/log-viewer/src/features/database/components/DatabaseSection.ts @@ -4,51 +4,126 @@ import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import type { LogEvent } from 'apex-log-parser'; - // web components -import '../../../components/BadgeBase.js'; +import './DatabaseMetricCard.js'; +import type { DatabaseMetric } from './DatabaseMetricCard.js'; // styles import { globalStyles } from '../../../styles/global.styles.js'; +/** + * A database section header: a collapsible title row carrying its limit metrics + * inline as flat stat chips. Clicking the header toggles the section; the host + * (DatabaseView) owns the accent bar, left inset, collapsed state and the table. + */ @customElement('database-section') export class DatabaseSection extends LitElement { + /** Short section name, e.g. `DML`. */ @property({ type: String }) title = ''; - @property({ type: Object, attribute: false }) - dbLines: LogEvent[] = []; + + @property({ type: Boolean, reflect: true }) + collapsed = false; + + @property({ attribute: false }) + metrics: DatabaseMetric[] = []; static styles = [ globalStyles, css` - .dbSection { - padding: 10px 5px 5px 0px; + :host { + display: block; + } + + .header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 18px; + width: 100%; + box-sizing: border-box; + /* Accent bar lives on the header only; the 12px inset puts the title on + the same 15px edge as the toolbar and table below. */ + padding: 8px 6px 8px 12px; + background: none; + border: none; + border-left: 3px solid var(--accent, transparent); + color: inherit; + cursor: pointer; + text-align: left; + } + + .header:hover { + background: var(--vscode-list-hoverBackground); + } + + .title-group { + display: flex; + align-items: center; + gap: 8px; } - .dbTitle { - font-weight: bold; - font-size: 1.2em; + + .chevron { + flex: 0 0 auto; + color: var(--vscode-descriptionForeground); + transition: transform 150ms ease; + } + + :host([collapsed]) .chevron { + transform: rotate(-90deg); + } + + .title { + font-weight: 600; + font-size: 1.05rem; + letter-spacing: 0.02em; } - .dbBlock { - margin-left: 10px; - font-weight: normal; + + .metrics { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 18px; + } + + @media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; + } } `, ]; render() { - const totalCount = this.dbLines.length; - let totalRows = 0; - this.dbLines.forEach((value) => { - totalRows += value.dmlRowCount.self || value.soqlRowCount.self || 0; - }); - - return html` -
- ${this.title} - Count: ${totalCount} - Rows: ${totalRows} -
- `; + return html``; + } + + private _toggle() { + this.dispatchEvent( + new CustomEvent('section-toggle', { + bubbles: true, + composed: true, + detail: { collapsed: !this.collapsed }, + }), + ); } } diff --git a/log-viewer/src/features/database/components/DatabaseView.ts b/log-viewer/src/features/database/components/DatabaseView.ts index bc0f9961..3426534d 100644 --- a/log-viewer/src/features/database/components/DatabaseView.ts +++ b/log-viewer/src/features/database/components/DatabaseView.ts @@ -2,10 +2,20 @@ * Copyright (c) 2022 Certinia Inc. All rights reserved. */ -import { LitElement, css, html } from 'lit'; +import { LitElement, css, html, nothing, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import type { ApexLog } from 'apex-log-parser'; +import type { + ApexLog, + DMLBeginLine, + Limits, + SOQLExecuteBeginLine, + SOSLExecuteBeginLine, +} from 'apex-log-parser'; + +import { isVisible } from '../../../core/utility/Util.js'; +import { SOSL_ROWS_PER_QUERY_LIMIT } from '../limits.js'; +import { DatabaseAccess } from '../services/Database.js'; // styles import { globalStyles } from '../../../styles/global.styles.js'; @@ -15,27 +25,44 @@ import '../../../components/CallStack.js'; import './DMLView.js'; import './DatabaseSOQLDetailPanel.js'; import './DatabaseSection.js'; +import type { DatabaseMetric } from './DatabaseMetricCard.js'; +import './GovernorSummary.js'; +import type { GaugeMetric } from './GovernorSummary.js'; import './SOQLView.js'; +import './SOSLView.js'; + +type SectionKind = 'dml' | 'soql' | 'sosl'; @customElement('database-view') export class DatabaseView extends LitElement { @property() timelineRoot: ApexLog | null = null; + @state() + private dmlLines: DMLBeginLine[] = []; + @state() + private soqlLines: SOQLExecuteBeginLine[] = []; + @state() + private soslLines: SOSLExecuteBeginLine[] = []; + @state() + private loaded = false; + + /** Per-section collapsed state; empty types default to collapsed once loaded. */ + @state() + private collapsed: Record = { dml: false, soql: false, sosl: false }; + + // Match totals per table, routing the shared find widget's current match to the + // right table, accumulated in view order: DML, then SOQL, then SOSL. dmlMatches = 0; soqlMatches = 0; + soslMatches = 0; @state() dmlHighlightIndex = 0; @state() soqlHighlightIndex = 0; - - findArgs: { text: string; count: number; options: { matchCase: boolean } } = { - text: '', - count: 0, - options: { matchCase: false }, - }; - findMap = {}; + @state() + soslHighlightIndex = 0; constructor() { super(); @@ -52,6 +79,34 @@ export class DatabaseView extends LitElement { document.removeEventListener('lv-find', this._findHandler as EventListener); } + updated(changed: PropertyValues): void { + if (this.timelineRoot && changed.has('timelineRoot') && !this.loaded) { + void this._loadData(); + } + } + + private async _loadData(): Promise { + const root = this.timelineRoot; + if (!root) { + return; + } + const visible = await isVisible(this); + if (!visible || this.loaded) { + return; + } + const db = await DatabaseAccess.create(root); + this.dmlLines = db.getDMLLines(); + this.soqlLines = db.getSOQLLines(); + this.soslLines = db.getSOSLLines(); + this.loaded = true; + // Collapse types the transaction never touched (usually SOSL). + this.collapsed = { + dml: this._isEmpty('dml'), + soql: this._isEmpty('soql'), + sosl: this._isEmpty('sosl'), + }; + } + static styles = [ globalStyles, css` @@ -61,22 +116,190 @@ export class DatabaseView extends LitElement { height: 100%; width: 100%; } + + governor-summary { + border-bottom: 1px solid var(--vscode-panel-border); + } + + .db-panel { + display: flex; + flex-direction: column; + } + + /* The accent bar lives on the section header (inherits --accent); the grid + view is inset to the same 15px content edge (3px bar + 12px header pad) + so header, toolbar and table line up on one left edge. */ + .db-panel--dml { + --accent: var(--vscode-charts-blue, #4e94ce); + } + .db-panel--soql { + --accent: var(--vscode-charts-purple, #b180d7); + } + .db-panel--sosl { + --accent: var(--vscode-charts-orange, #d18616); + } + + .db-panel dml-view, + .db-panel soql-view, + .db-panel sosl-view { + box-sizing: border-box; + padding-left: 15px; + } + + .db-panel + .db-panel { + margin-top: 16px; + border-top: 1px solid var(--vscode-panel-border); + padding-top: 8px; + } `, ]; render() { return html` - - + + ${this._renderSection('dml')} ${this._renderSection('soql')} ${this._renderSection('sosl')} `; } + private _renderSection(kind: SectionKind) { + const collapsed = this.collapsed[kind]; + return html`
+ + ${collapsed || !this.loaded ? nothing : this._renderTable(kind)} +
`; + } + + private _renderTable(kind: SectionKind) { + switch (kind) { + case 'dml': + return html``; + case 'soql': + return html``; + case 'sosl': + return html``; + } + } + + private _toggle(kind: SectionKind) { + this.collapsed = { ...this.collapsed, [kind]: !this.collapsed[kind] }; + } + + private get _limits(): Limits | undefined { + return this.timelineRoot?.governorLimits; + } + + /** Cumulative limits are only present when the log recorded a usage snapshot. */ + private get _hasLimits(): boolean { + return (this.timelineRoot?.governorLimits.snapshots.length ?? 0) > 0; + } + + private _rows(kind: SectionKind): number { + switch (kind) { + case 'dml': + return this.dmlLines.reduce((sum, l) => sum + l.dmlRowCount.self, 0); + case 'soql': + return this.soqlLines.reduce((sum, l) => sum + l.soqlRowCount.self, 0); + case 'sosl': + return this.soslLines.reduce((sum, l) => sum + l.soslRowCount.self, 0); + } + } + + private _count(kind: SectionKind): number { + return kind === 'dml' + ? this.dmlLines.length + : kind === 'soql' + ? this.soqlLines.length + : this.soslLines.length; + } + + private _used(value: number): number | null { + return this._hasLimits ? value : null; + } + + private _isEmpty(kind: SectionKind): boolean { + const limits = this._limits; + const consumed = limits ? SECTION_META[kind].statement(limits).used : 0; + return this._count(kind) === 0 && consumed === 0; + } + + /** The tracked-vs-consumed cards for a section. */ + private _sectionMetrics(kind: SectionKind): DatabaseMetric[] { + const limits = this._limits; + const statement = limits ? SECTION_META[kind].statement(limits) : { used: 0, limit: 0 }; + const rows = limits ? SECTION_META[kind].rows(limits) : { used: 0, limit: 0 }; + const metrics: DatabaseMetric[] = [ + { + label: SECTION_META[kind].statementLabel, + found: this._count(kind), + used: this._used(statement.used), + limit: statement.limit, + }, + ]; + if (kind === 'sosl') { + // SOSL rows aren't reported as a transaction total, but there's a hard + // ceiling: at most 2,000 rows per query ร— the SOSL-query limit (20). Meter + // the total against that derived ceiling; the per-query cap is on hover and + // metered per row in the table. + const maxQueries = limits?.soslQueries.limit || 20; + const total = this._rows(kind); + metrics.push({ + label: 'Rows', + found: total, + used: total, + limit: maxQueries * SOSL_ROWS_PER_QUERY_LIMIT, + note: `Up to ${SOSL_ROWS_PER_QUERY_LIMIT.toLocaleString()} rows per SOSL query ร— ${maxQueries} queries = ${(maxQueries * SOSL_ROWS_PER_QUERY_LIMIT).toLocaleString()} max per transaction.`, + }); + } else { + metrics.push({ + label: 'Rows', + found: this._rows(kind), + used: this._used(rows.used), + limit: rows.limit, + }); + } + return metrics; + } + + /** + * The overview strip gauges: statement counts then row counts. The full core + * set is always shown (even at zero) so gauge positions stay stable across + * logs and confirm coverage; fully-zero gauges are muted by the component. + */ + private _stripMetrics(): GaugeMetric[] { + if (!this.loaded) { + return []; + } + const limits = this._limits; + const gauges: GaugeMetric[] = []; + const add = (label: string, found: number, metric: { used: number; limit: number }) => { + gauges.push({ label, found, used: this._used(metric.used), limit: metric.limit }); + }; + const z = { used: 0, limit: 0 }; + add('DML', this._count('dml'), limits?.dmlStatements ?? z); + add('SOQL', this._count('soql'), limits?.soqlQueries ?? z); + add('SOSL', this._count('sosl'), limits?.soslQueries ?? z); + add('DML Rows', this._rows('dml'), limits?.dmlRows ?? z); + add('Query Rows', this._rows('soql'), limits?.queryRows ?? z); + return gauges; + } + _findHandler = ( e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>, ) => { @@ -88,25 +311,63 @@ export class DatabaseView extends LitElement { if (matchIndex <= this.dmlMatches) { this.dmlHighlightIndex = matchIndex; this.soqlHighlightIndex = 0; - } else { + this.soslHighlightIndex = 0; + } else if (matchIndex <= this.dmlMatches + this.soqlMatches) { this.soqlHighlightIndex = matchIndex - this.dmlMatches; this.dmlHighlightIndex = 0; + this.soslHighlightIndex = 0; + } else { + this.soslHighlightIndex = matchIndex - this.dmlMatches - this.soqlMatches; + this.dmlHighlightIndex = 0; + this.soqlHighlightIndex = 0; } }; - _findResults = (e: CustomEvent<{ totalMatches: number; type: 'dml' | 'soql' }>) => { + _findResults = (e: CustomEvent<{ totalMatches: number; type: SectionKind }>) => { if (e.detail.type === 'dml') { this.dmlMatches = e.detail.totalMatches; } else if (e.detail.type === 'soql') { this.soqlMatches = e.detail.totalMatches; + } else if (e.detail.type === 'sosl') { + this.soslMatches = e.detail.totalMatches; } this._find({ count: 1 }); document.dispatchEvent( new CustomEvent('lv-find-results', { - detail: { totalMatches: this.dmlMatches + this.soqlMatches }, + detail: { totalMatches: this.dmlMatches + this.soqlMatches + this.soslMatches }, }), ); }; } + +interface SectionSpec { + title: string; + statementLabel: string; + statement: (limits: Limits) => { used: number; limit: number }; + rows: (limits: Limits) => { used: number; limit: number }; +} + +// Which governor each type reconciles against. Order of use is DML, SOQL, SOSL โ€” +// DML first (as it always has been), SOSL last (usually absent). +const SECTION_META: Record = { + dml: { + title: 'DML', + statementLabel: 'Statements', + statement: (l) => l.dmlStatements, + rows: (l) => l.dmlRows, + }, + soql: { + title: 'SOQL', + statementLabel: 'Queries', + statement: (l) => l.soqlQueries, + rows: (l) => l.queryRows, + }, + sosl: { + title: 'SOSL', + statementLabel: 'Searches', + statement: (l) => l.soslQueries, + rows: (l) => l.queryRows, + }, +}; diff --git a/log-viewer/src/features/database/components/GovernorSummary.ts b/log-viewer/src/features/database/components/GovernorSummary.ts new file mode 100644 index 00000000..7a7d88d3 --- /dev/null +++ b/log-viewer/src/features/database/components/GovernorSummary.ts @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LitElement, css, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { globalStyles } from '../../../styles/global.styles.js'; + +const integer = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); + +/** One at-a-glance governor gauge for the overview strip. */ +export interface GaugeMetric { + label: string; + /** Count found in the log (fallback display when limits aren't available). */ + found: number; + /** Governor-consumed count, or `null` when cumulative limits weren't logged. */ + used: number | null; + /** Governor limit (0 when none applies). */ + limit: number; +} + +function tier(percent: number): 'safe' | 'warn' | 'danger' { + if (percent >= 100) { + return 'danger'; + } + return percent >= 80 ? 'warn' : 'safe'; +} + +/** + * The database overview strip: a single compact row of governor gauges + * (`used / limit`) for an at-a-glance read across statement types. Fully + * controlled โ€” the host passes the metrics to show, already filtered and + * ordered. + */ +@customElement('governor-summary') +export class GovernorSummary extends LitElement { + @property({ attribute: false }) + metrics: GaugeMetric[] = []; + + static styles = [ + globalStyles, + css` + :host { + display: block; + } + + .gauges { + display: flex; + flex-wrap: wrap; + gap: 8px 22px; + /* Left inset matches the sections' content edge (3px accent + 12px). */ + padding: 10px 12px 12px 15px; + } + + .gauge { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 6.5rem; + flex: 1 1 6.5rem; + max-width: 12rem; + } + + /* A governor with no activity and nothing consumed โ€” kept for stable + positions but de-emphasised. */ + .gauge.muted { + opacity: 0.5; + } + + .gauge__label { + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + } + + .gauge__value { + font-family: var(--vscode-editor-font-family, monospace); + font-variant-numeric: tabular-nums; + font-size: 0.95rem; + white-space: nowrap; + } + + .gauge__limit, + .gauge__na { + color: var(--vscode-descriptionForeground); + } + + .gauge__na { + font-size: 0.78rem; + font-style: italic; + } + + .gauge__track { + height: 5px; + border-radius: 3px; + background: var(--vscode-editorWidget-border, var(--vscode-panel-border)); + overflow: hidden; + } + + .gauge__fill { + height: 100%; + border-radius: 3px; + transition: width 150ms ease; + } + + .gauge__fill--safe { + background: var(--vscode-charts-green, #388a34); + } + .gauge__fill--warn { + background: var(--vscode-charts-yellow, var(--vscode-editorWarning-foreground)); + } + .gauge__fill--danger { + background: var(--vscode-errorForeground, #f14c4c); + } + + @media (prefers-reduced-motion: reduce) { + .gauge__fill { + transition: none; + } + } + `, + ]; + + render() { + if (!this.metrics.length) { + return nothing; + } + return html`
${this.metrics.map((m) => this._renderGauge(m))}
`; + } + + private _renderGauge(metric: GaugeMetric) { + const muted = metric.found === 0 && (metric.used ?? 0) === 0; + + if (metric.used === null || metric.limit <= 0) { + return html`
+ ${metric.label} + ${integer.format(metric.found)} seen +
`; + } + + const percent = (metric.used / metric.limit) * 100; + return html`
+ ${metric.label} + ${integer.format(metric.used)} + / ${integer.format(metric.limit)} +
+
+
+
`; + } +} diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index ee661f2d..14aa09cc 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -17,10 +17,10 @@ import type { ApexLog, SOQLExecuteBeginLine } from 'apex-log-parser'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { isVisible } from '../../../core/utility/Util.js'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; +import { deriveSoqlObject } from '../services/sobjectClassification.js'; import { soqlGroupHeader } from '../../soql/format/groupHeader.js'; import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; import { getSettings, updateSetting } from '../../settings/Settings.js'; -import { DatabaseAccess } from '../services/Database.js'; import { applyColumnView, buildColumnMenuItems, @@ -54,11 +54,20 @@ import '../../../components/ContextMenu.js'; import type { ContextMenu } from '../../../components/ContextMenu.js'; import '../../../components/datagrid-filter-bar.js'; import './DatabaseSOQLDetailPanel.js'; -import './DatabaseSection.js'; /** The SOQL column is always shown in the SOQL table. */ const ALWAYS_VISIBLE = ['soql']; +// Group-by dropdown label โ†’ row field. Labels that don't map 1:1 to a field +// name (Object, Caller Namespace) need this indirection. +const groupLabelsToFields = new Map([ + ['SOQL', 'soql'], + ['Object', 'objectType'], + ['Namespace', 'namespace'], + ['Caller Namespace', 'callerNamespace'], + ['None', ''], +]); + @customElement('soql-view') export class SOQLView extends LitElement { @property() @@ -67,11 +76,12 @@ export class SOQLView extends LitElement { @property() highlightIndex: number = 0; - @state() - oldIndex: number = 0; + /** SOQL lines to display; supplied by the parent DatabaseView. */ + @property({ attribute: false }) + lines: SOQLExecuteBeginLine[] = []; @state() - soqlLines: SOQLExecuteBeginLine[] = []; + oldIndex: number = 0; soqlTable: Tabulator | null = null; holder: HTMLElement | null = null; @@ -124,8 +134,7 @@ export class SOQLView extends LitElement { updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && - changedProperties.has('timelineRoot') && - !changedProperties.get('timelineRoot') + (changedProperties.has('lines') || changedProperties.has('timelineRoot')) ) { this._appendTableWhenVisible(); } @@ -162,8 +171,6 @@ export class SOQLView extends LitElement { render() { const soqlSkeleton = !this.timelineRoot ? html`` : ``; return html` - - SOQL + Object Namespace + Caller Namespace None @@ -238,7 +247,9 @@ export class SOQLView extends LitElement { private _setColumnView(id: string) { this.columnView = id; - if (this.soqlTable) { + // Only apply once the table is laid out; otherwise tableBuilt โ†’ _initTableColumns + // applies the current view (redraw on an unrendered table throws). + if (this.soqlTable?.element?.clientHeight) { applyColumnView(this.soqlTable, this._columnViewFields(id), ALWAYS_VISIBLE); } } @@ -356,8 +367,7 @@ export class SOQLView extends LitElement { return; } const target = event.target as HTMLInputElement; - const fieldName = target.value.toLowerCase(); - const groupValue = fieldName !== 'none' ? fieldName : ''; + const groupValue = groupLabelsToFields.get(target.value) ?? ''; //@ts-expect-error This is a custom function added in the GroupSort custom module this.soqlTable.setSortedGroupBy(groupValue); } @@ -367,12 +377,9 @@ export class SOQLView extends LitElement { return; } - isVisible(this).then(async (isVisible) => { - const treeRoot = this.timelineRoot; + isVisible(this).then((isVisible) => { const tableWrapper = this._soqlTableWrapper; - if (tableWrapper && treeRoot && isVisible) { - this.soqlLines = (await DatabaseAccess.create(treeRoot)).getSOQLLines(); - + if (tableWrapper && this.timelineRoot && isVisible) { Tabulator.registerModule(Object.values(CommonModules)); Tabulator.registerModule([ RowKeyboardNavigation, @@ -382,7 +389,7 @@ export class SOQLView extends LitElement { GroupChildIndent, GroupSort, ]); - this._renderSOQLTable(tableWrapper, this.soqlLines); + this._renderSOQLTable(tableWrapper, this.lines); } }); } @@ -462,6 +469,7 @@ export class SOQLView extends LitElement { rowCount: soql.soqlRowCount.self, timeTaken: soql.duration.total, aggregations: soql.aggregations, + objectType: deriveSoqlObject(soql), leadingOperationType: explainLine?.leadingOperationType ?? null, sObjectType: explainLine?.sObjectType ?? null, cardinality: explainLine?.cardinality ?? null, @@ -612,6 +620,22 @@ export class SOQLView extends LitElement { return data.relativeCost; }, }, + { + title: 'Object', + field: 'objectType', + sorter: 'string', + width: 150, + visible: false, + headerFilter: 'list', + headerFilterFunc: 'in', + headerFilterParams: { + valuesLookup: 'all', + clearable: true, + multiselect: true, + }, + headerFilterLiveFilter: false, + formatter: (cell) => (cell.getValue() as string | null) ?? 'โ€”', + }, { title: 'Namespace', field: 'namespace', @@ -898,6 +922,7 @@ interface GridSOQLData { rowCount?: number | null; timeTaken?: number | null; aggregations?: number; + objectType?: string | null; leadingOperationType?: string | null; sObjectType?: string | null; cardinality?: number | null; diff --git a/log-viewer/src/features/database/components/SOSLView.ts b/log-viewer/src/features/database/components/SOSLView.ts new file mode 100644 index 00000000..6f5fa055 --- /dev/null +++ b/log-viewer/src/features/database/components/SOSLView.ts @@ -0,0 +1,754 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import '#vscode-elements/vscode-option.js'; +import '../../../components/VsSelect.js'; +import '#vscode-elements/vscode-toolbar-button.js'; +import { LitElement, css, html, render, unsafeCSS, type PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { Tabulator, type GroupComponent, type RowComponent } from 'tabulator-tables'; + +import type { ApexLog, SOSLExecuteBeginLine } from 'apex-log-parser'; +import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; +import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; +import { isVisible } from '../../../core/utility/Util.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; +import { + applyColumnView, + buildColumnMenuItems, + getColumnView, + getTableFields, + resolveColumnView, + SOSL_VIEWS, + toggleField, +} from '../../../tabulator/ColumnViews.js'; + +// Tabulator custom modules, imports + styles +import NumberAccessor from '../../../tabulator/dataaccessor/Number.js'; +import Number from '../../../tabulator/format/Number.js'; +import { progressFormatter } from '../../../tabulator/format/Progress.js'; +import { SOSL_ROWS_PER_QUERY_LIMIT } from '../limits.js'; +import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js'; +import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js'; +import { GroupSort } from '../../../tabulator/groups/GroupSort.js'; +import * as CommonModules from '../../../tabulator/module/CommonModules.js'; +import { Find } from '../../../tabulator/module/Find.js'; +import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; +import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; +import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; + +// styles +import { globalStyles } from '../../../styles/global.styles.js'; +import databaseViewStyles from './DatabaseView.scss'; + +// web components +import '../../../components/CallStack.js'; +import '../../../components/ContextMenu.js'; +import type { ContextMenu } from '../../../components/ContextMenu.js'; +import '../../../components/datagrid-filter-bar.js'; + +/** The SOSL column is always shown in the SOSL table. */ +const ALWAYS_VISIBLE = ['sosl']; + +const groupLabelsToFields = new Map([ + ['SOSL', 'sosl'], + ['Namespace', 'namespace'], + ['Caller Namespace', 'callerNamespace'], + ['None', ''], +]); + +const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); + +@customElement('sosl-view') +export class SOSLView extends LitElement { + @property() + timelineRoot: ApexLog | null = null; + + @property() + highlightIndex: number = 0; + + @property() + oldIndex: number = 0; + + /** SOSL lines to display; supplied by the parent DatabaseView. */ + @property({ attribute: false }) + lines: SOSLExecuteBeginLine[] = []; + + soslTable: Tabulator | null = null; + holder: HTMLElement | null = null; + table: HTMLElement | null = null; + findArgs: { text: string; count: number; options: { matchCase: boolean } } = { + text: '', + count: 0, + options: { matchCase: false }, + }; + findMap: { [key: number]: RowComponent } = {}; + totalMatches = 0; + blockClearHighlights = true; + + @state() + columnView = 'General'; + + /** Per-view column overrides (view id โ†’ visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; + + constructor() { + super(); + + document.addEventListener('lv-find', this._findEvt); + document.addEventListener('lv-find-close', this._findEvt); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('lv-find', this._findEvt); + document.removeEventListener('lv-find-close', this._findEvt); + } + + firstUpdated(): void { + this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.database?.sosl?.columnOverrides ?? {}; + this._setColumnView(resolveColumnView(SOSL_VIEWS, settings.database?.sosl?.columnView)); + } + + updated(changedProperties: PropertyValues): void { + if ( + this.timelineRoot && + (changedProperties.has('lines') || changedProperties.has('timelineRoot')) + ) { + this._appendTableWhenVisible(); + } + + if (changedProperties.has('highlightIndex')) { + this._highlightMatches(this.highlightIndex); + } + } + + static styles = [ + unsafeCSS(dataGridStyles), + unsafeCSS(databaseViewStyles), + globalStyles, + css` + :host { + display: flex; + flex-direction: column; + width: 100%; + } + + #sosl-table-container { + height: 100%; + } + + #db-sosl-table { + overflow: hidden; + table-layout: fixed; + width: 100%; + margin-bottom: 1rem; + } + `, + ]; + + render() { + const soslSkeleton = !this.timelineRoot ? html`` : ``; + + return html` + + + ${SOSL_VIEWS.map( + (view) => + html`${view.id}`, + )} + + + + SOSL + Namespace + Caller Namespace + None + + +
+ + + +
+
+ +
+ ${soslSkeleton} +
+
+ + `; + } + + private _handleColumnViewChange(event: Event) { + const id = (event.target as HTMLInputElement).value || 'General'; + this._setColumnView(id); + updateSetting('database.sosl.columnView', id); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private _columnViewFields(id: string): string[] | null { + return this.columnOverrides[id] ?? getColumnView(SOSL_VIEWS, id)?.fields ?? null; + } + + private _setColumnView(id: string) { + this.columnView = id; + // Only apply once the table is laid out; otherwise tableBuilt โ†’ _initTableColumns + // applies the current view (redraw on an unrendered table throws). + if (this.soslTable?.element?.clientHeight) { + applyColumnView(this.soslTable, this._columnViewFields(id), ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once the table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + this._showColumnMenu(event.clientX, event.clientY); + }); + } + + private _showColumnMenu(x: number, y: number) { + if (!this.contextMenu || !this.soslTable) { + return; + } + this.contextMenu.show( + buildColumnMenuItems( + this.soslTable, + this.columnView, + SOSL_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), + x, + y, + ); + } + + private _openColumnMenu(event: Event) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showColumnMenu(rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.soslTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.soslTable, + this.columnView, + SOSL_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { + const { itemId } = e.detail; + const table = this.soslTable; + if (!table) { + return; + } + if (itemId.startsWith('view:')) { + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('database.sosl.columnView', id); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('col:')) { + const field = itemId.slice('col:'.length); + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + applyColumnView(table, fields, ALWAYS_VISIBLE); + updateSetting('database.sosl.columnOverrides', this.columnOverrides); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); + } + } + + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + const table = this.soslTable; + if (!table || !this.columnOverrides[id]) { + return; + } + const { [id]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + if (id === this.columnView) { + applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); + } + updateSetting('database.sosl.columnOverrides', this.columnOverrides); + } + + _copyToClipboard() { + this.soslTable?.copyToClipboard('all'); + } + + _exportToCSV() { + this.soslTable?.download('csv', 'sosl.csv', { bom: true, delimiter: ',' }); + } + + _findEvt = ((event: FindEvt) => { + this._find(event); + }) as EventListener; + + _soslGroupBy(event: Event) { + if (!this.soslTable) { + return; + } + const target = event.target as HTMLInputElement; + const groupValue = groupLabelsToFields.get(target.value) ?? ''; + //@ts-expect-error This is a custom function added in the GroupSort custom module + this.soslTable.setSortedGroupBy(groupValue); + } + + get _soslTableWrapper(): HTMLDivElement | null { + return this.renderRoot?.querySelector('#db-sosl-table'); + } + + _appendTableWhenVisible() { + if (this.soslTable) { + return; + } + + isVisible(this).then((isVisible) => { + const tableWrapper = this._soslTableWrapper; + if (tableWrapper && this.timelineRoot && isVisible) { + Tabulator.registerModule(Object.values(CommonModules)); + Tabulator.registerModule([ + RowKeyboardNavigation, + RowNavigation, + Find, + GroupCalcs, + GroupChildIndent, + GroupSort, + ]); + this._renderSOSLTable(tableWrapper, this.lines); + } + }); + } + + async _highlightMatches(highlightIndex: number) { + if (!this.soslTable?.element?.clientHeight) { + return; + } + + this.findArgs.count = highlightIndex; + const currentRow = this.findMap[highlightIndex]; + this.blockClearHighlights = true; + //@ts-expect-error This is a custom function added in by Find custom module + await this.soslTable.setCurrentMatch(highlightIndex, currentRow, { + scrollIfVisible: false, + focusRow: false, + }); + this.blockClearHighlights = false; + this.oldIndex = highlightIndex; + } + + async _find(e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>) { + const isTableVisible = !!this.soslTable?.element?.clientHeight; + if (!isTableVisible && !this.totalMatches) { + return; + } + + const newFindArgs = JSON.parse(JSON.stringify(e.detail)); + const newSearch = + newFindArgs.text !== this.findArgs.text || + newFindArgs.options.matchCase !== this.findArgs.options?.matchCase; + this.findArgs = newFindArgs; + + const clearHighlights = e.type === 'lv-find-close'; + if (clearHighlights) { + newFindArgs.text = ''; + } + if (newSearch || clearHighlights) { + this.blockClearHighlights = true; + //@ts-expect-error This is a custom function added in by Find custom module + const result = await this.soslTable.find(this.findArgs); + this.blockClearHighlights = false; + this.totalMatches = result.totalMatches; + this.findMap = result.matchIndexes; + + if (!clearHighlights) { + document.dispatchEvent( + new CustomEvent('db-find-results', { + detail: { totalMatches: result.totalMatches, type: 'sosl' }, + }), + ); + } + } + } + + _renderSOSLTable(soslTableContainer: HTMLElement, soslLines: SOSLExecuteBeginLine[]) { + const soslData: SOSLRow[] = []; + let nextRowId = 0; + if (soslLines) { + for (const sosl of soslLines) { + soslData.push({ + id: ++nextRowId, + sosl: sosl.text, + namespace: sosl.namespace, + callerNamespace: getCallerNamespace(sosl), + rowCount: sosl.soslRowCount.self, + timeTaken: sosl.duration.total, + eventIndex: sosl.eventIndex, + _children: [ + { + id: ++nextRowId, + eventIndex: sosl.eventIndex, + isDetail: true, + }, + ], + }); + } + } + + this.soslTable = new Tabulator(soslTableContainer, { + index: 'id', + height: '100%', + clipboard: true, + downloadEncoder: this.downlodEncoder('sosl.csv'), + downloadRowRange: 'all', + downloadConfig: { + columnHeaders: true, + columnGroups: true, + rowGroups: true, + columnCalcs: false, + dataTree: true, + }, + //@ts-expect-error types need update array is valid + keybindings: { copyToClipboard: ['ctrl + 67', 'meta + 67'] }, + clipboardCopyRowRange: 'all', + rowKeyboardNavigation: true, + data: soslData, + layout: 'fitColumns', + placeholder: 'No SOSL queries found', + columnCalcs: 'table', + groupCalcs: true, + groupSort: true, + groupClosedShowCalcs: true, + groupStartOpen: false, + groupToggleElement: false, + selectableRowsCheck: function (row: RowComponent) { + return !row.getData().isDetail; + }, + selectableRows: 'highlight', + dataTree: true, + dataTreeBranchElement: false, + dataTreeStartExpanded: false, + columnDefaults: { + title: 'default', + resizable: true, + headerSortStartingDir: 'desc', + headerTooltip: true, + headerWordWrap: true, + }, + headerSortElement: function (_column, dir) { + switch (dir) { + case 'asc': + return "
"; + case 'desc': + return "
"; + default: + return "
"; + } + }, + columns: [ + { + title: 'SOSL', + field: 'sosl', + sorter: 'string', + bottomCalc: () => { + return 'Total'; + }, + headerSortTristate: true, + cssClass: 'datagrid-textarea datagrid-code-text', + variableHeight: true, + formatter: (cell, _formatterParams, _onRendered) => { + const data = cell.getData() as SOSLRow; + return ``; + }, + }, + { + title: 'Namespace', + field: 'namespace', + sorter: 'string', + width: 120, + headerFilter: 'list', + headerFilterFunc: 'in', + headerFilterParams: { + valuesLookup: 'all', + clearable: true, + multiselect: true, + }, + headerFilterLiveFilter: false, + }, + { + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, + }, + { + // SOSL's row limit is per query (2,000), not a transaction total โ€” so + // each row meters against that per-query cap; the footer is a plain sum. + title: 'Row Count', + field: 'rowCount', + sorter: 'number', + cssClass: 'number-cell', + width: 100, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams: { + precision: 0, + totalValue: SOSL_ROWS_PER_QUERY_LIMIT, + showPercentageText: false, + }, + // The group/total is a plain sum (a per-query bar there would be + // meaningless); use an integer formatter, NOT the nsโ†’ms `Number` one. + bottomCalc: 'sum', + bottomCalcFormatter: (cell) => countFormat.format((cell.getValue() as number) ?? 0), + tooltip: (_e, cell) => `${cell.getValue()} / ${SOSL_ROWS_PER_QUERY_LIMIT} per query`, + }, + { + title: 'Time Taken (ms)', + field: 'timeTaken', + sorter: 'number', + cssClass: 'number-cell', + width: 110, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: Number, + formatterParams: { + thousand: false, + precision: 2, + }, + accessorDownload: NumberAccessor, + bottomCalcFormatter: Number, + bottomCalc: 'sum', + bottomCalcFormatterParams: { precision: 2 }, + }, + ], + rowFormatter: (row) => { + const data = row.getData(); + if (data.isDetail && data.eventIndex !== undefined) { + const detailContainer = this.createDetailPanel(data.eventIndex); + row.getElement().replaceChildren(detailContainer); + } + }, + }); + + this.soslTable.on('groupClick', (_e: UIEvent, group: GroupComponent) => { + const { type } = window.getSelection() ?? {}; + if (type === 'Range') { + return; + } + + group.toggle(); + if (this.soslTable && group.isVisible()) { + this.soslTable.blockRedraw(); + for (const row of group.getRows()) { + if (row.getTreeChildren() && !row.isTreeExpanded()) { + row.treeExpand(); + } + } + this.soslTable.restoreRedraw(); + } + }); + + this.soslTable.on('rowClick', function (_e, row) { + const { type } = window.getSelection() ?? {}; + if (type === 'Range') { + return; + } + + const data = row.getData(); + if (!(data.eventIndex !== undefined && data.sosl)) { + return; + } + + const origRowHeight = row.getElement().offsetHeight; + row.treeToggle(); + row.getCell('sosl').getElement().style.height = origRowHeight + 'px'; + }); + + this.soslTable.on('tableBuilt', () => { + const holder = this._getTableHolder(); + holder.style.overflowAnchor = 'none'; + //@ts-expect-error This is a custom function added in the GroupSort custom module + this.soslTable?.setSortedGroupBy('sosl'); + if (this.soslTable) { + this._initTableColumns(this.soslTable); + } + }); + + this.soslTable.on('renderComplete', () => { + const holder = this._getTableHolder(); + const table = this._getTable(); + holder.style.minHeight = Math.min(holder.clientHeight, table.clientHeight) + 'px'; + }); + + this.soslTable.on('dataSorted', () => { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + }); + + this.soslTable.on('dataGrouped', () => { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + }); + + this.soslTable.on('dataFiltering', () => { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + }); + } + + _resetFindWidget() { + document.dispatchEvent( + new CustomEvent('db-find-results', { + detail: { totalMatches: 0, type: 'sosl' }, + }), + ); + } + + private _clearSearchHighlights() { + this.findArgs.text = ''; + this.findArgs.count = 0; + //@ts-expect-error This is a custom function added in by Find custom module + this.soslTable.clearFindHighlights(); + this.findMap = {}; + this.totalMatches = 0; + + document.dispatchEvent( + new CustomEvent('db-find-results', { + detail: { totalMatches: this.totalMatches, type: 'sosl' }, + }), + ); + } + + _getTable() { + this.table ??= this.soslTable?.element.querySelector('.tabulator-table') as HTMLElement; + return this.table; + } + + _getTableHolder() { + this.holder = this.soslTable?.element.querySelector('.tabulator-tableholder') as HTMLElement; + return this.holder; + } + + createDetailPanel(eventIndex: number) { + const detailContainer = document.createElement('div'); + detailContainer.className = 'row__details-container'; + render(html``, detailContainer); + + return detailContainer; + } + + downlodEncoder(defaultFileName: string) { + return function (fileContents: string, mimeType: string) { + const vscode = vscodeMessenger.getVsCodeAPI(); + if (vscode) { + vscodeMessenger.send('saveFile', { + fileContent: fileContents, + options: { + defaultFileName: defaultFileName, + }, + }); + return false; + } + + return new Blob([fileContents], { type: mimeType }); + }; + } +} + +type VSCodeSaveFile = { + fileContent: string; + options: { + defaultFileName: string; + }; +}; + +interface SOSLRow { + id: number; + sosl?: string; + namespace?: string; + callerNamespace?: string; + rowCount?: number; + timeTaken?: number; + eventIndex?: number; + isDetail?: boolean; + _children?: SOSLRow[]; +} + +type FindEvt = CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>; diff --git a/log-viewer/src/features/database/limits.ts b/log-viewer/src/features/database/limits.ts new file mode 100644 index 00000000..b59acd69 --- /dev/null +++ b/log-viewer/src/features/database/limits.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Canonical reference for the numbers used across the Database tab. Check here + * when a limit looks wrong or Salesforce changes one. + * + * Per-transaction limits (tracked in CUMULATIVE_LIMIT_USAGE): SOQL queries 100, + * SOSL queries 20, SOQL query rows 50,000, DML statements 150, DML rows 10,000. + * Per-query limit (NOT a transaction total): a single SOSL query returns at most + * 2,000 rows โ€” see {@link SOSL_ROWS_PER_QUERY_LIMIT}. + */ +export const APEX_GOVERNOR_LIMITS_DOC = + 'https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits.htm'; + +/** + * Maximum records returned by a *single* SOSL query. This is a per-query cap, + * not a cumulative per-transaction total โ€” so it's metered per row in the SOSL + * table, not summed against a transaction limit. + */ +export const SOSL_ROWS_PER_QUERY_LIMIT = 2000; diff --git a/log-viewer/src/features/database/services/Database.ts b/log-viewer/src/features/database/services/Database.ts index f2464c4f..9eb0af8b 100644 --- a/log-viewer/src/features/database/services/Database.ts +++ b/log-viewer/src/features/database/services/Database.ts @@ -1,7 +1,13 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { type ApexLog, DMLBeginLine, type LogEvent, SOQLExecuteBeginLine } from 'apex-log-parser'; +import { + type ApexLog, + DMLBeginLine, + type LogEvent, + SOQLExecuteBeginLine, + SOSLExecuteBeginLine, +} from 'apex-log-parser'; export type Stack = LogEvent[]; @@ -80,4 +86,23 @@ export class DatabaseAccess { return results; } + + public getSOSLLines(line: LogEvent = DatabaseAccess._treeRoot): SOSLExecuteBeginLine[] { + const results: SOSLExecuteBeginLine[] = []; + + const children = line.children; + const len = children.length; + for (let i = 0; i < len; ++i) { + const child = children[i]; + if (child instanceof SOSLExecuteBeginLine) { + results.push(child); + } + + if (child?.isParent) { + Array.prototype.push.apply(results, this.getSOSLLines(child)); + } + } + + return results; + } } diff --git a/log-viewer/src/features/database/services/__tests__/sobjectClassification.test.ts b/log-viewer/src/features/database/services/__tests__/sobjectClassification.test.ts new file mode 100644 index 00000000..934deeac --- /dev/null +++ b/log-viewer/src/features/database/services/__tests__/sobjectClassification.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import type { SOQLExecuteBeginLine } from 'apex-log-parser'; + +import { deriveSoqlObject, parseFromObject } from '../sobjectClassification.js'; + +/** Minimal SOQL line stub โ€” only the fields deriveSoqlObject reads. */ +function soqlLine(text: string, explainSObject?: string): SOQLExecuteBeginLine { + return { + text, + children: explainSObject ? [{ sObjectType: explainSObject }] : [], + } as unknown as SOQLExecuteBeginLine; +} + +describe('parseFromObject', () => { + it('reads a simple FROM clause', () => { + expect(parseFromObject('SELECT Id FROM Account')).toBe('Account'); + }); + + it('is case-insensitive on the FROM keyword', () => { + expect(parseFromObject('select Id from Contact')).toBe('Contact'); + }); + + it('reads namespaced custom / metadata objects', () => { + expect(parseFromObject('SELECT Id FROM ns__Config__mdt')).toBe('ns__Config__mdt'); + expect(parseFromObject('SELECT Id FROM ns2__MyObject__c')).toBe('ns2__MyObject__c'); + }); + + it('ignores a subquery FROM and returns the outer object', () => { + expect(parseFromObject('SELECT Id, (SELECT Id FROM Contacts) FROM Account')).toBe('Account'); + }); + + it('handles nested subqueries', () => { + const query = 'SELECT Id, (SELECT Id, (SELECT Id FROM Notes) FROM Contacts) FROM Account'; + expect(parseFromObject(query)).toBe('Account'); + }); + + it('returns null when there is no FROM', () => { + expect(parseFromObject('DELETE something')).toBeNull(); + expect(parseFromObject(undefined)).toBeNull(); + }); +}); + +describe('deriveSoqlObject', () => { + it('prefers the query-plan sObjectType when present', () => { + const line = soqlLine('SELECT Id FROM Account', 'Contact'); + expect(deriveSoqlObject(line)).toBe('Contact'); + }); + + it('falls back to the FROM clause without a query plan', () => { + expect(deriveSoqlObject(soqlLine('SELECT Id FROM Opportunity'))).toBe('Opportunity'); + }); +}); diff --git a/log-viewer/src/features/database/services/sobjectClassification.ts b/log-viewer/src/features/database/services/sobjectClassification.ts new file mode 100644 index 00000000..66e3e6f9 --- /dev/null +++ b/log-viewer/src/features/database/services/sobjectClassification.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { SOQLExecuteBeginLine } from 'apex-log-parser'; + +/** + * Derives the queried SObject name for the Database SOQL view. (An earlier + * revision also classified whether the object counts toward the SOQL limit โ€” + * dropped because it's a 1:1 function of the object name, so the name alone + * carries the signal, e.g. the `__mdt` suffix.) + */ + +/** + * The outermost `FROM` object of a SOQL string, best-effort. Parenthesised + * groups (inner SELECTs, function args) are stripped first so a subquery's + * `FROM` can't be mistaken for the main object. Returns `null` when no `FROM` + * is found. + */ +export function parseFromObject(soql: string | undefined): string | null { + if (!soql) { + return null; + } + // Collapse nested parentheses from the inside out until none remain. + let stripped = soql; + let previous: string; + do { + previous = stripped; + stripped = stripped.replace(/\([^()]*\)/g, ' '); + } while (stripped !== previous); + + const match = /\bFROM\s+([A-Za-z0-9_]+)/i.exec(stripped); + return match?.[1] ?? null; +} + +/** + * The queried SObject name for a SOQL line. Prefers the query-plan `sObjectType` + * (accurate, present only at Database=FINEST); otherwise falls back to a + * best-effort parse of the query text's `FROM` clause. + */ +export function deriveSoqlObject(soql: SOQLExecuteBeginLine): string | null { + return soql.children[0]?.sObjectType ?? parseFromObject(soql.text); +} diff --git a/log-viewer/src/features/settings/Settings.ts b/log-viewer/src/features/settings/Settings.ts index 97c57779..40ab3617 100644 --- a/log-viewer/src/features/settings/Settings.ts +++ b/log-viewer/src/features/settings/Settings.ts @@ -37,6 +37,7 @@ export type LanaSettings = { database: { soql: { columnView: string; columnOverrides: Record }; dml: { columnView: string; columnOverrides: Record }; + sosl: { columnView: string; columnOverrides: Record }; }; }; diff --git a/log-viewer/src/tabulator/ColumnViews.ts b/log-viewer/src/tabulator/ColumnViews.ts index 9f902c00..445afa09 100644 --- a/log-viewer/src/tabulator/ColumnViews.ts +++ b/log-viewer/src/tabulator/ColumnViews.ts @@ -90,17 +90,31 @@ export const CALL_TREE_VIEWS: ColumnView[] = [ /** Column views for the SOQL database table. */ export const SOQL_VIEWS: ColumnView[] = [ - { id: 'General', fields: ['isSelective', 'namespace', 'rowCount', 'timeTaken', 'aggregations'] }, + { + // Object is visible by default (its __mdt suffix flags the "does this count + // toward the SOQL limit?" case of #162). The derived Counts column lives in + // the focused Limits view to avoid duplicating that signal. + id: 'General', + fields: ['isSelective', 'objectType', 'namespace', 'rowCount', 'timeTaken', 'aggregations'], + }, { id: 'Performance', fields: ['isSelective', 'relativeCost', 'rowCount', 'timeTaken'] }, { id: 'Query Plan', fields: ['relativeCost', 'leadingOperationType', 'sObjectType', 'cardinality'], }, + { id: 'Limits', fields: ['objectType', 'namespace', 'rowCount', 'timeTaken'] }, ]; /** Column views for the DML database table. */ export const DML_VIEWS: ColumnView[] = [ - { id: 'General', fields: ['callerNamespace', 'rowCount', 'timeTaken'] }, + { id: 'General', fields: ['objectType', 'callerNamespace', 'rowCount', 'timeTaken'] }, + { id: 'Timing', fields: ['rowCount', 'timeTaken'] }, + { id: 'Limits', fields: ['objectType', 'callerNamespace', 'rowCount'] }, +]; + +/** Column views for the SOSL database table. */ +export const SOSL_VIEWS: ColumnView[] = [ + { id: 'General', fields: ['namespace', 'callerNamespace', 'rowCount', 'timeTaken'] }, { id: 'Timing', fields: ['rowCount', 'timeTaken'] }, ]; diff --git a/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts index 2e3de171..3ca644ec 100644 --- a/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts +++ b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts @@ -14,6 +14,7 @@ import { getVisibleFields, resolveColumnView, SOQL_VIEWS, + SOSL_VIEWS, toggleField, } from '../ColumnViews.js'; @@ -231,8 +232,17 @@ describe('view sets', () => { ); }); - it('SOQL views are General/Performance/Query Plan (no redundant Rows & Time)', () => { - expect(SOQL_VIEWS.map((v) => v.id)).toEqual(['General', 'Performance', 'Query Plan']); + it('SOQL views are General/Performance/Query Plan/Limits', () => { + expect(SOQL_VIEWS.map((v) => v.id)).toEqual(['General', 'Performance', 'Query Plan', 'Limits']); + }); + + it('SOQL and DML expose the object-type column by default', () => { + expect(getColumnView(SOQL_VIEWS, 'General')?.fields).toContain('objectType'); + expect(getColumnView(DML_VIEWS, 'General')?.fields).toContain('objectType'); + }); + + it('SOSL views are General/Timing', () => { + expect(SOSL_VIEWS.map((v) => v.id)).toEqual(['General', 'Timing']); }); it('getTableFields returns every column field in order', () => { From 2f8901e6192d7501a00173de2bf17b5f887ea42c Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:06:06 +0100 Subject: [PATCH 2/3] docs(database): document governor-limit visibility + SOSL; tidy changelog (#162) - README: Database Analysis covers the governor overview strip, tracked-vs-consumed reconciliation, the searchable SOSL table, and the Object column / group-by. Screenshot flagged for re-capture. - docs site: database page gains a Governor limits section and refreshed Column Views + Group sections. - CHANGELOG: merge the overlapping #162/#298 entries into one; order the Unreleased section by impact (Changed now leads with the vscode-elements migration, cosmetic styling last). --- CHANGELOG.md | 20 +++++++--------- README.md | 16 +++++++------ lana-docs/docs/docs/features/database.md | 29 +++++++++++++++++------- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fac272..7041efda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,25 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- ๐Ÿ—„๏ธ **Database tab: governor-limit visibility + SOSL**. ([#162]) - - ๐Ÿ“ **Limit gauge strip**: SOQL, SOSL, DML, query/DML rows, CPU and heap shown as `used / limit`, colored as they approach the limit. - - ๐Ÿงฎ **Found vs Counted**: each section reconciles statements found in the log against the governor-counted total, flagging queries that didn't consume the limit (e.g. custom metadata). - - ๐Ÿท๏ธ **Object column** (SOQL/DML): the queried SObject, with grouping by object. Custom metadata (`__mdt`) is visible via its suffix โ€” a reminder those queries may be free (they don't count toward the SOQL limit unless they select a long text area field or run in a Flow). - - ๐Ÿ”Ž **SOSL table**: a dedicated, searchable table with column views, grouping and CSV export. +- ๐Ÿ—„๏ธ **Database tab + configurable table columns**: governor-limit visibility, a SOSL table, and column views across tables. ([#162] [#298]) + - ๐Ÿ“ **Governor-limit overview** (Database): SOQL, SOSL, DML and query/DML rows shown as `used / limit`, colored as they approach the limit. + - ๐Ÿงฎ **Found vs Counted** (Database): each section reconciles statements found in the log against the governor-counted total, flagging queries that didn't consume the limit (e.g. custom metadata, which is free unless it selects a long text area field or runs in a Flow). + - ๐Ÿ”Ž **SOSL table**: a dedicated, searchable Database table. + - ๐Ÿ—‚๏ธ **Column views** (Call Tree, Analysis, Database): switch preset column sets, show/hide columns from the **Columns** button or the header right-click menu, inline **reset** to restore defaults; choices persist per view. + - ๐Ÿท๏ธ **New columns**: **Object** (queried/target SObject, with group-by) on SOQL/DML; **SOSL Count/Rows**, **Avg Self Time** and optional **Self** variants for every governor metric; **Heap Allocated** (+ Self) via the `Memory` view and Timeline strip; and a SOQL **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality). - ๐Ÿ”ด **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828]) -- ๐Ÿงฉ **Call Tree, Analysis and Database tables** gain configurable columns and views. ([#298]) - - ๐Ÿ—‚๏ธ **Column views**: switch each table between column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`); edit a view by showing/hiding columns from the **Columns** toolbar button or the header right-click menu, with inline **reset** to restore defaults; choices persist per view. - - ๐Ÿ“Š **New columns**: **SOSL Count/Rows**, **Avg Self Time**, and optional **Self** variants for every governor metric. - - ๐Ÿง  **Heap / memory analysis**: **Heap Allocated** (+ Self) columns, surfaced through the `Memory` view and the Timeline governor limit strip. - - ๐Ÿ”Ž **SOQL Query Plan** view: Relative Cost, Leading Operation, SObject Type and Cardinality. ### Changed +- โ™ป๏ธ Replaced the deprecated `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]). +- ๐ŸŽ›๏ธ **Modernised dropdowns**: searchable, compact controls that carry the field and value in one place (e.g. `Group: Namespace`, `Type: All`) ([#848]). - ๐Ÿ“Š **Timeline governor limits**: tooltip rows keep a stable order and always show the `used / limit` value, so figures no longer jump around as you move the pointer. ([#827]) - ๐Ÿšง **Timeline truncation markers** now end where the log recovers, so trusted sections are no longer greyed out. ([#828]) -- ๐ŸŽ›๏ธ **Modernised dropdowns**: searchable, compact controls that carry the field and value in one place (e.g. `Group: Namespace`, `Type: All`) ([#848]). - ๐Ÿ—‚๏ธ **Call Tree + Database styling**: VS Code style tree icons, and rows indent under their group headings. ([#832]). -- โ™ป๏ธ Replaced the deprecated `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]). ## [1.20.0] 2026-06-18 diff --git a/README.md b/README.md index 746faa9b..d8209b2e 100644 --- a/README.md +++ b/README.md @@ -124,15 +124,17 @@ See which methods are the slowest, most frequent. or expensive. ## ๐Ÿ—„๏ธ Database Analysis -Highlight slow Salesforce SOQL queries, non-selective filters, and DML issues. +Highlight slow Salesforce SOQL queries, non-selective filters, and DML issues, and see how each contributes to governor limits. -- **SOQL + DML Duration, Selectivity, Aggregates, Row Count** -- **Group by Namespace, Caller Namespace or Query** +- **Governor limit overview** โ€“ SOQL, SOSL, DML and query/DML rows shown as `used / limit` at the top of the tab. +- **Tracked vs consumed** โ€“ each section reconciles statements seen in the log against the governor-counted total, so you can spot queries that didn't count (e.g. custom metadata). +- **Separate SOQL, DML and SOSL tables** โ€“ SOSL is fully searchable, with its rows metered against the 2,000-per-query cap. +- **Object column + Group by Object / Namespace / Caller Namespace / Query** +- **SOQL Duration, Selectivity, Aggregates, Row Count** - **Column Views** โ€“ Preset column sets (incl. a SOQL Query Plan view), show/hide columns, reset to defaults -- **View the Call Stack** -- **SOQL Optimization Tips** -- **Sort by SOQL or DML, Duration, Selectivity, Aggregates, Row Count** -- **Copy or Export to CSV** +- **View the Call Stack**, **SOQL Optimization Tips**, **Sort**, **Copy or Export to CSV** + + ![Database](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/1_20/database.png) diff --git a/lana-docs/docs/docs/features/database.md b/lana-docs/docs/docs/features/database.md index 6d953bc0..7dbbfdad 100644 --- a/lana-docs/docs/docs/features/database.md +++ b/lana-docs/docs/docs/features/database.md @@ -5,8 +5,11 @@ description: Database insights help Salesforce developers analyze SOQL and DML o keywords: [ salesforce database analysis, - soql optimization, - dml performance, + soql, + sosl, + dml, + optimization, + performance, apex query tuning, salesforce log analysis, database insights, @@ -19,9 +22,19 @@ hide_title: true ## ๐Ÿ’พ Database -Database insights help Salesforce developers analyze SOQL and DML operations, assess query selectivity, performance, and aggregations, and optimize Apex code using advanced sorting, grouping, filtering, call stack tracing, and CSV export tools. +Database insights help Salesforce developers analyze DML, SOQL and SOSL operations, assess query selectivity, performance, and aggregations, and optimize Apex code using advanced sorting, grouping, filtering, call stack tracing, and CSV export tools. -![Database view screenshot displaying SOQL and DML operations with row counts, execution times, selectivity indicators, and aggregation details for Salesforce log analysis.](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/1_20/database.png) +![Database view screenshot displaying DML, SOQL and SOSL operations with row counts, execution times, selectivity indicators, and aggregation details for Salesforce log analysis.](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/1_20/database.png) + +The tab has separate **DML**, **SOQL** and **SOSL** sections + +### Governor limits + +- **Overview strip** โ€” SOQL, SOSL, DML and query/DML rows as `used / limit`, coloured as they near the limit. +- **Tracked vs consumed** โ€” per section, statements seen in the log vs the number Salesforce counted (`CUMULATIVE_LIMIT_USAGE`). A gap is usually custom metadata (`__mdt`), which is free unless the query selects a long text area field or runs in a Flow. +- **SOSL rows** โ€” metered per query against the 2,000-rows-per-query cap. + +> Consumed figures need the Apex Profiling log category. Without it, sections show the tracked count and mark the limit _n/a_. The _Selectivity_ column will have a green tick if the query is selective, a red cross if it is not and will be blank if the selectivity could not be determine. Sorting on this column will sort the rows by relative query cost, this number can be seen by hovering the cell on the selectivity column. @@ -39,15 +52,15 @@ If the grouping is removed the sorting applies the same but across all rows inst ### Column Views -Switch column sets from the **Columns** button in the toolbar (or the header right-click menu). SOQL offers **General**, **Performance** and **Query Plan** (Relative Cost, Leading Operation, SObject Type, Cardinality); DML offers **General** and **Timing**. Show or hide individual columns there; an edited view shows a **reset** icon. Choices persist per table. +Switch column sets from the **Columns** button in the toolbar (or the header right-click menu). SOQL offers **General** (incl. the **Object** column), **Performance**, **Query Plan** (Relative Cost, Leading Operation, SObject Type, Cardinality) and **Limits**; DML offers **General**, **Timing** and **Limits**; SOSL offers **General** and **Timing**. Show or hide individual columns there; an edited view shows a **reset** icon. Choices persist per table. ### Group -By default rows are grouped by the SOQL/ DML text, grouping can be removed and the rows shows as a flat list using the _Group by_ item in the header menu. The groups are default sorted with the groups with the most items at the top. +By default rows are grouped by the SOQL/ DML text, grouping can be removed and the rows shows as a flat list using the _Group by_ dropdown. The groups are default sorted with the groups with the most items at the top. -SOQL Statements can also be grouped by package namespace including the default namespace +SOQL and DML can be grouped by **Object** (the queried/target SObject), **Namespace**, or **Caller Namespace**, as well as the statement text. SOSL can be grouped by Namespace or Caller Namespace. -Rows can also be grouped by **Caller Namespace** โ€” the namespace of the direct caller that issued the DML. This makes it easy to see which package's code is responsible for that DML, even though the time is attributed to the default namespace. +**Caller Namespace** is the namespace of the direct caller that issued the statement โ€” handy for seeing which package's code is responsible, even when the time is attributed to the default namespace. ### DML / SOQL Call Stack From 953752dbdc52914bd7281224a0085d87052999c6 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:00:54 +0100 Subject: [PATCH 3/3] fix(database): drop phantom SOSL-rows ceiling when limits unlogged (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SOSL Rows card invented a 40,000 ceiling (soslQueries.limit || 20 ร— 2,000) and rendered a live bar even when the log had no CUMULATIVE_LIMIT_USAGE. - extract `soslRowsMetric` into limits.ts: derives the ceiling from the SOSL-query limit and returns `used: null` / no ceiling when no snapshot, so the card shows "limit n/a" like every sibling metric - DatabaseView routes the SOSL Rows metric through the helper and `_hasLimits`, replacing the `|| 20` fallback that masked a real limit of 0 - reword the "limit n/a" tooltip; cover the helper with unit tests --- .../database/__tests__/limits.test.ts | 27 +++++++++++++++++ .../database/components/DatabaseMetricCard.ts | 2 +- .../database/components/DatabaseView.ts | 14 ++++----- log-viewer/src/features/database/limits.ts | 29 +++++++++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 log-viewer/src/features/database/__tests__/limits.test.ts diff --git a/log-viewer/src/features/database/__tests__/limits.test.ts b/log-viewer/src/features/database/__tests__/limits.test.ts new file mode 100644 index 00000000..cc8db146 --- /dev/null +++ b/log-viewer/src/features/database/__tests__/limits.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { SOSL_ROWS_PER_QUERY_LIMIT, soslRowsMetric } from '../limits.js'; + +describe('soslRowsMetric', () => { + it('degrades to "limit n/a" (used null, no ceiling, no note) with no cumulative snapshot', () => { + const metric = soslRowsMetric(500, 0, false); + expect(metric.used).toBeNull(); + expect(metric.limit).toBe(0); + expect(metric.note).toBeUndefined(); + }); + + it('derives the ceiling from soslQueries.limit ร— per-query cap when a snapshot is present', () => { + const metric = soslRowsMetric(500, 20, true); + expect(metric.used).toBe(500); + expect(metric.limit).toBe(20 * SOSL_ROWS_PER_QUERY_LIMIT); + expect(metric.note).toContain('max per transaction'); + }); + + it('shows no ceiling when a snapshot exists but the SOSL-query limit is 0', () => { + const metric = soslRowsMetric(500, 0, true); + expect(metric.used).toBe(500); // count is trusted (snapshot present) + expect(metric.limit).toBe(0); // but no meaningful ceiling to meter against + expect(metric.note).toBeUndefined(); + }); +}); diff --git a/log-viewer/src/features/database/components/DatabaseMetricCard.ts b/log-viewer/src/features/database/components/DatabaseMetricCard.ts index 04b8351a..5cd1332d 100644 --- a/log-viewer/src/features/database/components/DatabaseMetricCard.ts +++ b/log-viewer/src/features/database/components/DatabaseMetricCard.ts @@ -157,7 +157,7 @@ export class DatabaseMetricCard extends LitElement { if (metric.used === null) { return html`limit n/a`; } diff --git a/log-viewer/src/features/database/components/DatabaseView.ts b/log-viewer/src/features/database/components/DatabaseView.ts index 3426534d..ed3468ca 100644 --- a/log-viewer/src/features/database/components/DatabaseView.ts +++ b/log-viewer/src/features/database/components/DatabaseView.ts @@ -14,7 +14,7 @@ import type { } from 'apex-log-parser'; import { isVisible } from '../../../core/utility/Util.js'; -import { SOSL_ROWS_PER_QUERY_LIMIT } from '../limits.js'; +import { soslRowsMetric } from '../limits.js'; import { DatabaseAccess } from '../services/Database.js'; // styles @@ -253,18 +253,14 @@ export class DatabaseView extends LitElement { }, ]; if (kind === 'sosl') { - // SOSL rows aren't reported as a transaction total, but there's a hard - // ceiling: at most 2,000 rows per query ร— the SOSL-query limit (20). Meter - // the total against that derived ceiling; the per-query cap is on hover and - // metered per row in the table. - const maxQueries = limits?.soslQueries.limit || 20; + // SOSL rows aren't a transaction total โ€” the ceiling is derived from the + // SOSL-query limit; without a snapshot it degrades to "limit n/a". The + // per-query cap is shown on hover and metered per row in the table. const total = this._rows(kind); metrics.push({ label: 'Rows', found: total, - used: total, - limit: maxQueries * SOSL_ROWS_PER_QUERY_LIMIT, - note: `Up to ${SOSL_ROWS_PER_QUERY_LIMIT.toLocaleString()} rows per SOSL query ร— ${maxQueries} queries = ${(maxQueries * SOSL_ROWS_PER_QUERY_LIMIT).toLocaleString()} max per transaction.`, + ...soslRowsMetric(total, limits?.soslQueries.limit ?? 0, this._hasLimits), }); } else { metrics.push({ diff --git a/log-viewer/src/features/database/limits.ts b/log-viewer/src/features/database/limits.ts index b59acd69..456f09a1 100644 --- a/log-viewer/src/features/database/limits.ts +++ b/log-viewer/src/features/database/limits.ts @@ -20,3 +20,32 @@ export const APEX_GOVERNOR_LIMITS_DOC = * table, not summed against a transaction limit. */ export const SOSL_ROWS_PER_QUERY_LIMIT = 2000; + +/** Derived SOSL-rows metric fields (label/found are supplied by the caller). */ +export interface SoslRowsMetric { + used: number | null; + limit: number; + note?: string; +} + +/** + * SOSL rows aren't reported as a transaction total, so the ceiling is derived: + * {@link SOSL_ROWS_PER_QUERY_LIMIT} ร— the SOSL-query limit. Only meaningful when the + * log captured that limit (`hasLimits`); otherwise this degrades to "limit n/a" + * (`used` null, no ceiling, no note) like every other metric. + */ +export function soslRowsMetric( + seenRows: number, + soslQueriesLimit: number, + hasLimits: boolean, +): SoslRowsMetric { + const limit = soslQueriesLimit * SOSL_ROWS_PER_QUERY_LIMIT; + return { + used: hasLimits ? seenRows : null, + limit, + note: + limit > 0 + ? `Up to ${SOSL_ROWS_PER_QUERY_LIMIT.toLocaleString()} rows per SOSL query ร— ${soslQueriesLimit} queries = ${limit.toLocaleString()} max per transaction.` + : undefined, + }; +}