feat: Принес класс хранилища SQLite и зависимости
This commit is contained in:
parent
e799da6a48
commit
79cdca410e
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
*.db
|
1746
package-lock.json
generated
1746
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -50,7 +50,9 @@
|
|||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"mongodb": "^4.1.2",
|
"mongodb": "^4.1.2",
|
||||||
"mongodb-client-encryption": "^1.2.7"
|
"mongodb-client-encryption": "^1.2.7",
|
||||||
|
"sqlite": "^4.0.23",
|
||||||
|
"sqlite3": "^5.0.2"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
|
207
src/app/storage/sqlite.ts
Normal file
207
src/app/storage/sqlite.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { Database, open } from 'sqlite'
|
||||||
|
import sqlite3 from 'sqlite3'
|
||||||
|
import { ErrStorage, Storage, StorageCursor, StorageSession } from '../storage'
|
||||||
|
import { ErrEntityHasNoUniqKeyValue } from '../entity'
|
||||||
|
|
||||||
|
export type SQLiteCriteria = Record<string, Array<string | number>>
|
||||||
|
|
||||||
|
function parseCriteria(criteria?: SQLiteCriteria): { where: string | null; params: Array<string | number> } {
|
||||||
|
if (criteria) {
|
||||||
|
const conditions = Object.entries(criteria)
|
||||||
|
const params = []
|
||||||
|
const where = []
|
||||||
|
if (conditions.length) {
|
||||||
|
for (const condition of conditions) {
|
||||||
|
where.push(condition[0].trim())
|
||||||
|
params.push(...condition[1])
|
||||||
|
}
|
||||||
|
return { where: where.join(' AND '), params }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { where: null, params: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteStr(val: string | number | unknown) {
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
return `'${val.replace(/'/g, `"`)}'`
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformDate(val: Date | unknown) {
|
||||||
|
return val instanceof Date ? `'${val.getTime()}'` : val
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageSQLiteParams {
|
||||||
|
dsn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageSQLite extends Storage {
|
||||||
|
private _db: Database | null = null
|
||||||
|
|
||||||
|
constructor(params: StorageSQLiteParams) {
|
||||||
|
super(params.dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<Database> {
|
||||||
|
try {
|
||||||
|
sqlite3.verbose()
|
||||||
|
this._db = await open({
|
||||||
|
filename: this._dsn,
|
||||||
|
driver: sqlite3.Database,
|
||||||
|
})
|
||||||
|
return this._db
|
||||||
|
} catch (e) {
|
||||||
|
throw new ErrStorage(`error on connect to db [${this._dsn}]`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(name: string, criteria?: SQLiteCriteria): Promise<number> {
|
||||||
|
if (!this._db) await this.connect()
|
||||||
|
const query = [`SELECT COUNT(*) as cnt FROM ${name}`]
|
||||||
|
const { where, params } = parseCriteria(criteria)
|
||||||
|
if (where) {
|
||||||
|
query.push(`WHERE ${where}`)
|
||||||
|
}
|
||||||
|
const result = await this._db?.get(query.join(' '), params)
|
||||||
|
return parseInt(result.cnt, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(name: string, criteria: SQLiteCriteria): Promise<SQLiteCursor> {
|
||||||
|
if (!this._db) this._db = await this.connect()
|
||||||
|
return new SQLiteCursor(this._db, name, criteria)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вернет тру, даже если записи не было
|
||||||
|
* @param name
|
||||||
|
* @param uniqKey
|
||||||
|
* @param uniqValue
|
||||||
|
*/
|
||||||
|
async remove(name: string, uniqKey: string, uniqValue: string): Promise<boolean> {
|
||||||
|
if (!this._db) this._db = await this.connect()
|
||||||
|
const sql = `DELETE FROM ${name} WHERE ${uniqKey}=${quoteStr(uniqValue)}`
|
||||||
|
const result = await this._db.run(sql)
|
||||||
|
return !!(result && result.changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(name: string, uniqKey: string, data: Record<string, unknown>): Promise<string> {
|
||||||
|
if (!this._db) this._db = await this.connect()
|
||||||
|
const keys = []
|
||||||
|
const values = []
|
||||||
|
const assigns = []
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (v === undefined) continue
|
||||||
|
|
||||||
|
keys.push(k)
|
||||||
|
|
||||||
|
let vp
|
||||||
|
if (v === null) {
|
||||||
|
vp = 'NULL'
|
||||||
|
} else {
|
||||||
|
vp = quoteStr(v)
|
||||||
|
vp = transformDate(vp)
|
||||||
|
}
|
||||||
|
values.push(vp)
|
||||||
|
assigns.push(`${k}=${vp}`)
|
||||||
|
}
|
||||||
|
const id = data[uniqKey]
|
||||||
|
if (id !== null && id !== undefined) {
|
||||||
|
const sql = `INSERT INTO ${name}(${keys.join(', ')})
|
||||||
|
VALUES (${values.join(', ')})
|
||||||
|
ON CONFLICT(${uniqKey}) DO UPDATE SET ${assigns.join(', ')}
|
||||||
|
WHERE ${uniqKey} = ${quoteStr(id)}`
|
||||||
|
try {
|
||||||
|
const result = await this._db.run(sql)
|
||||||
|
return result.lastID + ''
|
||||||
|
} catch (e) {
|
||||||
|
throw new ErrStorage(`can not save data to ${name} by sql:\n"${sql}"\nwith error: ${e}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new ErrEntityHasNoUniqKeyValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(): StorageSession {
|
||||||
|
return new SQLiteSession(this._db)
|
||||||
|
}
|
||||||
|
|
||||||
|
get db(): Database | null {
|
||||||
|
return this._db
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class SQLiteCursor implements StorageCursor {
|
||||||
|
private _limit = 0
|
||||||
|
private _offset = 0
|
||||||
|
private _order: Record<string, string> = {}
|
||||||
|
constructor(private _db: Database, private _tableName: string, private _criteria?: SQLiteCriteria) {}
|
||||||
|
|
||||||
|
limit(number: number): this {
|
||||||
|
this._limit = number
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
skip(offset: number): this {
|
||||||
|
this._offset = offset
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
sort(parameters: Record<string, string>): this {
|
||||||
|
this._order = parameters
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSQL(): { query: string[]; params: (string | number)[] } {
|
||||||
|
const query = [`SELECT * FROM ${this._tableName}`]
|
||||||
|
const { where, params } = parseCriteria(this._criteria)
|
||||||
|
if (where) {
|
||||||
|
query.push(`WHERE ${where}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = Object.entries(this._order)
|
||||||
|
if (orders.length) {
|
||||||
|
const orderStr = orders.map(e => `${e[0]} ${e[1]}`).join(', ')
|
||||||
|
query.push(`ORDER BY ${orderStr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._limit) query.push(`LIMIT ${this._limit}`)
|
||||||
|
if (this._offset) query.push(`OFFSET ${this._offset}`)
|
||||||
|
|
||||||
|
return { query, params }
|
||||||
|
}
|
||||||
|
|
||||||
|
async toArray(): Promise<Record<string, any>[]> {
|
||||||
|
const { query, params } = this._buildSQL()
|
||||||
|
return this._db.all(query.join(' '), params.length ? params : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class SQLiteSession extends StorageSession {
|
||||||
|
constructor(private _db: Database | null) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this._db?.run('BEGIN TRANSACTION')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async commit(fn: () => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
} catch (e) {
|
||||||
|
await this._db?.run('ROLLBACK')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._db?.run('COMMIT')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
137
src/tests/storage/sqlite/sqlite.test.ts
Normal file
137
src/tests/storage/sqlite/sqlite.test.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { assert } from 'chai'
|
||||||
|
import { StorageSQLite } from '../../../app/storage/sqlite'
|
||||||
|
|
||||||
|
const abbrList = [
|
||||||
|
['bar', '{}', 2],
|
||||||
|
['pew', '{}', 2],
|
||||||
|
['baz', '{}', 2],
|
||||||
|
['xyz', '{}', 1],
|
||||||
|
['yes', '{}', 1],
|
||||||
|
['lol', JSON.stringify({ hello: 'world' }), 1],
|
||||||
|
['idk', JSON.stringify({ pew: 1 }), 1],
|
||||||
|
['gg', JSON.stringify({ pew: 100 }), 1],
|
||||||
|
]
|
||||||
|
const dsn = __dirname + `/sqlite.test.db`
|
||||||
|
|
||||||
|
describe('StorageSQLite', () => {
|
||||||
|
interface Item {
|
||||||
|
abbr: string
|
||||||
|
foo: string
|
||||||
|
n: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage: StorageSQLite
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
storage = new StorageSQLite({ dsn })
|
||||||
|
await storage.connect()
|
||||||
|
storage.db.on('trace', (data: string) => {
|
||||||
|
console.log(data)
|
||||||
|
})
|
||||||
|
await storage.db.run('drop table if exists items')
|
||||||
|
// UNIQUE нужно, чтобы работал UPSERT в методе save()
|
||||||
|
await storage.db.run(
|
||||||
|
'create table items (abbr varchar(4) not null unique, foo text null, num integer not null default 0)'
|
||||||
|
)
|
||||||
|
const stmt = await storage.db.prepare('insert into "items" (abbr, foo, num) values (?, ?, ?)')
|
||||||
|
for (const values of abbrList) {
|
||||||
|
await stmt.run(values)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#find', () => {
|
||||||
|
it('should find all', async () => {
|
||||||
|
const cursor = await storage.find('items', {})
|
||||||
|
const items = await cursor.toArray()
|
||||||
|
assert.equal(items.length, abbrList.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should find only one', async () => {
|
||||||
|
const cursor = await storage.find('items', { 'abbr = ?': ['bar'] })
|
||||||
|
const items = await cursor.toArray()
|
||||||
|
assert.equal(items.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should find 2 with offset', async () => {
|
||||||
|
const cursor = await storage.find('items', {})
|
||||||
|
const items = await cursor.limit(2).toArray()
|
||||||
|
assert.equal(items.length, 2)
|
||||||
|
const nextItems = await cursor.skip(2).toArray()
|
||||||
|
assert.equal(nextItems.length, 2)
|
||||||
|
assert.notDeepEqual(items[0], nextItems[0])
|
||||||
|
assert.notDeepEqual(items[1], nextItems[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should find reversed order ', async () => {
|
||||||
|
const cursor = await storage.find('items', {})
|
||||||
|
const items = await cursor.sort({ abbr: 'DESC' }).toArray()
|
||||||
|
assert.equal(items.length, abbrList.length)
|
||||||
|
assert.equal(items[0].abbr, 'yes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should find and get string from string field for json', async () => {
|
||||||
|
const cursor = await storage.find('items', { 'abbr = ?': ['gg'] })
|
||||||
|
const items = await cursor.toArray()
|
||||||
|
assert.lengthOf(items, 1)
|
||||||
|
|
||||||
|
const item = items[0] as Item
|
||||||
|
assert.equal(typeof item.foo, 'string')
|
||||||
|
assert.deepEqual(JSON.parse(item.foo), { pew: 100 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should find with criteria with many params 1', async () => {
|
||||||
|
const cursor = await storage.find('items', { 'num = ?': [1], 'foo != ?': ['{}'] })
|
||||||
|
const items = await cursor.toArray()
|
||||||
|
assert.lengthOf(items, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should find with criteria with many params 2', async () => {
|
||||||
|
const cursor = await storage.find('items', { 'num = ?': [2], 'foo = ?': ['{}'] })
|
||||||
|
const items = await cursor.toArray()
|
||||||
|
assert.lengthOf(items, 3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#count', () => {
|
||||||
|
it('should return total count', async () => {
|
||||||
|
const count = await storage.count('items', {})
|
||||||
|
assert.equal(count, abbrList.length)
|
||||||
|
})
|
||||||
|
it('should return count with criteria', async () => {
|
||||||
|
const count1 = await storage.count('items', { "abbr LIKE '%'": [] })
|
||||||
|
assert.equal(count1, abbrList.length)
|
||||||
|
const count2 = await storage.count('items', { 'abbr = ? OR abbr = ?': ['bar', 'pew'] })
|
||||||
|
assert.equal(count2, 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#save', () => {
|
||||||
|
it('should save new item', async () => {
|
||||||
|
let lastId = await storage.save('items', 'abbr', { abbr: 'banzay' })
|
||||||
|
assert.equal(lastId, abbrList.length + 1 + '')
|
||||||
|
abbrList.push(['banzay', '{}'])
|
||||||
|
lastId = await storage.save('items', 'abbr', { abbr: 'babam' })
|
||||||
|
assert.equal(lastId, abbrList.length + 1 + '')
|
||||||
|
abbrList.push(['babam', '{}'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update item', async () => {
|
||||||
|
let lastId = await storage.save('items', 'abbr', { abbr: 'kish' })
|
||||||
|
assert.equal(lastId, abbrList.length + 1 + '')
|
||||||
|
abbrList.push(['kish', '{}'])
|
||||||
|
lastId = await storage.save('items', 'abbr', { abbr: 'kish' })
|
||||||
|
assert.equal(lastId, abbrList.length + '')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#remove', () => {
|
||||||
|
it('should remove item', async () => {
|
||||||
|
const lastId = await storage.save('items', 'abbr', { abbr: 'thx' })
|
||||||
|
assert.equal(lastId, abbrList.length + 1 + '')
|
||||||
|
const deleted = await storage.remove('items', 'abbr', 'thx')
|
||||||
|
assert.isTrue(deleted)
|
||||||
|
const count = await storage.count('items')
|
||||||
|
assert.equal(abbrList.length, count)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
46
src/tests/storage/sqlite/sqlsession.test.ts
Normal file
46
src/tests/storage/sqlite/sqlsession.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { assert } from 'chai'
|
||||||
|
import { StorageSQLite } from '../../../app/storage/sqlite'
|
||||||
|
|
||||||
|
const itemsData = ['zero', 'one', 'two', 'three', 'four', 'five']
|
||||||
|
const dsn = __dirname + '/sqlsession.test.db'
|
||||||
|
|
||||||
|
describe('SQLiteSession', () => {
|
||||||
|
let storage: StorageSQLite
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
storage = new StorageSQLite({ dsn })
|
||||||
|
await storage.connect()
|
||||||
|
storage.db.on('trace', (data: string) => {
|
||||||
|
console.log(data)
|
||||||
|
})
|
||||||
|
await storage.db.run('DROP TABLE IF EXISTS items')
|
||||||
|
|
||||||
|
// UNIQUE нужно, чтобы работал UPSERT в методе save()
|
||||||
|
await storage.db.run(
|
||||||
|
'CREATE TABLE items (key varchar(8) NOT NULL UNIQUE, val integer NOT NULL DEFAULT 0)'
|
||||||
|
)
|
||||||
|
const stmt = await storage.db.prepare('INSERT INTO items (key, val) VALUES (?, ?)')
|
||||||
|
for (let k = 0; k < itemsData.length; k++) {
|
||||||
|
await stmt.run([k, itemsData[k]])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#commit', () => {
|
||||||
|
it('should commit save', async () => {
|
||||||
|
const session = storage.createSession()
|
||||||
|
await session.start()
|
||||||
|
await session.commit(async () => {
|
||||||
|
await storage.save('items', 'key', { key: 'ten', val: 10 })
|
||||||
|
await storage.save('items', 'key', { key: 'eleven', val: 11 })
|
||||||
|
await storage.remove('items', 'key', 'ten')
|
||||||
|
})
|
||||||
|
|
||||||
|
const cursor = await storage.find('items', {})
|
||||||
|
const items = await cursor.toArray()
|
||||||
|
assert.equal(itemsData.length + 1, items.length)
|
||||||
|
for (const item of items) {
|
||||||
|
if (item['key'] === 'ten') assert.fail('ten had to be removed in session')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user