カスタムデータベースサービス
Waline はデータベース操作を CURD などのいくつかの操作に分類しており、上位のロジックはすべてこれらの基本操作の組み合わせで実現されています。アダプターモードにより、各種類のデータベースストレージサービスはこれらの低レベルなアトミック操作を実装するだけで、システム全体のロジックを動作させることができます。
// index.js
const Application = require('@waline/vercel');
module.exports = Application({
model: class CustomModel {},
});Waline はデータベースモデルをカスタマイズするための model オプションを提供しており、データベース操作には渡されたモデルを統一的に使用します。あとはこの CustomModel クラスを実装するだけです。
class CustomModel {
constructor(tableName) {
this.tableName = tableName;
}
async select(where, { desc, limit, offset, field, order } = {}) {
//to be implemented
}
async count(where = {}, options = {}) {
//to be implemented
}
async add(data, { access } = { access: { read: true, write: true } }) {
//to be implemented
}
async update(data, where) {
//to be implemented
}
async delete(where) {
//to be implemented
}
}以上が CustomModel クラスが実装すべき基本構造であり、select、add、update、delete、count という基本メソッドの実装を含める必要があります。Waline は ThinkJS フレームワークをベースに開発されており、基盤となるデータベース操作にはフレームワーク付属のデータベース操作構文を使用します。これらのメソッドを実装する前に、データベースの条件クエリの構文について基本的な理解が必要です。
条件クエリ
完全な条件クエリ構文については、ThinkJS ドキュメント を参照してください。Waline の実装はそのサブセットです。
オブジェクトを通じて複数の条件クエリを渡すことができ、デフォルトは等しいという条件になります。値が二次元配列の場合、1 番目に他の判定操作、2 番目に対応する値を渡します(例: {user_id: ['!=', 0]})。現在サポートされているのは !=、>、IN、NOT IN、LIKE の各操作です。
MySQL と同様に、LIKE 操作では % の位置によってあいまい検索のモードを定義します。
content%はcontentで始まる内容の検索%contentはcontentで終わる内容の検索%content%はcontentを含む内容の検索
条件クエリオブジェクトは複数のクエリ条件を渡すことをサポートしています。これらの条件のデフォルトの関係は AND で、_logic マジックキーワードを使って関係を OR に指定できます。AND と OR が混在する場合は、_complex マジックキーワード式を使用できます。
文章だけでは理解しにくいかもしれません。プロジェクトで使われているクエリの例を見て、理解を深めましょう。
一般的なクエリ:
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";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');LIKE クエリ
const model = new CustomModel('Comment'); await model.select({ content: ['LIKE', '%content%'] }); // SELECT * FROM Comment WHERE content LIKE "%content%";複数条件のクエリ
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";複合クエリ
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'));
TypeScript に詳しい場合は、条件クエリの型定義はこちら を参照してください。
クエリを実装する
アダプターの select、update、delete、count メソッドで実際に複雑なのは条件クエリの部分です。前のパートで条件クエリ構文を理解すれば、その後のロジックはデータベース操作そのものです。
select() メソッドには 2 番目の引数 {desc, limit, offset, field} があります。これも理解しやすいはずです。
desc: そのフィールドの値の降順で並び替えるフィールドを指定limit: 返すデータの件数を指定offset: 返すデータを何件目から返すかを指定field: 返すデータのフィールドを指定。デフォルトではすべてのフィールドが返されます
update() メソッドは、data 引数が計算関数になるケースに対応する必要があります。例えば、閲覧数に 1 を加算する場合です。
const model = new CustomModel('Count');
await model.update((thread) => ({ view: thread.view + 1 }), { url: '/' });戻り値について、select() は常に配列を返し、add() と update() はインデックスフィールドを含む完全なデータを含める必要があります。
参考
以上のロジックに基づき、専門的なデータベースのストレージサービスを実装する以外に、公式は GitHub ストレージサービスも非常に面白い形で実装しています。データを CSV ファイルの形式で GitHub に保存し、クエリのたびに CSV ファイルの内容を取得して、JS で条件クエリ文に従って最終的なデータを絞り込んで返します。以下は公式の実装です。参考になれば幸いです。
//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, order } = {}) {
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);
}
};