import { expect } from 'chai'; import type { PreferencesAccess } from '@mongodb-js/compass-preferences-model'; import { createSandboxFromDefaultPreferences } from '@mongodb-js/compass-preferences-model'; import type { Document } from 'mongodb'; import { mockDataService } from '../../../test/mocks/data-service'; import { createPreviewAggregation, DEFAULT_PREVIEW_LIMIT, DEFAULT_SAMPLE_SIZE, PipelinePreviewManager, } from './pipeline-preview-manager'; describe('PipelinePreviewManager', function () { let preferences: PreferencesAccess; beforeEach(async function () { preferences = await createSandboxFromDefaultPreferences(); }); it('should return pipeline results', async function () { const dataService = mockDataService({ data: [{ foo: 'bar' }] }); const previewManager = new PipelinePreviewManager(dataService, preferences); expect( await previewManager.getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => Promise.resolve({ debounceMs: 10, }) ) ).to.deep.eq([{ foo: 'bar' }]); }); it('should throw a cancelled error if preview request was cancelled', async function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager(dataService, preferences); try { const result = previewManager.getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => Promise.resolve({}) ); previewManager.cancelPreviewForStage(0); await result; expect.fail('Expected promise to reject'); } catch (err) { expect(err).to.have.property('name', 'AbortError'); } }); it('should debounce aggregation calls for the same stage', async function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager(dataService, preferences); previewManager .getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => Promise.resolve({ debounceMs: 10, }) ) .catch(() => { /* ignore error */ }); previewManager .getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => Promise.resolve({ debounceMs: 10, }) ) .catch(() => { /* ignore error */ }); await previewManager.getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => Promise.resolve({ debounceMs: 10, }) ); // eslint-disable-next-line @typescript-eslint/unbound-method expect(dataService.aggregate).to.have.been.calledOnce; }); it('should make aggregation calls for multiple stages', async function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager(dataService, preferences); await Promise.allSettled([ previewManager.getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => Promise.resolve({ debounceMs: 0, }) ), previewManager.getPreviewForStage( 1, 'test.test', () => Promise.resolve([]), () => Promise.resolve({ debounceMs: 0, }) ), previewManager.getPreviewForStage( 2, 'test.test', () => Promise.resolve([]), () => Promise.resolve({ debounceMs: 0, }) ), ]); // eslint-disable-next-line @typescript-eslint/unbound-method expect(dataService.aggregate).to.have.been.calledThrice; }); it('should cancel preview fetch for a stage', function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager(dataService, preferences); previewManager .getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => Promise.resolve({}) ) .catch(() => { /* ignore error */ }); expect(previewManager.cancelPreviewForStage(0)).to.eq(true); }); it('should clear the queue starting from the index', async function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager(dataService, preferences); await Promise.allSettled([ previewManager.getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => Promise.resolve({ debounceMs: 10, }) ), previewManager.getPreviewForStage( 1, 'test.test', () => Promise.resolve([]), () => Promise.resolve({}) ), previewManager.getPreviewForStage( 2, 'test.test', () => Promise.resolve([]), () => Promise.resolve({}) ), previewManager.getPreviewForStage( 3, 'test.test', () => Promise.resolve([]), () => Promise.resolve({}) ), previewManager.getPreviewForStage( 4, 'test.test', () => Promise.resolve([]), () => Promise.resolve({}) ), Promise.resolve(previewManager.clearQueue(1)), ]); // Only pipeline for stage 0 was executed // eslint-disable-next-line @typescript-eslint/unbound-method expect(dataService.aggregate).to.have.been.calledOnce; }); describe('when the pipeline is still being parsed', function () { function createPendingPipeline() { let resolve!: (pipeline: Document[]) => void; const promise = new Promise((r) => (resolve = r)); return { promise, resolve }; } it('should not run a preview cancelled before parsing finished', async function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager( dataService, preferences ); const pending = createPendingPipeline(); const result = previewManager.getPreviewForStage( 0, 'test.test', () => pending.promise, () => Promise.resolve({ debounceMs: 0 }) ); expect(previewManager.cancelPreviewForStage(0)).to.eq(true); pending.resolve([]); const [settled] = await Promise.allSettled([result]); expect(settled).to.have.nested.property('reason.name', 'AbortError'); // eslint-disable-next-line @typescript-eslint/unbound-method expect(dataService.aggregate).not.to.have.been.called; }); it('should not run previews stopped before parsing finished', async function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager( dataService, preferences ); const pending = createPendingPipeline(); const result = previewManager.getPreviewForStage( 0, 'test.test', () => pending.promise, () => Promise.resolve({ debounceMs: 0 }) ); previewManager.clearQueue(); pending.resolve([]); await Promise.allSettled([result]); // eslint-disable-next-line @typescript-eslint/unbound-method expect(dataService.aggregate).not.to.have.been.called; }); it('should not run a preview cancelled while options were resolving', async function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager( dataService, preferences ); let resolveOptions!: (options: { debounceMs: number }) => void; const options = new Promise<{ debounceMs: number }>( (r) => (resolveOptions = r) ); const result = previewManager.getPreviewForStage( 0, 'test.test', () => Promise.resolve([]), () => options ); expect(previewManager.cancelPreviewForStage(0)).to.eq(true); resolveOptions({ debounceMs: 0 }); const [settled] = await Promise.allSettled([result]); expect(settled).to.have.nested.property('reason.name', 'AbortError'); // eslint-disable-next-line @typescript-eslint/unbound-method expect(dataService.aggregate).not.to.have.been.called; }); it('should not let an older request that parsed last replace a newer one', async function () { const dataService = mockDataService({ data: [] }); const previewManager = new PipelinePreviewManager( dataService, preferences ); const olderPipeline = createPendingPipeline(); const newerPipeline = [{ $match: { newer: true } }]; const older = previewManager.getPreviewForStage( 0, 'test.test', () => olderPipeline.promise, () => Promise.resolve({ debounceMs: 0 }) ); const newer = previewManager.getPreviewForStage( 0, 'test.test', () => Promise.resolve(newerPipeline), () => Promise.resolve({ debounceMs: 0 }) ); olderPipeline.resolve([{ $match: { older: true } }]); const [olderResult, newerResult] = await Promise.allSettled([ older, newer, ]); expect(olderResult).to.have.nested.property('reason.name', 'AbortError'); expect(newerResult).to.have.property('status', 'fulfilled'); // eslint-disable-next-line @typescript-eslint/unbound-method expect(dataService.aggregate).to.have.been.calledOnce; expect(previewManager.isLastPipelineEqual(0, newerPipeline)).to.eq(true); }); }); describe('createPreviewAggregation', function () { it('should add a limit stage at the end of the pipeline', function () { expect(createPreviewAggregation([])).to.deep.eq([ { $limit: DEFAULT_PREVIEW_LIMIT }, ]); }); it('should not add a limit stage at the end of the pipeline if last stage is required as first stage', function () { expect(createPreviewAggregation([{ $indexStats: {} }])).to.deep.eq([ { $indexStats: {} }, ]); }); it('should prepend full scan stages with limit', function () { expect( createPreviewAggregation([{ $group: {} }, { $bucket: {} }]) ).to.deep.eq([ { $limit: DEFAULT_SAMPLE_SIZE }, { $group: {} }, { $limit: DEFAULT_SAMPLE_SIZE }, { $bucket: {} }, { $limit: DEFAULT_PREVIEW_LIMIT }, ]); }); it('should not prepend full scan stages with limit if total document size is smaller than sample size', function () { expect( createPreviewAggregation([{ $group: {} }, { $bucket: {} }], { totalDocumentCount: 10, }) ).to.deep.eq([ { $group: {} }, { $bucket: {} }, { $limit: DEFAULT_PREVIEW_LIMIT }, ]); }); it('should throw when last stage is output stage', function () { const pipeline = [{ $match: {} }, { $sort: {} }, { $out: 'test' }]; expect(() => { createPreviewAggregation(pipeline); }).to.throw(); }); it('should not throw when output stage is not at the end of pipeline', function () { const pipeline = [{ $match: {} }, { $out: 'test' }, { $sort: {} }]; expect(createPreviewAggregation(pipeline)).to.deep.eq([ { $match: {} }, { $out: 'test' }, { $sort: {} }, { $limit: DEFAULT_PREVIEW_LIMIT }, ]); }); }); });