Merge branch 'feature/couchdb'

This commit is contained in:
ry.yamafuji 2025-03-22 23:23:13 +09:00
commit 2a65449959
10 changed files with 708 additions and 2 deletions

17
examles/sampleCouchDB.js Normal file
View File

@ -0,0 +1,17 @@
async function main() {
const {CouchDatabaseClient} = require('../src/classes/couchdb/CouchDatabaseClient');
const config = {
protocol: 'http',
host: 'localhost',
port: 5984,
username: 'admin',
password: 'secret'
}
const client = new CouchDatabaseClient(config);
const users = client.getCollection('user');
const user = await users.create({name: 'testUser1', email: 'testUser1@example.com' });
}
main();

174
package-lock.json generated
View File

@ -13,7 +13,8 @@
"archiver": "^7.0.1", "archiver": "^7.0.1",
"csv-writer": "^1.6.0", "csv-writer": "^1.6.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"minio": "^8.0.5" "minio": "^8.0.5",
"nano": "^10.1.4"
}, },
"devDependencies": { "devDependencies": {
"jsdoc": "^4.0.4" "jsdoc": "^4.0.4"
@ -373,6 +374,32 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/axios": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@ -927,6 +954,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -1711,6 +1758,26 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nano": {
"version": "10.1.4",
"resolved": "https://registry.npmjs.org/nano/-/nano-10.1.4.tgz",
"integrity": "sha512-bJOFIPLExIbF6mljnfExXX9Cub4W0puhDjVMp+qV40xl/DBvgKao7St4+6/GB6EoHZap7eFnrnx4mnp5KYgwJA==",
"license": "Apache-2.0",
"dependencies": {
"axios": "^1.7.4",
"node-abort-controller": "^3.1.1",
"qs": "^6.13.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"license": "MIT"
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -1740,6 +1807,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -1819,6 +1898,12 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode.js": { "node_modules/punycode.js": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
@ -1829,6 +1914,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-string": { "node_modules/query-string": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
@ -1998,6 +2098,78 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",

View File

@ -17,7 +17,8 @@
"archiver": "^7.0.1", "archiver": "^7.0.1",
"csv-writer": "^1.6.0", "csv-writer": "^1.6.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"minio": "^8.0.5" "minio": "^8.0.5",
"nano": "^10.1.4"
}, },
"devDependencies": { "devDependencies": {
"jsdoc": "^4.0.4" "jsdoc": "^4.0.4"

View File

@ -0,0 +1,97 @@
// couchdb/CouchCollection.js
const { CouchDocument } = require('./CouchDocument.js');
/**
* @template T
*/
class CouchCollection {
/**
* @param {import('nano').DocumentScope<any>} db
*/
constructor(db) {
this.db = db;
}
/**
* @param {string} id
* @returns {Promise<import('../interfaces').IDocument<T> | null>}
*/
async get(id) {
try {
const doc = await this.db.get(id);
return new CouchDocument(
doc._id,
doc.data,
doc.createdAt ? new Date(doc.createdAt) : undefined,
doc.updatedAt ? new Date(doc.updatedAt) : undefined,
doc._rev
);
} catch (err) {
if (err.statusCode === 404) return null;
throw err;
}
}
/**
* @returns {Promise<Array<import('../interfaces').IDocument<T>>>}
*/
async list() {
const result = await this.db.list({ include_docs: true });
return result.rows
.filter(row => row.doc)
.map(row =>
new CouchDocument(
row.doc._id,
row.doc.data,
row.doc.createdAt ? new Date(row.doc.createdAt) : undefined,
row.doc.updatedAt ? new Date(row.doc.updatedAt) : undefined,
row.doc._rev
)
);
}
/**
* @param {T} data
* @returns {Promise<import('../interfaces').IDocument<T>>}
*/
async create(data) {
const now = new Date();
const doc = {
data,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
const response = await this.db.insert(doc);
return new CouchDocument(response.id, data, now, now, response.rev);
}
/**
* @param {string} id
* @param {Partial<T>} data
* @returns {Promise<import('../interfaces').IDocument<T>>}
*/
async update(id, data) {
const existing = await this.db.get(id);
const updated = {
...existing,
data: {
...existing.data,
...data,
},
updatedAt: new Date().toISOString(),
};
const response = await this.db.insert(updated);
return new CouchDocument(response.id, updated.data, existing.createdAt, new Date(), response.rev);
}
/**
* @param {string} id
* @returns {Promise<void>}
*/
async delete(id) {
const doc = await this.db.get(id);
await this.db.destroy(id, doc._rev);
}
}
module.exports = { CouchCollection };

View File

@ -0,0 +1,40 @@
// couchdb/CouchDatabaseClient.js
const nano = require('nano');
const { CouchCollection } = require('./CouchCollection.js');
/**
* @template T
* @typedef {import('../interfaces').ICollection<T>} ICollection
*/
/**
* @typedef {Object} CouchDBConfig
* @property {string} protocol
* @property {string} host
* @property {number} port
* @property {string} username
* @property {string} password
*/
class CouchDatabaseClient {
/**
* @param {CouchDBConfig} config
*/
constructor(config) {
const { protocol, host, port, username, password } = config;
const url = `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
this.server = nano(url);
}
/**
* @template T
* @param {string} name
* @returns {ICollection<T>}
*/
getCollection(name) {
const db = this.server.db.use(name);
return new CouchCollection(db);
}
}
module.exports = { CouchDatabaseClient };

View File

@ -0,0 +1,34 @@
// couchdb/CouchDocument.js
/**
* @template T
* @typedef {Object} IDocument
* @property {string} id
* @property {T} data
* @property {Date=} createdAt
* @property {Date=} updatedAt
* @property {string|number=} version
*/
/**
* @template T
* @implements {IDocument<T>}
*/
class CouchDocument {
/**
* @param {string} id
* @param {T} data
* @param {Date=} createdAt
* @param {Date=} updatedAt
* @param {string|number=} version
*/
constructor(id, data, createdAt, updatedAt, version) {
this.id = id;
this.data = data;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.version = version;
}
}
module.exports = { CouchDocument };

View File

@ -0,0 +1,175 @@
# [NoSQL][CouchDB]Node.jsの利用ガイド(nanoライブラリ)
CouchDBはドキュメント指向のNoSQLデータベースで、
HTTPベースのRESTful APIで操作可能です。
Node.jsでは公式の軽量クライアントであるnanoを使うことで
簡単にCouchDBとやりとりできます。
[戻る](https://wiki.pglikers.com/e/en/private/db/nosql/couch-db)
## nanoのインストール
```sh
npm install nano
```
nanoはNode.js向けに設計されているため
ブラウザ(フロントエンド)では使用できません。
## CouchDBとの接続
```sh
const nano = require('nano')('http://admin:secret@localhost:5984');
```
**envなどを活用して設定する場合**
```conf
COUCHDB_HOST=localhost
COUCHDB_PORT=5984
COUCHDB_USER=admin
COUCHDB_PASSWORD=secret
```
サンプルコード
```js
require('dotenv').config();
const protocol = 'http'; // or https
const {
COUCHDB_HOST,
COUCHDB_PORT,
COUCHDB_USER,
COUCHDB_PASSWORD
} = process.env;
const url = `${protocol}://${COUCHDB_USER}:${COUCHDB_PASSWORD}@${COUCHDB_HOST}:${COUCHDB_PORT}`;
const nano = require('nano')(url);
```
## CouchDBの使い方
### データベースの操作
```js
// データベース作成
await nano.db.create('user');
// データベース削除
await nano.db.destroy('user');
// 一覧取得
const dbs = await nano.db.list();
console.log(dbs);
```
### ドキュメントの操作
```js
const db = nano.db.use('user');
// 作成
await db.insert({ name: 'Alice', email: 'alice@example.com' });
// 取得
const doc = await db.get('document_id');
// 更新
doc.age = 30;
await db.insert(doc);
// 削除
await db.destroy(doc._id, doc._rev);
```
### インデックス作成(パフォーマンス向上)
Mangoクエリ(find)はインデックスがあると高速になります。
例としてユーザーの年齢でよく検索するなら、
事前に下記のようにインデックスを作ることを推奨します
```js
await db.createIndex({
index: { fields: ['age'] },
name: 'age-index',
type: 'json'
});
```
## Tips
### nanoの初期化オプション
```js
const nano = require('nano')({
url: 'http://localhost:5984',
requestDefaults: {
headers: {
Authorization: 'Basic ...' // 任意のカスタムヘッダー
}
}
});
```
### 検索のサンプル
#### 名前が'A'から始まる人だけ取得
CouchDBのMangoクエリでは文字列の部分一致は
範囲指定($gte, $ltを使う方法が一般的です
("startkey" などでは存在しない)
```js
const result = await db.find({
selector: {
name: {
"$gte": "A",
"$lt": "B"
}
},
sort: [{ name: "asc" }]
});
result.docs.forEach(doc => console.log(doc));
```
#### OR条件を使いたい($or)
```js
const result = await db.find({
selector: {
"$or": [
{ age: { "$lt": 30 } },
{
name: {
"$gte": "Z",
"$lt": "Z\ufff0"
}
}
]
}
});
```
複数条件やソートを行う場合は
インデックスも複数フィールドに対応しておくと効率が良くなります
```js
await db.createIndex({
index: {
fields: ['age', 'name']
},
name: 'age-name-index'
});
```
### フロントエンドで使えるのか
認証情報がURLに含まれるためセキュリティ上もフロントでの使用は不適切
* フロントエンドからCouchDBを扱いたい場合:
1. **PouchDBを活用する**
* フロント向けCouchDB互換DB。リアルタイム同期・オフライン対応も可能
2. サーバー側でnanoを使いREST APIを作ってフロントから叩く

View File

@ -0,0 +1,48 @@
/**
* Create a new database
* nanoでデータベースを作成する
*/
const nano = require('nano')('http://admin:secret@localhost:5984');
// データベース作成
const createDB = async (dbName) => {
nano.db.create(dbName, (err, body) => {
if (err) {
console.log(err);
return;
}
console.log(body);
});
}
// データベース削除
const deleteDB = async (dbName) => {
nano.db.destroy(dbName, (err, body) => {
if (err) {
console.log(err);
return;
}
console.log(body);
});
}
// データベース一覧表示
const getDbList = async () => {
nano.db.list((err, body) => {
if (err) {
console.log(err);
return;
}
console.log(body);
});
}
async function main() {
// await deleteDB('user');
// await createDB('product');
// await createDB('item');
// await getDbList();
}
main();

View File

@ -0,0 +1,85 @@
/**
* Create a new database
* nanoでデータベースを作成する
*/
const nano = require('nano')('http://admin:secret@localhost:5984');
// ドキュメント作成
const createDocument = async (dbName,post) => {
const db = nano.use(dbName);
db.insert(post, (err, body) => {
if (err) {
console.log(err);
return;
}
console.log(body);
});
}
// ドキュメントを一覧を取得する
const getDocumentList = async (dbName) => {
const db = nano.use(dbName);
const result = await db.list({ include_docs: true });
return result
}
// ドキュメントを一覧を取得する(抽出する)
const findDocument = async (dbName,selector) => {
const db = nano.use(dbName);
const result = await db.find({selector: selector});
return result
}
// ドキュメントを取得する
const getDocument = async (dbName,docId) => {
const db = nano.use(dbName);
const doc = await db.get(docId);
return doc;
}
// ドキュメントを更新する
const updateDocument = async (dbName,doc) => {
const db = nano.use(dbName);
return await db.insert(doc);
}
// ドキュメントを削除する
const deleteDocument = async (dbName,doc) => {
const db = nano.use(dbName);
return await db.destroy(doc._id, doc._rev);
}
async function main() {
// ドキュメントを作成する
await createDocument('user',{id:1,name:'Alice',email:'alice@example.com'});
// IDを指定する
await createDocument('user',{_id:'user_1',id:2,name:'Bob',email:'bob@example.com'});
// ドキュメントを取得する
const doc = await getDocument('user','user_1');
console.log(doc.name);
// ドキュメントを更新する
doc.age = 20;
await updateDocument('user',doc);
// ドキュメントを削除する
await deleteDocument('user',doc);
// ドキュメントの一覧を取得する
const docs = await getDocumentList('user');
console.log(docs);
// ドキュメントの検索する
const selector = {name: 'Bob'};
docs = await findDocument('user',selector);
console.log(docs);
}
main();

37
src/types/INoSQL.ts Normal file
View File

@ -0,0 +1,37 @@
/**
* Interface for NoSQL document
* @param T - Type of data
* @param id - Document ID
*/
export interface IDocument<T> {
id: string;
data: T;
createdAt?: Date;
updatedAt?: Date;
version?: string | number;
}
/**
* Function to unsubscribe from updates *
* watch()使void戻り値でも問題なし
* unsubscribeするための関数を返す
*/
export type UnsubscribeFn = () => void;
/**
* Interface for NoSQL collection
* @param T - Type of data
*/
export interface ICollection<T> {
get(id: string): Promise<IDocument<T> | null>;
list(): Promise<IDocument<T>[]>;
create(data: T): Promise<IDocument<T>>;
update(id: string, data: Partial<T>): Promise<IDocument<T>>;
delete(id: string): Promise<void>;
// オプション: 更新監視など
watch?(onChange: (doc: IDocument<T>) => void): UnsubscribeFn;
}
export interface IDatabaseClient {
getCollection<T>(name: string): ICollection<T>;
}