UNPKG

@electric-sql/d2mini

Version:

D2Mini is a minimal implementation of Differential Dataflow for performing in-memory incremental view maintenance.

600 lines (527 loc) 17.1 kB
import { describe, test, expect, beforeEach, afterEach } from 'vitest' import { D2 } from '../../src/d2.js' import { MultiSet } from '../../src/multiset.js' import { topK } from '../../src/operators/topK.js' import { output } from '../../src/operators/output.js' describe('Operators', () => { describe('TopK operation', () => { test('initial results with limit - no key', () => { const graph = new D2() const input = graph.newInput< [ null, { id: number // 1, 2, 3, 4, 5 value: string // a, z, b, y, c }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 3 }), output((message) => { latestMessage = message }), ) graph.finalize() input.sendData( new MultiSet([ [[null, { id: 1, value: 'a' }], 1], [[null, { id: 2, value: 'z' }], 1], [[null, { id: 3, value: 'b' }], 1], [[null, { id: 4, value: 'y' }], 1], [[null, { id: 5, value: 'c' }], 1], ]), ) graph.run() expect(latestMessage).not.toBeNull() const result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 1, value: 'a' }], 1], [[null, { id: 3, value: 'b' }], 1], [[null, { id: 5, value: 'c' }], 1], ]) }) test('initial results with limit and offset - no key', () => { const graph = new D2() const input = graph.newInput< [ null, { id: number // 1, 2, 3, 4, 5 value: string // a, z, b, y, c }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 3, offset: 2, }), output((message) => { latestMessage = message }), ) graph.finalize() input.sendData( new MultiSet([ [[null, { id: 1, value: 'a' }], 1], [[null, { id: 2, value: 'z' }], 1], [[null, { id: 3, value: 'b' }], 1], [[null, { id: 4, value: 'y' }], 1], [[null, { id: 5, value: 'c' }], 1], ]), ) graph.run() expect(latestMessage).not.toBeNull() const result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 2, value: 'z' }], 1], [[null, { id: 4, value: 'y' }], 1], [[null, { id: 5, value: 'c' }], 1], ]) }) test('initial results with limit - with key', () => { const graph = new D2() const input = graph.newInput< [ string, { id: number // 1, 2, 3, 4, 5 value: string // a, z, b, y, c }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 3 }), output((message) => { latestMessage = message }), ) graph.finalize() input.sendData( new MultiSet([ [['one', { id: 1, value: '9' }], 1], [['one', { id: 2, value: '8' }], 1], [['one', { id: 3, value: '7' }], 1], [['one', { id: 4, value: '6' }], 1], [['one', { id: 5, value: '5' }], 1], [['two', { id: 6, value: '4' }], 1], [['two', { id: 7, value: '3' }], 1], [['two', { id: 8, value: '2' }], 1], [['two', { id: 9, value: '1' }], 1], [['two', { id: 10, value: '0' }], 1], ]), ) graph.run() expect(latestMessage).not.toBeNull() const result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [['one', { id: 3, value: '7' }], 1], [['one', { id: 4, value: '6' }], 1], [['one', { id: 5, value: '5' }], 1], [['two', { id: 8, value: '2' }], 1], [['two', { id: 9, value: '1' }], 1], [['two', { id: 10, value: '0' }], 1], ]) }) test('initial results with limit and offset - with key', () => { const graph = new D2() const input = graph.newInput< [ string, { id: number // 1, 2, 3, 4, 5 value: string // a, z, b, y, c }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 3, offset: 2, }), output((message) => { latestMessage = message }), ) graph.finalize() input.sendData( new MultiSet([ [['one', { id: 1, value: '9' }], 1], [['one', { id: 2, value: '8' }], 1], [['one', { id: 3, value: '7' }], 1], [['one', { id: 4, value: '6' }], 1], [['one', { id: 5, value: '5' }], 1], [['two', { id: 6, value: '4' }], 1], [['two', { id: 7, value: '3' }], 1], [['two', { id: 8, value: '2' }], 1], [['two', { id: 9, value: '1' }], 1], [['two', { id: 10, value: '0' }], 1], ]), ) graph.run() expect(latestMessage).not.toBeNull() const result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [['one', { id: 1, value: '9' }], 1], [['one', { id: 2, value: '8' }], 1], [['one', { id: 3, value: '7' }], 1], [['two', { id: 6, value: '4' }], 1], [['two', { id: 7, value: '3' }], 1], [['two', { id: 8, value: '2' }], 1], ]) }) // Incremental update tests test('incremental update - adding rows that should appear in result set', () => { const graph = new D2() const input = graph.newInput< [ null, { id: number value: string }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 3 }), output((message) => { latestMessage = message }), ) graph.finalize() // Initial data input.sendData( new MultiSet([ [[null, { id: 1, value: 'd' }], 1], [[null, { id: 2, value: 'e' }], 1], [[null, { id: 3, value: 'f' }], 1], ]), ) graph.run() // Initial result should be all three items let result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 1, value: 'd' }], 1], [[null, { id: 2, value: 'e' }], 1], [[null, { id: 3, value: 'f' }], 1], ]) // Add a new row that should appear in the result (before 'd') input.sendData(new MultiSet([[[null, { id: 4, value: 'a' }], 1]])) graph.run() // Result should now include 'a' and drop 'f' due to limit result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 3, value: 'f' }], -1], // Moved out of the limit so it's removed [[null, { id: 4, value: 'a' }], 1], // New row at the beginning ]) }) test('incremental update - removing rows from result set', () => { const graph = new D2() const input = graph.newInput< [ null, { id: number value: string }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 3 }), output((message) => { latestMessage = message }), ) graph.finalize() // Initial data input.sendData( new MultiSet([ [[null, { id: 1, value: 'a' }], 1], [[null, { id: 2, value: 'b' }], 1], [[null, { id: 3, value: 'c' }], 1], [[null, { id: 4, value: 'd' }], 1], ]), ) graph.run() // Initial result should be first three items let result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 1, value: 'a' }], 1], [[null, { id: 2, value: 'b' }], 1], [[null, { id: 3, value: 'c' }], 1], ]) // Remove 'b' from the result set input.sendData(new MultiSet([[[null, { id: 2, value: 'b' }], -1]])) graph.run() // Result should show 'b' being removed and 'd' being added result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 2, value: 'b' }], -1], // Removed row [[null, { id: 4, value: 'd' }], 1], // New row added to results ]) }) test('incremental update - adding rows that push existing rows out of limit window', () => { const graph = new D2() const input = graph.newInput< [ null, { id: number value: string }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 3 }), output((message) => { latestMessage = message }), ) graph.finalize() // Initial data input.sendData( new MultiSet([ [[null, { id: 1, value: 'c' }], 1], [[null, { id: 2, value: 'd' }], 1], [[null, { id: 3, value: 'e' }], 1], ]), ) graph.run() // Initial result should be all three items let result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 1, value: 'c' }], 1], [[null, { id: 2, value: 'd' }], 1], [[null, { id: 3, value: 'e' }], 1], ]) // Add two new rows that should appear before existing rows input.sendData( new MultiSet([ [[null, { id: 4, value: 'a' }], 1], [[null, { id: 5, value: 'b' }], 1], ]), ) graph.run() // Result should show the new rows being added and the row that got pushed out result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 2, value: 'd' }], -1], // Row pushed out due to limit [[null, { id: 3, value: 'e' }], -1], // Row pushed out due to limit [[null, { id: 4, value: 'a' }], 1], // New row [[null, { id: 5, value: 'b' }], 1], // New row ]) }) test('incremental update - with offset', () => { const graph = new D2() const input = graph.newInput< [ null, { id: number value: string }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 2, offset: 1, }), output((message) => { latestMessage = message }), ) graph.finalize() // Initial data input.sendData( new MultiSet([ [[null, { id: 1, value: 'a' }], 1], [[null, { id: 2, value: 'b' }], 1], [[null, { id: 3, value: 'c' }], 1], [[null, { id: 4, value: 'd' }], 1], ]), ) graph.run() // Initial result should be items at positions 1 and 2 (b and c) let result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 2, value: 'b' }], 1], [[null, { id: 3, value: 'c' }], 1], ]) // Add a new row that should appear at the beginning input.sendData( new MultiSet([ [[null, { id: 5, value: '0' }], 1], // Should be first alphabetically ]), ) graph.run() // Result should show the changes in the window due to offset shift result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 1, value: 'a' }], 1], // Now in window due to offset shift [[null, { id: 3, value: 'c' }], -1], // Pushed out of window ]) }) test('incremental update - with key groups', () => { const graph = new D2() const input = graph.newInput< [ string, { id: number value: string }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 2 }), output((message) => { latestMessage = message }), ) graph.finalize() // Initial data input.sendData( new MultiSet([ [['group1', { id: 1, value: 'c' }], 1], [['group1', { id: 2, value: 'd' }], 1], [['group1', { id: 3, value: 'e' }], 1], [['group2', { id: 4, value: 'a' }], 1], [['group2', { id: 5, value: 'b' }], 1], [['group2', { id: 6, value: 'f' }], 1], ]), ) graph.run() // Initial result should be top 2 from each group let result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [['group1', { id: 1, value: 'c' }], 1], [['group1', { id: 2, value: 'd' }], 1], [['group2', { id: 4, value: 'a' }], 1], [['group2', { id: 5, value: 'b' }], 1], ]) // Add a new row to group1 that should appear in results // Remove a row from group2 that was in results input.sendData( new MultiSet([ [['group1', { id: 7, value: 'a' }], 1], // Should be first in group1 [['group2', { id: 4, value: 'a' }], -1], // Remove from group2 ]), ) graph.run() // Result should show the changes in each key group result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [['group1', { id: 2, value: 'd' }], -1], // Pushed out of limit in group1 [['group2', { id: 4, value: 'a' }], -1], // Removed from group2 [['group2', { id: 6, value: 'f' }], 1], // Now in window for group2 [['group1', { id: 7, value: 'a' }], 1], // New row in group1 ]) }) test('incremental update - complex scenario with multiple changes', () => { const graph = new D2() const input = graph.newInput< [ null, { id: number value: string }, ] >() let latestMessage: any = null input.pipe( topK((a, b) => a.value.localeCompare(b.value), { limit: 3, offset: 1, }), output((message) => { latestMessage = message }), ) graph.finalize() // Initial data - a, b, c, d, e input.sendData( new MultiSet([ [[null, { id: 1, value: 'a' }], 1], [[null, { id: 2, value: 'b' }], 1], [[null, { id: 3, value: 'c' }], 1], [[null, { id: 4, value: 'd' }], 1], [[null, { id: 5, value: 'e' }], 1], ]), ) graph.run() // Initial result should be b, c, d (offset 1, limit 3) let result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 2, value: 'b' }], 1], [[null, { id: 3, value: 'c' }], 1], [[null, { id: 4, value: 'd' }], 1], ]) // Multiple changes: // 1. Remove 'c' // 2. Add '_' (before 'a') // 3. Add 'aa' (between 'a' and 'b') input.sendData( new MultiSet([ [[null, { id: 3, value: 'c' }], -1], [[null, { id: 6, value: '_' }], 1], [[null, { id: 7, value: 'aa' }], 1], ]), ) graph.run() // New order: _, a, aa, b, d, e // With offset 1, limit 3, result should show changes result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 1, value: 'a' }], 1], // Now in window due to offset shift [[null, { id: 3, value: 'c' }], -1], // Removed row [[null, { id: 4, value: 'd' }], -1], // Pushed out of window [[null, { id: 7, value: 'aa' }], 1], // New row in window ]) // More changes: // 1. Remove 'a' // 2. Add 'z' (at the end) input.sendData( new MultiSet([ [[null, { id: 1, value: 'a' }], -1], [[null, { id: 8, value: 'z' }], 1], ]), ) graph.run() // New order: _, aa, b, d, e, z // With offset 1, limit 3, result should show changes result = latestMessage.getInner() expect(sortResults(result)).toEqual([ [[null, { id: 1, value: 'a' }], -1], // Removed row [[null, { id: 4, value: 'd' }], 1], // Now back in window ]) }) }) }) /** * Helper function to sort results by multiplicity and then id * This is necessary as the implementation does not guarantee order of the messages * only that the materialization is correct */ function sortResults(results: any[]) { return [...results] .sort( ([_a, aMultiplicity], [_b, bMultiplicity]) => aMultiplicity - bMultiplicity, ) .sort( ([[_aKey, aValue], _aMultiplicity], [[_bKey, bValue], _bMultiplicity]) => aValue.id - bValue.id, ) }