Skip to content

Commit 12e5d85

Browse files
committed
feat: implement SqliteMap class with CRUD operations and add tests
1 parent 9c2d597 commit 12e5d85

6 files changed

Lines changed: 273 additions & 8 deletions

File tree

.github/workflows/test.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ jobs:
3030
- name: Install dependencies
3131
run: pnpm install --no-frozen-lockfile
3232

33+
# Install tsx
34+
- name: Install dependencies
35+
run: pnpm install --save-dev tsx
36+
3337
# Run test
3438
- name: Run test
3539
run: node --run test

.npmignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
*.test.ts
2-
*.test.js
1+
*.test*.ts
2+
*.test*.js
33
.github/
44
src/
55
.gitignore

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"scripts": {
1111
"lint": "prettier --config=.prettierrc --check src",
1212
"fmt": "prettier --config=.prettierrc --write src",
13-
"test": "tsc --noEmit",
13+
"test": "(tsc --noEmit) && (tsx src/index.test.ts)",
1414
"dev": "tsc --watch",
1515
"build": "tsc"
1616
},

src/index.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { test, describe } from "node:test"
2+
import assert from "node:assert/strict"
3+
import { SqliteMap } from "./index"
4+
5+
describe("SqliteMap", () => {
6+
test("set and get", () => {
7+
const map = new SqliteMap(":memory:")
8+
map.set("foo", { bar: "baz" })
9+
assert.deepEqual(map.get("foo"), { bar: "baz" })
10+
})
11+
12+
test("get returns undefined for missing key", () => {
13+
const map = new SqliteMap(":memory:")
14+
assert.equal(map.get("missing"), undefined)
15+
})
16+
17+
test("has", () => {
18+
const map = new SqliteMap(":memory:")
19+
map.set("foo", 1)
20+
assert.equal(map.has("foo"), true)
21+
assert.equal(map.has("bar"), false)
22+
})
23+
24+
test("delete", () => {
25+
const map = new SqliteMap(":memory:")
26+
map.set("foo", 1)
27+
assert.equal(map.delete("foo"), true)
28+
assert.equal(map.delete("foo"), false)
29+
assert.equal(map.has("foo"), false)
30+
})
31+
32+
test("clear", () => {
33+
const map = new SqliteMap(":memory:")
34+
map.set("a", 1)
35+
map.set("b", 2)
36+
map.clear()
37+
assert.equal(map.size, 0)
38+
})
39+
40+
test("size", () => {
41+
const map = new SqliteMap(":memory:")
42+
assert.equal(map.size, 0)
43+
map.set("a", 1)
44+
map.set("b", 2)
45+
assert.equal(map.size, 2)
46+
map.delete("a")
47+
assert.equal(map.size, 1)
48+
})
49+
50+
test("set returns this (chaining)", () => {
51+
const map = new SqliteMap(":memory:")
52+
const result = map.set("a", 1).set("b", 2)
53+
assert.equal(result, map)
54+
assert.equal(map.size, 2)
55+
})
56+
57+
test("getOrInsert - existing key", () => {
58+
const map = new SqliteMap(":memory:")
59+
map.set("foo", 42)
60+
assert.equal(map.getOrInsert("foo", 99), 42)
61+
})
62+
63+
test("getOrInsert - missing key", () => {
64+
const map = new SqliteMap(":memory:")
65+
assert.equal(map.getOrInsert("foo", 99), 99)
66+
assert.equal(map.get("foo"), 99)
67+
})
68+
69+
test("getOrInsertComputed - existing key", () => {
70+
const map = new SqliteMap(":memory:")
71+
map.set("foo", 42)
72+
assert.equal(
73+
map.getOrInsertComputed("foo", () => 99),
74+
42
75+
)
76+
})
77+
78+
test("getOrInsertComputed - missing key", () => {
79+
const map = new SqliteMap(":memory:")
80+
assert.equal(
81+
map.getOrInsertComputed("foo", (k) => k.length),
82+
3
83+
)
84+
assert.equal(map.get("foo"), 3)
85+
})
86+
87+
test("keys", () => {
88+
const map = new SqliteMap(":memory:")
89+
map.set("a", 1)
90+
map.set("b", 2)
91+
assert.deepEqual([...map.keys()], ["a", "b"])
92+
})
93+
94+
test("values", () => {
95+
const map = new SqliteMap(":memory:")
96+
map.set("a", 1)
97+
map.set("b", 2)
98+
assert.deepEqual([...map.values()], [1, 2])
99+
})
100+
101+
test("entries", () => {
102+
const map = new SqliteMap(":memory:")
103+
map.set("a", 1)
104+
map.set("b", 2)
105+
assert.deepEqual(
106+
[...map.entries()],
107+
[
108+
["a", 1],
109+
["b", 2]
110+
]
111+
)
112+
})
113+
114+
test("forEach", () => {
115+
const map = new SqliteMap<string, number>(":memory:")
116+
map.set("a", 1)
117+
map.set("b", 2)
118+
const result: [string, number][] = []
119+
map.forEach((value, key) => result.push([key, value]))
120+
assert.deepEqual(result, [
121+
["a", 1],
122+
["b", 2]
123+
])
124+
})
125+
126+
test("[Symbol.iterator]", () => {
127+
const map = new SqliteMap(":memory:")
128+
map.set("a", 1)
129+
map.set("b", 2)
130+
assert.deepEqual(
131+
[...map],
132+
[
133+
["a", 1],
134+
["b", 2]
135+
]
136+
)
137+
})
138+
139+
test("[Symbol.toStringTag]", () => {
140+
const map = new SqliteMap(":memory:")
141+
assert.equal(Object.prototype.toString.call(map), "[object SqliteMap]")
142+
})
143+
144+
test("toJSON", () => {
145+
const map = new SqliteMap(":memory:")
146+
map.set("a", 1)
147+
map.set("b", 2)
148+
assert.deepEqual(map.toJSON(), { a: 1, b: 2 })
149+
})
150+
151+
test("JSON.stringify", () => {
152+
const map = new SqliteMap(":memory:")
153+
map.set("a", 1)
154+
assert.equal(JSON.stringify(map), '{"a":1}')
155+
})
156+
157+
test("overwrites existing key", () => {
158+
const map = new SqliteMap(":memory:")
159+
map.set("foo", 1)
160+
map.set("foo", 2)
161+
assert.equal(map.get("foo"), 2)
162+
assert.equal(map.size, 1)
163+
})
164+
165+
test("supports complex values", () => {
166+
const map = new SqliteMap(":memory:")
167+
const val = { nested: { arr: [1, 2, 3], flag: true } }
168+
map.set("x", val)
169+
assert.deepEqual(map.get("x"), val)
170+
})
171+
})

src/index.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,104 @@ export class SqliteMap<K extends string, V> {
55
private db: DatabaseSync
66

77
constructor(path: string, options?: DatabaseSyncOptions) {
8-
this.db = new DatabaseSync(path, options)
8+
this.db = new DatabaseSync(path, { ...options })
99
this.db.exec("CREATE TABLE IF NOT EXISTS map (key TEXT PRIMARY KEY, value TEXT)")
1010
}
1111

1212
set(key: K, data: V): this {
13-
const stmt = this.db.prepare(`INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)`)
14-
stmt.run(String(key), JSON.stringify(data), Date.now())
13+
this.db
14+
.prepare(`INSERT OR REPLACE INTO map (key, value) VALUES (?, ?)`)
15+
.run(this.serializeKey(key), this.serialize(data))
1516
return this
1617
}
18+
19+
get(key: K): V | undefined {
20+
const row = this.db.prepare(`SELECT value FROM map WHERE key = ?`).get(this.serializeKey(key))
21+
return typeof row?.value === "string" ? this.deserialize(row.value) : undefined
22+
}
23+
24+
has(key: K): boolean {
25+
const row = this.db.prepare(`SELECT 1 FROM map WHERE key = ?`).get(this.serializeKey(key))
26+
return Boolean(row)
27+
}
28+
29+
delete(key: K): boolean {
30+
const result = this.db.prepare(`DELETE FROM map WHERE key = ?`).run(this.serializeKey(key))
31+
return result.changes > 0
32+
}
33+
34+
clear(): void {
35+
this.db.exec("DELETE FROM map")
36+
}
37+
38+
getOrInsert(key: K, defaultValue: V): V {
39+
const existing = this.get(key)
40+
if (existing !== undefined) return existing
41+
this.set(key, defaultValue)
42+
return defaultValue
43+
}
44+
45+
getOrInsertComputed(key: K, callbackFn: (key: K) => V): V {
46+
const existing = this.get(key)
47+
if (existing !== undefined) return existing
48+
const value = callbackFn(key)
49+
this.set(key, value)
50+
return value
51+
}
52+
53+
keys(): IterableIterator<K> {
54+
const rows = this.db.prepare(`SELECT key FROM map`).all() as { key: string }[]
55+
return rows.map((row) => row.key as K)[Symbol.iterator]()
56+
}
57+
58+
values(): IterableIterator<V> {
59+
const rows = this.db.prepare(`SELECT value FROM map`).all() as { value: string }[]
60+
return rows.map((row) => this.deserialize(row.value))[Symbol.iterator]()
61+
}
62+
63+
entries(): IterableIterator<[K, V]> {
64+
const rows = this.db.prepare(`SELECT key, value FROM map`).all() as { key: string; value: string }[]
65+
return rows.map((row) => [row.key as K, this.deserialize(row.value)] as [K, V])[Symbol.iterator]()
66+
}
67+
68+
forEach(cb: (value: V, key: K, map: this) => void): void {
69+
const rows = this.db.prepare(`SELECT key, value FROM map`).all() as { key: string; value: string }[]
70+
for (const row of rows) {
71+
cb(this.deserialize(row.value), row.key as K, this)
72+
}
73+
}
74+
75+
toJSON(): Record<string, V> {
76+
const obj: Record<string, V> = {}
77+
const rows = this.db.prepare(`SELECT key, value FROM map`).all() as { key: string; value: string }[]
78+
for (const row of rows) {
79+
obj[row.key] = this.deserialize(row.value)
80+
}
81+
return obj
82+
}
83+
84+
[Symbol.iterator](): IterableIterator<[K, V]> {
85+
return this.entries()
86+
}
87+
88+
get size(): number {
89+
const row = this.db.prepare(`SELECT COUNT(*) AS count FROM map`).get()
90+
return typeof row?.count === "number" ? row.count : 0
91+
}
92+
93+
get [Symbol.toStringTag](): string {
94+
return "SqliteMap"
95+
}
96+
97+
private serialize(value: V): string {
98+
return JSON.stringify(value)
99+
}
100+
101+
private deserialize(raw: string): V {
102+
return JSON.parse(raw) as V
103+
}
104+
105+
private serializeKey(key: K): string {
106+
return String(key)
107+
}
17108
}

tsconfig.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,5 @@
106106
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
107107
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
108108
"ignoreDeprecations": "6.0"
109-
},
110-
"exclude": ["tsup.config.ts", "out"]
109+
}
111110
}

0 commit comments

Comments
 (0)