Layanan Database Kustom
Waline mengklasifikasikan operasi database ke dalam beberapa operasi seperti CURD, dan semua logika tingkat atas diselesaikan melalui penumpukan operasi dasar tersebut. Melalui pola adapter, berbagai jenis layanan penyimpanan database hanya perlu mengimplementasikan operasi atomik tingkat rendah ini agar seluruh logika sistem dapat berjalan.
// index.js
const Application = require('@waline/vercel');
module.exports = Application({
model: class CustomModel {},
});Waline menyediakan opsi model untuk mengkustomisasi model database, dan kami akan menggunakan model yang diteruskan secara seragam untuk operasi database. Yang tersisa adalah kita perlu mengimplementasikan kelas CustomModel ini.
class CustomModel {
constructor(tableName) {
this.tableName = tableName;
}
async select(where, { desc, limit, offset, field } = {}) {
//to be implemented
}
async count(where = {}, options = {}) {
//to be implemented
}
async add(data) {
//to be implemented
}
async update(data, where) {
//to be implemented
}
async delete(where) {
//to be implemented
}
}Di atas adalah struktur dasar yang harus diimplementasikan oleh kelas CustomModel, dan harus mencakup implementasi beberapa metode dasar yaitu select, add, update, delete, dan count. Waline dikembangkan berdasarkan framework ThinkJS, dan operasi database dasarnya menggunakan sintaks operasi database yang disertakan oleh framework tersebut. Sebelum mengimplementasikan metode-metode ini, Anda perlu memiliki pemahaman dasar tentang sintaks kueri kondisional database.
Kueri kondisional
Untuk sintaks kueri kondisional yang lengkap, silakan merujuk ke Dokumentasi ThinkJS, dan implementasi Waline adalah bagian darinya.
Beberapa kueri kondisional dapat diteruskan melalui objek, dan defaultnya adalah kondisi sama dengan. Ketika nilainya adalah array dua dimensi, bit pertama dapat diteruskan ke operasi penilaian lain, dan bit kedua sesuai dengan nilai, seperti {user_id: ['!=', 0]}. Saat ini yang didukung adalah operasi terpusat !=, >, IN, NOT IN, LIKE.
Mirip dengan MySQL, dalam operasi LIKE, kita mendefinisikan mode kueri fuzzy melalui posisi %:
content%berarti mencari konten yang dimulai dengancontent%contentberarti mencari konten yang diakhiri dengancontent%content%berarti mencari konten yang mengandungcontent
Objek kueri kondisional mendukung penerusan beberapa kondisi kueri. Hubungan default antara kondisi-kondisi ini adalah AND, dan kata kunci ajaib _logic dapat digunakan untuk menentukan hubungannya sebagai OR. Ketika ada AND dan OR, kita dapat menggunakan ekspresi kata kunci ajaib _complex.
Teks mungkin tidak mudah dipahami. Mari kita lihat contoh kueri yang digunakan dalam proyek untuk memperdalam pemahaman kita.
Kueri umum:
const model = new CustomModel('Comment'); await model.select({ url: '/', user_id: ['!=', 0], createdAt: ['>', '2023-04-16 00:00:00'], }); // SELECT * FROM Comment WHERE url = '/' AND user_id != 0 AND createdAt > "2023-04-16 00:00:00";Kueri IN / NOT IN
const model = new CustomModel('Users'); await model.select({ objectId: ['IN', [1, 2, 3, 4]] }); // SELECT * FROM Users WHERE objectId IN (1,2,3,4);const model = new CustomModel('Comment'); await model.select({ status: ['NOT IN', ['waiting', 'spam']] }); // SELECT * FROM Comment WHERE status NOT IN ('waiting', 'spam');Kueri LIKE
const model = new CustomModel('Comment'); await model.select({ content: ['LIKE', '%content%'] }); // SELECT * FROM Comment WHERE content LIKE "%content%";Kueri multi-kondisi
const model = new CustomModel('Comment'); await model.select({ url: '/', user_id: ['!=', 0], createdAt: ['>', '2023-04-16 00:00:00'], _logic: 'OR', }); // SELECT * FROM Comment WHERE url = '/' OR user_id != 0 OR createdAt > "2023-04-16 00:00:00";Kueri majemuk
const model = new CustomModel('Comment'); await model. select({ url: '/', _complex: { user_id: 0, status: ['NOT IN', ['waiting', 'spam']] _logic: 'OR' } }); // SELECT * FROM Comment WHERE url = '/' AND ( user_id = 0 OR status NOT IN ('waiting', 'spam'));
Jika Anda lebih familiar dengan TypeScript, definisi tipe untuk kueri kondisional ada di sini.
Implementasi kueri
Metode select, update, delete, count dalam adapter sebenarnya rumit dalam kueri kondisional. Setelah memahami sintaks kueri kondisional di bagian sebelumnya, logika selanjutnya adalah operasi database.
Metode select() memiliki argumen kedua {desc, limit, offset, field}. Ini juga lebih mudah dipahami:
desc: Menentukan field untuk diurutkan secara menurun berdasarkan nilai field tersebutlimit: Menentukan jumlah data yang dikembalikanoffset: Menentukan data yang dikembalikan mulai dari item manafield: Menentukan field untuk mengembalikan data, semua field dikembalikan secara default
Metode update() perlu kompatibel dengan skenario di mana parameter input data mungkin berupa fungsi kalkulasi, seperti menambahkan 1 pada jumlah penonton halaman:
const model = new CustomModel('Count');
await model.update((thread) => ({ view: thread.view + 1 }), { url: '/' });Tipe data yang dikembalikan select() selalu mengembalikan array, add() dan update() perlu menyertakan data lengkap dari field yang diindeks.
Referensi
Berdasarkan logika di atas, selain mengimplementasikan layanan penyimpanan database profesional, pihak resmi juga mengimplementasikan layanan penyimpanan GitHub dengan cara yang sangat menarik. Kami menyimpan data di GitHub dalam bentuk file CSV, dan memperoleh konten file CSV setiap kali melakukan kueri, lalu menyaring data akhir berdasarkan pernyataan kueri kondisional dalam JS dan mengembalikannya. Berikut adalah implementasi resmi, dengan harapan dapat memberikan referensi bagi Anda.
//source code: https://github.com/walinejs/waline/blob/main/packages/server/src/service/storage/github.js
const path = require('path');
const { parseString, writeToString } = require('fast-csv');
const Base = require('./base');
const CSV_HEADERS = {
Comment: [
'objectId',
'user_id',
'comment',
'insertedAt',
'ip',
'link',
'mail',
'nick',
'pid',
'rid',
'status',
'ua',
'url',
'createdAt',
'updatedAt',
],
Counter: ['objectId', 'time', 'url', 'createdAt', 'updatedAt'],
Users: [
'objectId',
'display_name',
'email',
'password',
'type',
'url',
'avatar',
'label',
'github',
'twitter',
'facebook',
'google',
'weibo',
'qq',
'createdAt',
'updatedAt',
],
};
class Github {
constructor(repo, token) {
this.token = token;
this.repo = repo;
}
// content api can only get file < 1MB
async get(filename) {
const resp = await fetch(
'https://api.github.com/repos/' + path.join(this.repo, 'contents', filename),
{
headers: {
accept: 'application/vnd.github.v3+json',
authorization: 'token ' + this.token,
'user-agent': 'Waline',
},
},
)
.then((resp) => resp.json())
.catch((e) => {
const isTooLarge = e.message.includes('"too_large"');
if (!isTooLarge) {
throw e;
}
return this.getLargeFile(filename);
});
return {
data: Buffer.from(resp.content, 'base64').toString('utf-8'),
sha: resp.sha,
};
}
// blob api can get file larger than 1MB
async getLargeFile(filename) {
const { tree } = await fetch(
'https://api.github.com/repos/' + path.join(this.repo, 'git/trees/HEAD') + '?recursive=1',
{
headers: {
accept: 'application/vnd.github.v3+json',
authorization: 'token ' + this.token,
'user-agent': 'Waline',
},
},
).then((resp) => resp.json());
const file = tree.find(({ path }) => path === filename);
if (!file) {
const error = new Error('NOT FOUND');
error.statusCode = 404;
throw error;
}
return fetch(file.url, {
headers: {
accept: 'application/vnd.github.v3+json',
authorization: 'token ' + this.token,
'user-agent': 'Waline',
},
}).then((resp) => resp.json());
}
async set(filename, content, { sha }) {
return fetch('https://api.github.com/repos/' + path.join(this.repo, 'contents', filename), {
method: 'PUT',
headers: {
accept: 'application/vnd.github.v3+json',
authorization: 'token ' + this.token,
'user-agent': 'Waline',
},
body: JSON.stringify({
sha,
message: 'feat(waline): update comment data',
content: Buffer.from(content, 'utf-8').toString('base64'),
}),
});
}
}
module.exports = class extends Base {
constructor(tableName) {
super();
this.tableName = tableName;
const { GITHUB_TOKEN, GITHUB_REPO, GITHUB_PATH } = process.env;
this.git = new Github(GITHUB_REPO, GITHUB_TOKEN);
this.basePath = GITHUB_PATH;
}
async collection(tableName) {
const filename = path.join(this.basePath, tableName + '.csv');
const file = await this.git.get(filename).catch((e) => {
if (e.statusCode === 404) {
return '';
}
throw e;
});
return new Promise((resolve, reject) => {
const data = [];
data.sha = file.sha;
return parseString(file.data, {
headers: file ? true : CSV_HEADERS[tableName],
})
.on('error', reject)
.on('data', (row) => data.push(row))
.on('end', () => resolve(data));
});
}
async save(tableName, data, sha) {
const filename = path.join(this.basePath, tableName + '.csv');
const csv = await writeToString(data, {
headers: sha ? true : CSV_HEADERS[tableName],
writeHeaders: true,
});
return this.git.set(filename, csv, { sha });
}
parseWhere(where) {
const _where = [];
if (think.isEmpty(where)) {
return _where;
}
const filters = [];
for (let k in where) {
if (k === '_complex') {
continue;
}
if (k === 'objectId') {
filters.push((item) => item.id === where[k]);
continue;
}
if (think.isString(where[k])) {
filters.push((item) => item[k] === where[k]);
continue;
}
if (where[k] === undefined) {
filters.push((item) => item[k] === null || item[k] === undefined);
}
if (!Array.isArray(where[k]) || !where[k][0]) {
continue;
}
const handler = where[k][0].toUpperCase();
switch (handler) {
case 'IN':
filters.push((item) => where[k][1].includes(item[k]));
break;
case 'NOT IN':
filters.push((item) => !where[k][1].includes(item[k]));
break;
case 'LIKE': {
const first = where[k][1][0];
const last = where[k][1].slice(-1);
let reg;
if (first === '%' && last === '%') {
reg = new RegExp(where[k][1].slice(1, -1));
} else if (first === '%') {
reg = new RegExp(where[k][1].slice(1) + '$');
} else if (last === '%') {
reg = new RegExp('^' + where[k][1].slice(0, -1));
}
filters.push((item) => reg.test(item[k]));
break;
}
case '!=':
filters.push((item) => item[k] !== where[k][1]);
break;
case '>':
filters.push((item) => item[k] >= where[k][1]);
break;
}
}
return filters;
}
where(data, where) {
const filter = this.parseWhere(where);
if (!where._complex) {
return data.filter((item) => filter.every((fn) => fn(item)));
}
const logicMap = {
and: Array.prototype.every,
or: Array.prototype.some,
};
const filters = [];
for (const k in where._complex) {
if (k === '_logic') {
continue;
}
filters.push([...filter, ...this.parseWhere({ [k]: where._complex[k] })]);
}
const logicFn = logicMap[where._complex._logic];
return data.filter((item) => logicFn.call(filters, (filter) => filter.every((fn) => fn(item))));
}
async select(where, { desc, limit, offset, field } = {}) {
const instance = await this.collection(this.tableName);
let data = this.where(instance, where);
if (desc) {
data.sort((a, b) => {
if (['insertedAt', 'createdAt', 'updatedAt'].includes(desc)) {
const aTime = new Date(a[desc]).getTime();
const bTime = new Date(b[desc]).getTime();
return bTime - aTime;
}
return a[desc] - b[desc];
});
}
data = data.slice(limit || 0, offset || data.length);
if (field) {
field.push('id');
const fieldObj = {};
field.forEach((f) => (fieldObj[f] = true));
data = data.map((item) => {
const ret = {};
for (const k in item) {
if (fieldObj[k]) {
ret[k] = item[k];
}
}
return ret;
});
}
return data.map(({ id, ...cmt }) => ({ ...cmt, objectId: id }));
}
async count(where = {}, { group } = {}) {
const instance = await this.collection(this.tableName);
const data = this.where(instance, where);
if (!group) {
return data.length;
}
const counts = {};
for (let i = 0; i < data.length; i++) {
const key = group.map((field) => data[field]).join();
if (!counts[key]) {
counts[key] = { count: 0 };
group.forEach((field) => {
counts[key][field] = data[field];
});
}
counts[key].count += 1;
}
return Object.keys(counts);
}
async add(
data,
// { access: { read = true, write = true } = { read: true, write: true } } = {}
) {
const instance = await this.collection(this.tableName);
const id = Math.random().toString(36).substr(2, 15);
instance.push({ ...data, id });
await this.save(this.tableName, instance, instance.sha);
return { ...data, objectId: id };
}
async update(data, where) {
delete data.objectId;
const instance = await this.collection(this.tableName);
const list = this.where(instance, where);
list.forEach((item) => {
if (typeof data === 'function') {
data(item);
} else {
for (const k in data) {
item[k] = data[k];
}
}
});
await this.save(this.tableName, instance, instance.sha);
return list;
}
async delete(where) {
const instance = await this.collection(this.tableName);
const deleteData = this.where(instance, where);
const deleteId = deleteData.map(({ id }) => id);
const data = instance.filter((data) => !deleteId.includes(data.id));
await this.save(this.tableName, data, instance.sha);
}
};