Skip to content

Commit 81a34f0

Browse files
feat: implement bulk delete and undelete (#18)
* feat: Impl bulk delete and undelete * ci: add simple test actions * test to delete elements not all known * fix: fallback delete one when the first id is known * feat: impl bulk delete cross leaf nodes * chore: format code * test: add bulk tests * feat: cleanup logic * remove unused methods * additional tests --------- Co-authored-by: Matthew Weidner <[email protected]>
1 parent fe80ecb commit 81a34f0

File tree

3 files changed

+227
-28
lines changed

3 files changed

+227
-28
lines changed

.github/workflows/test.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Test
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- master
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: "20"
19+
cache: "npm"
20+
21+
- run: npm ci
22+
23+
- run: npm test

src/id_list.ts

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -570,26 +570,39 @@ export class IdList {
570570
*/
571571
delete(id: ElementId, count = 1) {
572572
checkCount(count);
573+
if (count === 0) return this;
573574

574575
// eslint-disable-next-line @typescript-eslint/no-this-alias
575576
let ans: IdList = this;
576-
for (let i = 0; i < count; i++) {
577-
ans = ans.deleteOne({ bunchId: id.bunchId, counter: id.counter + i });
578-
}
579-
return ans;
580-
}
577+
let currentCounter = id.counter;
578+
while (currentCounter < id.counter + count) {
579+
const currentId: ElementId = {
580+
bunchId: id.bunchId,
581+
counter: currentCounter,
582+
};
583+
const located = ans.locate(currentId);
584+
if (located === null) {
585+
// Id is not known, skip
586+
currentCounter++;
587+
continue;
588+
}
581589

582-
private deleteOne(id: ElementId) {
583-
const located = this.locate(id);
584-
if (located === null) return this;
590+
const leaf = located[0].node;
585591

586-
const leaf = located[0].node;
587-
if (!leaf.present.has(id.counter)) return this;
592+
// Calculate how many ids can be deleted in this leaf
593+
const leafEnd = leaf.startCounter + leaf.count;
594+
const remaining = id.counter + count - currentCounter;
595+
const canDeleteInLeaf = Math.min(leafEnd - currentCounter, remaining);
588596

589-
const newPresent = leaf.present.clone();
590-
newPresent.delete(id.counter);
597+
const newPresent = leaf.present.clone();
598+
newPresent.delete(currentCounter, canDeleteInLeaf);
599+
ans = ans.replaceLeaf(located, { ...leaf, present: newPresent });
591600

592-
return this.replaceLeaf(located, { ...leaf, present: newPresent });
601+
// Move to the next batch
602+
currentCounter += canDeleteInLeaf;
603+
}
604+
605+
return ans;
593606
}
594607

595608
/**
@@ -624,28 +637,36 @@ export class IdList {
624637
*/
625638
undelete(id: ElementId, count = 1) {
626639
checkCount(count);
640+
if (count === 0) return this;
627641

628642
// eslint-disable-next-line @typescript-eslint/no-this-alias
629643
let ans: IdList = this;
630-
for (let i = count - 1; i >= 0; i--) {
631-
ans = ans.undeleteOne({ bunchId: id.bunchId, counter: id.counter + i });
632-
}
633-
return ans;
634-
}
644+
let currentCounter = id.counter;
645+
while (currentCounter < id.counter + count) {
646+
const currentId: ElementId = {
647+
bunchId: id.bunchId,
648+
counter: currentCounter,
649+
};
650+
const located = ans.locate(currentId);
651+
if (located === null) {
652+
throw new Error("id is not known");
653+
}
635654

636-
private undeleteOne(id: ElementId) {
637-
const located = this.locate(id);
638-
if (located === null) {
639-
throw new Error("id is not known");
640-
}
655+
const leaf = located[0].node;
656+
// Calculate how many ids can be undeleted in this leaf
657+
const leafEnd = leaf.startCounter + leaf.count;
658+
const remaining = id.counter + count - currentCounter;
659+
const canUndeleteInLeaf = Math.min(leafEnd - currentCounter, remaining);
641660

642-
const leaf = located[0].node;
643-
if (leaf.present.has(id.counter)) return this;
661+
const newPresent = leaf.present.clone();
662+
newPresent.set(currentCounter, canUndeleteInLeaf);
663+
ans = ans.replaceLeaf(located, { ...leaf, present: newPresent });
644664

645-
const newPresent = leaf.present.clone();
646-
newPresent.set(id.counter);
665+
// Move to the next batch
666+
currentCounter += canUndeleteInLeaf;
667+
}
647668

648-
return this.replaceLeaf(located, { ...leaf, present: newPresent });
669+
return ans;
649670
}
650671

651672
/**

test/basic.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,98 @@ describe("IdList", () => {
445445
.true;
446446
});
447447

448+
it("should bulk delete elements not all known", () => {
449+
let list = IdList.new();
450+
const bunchStartId = { bunchId: "abc", counter: 0 };
451+
const id: ElementId = { bunchId: "abc", counter: 5 };
452+
453+
// Insert counters 5..9; counters 0..4 are not known.
454+
list = list.insertAfter(null, id, 5);
455+
expect(list.length).to.equal(5);
456+
457+
// Delete the whole bunch starting at counter 0.
458+
list = list.delete(bunchStartId, 10);
459+
expect(list.length).to.equal(0);
460+
expect(list.has(id)).to.be.false;
461+
expect(list.isKnown(id)).to.be.true;
462+
expect(list.isKnown(bunchStartId)).to.be.false;
463+
});
464+
465+
it("should bulk delete across multiple leaves", () => {
466+
let list = IdList.new();
467+
list = list.insertAfter(null, { bunchId: "test", counter: 0 }, 10);
468+
list = list.insertAfter(
469+
{ bunchId: "test", counter: 9 },
470+
{ bunchId: "test", counter: 100 },
471+
10
472+
);
473+
// Leaf 1: counters 0..9
474+
// Leaf 2: counters 100..109
475+
expect(list.length).to.equal(20);
476+
477+
// Delete across multiple leaves
478+
list = list.delete({ bunchId: "test", counter: 5 }, 100);
479+
expect(list.length).to.equal(10);
480+
});
481+
482+
it("should bulk delete and undelete across split leaves", () => {
483+
let list = IdList.new();
484+
list = list.insertAfter(null, { bunchId: "abc", counter: 0 }, 10);
485+
// Split the leaf with a different bunch.
486+
list = list.insertAfter(
487+
{ bunchId: "abc", counter: 5 },
488+
{ bunchId: "def", counter: 0 },
489+
10
490+
);
491+
expect(list.length).to.equal(20);
492+
493+
// Delete the original bunch.
494+
list = list.delete({ bunchId: "abc", counter: 0 }, 10);
495+
496+
expect(list.length).to.equal(10);
497+
for (const id of list.values()) {
498+
expect(id.bunchId).to.equal("def");
499+
}
500+
501+
// Undelete.
502+
list = list.undelete({ bunchId: "abc", counter: 0 }, 10);
503+
expect(list.length).to.equal(20);
504+
expect(list.at(5)).to.deep.equal({ bunchId: "abc", counter: 5 });
505+
expect(list.at(16)).to.deep.equal({ bunchId: "abc", counter: 6 });
506+
});
507+
508+
it("should bulk delete and undelete across split leaves 2", () => {
509+
let list = IdList.new();
510+
list = list.insertAfter(null, { bunchId: "abc", counter: 0 }, 10);
511+
// Split the leaf with a different bunch.
512+
list = list.insertAfter(
513+
{ bunchId: "abc", counter: 5 },
514+
{ bunchId: "def", counter: 0 },
515+
10
516+
);
517+
expect(list.length).to.equal(20);
518+
519+
// Delete part of the original bunch, crossing the split.
520+
list = list.delete({ bunchId: "abc", counter: 2 }, 6);
521+
522+
expect(list.length).to.equal(14);
523+
for (let i = 0; i < 2; i++) {
524+
expect(list.at(i).bunchId).to.equal("abc");
525+
}
526+
for (let i = 2; i < 12; i++) {
527+
expect(list.at(i).bunchId).to.equal("def");
528+
}
529+
for (let i = 12; i < 14; i++) {
530+
expect(list.at(i).bunchId).to.equal("abc");
531+
}
532+
533+
// Undelete.
534+
list = list.undelete({ bunchId: "abc", counter: 2 }, 6);
535+
expect(list.length).to.equal(20);
536+
expect(list.at(5)).to.deep.equal({ bunchId: "abc", counter: 5 });
537+
expect(list.at(16)).to.deep.equal({ bunchId: "abc", counter: 6 });
538+
});
539+
448540
it("should delete a range of elements", () => {
449541
let list = IdList.new();
450542
const id1: ElementId = { bunchId: "abc", counter: 1 };
@@ -465,6 +557,18 @@ describe("IdList", () => {
465557
{ bunchId: id1.bunchId, counter: 5 },
466558
]);
467559
});
560+
561+
it("should do nothing when count = 0", () => {
562+
let list = IdList.new();
563+
const id: ElementId = { bunchId: "abc", counter: 1 };
564+
565+
list = list.insertAfter(null, id);
566+
expect(list.length).to.equal(1);
567+
568+
list = list.delete(id, 0);
569+
expect(list.length).to.equal(1);
570+
expect(list.has(id)).to.be.true;
571+
});
468572
});
469573

470574
describe("undelete operations", () => {
@@ -487,6 +591,17 @@ describe("IdList", () => {
487591
expect(() => (list = list.undelete(id))).to.throw();
488592
});
489593

594+
it("should throw when any bulk ID is unknown", () => {
595+
let list = IdList.new();
596+
const id1: ElementId = { bunchId: "abc", counter: 1 };
597+
const id3: ElementId = { bunchId: "abc", counter: 3 };
598+
list = list.insertAfter(null, id1);
599+
list = list.insertAfter(id1, id3);
600+
601+
// Counter 2 is not known.
602+
expect(() => (list = list.undelete(id1, 3))).to.throw();
603+
});
604+
490605
it("should do nothing when undeleting an already present ID", () => {
491606
let list = IdList.new();
492607
const id: ElementId = { bunchId: "abc", counter: 1 };
@@ -518,6 +633,46 @@ describe("IdList", () => {
518633
expect(list.has({ bunchId: id.bunchId, counter: id.counter + 3 })).to.be
519634
.true;
520635
});
636+
637+
it("should bulk undelete across multiple leaves", () => {
638+
let list = IdList.new();
639+
list = list.insertAfter(null, { bunchId: "test", counter: 0 }, 20);
640+
list = list.insertAfter(
641+
{ bunchId: "test", counter: 9 },
642+
{ bunchId: "test", counter: 100 },
643+
1
644+
);
645+
// Leaf A: counter 0..9
646+
// Leaf B: counter 100
647+
// Leaf C: counter 10..19
648+
expect(list.length).to.equal(21);
649+
650+
// Delete counters 5..15
651+
list = list.delete({ bunchId: "test", counter: 5 }, 11);
652+
expect(list.length).to.equal(10);
653+
654+
// Undelete counters 5..15
655+
list = list.undelete({ bunchId: "test", counter: 5 }, 11);
656+
expect(list.length).to.equal(21);
657+
658+
for (let i = 0; i < 20; i++) {
659+
expect(list.has({ bunchId: "test", counter: i })).to.be.true;
660+
}
661+
expect(list.has({ bunchId: "test", counter: 100 })).to.be.true;
662+
});
663+
664+
it("should do nothing when count = 0", () => {
665+
let list = IdList.new();
666+
const id: ElementId = { bunchId: "abc", counter: 1 };
667+
668+
list = list.insertAfter(null, id).delete(id);
669+
expect(list.length).to.equal(0);
670+
671+
list = list.undelete(id, 0);
672+
expect(list.length).to.equal(0);
673+
expect(list.has(id)).to.be.false;
674+
expect(list.isKnown(id)).to.be.true;
675+
});
521676
});
522677

523678
describe("accessor operations", () => {

0 commit comments

Comments
 (0)