リポジトリパターン.JSとネイティブのPostgreSQLドライバ
107426 ワード
あまり古くなかったので、MongoDBやPostgreSQL、Mongoose、TypeOrmなどのデータベースで働いていました.最後のプロジェクトの一つでは、高負荷、ジオ分散システムを構築する必要があります.
面白い仕事😋
この種のシステムではPostgreSQLは最良の解決策ではありません.また、ボックスの複製不足のような多くの理由についても.そして、私たちは厳密にはベンダーロックAmazon Aurora . そして終わりの終わりは、選択が好意的になされましたCassandra , この記事では、レポジトリパターンのローレバー実装についてお話しますHBase 例えば.
さて、データベースCassandraを選んだが、どのように我々はデータ層を整理するデータベースと対話するか?🤨
我々はKNexを使用することはできません、それは単にCQLをサポートしていない、我々は良い選択肢を持っていない.そして、私は純粋なCQLが良い考えを使用しないことを明確に理解しています.
すべてのソースコードを見つけることができますhere .
手作業を準備する 良いタイプスクリプトサポート 支援取引 カラムのエイリアス( code "createdstra at ", code "createdat " 隠しコラム 特定の列を選択 簡単にするために、“OR”演算子サポートなしで簡単な実装を示します サポート関係(単純さのために、私は記事の中でこのパラグラフをスキップしますが、追加するのは簡単です) Insertイベントのサブスクリプション、後に挿入、Beforeedelete、アフター削除などを行います(関係と同じ) 能力を簡単に拡張API 移行(この記事の一部ではない) 良い既製のソリューションがない場合、優れたオプションはリポジトリパターンを実装し、ヘルパーのようなクエリビルダを実装することです.
ノード.JS 17.5.0 + PostgreSQL 14.2 + バージョン4.6.2 + pg 8.7.3 + ノードpg移行6.2.1 + なぜpg?
開発者の大きな円への記事の明快さのために、説明全体はPostgreSQLとPG パッケージ.
そして、実際のプロジェクトでは、データベーススキーマは時間とともに変化するでしょう、そして、移行を実行することができるために、我々は使用しますNode PG migrate .
始める前にパッケージをインストールする必要があります.
はじめに、リポジトリを実装する前に、いくつかのヘルパー関数を作成する必要があります.
私たちは
例えば
まず、私は
そして、任意のデータベースDialectリポジトリのベースインタフェースを定義する必要があります.
このような矢印関数を使用しないでください.
将来的には、オーバーライドするメソッドをsuper.create() calls .
上記の魔法機能を見ることができる
さて、特定のエンティティにリポジトリファイルを作成します.
私は、それが本当のプロジェクトでどのように仕事であるかについて説明し始めることを提案します
ルーティングのためにfastify .
しかし、例えばアーキテクチャを使う
実際のプロジェクトの場合は、使用する必要があります
ユーザサービスの実現をスキップします.
検証をスキップします.
すべてのソースコードを見つけることができますhere .
機能的なプログラミングのファンのためにも、私は、接頭辞でフォルダ/ファイルの私自身のバージョンを準備しました
私の側からのアドバイス より良い保全性のために強く、リポジトリのベースコードを別々のNPMパッケージに動かすことを勧めます.特にいくつかのバックエンドアプリケーションを持っているか、またはマイクロサービスアーキテクチャを持っている場合.
機能を追加し、バグを見つけると修正後、すべてのプロジェクトに変更を必要とするNPMパッケージを使用すると、パッケージのバージョンを更新する必要があります. 私はどこのような多くの機能をスキップ
テストを書く!!!私は真剣に、データアクセス層は、それが重要なことであるので、すべてのCodeBaseの変更を行った後、すべての作品を確認する必要があります. そして、あなたのような関数のためのより多くのコード最適化を実装することができます この記事では、低レベルのデータベースドライバでリポジトリパターンを実装する方法を説明しました.
しかし、私はこのようなことにより、このソリューションを使用する前に確認してください. あなたのデータベースの良いオープンソースのORM/QueryBuilder/ライブラリの選択肢を持っていない. あなたは完全に彼らがやっている理由を理解し、なぜ開発者を経験している. しかし、あなたが答えることができないならば、どうですか
君にとって難しすぎる仕事になったと思う.
面白い仕事😋
この種のシステムではPostgreSQLは最良の解決策ではありません.また、ボックスの複製不足のような多くの理由についても.そして、私たちは厳密にはベンダーロックAmazon Aurora . そして終わりの終わりは、選択が好意的になされましたCassandra , この記事では、レポジトリパターンのローレバー実装についてお話しますHBase 例えば.
さて、データベースCassandraを選んだが、どのように我々はデータ層を整理するデータベースと対話するか?🤨
我々はKNexを使用することはできません、それは単にCQLをサポートしていない、我々は良い選択肢を持っていない.そして、私は純粋なCQLが良い考えを使用しないことを明確に理解しています.
すべてのソースコードを見つけることができますhere .
我々はデータアクセス層から見たいと思う基本的な機能は何ですか?
要件
NPM Packeges :
開発者の大きな円への記事の明快さのために、説明全体はPostgreSQLとPG パッケージ.
そして、実際のプロジェクトでは、データベーススキーマは時間とともに変化するでしょう、そして、移行を実行することができるために、我々は使用しますNode PG migrate .
環境設定
始める前にパッケージをインストールする必要があります.
yarn add pg && yarn add -D typescript @types/pg node-pg-migrate
PGドライバで動作する低レバーヘルパー
はじめに、リポジトリを実装する前に、いくつかのヘルパー関数を作成する必要があります.
私たちは
queryRow
データベースから1行だけ取得したい場合の関数.export const queryRow = async <T = any>(sql: string, values: any[] | null, tx?: PoolClient): Promise<T> => {
// Get connection from PG Pool or use passed connection, will be explained below
const client = await getConnect(tx)
// I think will be better to separate handling query with passed values
if (Array.isArray(values)) {
try {
const res = await client.query(sql, values)
return res.rows[0] as T
} catch (e) {
throw e
} finally {
// if we not have passed connection, we need close opened connection
if (!tx) client.release()
}
}
try {
const res = await client.query(sql)
return res.rows[0] as T
} catch (e) {
throw e
} finally {
if (!tx) client.release()
}
}
そして我々はquery
データベースによって返されたデータのリストで動作する関数.export const query = async <T = any>(sql: string, values?: any[] | null, tx?: PoolClient) => {
const client = await getConnect(tx)
if (Array.isArray(values)) {
try {
const res = await client.query(sql, values)
return res.rows as T[]
} catch (e) {
throw e
} finally {
if (!tx) client.release()
}
}
try {
const res = await client.query(sql)
return res.rows as T[]
} catch (e) {
throw e
} finally {
if (!tx) client.release()
}
}
データベースで作業するか、SQLを生成する責任があるすべての関数をヘルパーに追加できます.例えば
getConnect
, 私たちが接続を通過しなかったならば、それは見ます.export const getConnect = (tx?: PoolClient): Promise<PoolClient> => {
if (tx) {
return tx as unknown as Promise<PoolClient>
}
// pool it is global connection variable
// !!! Warning !!!
// Be very-very carefully when working with global variables
// And you should not access this variable from business logic
return pool.connect()
}
または、トランザクションで作業するときにSQLコードを生成する関数の例です.export const startTrx = async (pool: Pool) => {
const tx = await pool.connect()
await tx.query('BEGIN')
return tx
}
export const commit = (pool: PoolClient) => pool.query('COMMIT')
export const rollback = (pool: PoolClient) => pool.query('ROLLBACK')
またはエラー処理を処理する際にエラーの種類を決定するのに役立つ関数です.export const isUniqueErr = (error: any, table?: string) => {
if (table) {
// 23505 it is one of PostgreSQL error codes, what mean it is unique error
// Read more here: https://www.postgresql.org/docs/14/errcodes-appendix.html
return error.code === '23505' && error.severity === 'ERROR' && error.table === table
}
return error.code === '23505' && error.severity === 'ERROR'
}
そしてついにリポジトリの実装
まず、私は
create
ルックスを表示する方法.今のインターフェイスを作成する必要がありますどのような作成と読み込みのような操作をカバーします.interface Writer<T, C> {
create(value: Partial<T>, tx?: C): Promise<T>
}
どこT
それは実体/モデルタイピングであり、そしてC
データベースクライアントタイプです.そして、任意のデータベースDialectリポジトリのベースインタフェースを定義する必要があります.
export type BaseRepository<T, C> = Writer<T, C>
ここでデータベースリポジトリを作成することができます.私の場合、PostgreSQLデータベースをpg driverで使用しますが、他のデータベースを使用する場合は、データベースのAPIを使用してロジックを実装する必要があります.import type { Pool, PoolClient } from 'pg'
import type {
BaseRepository,
ColumnData,
} from './types'
import { buildAliasMapper, insertValues } from './queryBuilder'
import { queryRow } from './utils'
export class PGRepository<T> implements BaseRepository<T, PoolClient> {
readonly table: string
readonly pool: Pool
readonly columnAlias: (col: keyof T) => string
readonly allColumns: string
constructor({
pool,
table,
mapping,
}: {
table: string
pool: Pool
// model/entity alias mapping map, will be explained below
mapping: Record<keyof T, ColumnData>
}) {
// About aliasMapper will be explained below
const aliasMapper = buildAliasMapper<T>(mapping)
this.pool = pool
this.table = `"${table}"`
// About aliasMapper will be explained below
this.columnAlias = aliasMapper
// String with all of columns (SQL - '*'), it is will computed on class initialization
// Example of output: "id" AS "id", "name" AS "name", "email" AS "email", "created_at" AS "createdAt"
// It is just for optimization
this.allColumns = Object.entries(mapping).reduce((acc, [key, value]: [string, ColumnData]) => {
// skip hidden columns
if (typeof value === 'object' && value.hidden) {
return acc
}
const sql = `${aliasMapper(key as keyof T)} AS "${key}"`
return acc
? acc += `, ${sql}`
: sql
}, '')
}
async create(value: Partial<T>, tx?: PoolClient): Promise<T> {
// Here we will transform JavaScript object, to SQL columns string
const _cols: string[] = []
const _values: any[] = []
for (const key of Object.keys(value) as Array<keyof T>) {
// Extract from alias mapper original database columns
_cols.push(this.columnAlias(key))
_values.push(value[key])
}
// End of transform
const cols = _cols.join(', ')
// insertValues will create string with value bindings, to prevent SQL-injections
// Example of output: $1, $2, $3
const values = insertValues(_values)
const row = await queryRow<T>(
`INSERT INTO ${this.table} (${cols}) VALUES (${values}) RETURNING ${this.allColumns}`,
_values,
tx,
)
return row
}
}
警告このような矢印関数を使用しないでください.
将来的には、オーバーライドするメソッドをsuper.create() calls .
create = async (value: Partial<T>, tx?: PoolClient): Promise<T> => {
// code...
}
カラムエイリアスマッパー上記の魔法機能を見ることができる
const aliasMapper = buildAliasMapper<T>(mapping)
and insertValues
, Buildaliasmapper関数のコードを見てみましょう.export type ID = string | number
export type ColumnData = string | {
name: string
hidden?: boolean
}
export function buildAliasMapper<T extends AnyObject>(obj: Record<keyof T, ColumnData>) {
// use ES6 Map structure for performance reasons
// More here: https://www.measurethat.net/Benchmarks/Show/11290/4/map-vs-object-real-world-performance
const _mapper = new Map<keyof T, string>()
for (const [key, value] of Object.entries(obj)) {
// Create mapping
// JS representation property name to PostgreSQL column name
_mapper.set(key, typeof value === 'string'
? value
: value.name)
}
// And return function what will get JS key and output PostgreSQL column name
return (col: keyof T): string => `"${_mapper.get(col)!}"`
}
export const insertValues = (values: any[]) => values.map((_, index) => `$${index + 1}`).join(', ')
例buildAliasMapper
作品export interface User {
id: number
name: string
email: string
hash?: string
createdAt: string
}
const aliasMapper = buildAliasMapper<User>({
id: 'id',
name: 'name',
email: 'email',
hash: {
name: 'password_hash',
hidden: true,
},
createdAt: 'created_at',
})
aliasMapper('createdAt') // output: "created_at" (with double quotes)
なぜコンストラクタがプロパティを持っているのか理解できますmapping: Record<keyof T, ColumnData>
そして、どのようにエイリアスマッピングが動作します.さて、特定のエンティティにリポジトリファイルを作成します.
import type { Pool, PoolClient } from 'pg'
import { PGRepository, queryRow, ID } from 'repository'
export interface User {
id: number
name: string
email: string
hash?: string
createdAt: string
}
export class UserRepository extends PGRepository<User> {
constructor(pool: Pool) {
super({
pool,
table: 'users',
mapping: {
id: 'id',
name: 'name',
email: 'email',
hash: {
name: 'password_hash',
hidden: true,
},
createdAt: 'created_at',
},
})
}
async isTodayCreated(id: ID, tx?: PoolClient) {
const user = await this.findOne(id, {
select: ['createdAt'],
tx,
})
if (!user) {
throw new Error(`User with id '${id}' don't exists`)
}
const userDate = new Date(user.createdAt).getTime()
const todayDate = new Date().getTime()
const dayOffset = 3600 * 1000 * 24
return userDate + dayOffset > todayDate
}
}
データベースに接続します.import { Pool } from 'pg'
import 'dotenv/config'
const parsePostgresUrl = (url: string) => {
const sl1 = url.split(':')
const firstPart = sl1[1].replace('//', '')
const splittedFirstPart = firstPart.split('@')
const host = splittedFirstPart[1]
const userCredentials = splittedFirstPart[0].split(':')
const user = userCredentials[0]
const password = userCredentials[1]
const splittedSecondPart = sl1[2].split('/')
const port = Number(splittedSecondPart[0])
const database = splittedSecondPart[1]
return {
host,
user,
password,
port,
database,
}
}
// Global connections pool variable
// !!! Warning !!!
// Be very-very carefully when working with global variables
// And you should not access this variable from business logic
export let pool: Pool
export const connectPostgres = async () => {
const config = parsePostgresUrl(process.env.DATABASE_URL!)
const newPool = new Pool(config)
await newPool.connect()
pool = newPool
return newPool
}
さあ、作成したリポジトリを使いましょう.import { connectPostgres } from 'db'
import { UserRepository } from 'modules/product'
(async () => {
// connecting to database
const pool = await connectPostgres()
// initializing the repository
const userRepository = new UserRepository(pool)
// call create method from repository
const user = await userRepository.create({
name: 'fyapy',
email: '[email protected]',
hash: '123',
});
console.log(JSON.stringify(user, null, 2))
if (user) {
const isCreatedToday = await userRepository.isTodayCreated(user.id);
console.log(`is user ${user.name} created today? ${isCreatedToday}`)
}
})()
では、残りのCRUDメソッドのインターフェイスを作成しましょう.import type { PoolClient } from 'pg'
export type AnyObject = Record<string, any>
export type ColumnData = string | {
name: string
hidden?: boolean
}
export type ID = string | number
interface Writer<T, C> {
create(value: Partial<T>, tx?: C): Promise<T>
createMany(values: Partial<T>[], tx?: C): Promise<T[]>
update(id: ID, newValue: Partial<T>, tx?: C): Promise<T>
delete(id: ID, tx?: C): Promise<boolean>
}
export interface FindOptions<T, C> {
select?: Array<keyof T>
tx?: C
}
interface Reader<T, C> {
find(value: Partial<T>, options?: FindOptions<T, C>): Promise<T[]>
findOne(id: ID | Partial<T>, options?: FindOptions<T, C>): Promise<T>
exist(id: ID | Partial<T>, tx?: PoolClient): Promise<boolean>
}
export type BaseRepository<T, C> = Writer<T, C> & Reader<T, C>
今、インターフェイスに従って、メソッドの実装を記述します.import { Pool, PoolClient } from 'pg'
import { buildAliasMapper, insertValues } from './queryBuilder'
import {
BaseRepository,
FindOptions,
ID,
ColumnData,
} from './types'
import { query, queryRow } from './utils'
export class PGRepository<T> implements BaseRepository<T, PoolClient> {
readonly table: string
readonly primaryKey: string
readonly pool: Pool
readonly columnAlias: (col: keyof T) => string
readonly cols: (...args: Array<keyof T>) => string
readonly allColumns: string
readonly where: (values: Partial<T>, initialIndex?: number) => string
constructor({
pool,
table,
mapping,
// variable for storing id/primaryKey, for situations when out 'id' columns have name like 'postId'.
// by default we think what primaryKey is 'id'
primaryKey = 'id',
}: {
table: string
pool: Pool
primaryKey?: string
mapping: Record<keyof T, ColumnData>
}) {
const aliasMapper = buildAliasMapper<T>(mapping)
this.pool = pool
this.table = `"${table}"`
this.columnAlias = aliasMapper
this.primaryKey = primaryKey
// select SQL-generator for only specific columns
// example payload: ['createdAt']
// output: '"created_at" as "createdAt"'
this.cols = (...args: Array<keyof T>) => args.map(key => `${aliasMapper(key)} AS "${key}"`).join(', ')
// Almost the same as this.cols, only predefined and for all columns except hidden columns
this.allColumns = Object.entries(mapping).reduce((acc, [key, value]: [string, ColumnData]) => {
if (typeof value === 'object' && value.hidden) {
return acc
}
const sql = `${aliasMapper(key as keyof T)} AS "${key}"`
return acc
? acc += `, ${sql}`
: sql
}, '')
// SQL-generator for WHERE clause
this.where = (values: Partial<T>, initialIndex = 0) => {
const sql = Object.keys(values).reduce((acc, key, index) => {
const condition = `${aliasMapper(key as keyof T)} = $${index + initialIndex + 1}`
return acc === ''
? `${acc} ${condition}`
: `${acc}AND ${condition}`
}, '')
return `WHERE ${sql}`
}
}
async create(value: Partial<T>, tx?: PoolClient): Promise<T> {
const _cols: string[] = []
const _values: any[] = []
for (const key of Object.keys(value) as Array<keyof T>) {
_cols.push(this.columnAlias(key))
_values.push(value[key])
}
const cols = _cols.join(', ')
const values = insertValues(_values)
const row = await queryRow<T>(
`INSERT INTO ${this.table} (${cols}) VALUES (${values}) RETURNING ${this.allColumns}`,
_values,
tx,
)
return row
}
async createMany(values: Partial<T>[], tx?: PoolClient): Promise<T[]> {
const _cols: string[] = []
const _values: any[][] = []
for (const value of values) {
const keys = Object.keys(value) as Array<keyof T>
for (const key of keys) {
if (_cols.length !== keys.length) _cols.push(this.columnAlias(key))
_values.push(value[key] as any)
}
}
const cols = _cols.join(', ')
const inlinedValues = values
.map((_, index) => `(${_cols.map((_, cIndex) => {
const offset = index !== 0
? _cols.length * index
: 0
return `$${cIndex + 1 + offset}`
})})`)
.join(', ')
const rows = await query<T>(`
INSERT INTO ${this.table} (${cols})
VALUES ${inlinedValues}
RETURNING ${this.allColumns}
`, _values, tx)
return rows
}
update(id: ID, newValue: Partial<T>, tx?: PoolClient): Promise<T> {
const sqlSet = Object.keys(newValue).reduce((acc, key, index) => {
const sql = `${this.columnAlias(key as keyof T)} = $${index + 2}`
return acc
? `, ${sql}`
: sql
}, '')
return queryRow<T>(
`UPDATE ${this.table} SET ${sqlSet} WHERE "${this.primaryKey}" = $1 RETURNING ${this.allColumns}`,
[id, ...Object.values(newValue)],
tx,
)
}
delete(id: ID, tx?: PoolClient): Promise<boolean> {
return queryRow<boolean>(
`DELETE FROM ${this.table} WHERE "${this.primaryKey}" = $1`,
[id],
tx,
)
}
async find(value: Partial<T>, options: FindOptions<T, PoolClient> = {}): Promise<T[]> {
const cols = options.select
? this.cols(...options.select)
: this.allColumns
const sql = `SELECT ${cols} FROM ${this.table} ${this.where(value)}`
const res = await query<T>(sql, Object.values(value), options.tx)
return res
}
async findOne(id: ID | Partial<T>, options: FindOptions<T, PoolClient> = {}): Promise<T> {
const isPrimitive = typeof id !== 'object'
const cols = options.select
? this.cols(...options.select)
: this.allColumns
const values = isPrimitive
? [id]
: Object.values(id)
let sql = `SELECT ${cols} FROM ${this.table}`
if (isPrimitive) {
sql += ` WHERE "${this.primaryKey}" = $1`
} else {
sql += ` ${this.where(id)}`
}
const res = await queryRow<T>(sql, values, options.tx)
return res
}
async exist(id: ID | Partial<T>, tx?: PoolClient): Promise<boolean> {
let sql = `SELECT COUNT(*)::integer as count FROM ${this.table}`
const isPrimitive = typeof id !== 'object'
const values = isPrimitive
? [id]
: Object.values(id)
if (isPrimitive) {
sql += ` WHERE "${this.primaryKey}" = $1`
} else {
sql += ` ${this.where(id)}`
}
sql += ' LIMIT 1'
const res = await queryRow<{ count: number }>(sql, values, tx)
return res.count !== 0
}
}
実世界プロジェクトにおける使用
私は、それが本当のプロジェクトでどのように仕事であるかについて説明し始めることを提案します
main.ts
ファイル.ルーティングのためにfastify .
しかし、例えばアーキテクチャを使う
repositories > handlers
レイヤー.実際のプロジェクトの場合は、使用する必要があります
repositories > services > handlers
将来容易なコード保全性のための層.すべてのリポジトリ呼び出しはサービスによってproxiedされる必要があります、ハンドラの直接呼び出しリポジトリはありません.import type { Pool } from 'pg'
import fastify from 'fastify'
import { connectPostgres } from 'db'
import * as users from 'users'
// DI context analog, here repositories dependencies
// In this example I will skip services layer
// but repositories need to be passed to services
// and services will need to be passed to handlers
export interface Repositories {
pool: Pool
userRepository: users.UserRepository
}
const main = async () => {
const app = fastify({
trustProxy: true,
})
const pool = await connectPostgres()
const repositories: Repositories = {
pool,
userRepository: new users.UserRepository(pool),
}
// In real project here will be passed services
app.register(users.setupRoutes(repositories), {
prefix: '/users',
})
try {
const url = await app.listen(process.env.PORT || 8080, '0.0.0.0')
console.log(`Server started: ${url}`)
} catch (error) {
console.error('Server starting error:\n', error)
}
}
main()
コントローラ/ハンドラを作成しましょう.ユーザサービスの実現をスキップします.
検証をスキップします.
import type { FastifyPluginCallback } from 'fastify'
import type { Repositories } from 'types'
import { commit, isUniqueErr, rollback, startTrx } from 'repository'
export const setupRoutes = ({
pool,
userRepository,
}: Repositories): FastifyPluginCallback => (fastify, otps, done) => {
// select all columns
fastify.get<{
Params: { id: string }
}>('/:id/all', async ({ params }) => {
const user = await userRepository.findOne(params.id)
return {
user: user ?? null,
}
})
// select certain columns
fastify.get<{
Params: { id: string }
}>('/:id', async ({ params }) => {
const user = await userRepository.findOne(params.id, {
select: ['id', 'name', 'email'],
})
return {
user: user ?? null,
}
})
fastify.post<{
Body: {
name: string
email: string
password: string
}
}>('/', async ({ body }, res) => {
const tx = await startTrx(pool)
try {
const user = await userRepository.create({
name: body.name,
email: body.email,
hash: body.password,
}, tx)
await commit(tx)
res.status(201)
return {
user: user ?? null,
}
} catch (e) {
await rollback(tx)
if (isUniqueErr(e)) {
res.status(400)
return {
message: 'User aleady exist!',
}
}
throw e
} finally {
// don't forget to close connection
tx.release()
}
})
done()
}
ソースコード
すべてのソースコードを見つけることができますhere .
機能的なプログラミングのファンのためにも、私は、接頭辞でフォルダ/ファイルの私自身のバージョンを準備しました
fp
.結論
私の側からのアドバイス
機能を追加し、バグを見つけると修正後、すべてのプロジェクトに変更を必要とするNPMパッケージを使用すると、パッケージのバージョンを更新する必要があります.
or
サポートlimit
, INSERT INSERT、後に挿入、Beforeedelete、アフター削除などのようなエンティティイベントのサブスクリプション.テストを書く!!!私は真剣に、データアクセス層は、それが重要なことであるので、すべてのCodeBaseの変更を行った後、すべての作品を確認する必要があります.
this.cols
or this.where
しかし、最適化とコード可読性のバランスを保つ.しかし、私はこのようなことにより、このソリューションを使用する前に確認してください.
yes
これらの質問に?君にとって難しすぎる仕事になったと思う.
Reference
この問題について(リポジトリパターン.JSとネイティブのPostgreSQLドライバ), 我々は、より多くの情報をここで見つけました https://dev.to/fyapy/fully-featured-repository-pattern-with-typescript-and-native-postgresql-driver-4f2jテキストは自由に共有またはコピーできます。ただし、このドキュメントのURLは参考URLとして残しておいてください。
Collection and Share based on the CC Protocol