Ktor + Exposed + Cloud RunでCloud SQLに接続させる方法
Cloud RunにデプロイしようとしたKtor + Exposedのアプリケーションイメージが、Cloud SQLへ接続できないことが原因で何度もデプロイに失敗してしまった。解決までに数時間要してしまい、ネットを探してもExposedを使った時のCloud SQLへの接続方法を記した記事が日本語と英語も含めてほとんど見つからなかった。なので忘備録として残しておこうと思う。
まずローカルでCloud SQL Auth Proxyを立ち上げてCloud SQLに接続する場合、次のようなURLを構築すると思う。
val jdbcURL = "jdbc:postgresql://0.0.0.0:3306/postgres"
これに倣って、Cloud SQLの公開IPアドレスと取り替えてCloud SQLに接続しようとしてみた。
val jdbcURL = "jdbc:postgresql://公開IPアドレス/postgres"
次のようなエラーが発生した。
org.postgresql.util.PSQLException: The connection attempt failed. at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:331) at
Cloud RunからCloud SQLに接続する方法として、TCPで接続する方法とUnixドメインソケットを使って接続する方法がある。各URLのフォーマットは次の通り。
TCPで接続する場合
val jdbcURL = "jdbc:postgresql:///$databaseName?cloudSqlInstance=$instanceConnectionname&socketFactory=com.google.cloud.sql.postgres.SocketFactory&user=$user&password=$password"
val jdbcURL = "jdbc:postgresql:///$databaseName?unixSocketPath=$unixSocketPath&cloudSqlInstance=$instanceConnectionname&socketFactory=com.google.cloud.sql.postgres.SocketFactory&user=$user&password=$password"
Unixドメインソケットを使って接続する場合は、unixSocketPath
クエリーパラメーターを渡す必要があるので注意する。
各変数にどんな値を代入すれば良いかは次の通り。
databaseName
- 接続したいデータベース名
- postgres (例
- 接続したいデータベース名
instanceConnectionName
socketFactory
cloud-sql-jdbc-socket-factory
ライブラリにあるクラス。com.google.cloud.sql.postgres.SocketFactory
を渡す。
unixSocketPath
- ソケットへのパス
/cloudsql/接続名(instanceConnectionName変数と同じ値)
- ソケットへのパス
user
- データベースのユーザー名
password
- データベースのパスワード
Socket Factoryを使えるようにするためにcloud-sql-jdbc-socket-factory
を依存関係として追加する。
dependencies { implementation("org.postgresql:postgresql:$postgresql_version") ... implementation("com.google.cloud.sql:postgres-socket-factory:1.7.2") }
依存関係を追加しないでクラス名だけ指定した場合は次のようなエラーが発生する。
org.postgresql.util.PSQLException: The SocketFactory class provided com.google.cloud.sql.postgres.SocketFactory could not be instantiated. at org.postgresql.core.SocketFactoryFactory.getSocketFactory(SocketFactoryFactory.java:43) at org....
実際のコードは次の通り。
object DatabaseFactory { fun init( user: String = "", password: String = "", databaseName: String = "", instanceConnectionName: String = "", unixSocketPath: String = "", ) { val driverClassName = "org.postgresql.Driver" val jdbcURL = "jdbc:postgresql:///$databaseName?unixSocketPath=$unixSocketPath&cloudSqlInstance=$instanceConnectionName&socketFactory=com.google.cloud.sql.postgres.SocketFactory&user=$user&password=$password" val database = Database.connect( url = jdbcURL, driver = driverClassName, user = user, password = password, ) .... } }
各変数の値は環境変数として展開して渡すようにする。そのために、application.conf
を設定する。
google { cloud_sql { username = "takagimeow" username = ${?DB_USER_NAME} password = "takagimeow_pass" password = ${?DB_USER_PASS} path = "postgres" path = ${?DB_PATH} database_name = "" database_name = ${?DATABASE_NAME} instance_connection_name = "" instance_connection_name = ${?INSTANCE_CONNECTION_NAME} unix_socket_path = "" unix_socket_path = ${?UNIX_SOCKET_PATH} } }
設定した環境変数はApplication.module()
で取得し、DatabaseFactory.init()
の呼び出し時に渡す。
fun Application.module() { DatabaseFactory.init( user = environment.config.property("google.cloud_sql.username").getString(), password = environment.config.property("google.cloud_sql.password").getString(), databaseName = environment.config.property("google.cloud_sql.database_name").getString(), instanceConnectionName = environment.config.property("google.cloud_sql.instance_connection_name").getString(), unixSocketPath = environment.config.property("google.cloud_sql.unix_socket_path").getString() ) ... }
それでも接続できない場合
Cloud SQL Admin APIが有効になっていない可能性があるので、必ず有効になっているかを確認する。
NestJS + Prisma + Cloud Run + Cloud SQLを試す
ソケットパスに関して公式ドキュメントでは次の様に書かれている。
注: PostgreSQL スタンダードでは、ソケットパスに .s.PGSQL.5432 接尾辞が含まれている必要があります。一部のライブラリではこの接尾辞が自動的に適用されますが、他のライブラリでは、ソケットパスを以下のように指定する必要があります。
なので、一度/.s.PGSQL.5432
をソケットパスの末尾につけて試してみる。
Connect from App Engine standard environment | Cloud SQL for PostgreSQL | Google Cloud
参考
App Engine から Cloud SQL に接続する - albatrosary's blog
クイックスタート: Cloud Run から Cloud SQL for PostgreSQL に接続する | Google Cloud
Cloud Run から接続する | Cloud SQL for PostgreSQL | Google Cloud
インスタンスの作成 | Cloud SQL for PostgreSQL | Google Cloud
Cloud Run を徹底解説! - G-gen Tech Blog
Identity-Aware Proxy(IAP)とCloud Armorを使用してCloud Runサービスへのアクセス制御を実装する - G-gen Tech Blog
Cloud SQL Auth Proxy を使用して Cloud Run から Cloud SQL に接続する - G-gen Tech Blog
ktor - How connect tot Google Cloud SQL via Exposed - Stack Overflow
I ve been using unix domain sockets in mac with ktor It s be | Ktor | Kotlinlang
Node.js+SequelizeでCloud RunからCloud SQLへ接続する方法
Java を使用してソケット接続を作成する | Cloud SQL for PostgreSQL | Google Cloud
AndroidアプリでJetpack Composeを使って使用しているライブラリのライセンスを表示してみる
Androidアプリを開発していると直面するライセンス表示問題。
色々調べるとCookpadさんが公開しているプラグインにたどり着いた。だが、お察しのとおりすでにアーカイブされている。
代わりにおすすめされているのが、Googleが公開しているOSS Licenses Gradle Plugin
。
play-services-plugins/oss-licenses-plugin at master · google/play-services-plugins · GitHub
残念ながらJetpack Composeには現時点で対応していない。僕はJetpack Composeを使ってライセンスを表示したい。
そこで、見つけたのがJumpei Matsudaさんが公開しているlicense-list-plugin
。
このプラグインを使ってartifact-definition.yml
を生成し、そのデータをパースした内容をCompose可能な関数で表示してみる。
アプリのbuild.gradleにて、プラグインの設定を行う。
plugins { id 'com.android.application' id "io.github.jmatsu.license-list" } // Licensing apply plugin: "com.android.application" apply plugin: "io.github.jmatsu.license-list"
ルートディレクトリにあるプロジェクトのbuild.gradleにbuildscript
の設定を追加する。
buildscript { ext { license_list_gradle_version = '0.8.1' } dependencies { classpath "io.github.jmatsu:license-list-gradle:$license_list_gradle_version" } }
追加が完了したら、実際にプラグインを実行する。
./gradlew initLicenseList
実行後、app/artifact-definition.yml
とapp/license-catalog.yml
が作成されているはず。
モジュールを右クリックして、New -> Folder -> Assets Folderの順番で選択していき、assets
フォルダを作成する。
そのassets
の中に先ほど生成されたartifact-definition.yml
とlicense-catalog.yml
を移動する。
artifact-definition.yml
の中身をパースするために、kaml
ライブラリを導入する。
build.gradle
に次の設定と依存関係を追加する。
plugins { id 'org.jetbrains.kotlin.plugin.serialization' version '1.4.20' } dependencies { implementation "com.charleskorn.kaml:kaml:0.49.0" // Get the latest version number from https://github.com/charleskorn/kaml/releases/latest }
assets/artifact-definition.yml
を実際に読み込むために、Context
のインスタンスを使用する。
@kotlinx.serialization.Serializable data class License( val release: Map<String, List<ArtifactDefinition>>, val test: Map<String, List<ArtifactDefinition>>, val androidTest: Map<String, List<ArtifactDefinition>>, ) @kotlinx.serialization.Serializable data class ArtifactDefinition( val key: String, val displayName: String, val url: String?, val copyrightHolders: List<String>, val licenses: List<String> ) fun parseLicense(context: Context) { val yamlStr = context.assets.open("artifact-definition.yml").bufferedReader().use { it.readText() } val result = Yaml.default.decodeFromString(License.serializer(), yamlStr) }
ひとつのライセンス要素を表示するためのCompose可能な関数を定義する。
@Composable fun LicenseItem( displayName: String, url: String?, copyrightHolders: List<String>, licenses: List<String>, ) { Column() { Text( text = displayName, style = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.height(8.dp)) if (url != null) { Text( text = url ?: "" ) Spacer(modifier = Modifier.height(8.dp)) } if (copyrightHolders.isNotEmpty()) { copyrightHolders.forEach { copyrightHolder -> Text( text = copyrightHolder ) } Spacer(modifier = Modifier.height(8.dp)) } licenses.forEach { license -> Text( text = license ) } } }
そして、先ほど読み取ったデータを使い、LazyColumn
を呼び出してリスト形式で表示する。
LazyColumn( modifier = Modifier .fillMaxWidth() .fillMaxHeight() ) { if (license != null) { items(license.release.keys.toList()) { groupKey -> val group = license.release[groupKey] group?.forEach { groupItem -> LicenseItem( displayName = groupItem.displayName, url = groupItem.url, copyrightHolders = groupItem.copyrightHolders, licenses = groupItem.licenses ) Spacer(modifier = Modifier.height(24.dp)) } } items(license.test.keys.toList()) { groupKey -> val group = license.release[groupKey] group?.forEach { groupItem -> LicenseItem( displayName = groupItem.displayName, url = groupItem.url, copyrightHolders = groupItem.copyrightHolders, licenses = groupItem.licenses ) Spacer(modifier = Modifier.height(24.dp)) } } items(license.androidTest.keys.toList()) { groupKey -> val group = license.release[groupKey] group?.forEach { groupItem -> LicenseItem( displayName = groupItem.displayName, url = groupItem.url, copyrightHolders = groupItem.copyrightHolders, licenses = groupItem.licenses ) Spacer(modifier = Modifier.height(24.dp)) } } } }
実際の画面。
参考
Read a Text asset(text file from assets folder) as a String in Kotlin (Android) - Stack Overflow
以前の画面の状態が意図せぬ形で復元されてしまう場合の対処法
次の様な画面遷移図のアプリを考えてみる。
今までは全ての遷移時に次の様なコードを実行していた。
fun navigate( destination: TnaNavigationDestination, route: String? = null, from: NavBackStackEntry? = navController.currentBackStackEntry ) { if (from != null && from.lifecycleIsResumed()) { navController.navigate(route ?: destination.destination) { popUpTo(navController.graph.findStartDestination().id) { saveState = true } launchSingleTop = true restoreState = true } } }
この場合、FeedByLabelScreen
からnavController.popBackStack()
を呼び出したときに、FeedScreen
まで遡ってしまう問題に直面した。
ここでは、四角で囲まれている画面時における戻る操作でのみFeedScreen
まで遡ってほしいので、そのナビゲーション時のみpopUpTo
を呼び出したい。
そして他にも、一度AddCountTrainingActivityScreen
からFeedScreen
に戻り、ボトムナビゲーションのFeedScreen
に紐づいているアイコンをタップすると、今度はAddCountTrainingActivityScreen
の直前の画面であるLabelListScreen
に戻ってしまう問題も発生していた。
なので、LabelListScreen
から四角で囲まれている画面への遷移時のみ次のコードを実行するようにした。
fun navigateAndPopUp( destination: TnaNavigationDestination, route: String? = null, from: NavBackStackEntry? = navController.currentBackStackEntry ) { if (from != null && from.lifecycleIsResumed()) { val newRoute = when (destination) { is TopLevelDestination -> { destination.destination } else -> { route ?: destination.route } } navController.navigate(newRoute) { popUpTo(navController.graph.findStartDestination().id) {} launchSingleTop = true } } }
意図せぬ画面状態の復元を避けるために、saveState=true
とrestoreState=true
を削除した。
そして、他の画面の遷移時には次のコードを実行する様にした。
fun navigate( destination: TnaNavigationDestination, route: String? = null, from: NavBackStackEntry? = navController.currentBackStackEntry ) { if (from != null && from.lifecycleIsResumed()) { navController.navigate(route ?: destination.destination) { launchSingleTop = true } } }
参考
Androidのスプラッシュスクリーンで簡単に背景色をダークモード対応させる方法
Android Studioで、res
ディレクトリを右クリックして、New -> Android Resource Directory
を選択する。
Directory Nameにvalues-night
を入力して、OK
をクリックする。
values-night
ディレクトリが表示されない場合は、Project
ビューに切り替える。
values-night
ディレクトリを見つけたら、右クリックして、New -> Values Resource File
を選択する。
入力フィールドにcolors
を入力する。
作成されたvalues-night/colors.xml
にcolor
要素を追加する。nameにはactivity_backround
を指定する。値は#1f1f1f
。
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="activity_background">#1f1f1f</color> </resources>
もともと存在していたvalues/colors.xml
にはライトモードに対応するactivity_backround
カラーを追加する。
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="activity_background">#FFFFFF</color> ... </resources>
AndroidManifest.xml
を開いて、application
要素のandroid:theme
に設定されているテーマを確認する。
<application ... android:theme="@style/Theme.TrainingNoteApplication" >
該当のテーマが定義されているtheme.xml
を開いて、アプリケーション全体の背景色を変更するために次の要素を追加する。
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="Theme.TrainingNoteApplication" parent="android:Theme.Material.Light.NoActionBar"> <item name="android:windowBackground">@color/activity_background</item> </style> </resources>
これでシステムのダークモードの設定に合わせて、スプラッシュスクリーン表示時の背景色が自動的に変更される。
参考
Splash screens | Android Developers
スタイルとテーマ | Android デベロッパー | Android Developers
DarkTheme対応のリソース設計 - DMM inside
Androidでダークテーマを試す. ダークテーマの設定方法について紹介します。 | by Keisuke Kawajiri | URL Memo | Medium
Androidではデータベースに画像のリソースIDを保存してはいけないらしい!
Androidアプリをコンパイルした後だったり、新しい画像をResource Managerに追加した後に、アプリで表示されていた画像がまったく異なる他の画像に置き換えられているケースが多々あった。
そろそろ対処しないとなーと思って色々調べていたら次のコメントを我々の拠り所スタックオーバーフローにて見つけてしまった。
Don't do this. There's a major problem with storing image ids in the database- the numbers aren't stable. Each time you recompile the app, the values can change. If you add or remove any resource, the entire mapping can change. It isn't a stable method of storing that data. Using the drawable's name and converting to id would be more stable.
– Gabe Sechan Mar 24, 2017 at 6:46 android - Storing image resource id in Sqlite database and retrieving it in int array - Stack Overflow
つまり、アプリを再コンパイルするたびにリソースIDの値は変わる可能性があるし、リソースを追加したり削除したりすると、マッピング全体が変わってしまう可能性がある。なので、データを保存する方法として、リソースIDをそのままデータベースに保存するのは誉められたものではないとのこと。
ではどうすれば良いのかというと、ここで推奨されているのはリソースのIDではなく、エントリー名を保存する方法。
他のスタックオーバーフローでも推奨されていた。
Best way to store Resource id in Database on Android - Stack Overflow
早速実装してみる。
まず、DrawableConverter
インターフェースを実装する。
interface DrawableConverter { fun getResourceIdByEntryName(entryName: String): Int fun getResourceEntryNameByResourceId(resourceId: Int): String }
getResourceIdByEntryName()
では、リソースのエントリー名を受け取ってリソースIDに変換する。
getResourceEntryNameByResourceId()
では、リソースIDを受け取ってエントリー名に変換する。
次にDrawableConverterImpl
クラスを実装する。
class DrawableConverterImpl @Inject constructor( @ApplicationContext private val context: Context, ) : DrawableConverter { override fun getResourceIdByEntryName(entryName: String): Int { return context.resources.getIdentifier(entryName, "drawable", context.packageName) } override fun getResourceEntryNameByResourceId(resourceId: Int): String { return context.resources.getResourceEntryName(resourceId) } }
バインドを行う。
@Module @InstallIn(SingletonComponent::class) abstract class DataModule { ... @Binds abstract fun bindDrawableConverter( drawableConverter: DrawableConverterImpl ): DrawableConverter }
そしてリポジトリにて、DrawableConverter
の注入を行い、DAOから受け取ったエンティティをリポジトリ内でモデルに変換し、ViewModelなどでその内容を受け取るようにする。
@Singleton class AlbumRepositoryImpl @Inject constructor( private val albumDao: AlbumDao, private val drawableConverter: DrawableConverter, ) : AlbumRepository { override suspend fun getAlbums( ioDispatcher: CoroutineDispatcher ): Result<List<Album>> { return withContext(ioDispatcher) { val albums = albumDao.getAlbums() Result.Success(albums.first().map { val imageResourceId = drawableConverter.getResourceIdByEntryName(it.imageEntryName) it.asExternalModel(imageResourceId) }) } } override suspend fun insertAlbum( title: String, artist: String, imageResourceId: Int, ioDispatcher: CoroutineDispatcher ) { val entryName = drawableConverter.getResourceEntryNameByResourceId(imageResourceId) val albumEntity = getNewAlbumEntry( albumTitle = title, albumArtist = artist, albumImageEntryName = entryName, ) return withContext(ioDispatcher) { albumDao.insert(albumEntity) } } ... }
図解してみると、こんな感じ。
これにより、データベースに保存するときはDAOを経由してエントリー名を渡し、画面上に表示するときはリポジトリ内で受け取ったエントリー名をリソースIDに変換してからViewModelに渡すことができる。
その結果、リソースIDの意図せぬ変更を恐れることなく、データベースに対して画像関連の値を保存できるようになった。めでたしめでたし。
Kotlinでプロジェクト内のディレクトリにある複数のJSONファイルをまとめて処理してみる
次の様なディレクトリの構成を考えてみる。
- a_lot_of_settings
a_lot_of_settings
ディレクトリの中にはa
ディレクトリとb
ディレクトリと
cディレクトリが存在し、それぞれのディレクトリの中に
settings.json`が存在する。
このsettings.json
をそれぞれ読み取り処理をしたい。
settings.json
の中身は次のようなフォーマットになっている。
{ "name": "takagimeow" }
このフォーマットと一致するdata class
を実装する。
data class Settings( val name: String, )
JSONのパースライブラリとして、Jackson
を使用する。
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.registerKotlinModule ... val mapper = jacksonObjectMapper() mapper.registerKotlinModule()
File()
のコンストラクタに渡す相対パスは、実行しようとしているmain()
関数が記述されたファイルの場所が起点ではなく、プロジェクトのルートディレクトリが起点となるので注意が必要。
File("./a_lot_of_settings").walk().forEach { dir -> if (dir.isDirectory && dir.toString() != "./a_lot_of_settings") { val jsonString: String = File("$dir/settings.json").readText(Charsets.UTF_8) val result: Settings = mapper.readValue(jsonString, Settings::class.java) println("name: ${result.name}") } }
dir
には./a_lot_of_settings
自身も格納されてしまうので弾いている。
最終的な実装は次の様な感じ。
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.registerKotlinModule import java.io.File data class Settings( val name: String, ) suspend fun main() { val mapper = jacksonObjectMapper() mapper.registerKotlinModule() File("./a_lot_of_settings").walk().forEach { dir -> if (dir.isDirectory && dir.toString() != "./a_lot_of_settings") { val jsonString: String = File("$dir/settings.json").readText(Charsets.UTF_8) val result: Settings = mapper.readValue(jsonString, Settings::class.java) println("name: ${result.name}") } } }
参考
1つのPostgreSQLコンテナに複数のデータベースを作成して、複数のアプリケーションからアクセス可能にしてみる
docker-composeでPostgreSQLコンテナを立ち上げる。docker-compose.ymlの内容は次のような感じで、Ktorプロジェクトのルートディレクトリに作成する。
version: "3.7" services: postgres: image: 'postgres:13' container_name: takagimeow-postgres restart: always environment: - POSTGRES_USER=takagimeow - POSTGRES_PASSWORD=takagimeow_pass ports: - '5432:5432' volumes: - postgresql-data-vol:/var/lib/postgresql/data:rw - ./postgres-init:/docker-entrypoint-initdb.d volumes: postgresql-data-vol:
複数のデータベースを1つのコンテナに対して作成するにあたって、volumes
に./postgres-init:/docker-entrypoint-initdb.d
を指定している。これにより同じ階層にあるpostgres-init
ディレクトリにコンテナの初回起動時に実行して欲しいsqlファイルを配置することができる。
CREATE DATABASE serverside; CREATE DATABASE clientside;
コンテナを立ち上げる。
docker-compose up -d
TablePlusを使って接続のテストを行ってみる。
まずはclientside
データベースに対して。
次はserverside
データベースに対して。
ExposedとPostgreSQLのJDBCドライバを使って接続を行う
gradle.properties
にて、exposed_version
とpostgresql_version
を定義する。
exposed_version = 0.36.2 postgresql_version = 42.5.0
build.gradle
にてPostgreSQLへの接続に必要な依存関係を追加する。
val exposed_version: String by project val postgresql_version: String by project ... dependencies { implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation("org.postgresql:postgresql:$postgresql_version") }
DatabaseFactory
を定義する。
object DatabaseFactory { fun init() { val driverClassName = "org.postgresql.Driver" val jdbcURL = "jdbc:postgresql://localhost:5432/serverside" val user = "takagimeow" val password = "takagimeow_pass" val database = Database.connect( url = jdbcURL, driver = driverClassName, user = user, password = password ) transaction(database) { SchemaUtils.create(Users) } } suspend fun <T> dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } }
ExposedのDSL APIでusers
テーブルを定義する。
object Users : Table() { val id = integer("id").autoIncrement() val email = varchar("email", 128) val password = varchar("password", 128) val name = varchar("name", 128) val roleType = varchar("role_type", 128) override val primaryKey = PrimaryKey(id) }
Application.module
拡張メソッドの先頭で、DatabaseFactory.init()
を呼び出す。これにより、データベースへの接続を可能にしておく。
fun Application.module() { DatabaseFactory.init() configureKoin() configureSerialization() configureMonitoring() configureHTTP() configureSecurity() configureRouting() }
Ktorアプリケーションを起動後、TablePlusを使ってserverside
データベースの中身を確認してみる。users
テーブルが作成されているのがわかる。
Prismaを使ってclientsideデータベースに接続を行う
schema.prisma
は次のようする。こちらはRemix Blues Stackで使われているschema.prisma
と同じ内容になっているので、試したい方はぜひRemix Blues Stackを使ってプロジェクトを作成してみてほしい。
datasource db { provider = "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } model User { id String @id @default(cuid()) email String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt password Password? notes Note[] } model Password { hash String user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String @unique } model Note { id String @id @default(cuid()) title String body String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String }
.env
を設定する。
DATABASE_URL="postgresql://takagimeow:takagimeow_pass@localhost:5432/clientside"
定義したスキーマをデータベースに反映させる。
npx prisma db push
clientside
データベースの中身を確認してみる。スキーマで定義されているテーブルが作成されていることが確認できる。
参考
https://ysuzuki19.github.io/post/docker-mysql-postgres-multiple-databases