Skip to content

Commit 071db69

Browse files
authored
Add DatabaseUrl parser and URL-based dbOpen overloads (#328)
Introduce a DatabaseUrl type and parser (parseDatabaseUrl, asDatabaseUrl, helpers) and add URL-based dbOpen overloads for SQLite, MySQL, MariaDB and PostgreSQL; update connection exports, README/docs/examples and tests to use the new URL API.
1 parent e395e68 commit 071db69

18 files changed

Lines changed: 469 additions & 54 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
---
2+
description: 323-database-url-for-connectionブランチでの開発時に読み込む
3+
alwaysApply: false
4+
---
5+
dbOpen Database URL 対応 ブランチルール
6+
===
7+
8+
このブランチで実装することは以下の通りです。
9+
- `dbOpen` に database URL を使って接続できる新しいオーバーロードを追加し、既存の引数シグネチャは後方互換のため残す。
10+
- SQLite / MySQL / MariaDB / PostgreSQL は一般的な database URL 仕様に従い、scheme・認証情報・host・port・database path・query を解釈できる設計に整える。
11+
- URL 解析責務を各 driver の `dbOpen` 実装にベタ書きせず、共通化できる層へ寄せて公開 API を汚さない。
12+
- 既存の URL 文字列 1 引数版 `dbOpen` と、新しく追加する引数デザインの役割分担を明確にし、曖昧なオーバーロード解決を避ける。
13+
- 既存テスト・example・README・`documents/rdb/query_builder.md` を、最終仕様と齟齬がない状態へ同期する。
14+
15+
## 進捗
16+
- [x] `.cursor/rules/project.mdc` と `.cursor/rules/branch.mdc` を確認した
17+
- [x] 現在のブランチ名に対応するブランチルールファイルが未作成であることを確認した
18+
- [x] 既存 `dbOpen` 実装が SQLite / MySQL / MariaDB / PostgreSQL / SurrealDB でどう分かれているか確認した
19+
- [x] MySQL / MariaDB / PostgreSQL にはすでに URL 文字列 1 引数版 `dbOpen` があり、SQLite には未整備であることを確認した
20+
- [x] 既存 URL 実装が `split` ベースの簡易パーサであり、一般的な URL 仕様を十分に扱えていないことを確認した
21+
- [x] 今回のブランチでは SurrealDB を対象外とする方針を反映した
22+
- [x] ブランチルールに改修方針・進捗粒度・参考資料を整理した
23+
- [x] 一般的な database URL 仕様として採用する範囲を整理する
24+
- [x] 新しい `dbOpen` オーバーロードの引数デザインを確定する
25+
- [x] URL 解析の共通 helper / 型配置を決め、driver 差分の閉じ込め方を整理する
26+
- [x] SQLite の URL 受け口を追加し、ファイルパス指定との意味論を衝突させない
27+
- [x] MySQL / MariaDB / PostgreSQL の URL 解析を一般仕様に合わせて強化する
28+
- [x] 正常系・異常系・後方互換のテストを driver ごとに追加または更新する
29+
- [x] example / README / documents を同期する
30+
- [x] 実装途中で設計認識に誤りが見つかった場合は、本ファイルの `調査結果・設計まとめ` を更新する
31+
32+
## 参考資料
33+
- `src/allographer/connection.nim`
34+
- `src/allographer/query_builder/models/sqlite/sqlite_open.nim`
35+
- `src/allographer/query_builder/models/mysql/mysql_open.nim`
36+
- `src/allographer/query_builder/models/mariadb/mariadb_open.nim`
37+
- `src/allographer/query_builder/models/postgres/postgres_open.nim`
38+
- `src/allographer/query_builder/models/surreal/surreal_open.nim`
39+
- `tests/mysql/test_open.nim`
40+
- `tests/mariadb/test_open.nim`
41+
- `tests/postgres/test_open.nim`
42+
- `tests/sqlite/connections.nim`
43+
- `tests/postgres/connections.nim`
44+
- `tests/mysql/connections.nim`
45+
- `tests/mariadb/connections.nim`
46+
- `example/connections.nim`
47+
- `README.md`
48+
- `documents/rdb/query_builder.md`
49+
- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax
50+
- PostgreSQL Documentation: Connection URIs
51+
- MySQL / MariaDB ecosystem documentation for connection URI / DSN conventions
52+
53+
## 調査結果・設計まとめ
54+
55+
### 背景
56+
57+
現状の `dbOpen` は、RDB 系では「分解済みの接続情報を個別引数で渡す形」が基本で、一部 driver にだけ URL 文字列 1 引数版が追加されている。
58+
59+
ただし既存の URL 版は `split` を重ねた簡易実装で、一般的な URL で登場する予約文字・query・省略可能項目・scheme 差異に弱い。API としては URL 対応を名乗りつつ、仕様が狭く不均一な状態である。
60+
61+
今回の目的は、既存 API を壊さずに、database URL を正式な接続入力として扱える `dbOpen` オーバーロード群を整備し、RDB driver 間の設計を揃えることである。SurrealDB は今回の対象外とする。
62+
63+
### 現状の問題
64+
65+
1. SQLite はファイルパス引数のみで、URL ベース接続の入口がない
66+
2. MySQL / MariaDB / PostgreSQL の URL 版は実装されているが、一般的な URL 仕様を十分に扱えない
67+
3. driver ごとに URL の扱いがバラバラで、共通 API として学習コストが高い
68+
4. 既存の URL 文字列 1 引数版と、新たな「database url を使う関数」の住み分けを設計しないと、将来の拡張で曖昧さが増える
69+
70+
### 設計目標
71+
72+
1. 既存 `dbOpen` シグネチャを残しつつ、新しいオーバーロードで URL ベース接続を自然に表現できる
73+
2. SQLite / MySQL / MariaDB / PostgreSQL は一般的な URI 記法を優先する
74+
3. URL 解析と driver ごとの接続情報変換を責務分離し、公開 API へ場当たり的分岐を持ち込まない
75+
4. 後方互換・テスト・文書更新まで含めて、利用者が迷わない状態を作る
76+
77+
### 非目標
78+
79+
1. 既存の個別引数版 `dbOpen` を削除または非推奨化すること
80+
2. 接続 URL と無関係な query builder / schema builder API を同時に変更すること
81+
3. SurrealDB の接続モデルや URL 設計をこのブランチで見直すこと
82+
4. このブランチで ORM 的な接続設定 abstraction を全面導入すること
83+
84+
### API 方針
85+
86+
#### 1. 新しいオーバーロードは「URL であることが呼び出し側から明確に分かる」形を優先する
87+
88+
候補:
89+
1. `dbOpen(MySQL, url = "...", maxConnections = ..., timeout = ...)`
90+
2. `dbOpen(MySQL, databaseUrl = "...", maxConnections = ..., timeout = ...)`
91+
3. URL 専用の薄い型を導入して `dbOpen(MySQL, DatabaseUrl("..."), ...)`
92+
93+
採用した設計:
94+
- URL 専用の薄い型 `DatabaseUrl` を導入し、`dbOpen(driver, databaseUrl = asDatabaseUrl("..."), ...)` で呼べるようにした
95+
- 既存の `dbOpen(driver, url: string, ...)` は後方互換のため残し、内部では共通 parser に委譲する
96+
- SQLite の既存 `dbOpen(SQLite3, path: string, ...)` はそのまま維持し、URL 文字列が渡された場合のみ URL ルートへ寄せる
97+
98+
理由:
99+
- SQLite では単なる `string` がファイルパスと URL の両方になり得る
100+
- `DatabaseUrl` に分けることで、既存の string overload と overload 解決が衝突しない
101+
- 将来の拡張でも、URL 専用の入口を安全に追加しやすい
102+
103+
#### 2. RDB の URL 仕様は一般的な形式を優先する
104+
105+
最低限そろえる対象:
106+
1. `sqlite:///absolute/path/to/db.sqlite3`
107+
2. `sqlite://relative/path.db` またはこの扱いを不採用とするなら明文化する
108+
3. `mysql://user:pass@host:3306/database`
109+
4. `mariadb://user:pass@host:3306/database`
110+
5. `postgresql://user:pass@host:5432/database`
111+
112+
設計メモ:
113+
- `postgres://...` は `postgresql://...` の alias として受ける
114+
- percent-encoding の decode 要否を明確にする
115+
- query parameter は現段階で未使用でも、少なくとも破壊的に誤解釈しない方針を取る
116+
- database 名や password に `:` `/` `@` が含まれるケースを、単純 `split` で壊さない
117+
118+
### 実装方針
119+
120+
#### 1. URL 解析は共通 helper 化を優先する
121+
122+
方針:
123+
- `scheme`, `user`, `password`, `host`, `port`, `path`, `query` を扱える共通 parser を用意する
124+
- URL 専用の `DatabaseUrl` を導入し、driver ごとの差分は「解析結果からどう接続情報へ落とすか」に限定する
125+
- 既存 driver ファイルの `split` ベース処理は置き換え対象とする
126+
127+
#### 2. SQLite は URL とファイルパスの意味論を分ける
128+
129+
要点:
130+
- 既存 `dbOpen(SQLite3, "/path/to/file.db")` はそのまま維持する
131+
- URL 版は `asDatabaseUrl(...)` で「これは URL」と明示できる形を優先する
132+
- `:memory:` や相対パスとの整合を壊さない
133+
134+
#### 3. 既存 URL 1 引数版は互換維持しつつ内部実装を新 parser に寄せる
135+
136+
要点:
137+
- MySQL / MariaDB / PostgreSQL の既存テストは維持する
138+
- 既存の `ValueError` / `DbError` の期待が妥当なら保つ
139+
- 公開 API は残しつつ、仕様の狭さだけを改善する
140+
141+
#### 4. テストは「構文解析」と「実接続」を分けて考える
142+
143+
候補:
144+
1. URL から接続情報へ正しく分解されることを確認する軽量テスト
145+
2. 既存の integration 系 `dbOpen(...).isConnected()` テスト
146+
3. 不正 scheme・不足情報・不正 port・予約文字を含む入力の異常系テスト
147+
4. SQLite の URL 指定とファイルパス指定の後方互換テスト
148+
5. SurrealDB の namespace / database 解決規則テスト
149+
150+
### 受け入れ基準
151+
152+
1. 既存の `dbOpen` 呼び出しを壊さずに、新しい URL ベースのオーバーロードを追加できている
153+
2. SQLite / MySQL / MariaDB / PostgreSQL の URL 設計が一般的な仕様と整合している
154+
3. SurrealDB の URL 設計が独自要件を満たしつつ、他 driver と読後感が大きく乖離していない
155+
4. 既存の簡易 `split` 実装を置き換え、予約文字や query を誤解釈しにくい構造になっている
156+
5. driver ごとの正常系・異常系・後方互換テストが追加または更新されている
157+
6. example / README / documents / ブランチルールが最終仕様と一致している
158+
159+
### リスク
160+
161+
1. Nim 標準ライブラリだけで URL 解析を行う場合、RFC 3986 の細部を十分に扱えない可能性がある
162+
2. SQLite の URL 仕様を曖昧にすると、既存のファイルパス API と混同されやすい
163+
3. SurrealDB の namespace / database の載せ方を誤ると、RDB に寄せたつもりで逆に分かりにくくなる
164+
4. 既存の `dbOpen(driver, urlString)` と新オーバーロードが衝突すると、Nim の overload 解決で利用者体験が悪化する
165+
5. 文字列解析だけでなく実接続テストも必要なため、driver 横断で確認コストが上がる
166+
167+
### 段階導入案
168+
169+
1. 採用する URL 仕様と新オーバーロードの表面 API を確定する
170+
2. 共通 URL parser / 接続情報変換レイヤを用意する
171+
3. MySQL / MariaDB / PostgreSQL の既存 URL 実装を載せ替える
172+
4. SQLite の URL 入口を追加する
173+
5. テストを整備して後方互換と異常系を固める
174+
6. example / README / documents を同期する

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ import allographer/query_builder
1818
1919
let maxConnections = 95
2020
let timeout = 30
21-
let rdb = dbOpen(PostgreSql, "database", "user", "password" "localhost", 5432, maxConnections, timeout)
21+
let rdb = dbOpen(PostgreSQL, "database", "user", "password", "localhost", 5432, maxConnections, timeout)
2222
# also available
23-
# let rdb = dbOpen(Sqlite3, "/path/to/db/sqlite3.db", maxConnections=maxConnections, timeout=timeout)
24-
# let rdb = dbOpen(MySQL, "database", "user", "password" "localhost", 3306, maxConnections, timeout)
25-
# let rdb = dbOpen(MariaDB, "database", "user", "password" "localhost", 3306, maxConnections, timeout)
26-
# let surreal = dbOpen(SurrealDb, "test_ns" "test_db", "user", "password" "http://localhost", 8000, maxConnections, timeout)
23+
# let rdb = dbOpen(SQLite3, "/path/to/db/sqlite3.db", maxConnections=maxConnections, timeout=timeout)
24+
# let rdb = dbOpen(MySQL, "database", "user", "password", "localhost", 3306, maxConnections, timeout)
25+
# let rdb = dbOpen(MariaDB, "database", "user", "password", "localhost", 3306, maxConnections, timeout)
26+
# let rdb = dbOpen(PostgreSQL, databaseUrl = asDatabaseUrl("postgresql://user:password@localhost:5432/database"), maxConnections=maxConnections, timeout=timeout)
27+
# let surreal = dbOpen(SurrealDB, "test_ns" "test_db", "user", "password" "http://localhost", 8000, maxConnections, timeout)
2728
2829
proc main(){.async.} =
2930
let result = await rdb
@@ -139,12 +140,13 @@ database.nim
139140
```nim
140141
import allographer/connection
141142
142-
let rdb* = dbOpen(PostgreSql, "database", "user", "password" "localhost", 5432, maxConnections, timeout)
143+
let rdb* = dbOpen(PostgreSQL, "database", "user", "password", "localhost", 5432, maxConnections, timeout)
143144
144145
# you can create connection for multiple database at same time.
145146
let sqliteRdb* = dbOpen(Sqlite3, "/path/to/db/sqlite3.db", maxConnections=maxConnections, timeout=timeout)
146-
let mysqlRdb* = dbOpen(MySQL, "database", "user", "password" "localhost", 3306, maxConnections, timeout)
147-
let mariaRdb* = dbOpen(MariaDB, "database", "user", "password" "localhost", 3306, maxConnections, timeout)
147+
let mysqlRdb* = dbOpen(MySQL, "database", "user", "password", "localhost", 3306, maxConnections, timeout)
148+
let mariaRdb* = dbOpen(MariaDB, "database", "user", "password", "localhost", 3306, maxConnections, timeout)
149+
let pgUrlRdb* = dbOpen(PostgreSQL, databaseUrl = asDatabaseUrl("postgresql://user:password@localhost:5432/database"), maxConnections=maxConnections, timeout=timeout)
148150
let surrealDb* = dbOpen(SurrealDb, "test_ns" "test_db", "user", "password" "http://localhost", 8000, maxConnections, timeout)
149151
```
150152

documents/rdb/query_builder.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,13 @@ import allographer/connection
4848
4949
let maxConnections = 95
5050
let timeout = 30
51-
let rdb = dbOpen(PostgreSql, "database", "user", "password" "localhost", 5432, maxConnections, timeout)
51+
let rdb = dbOpen(PostgreSQL, "database", "user", "password", "localhost", 5432, maxConnections, timeout)
5252
5353
# also available
5454
# let rdb = dbOpen(Sqlite3, "/path/to/db/sqlite3.db", maxConnections=maxConnections, timeout=timeout)
55-
# let rdb = dbOpen(MySQL, "database", "user", "password" "localhost", 3306, maxConnections, timeout)
56-
# let rdb = dbOpen(MariaDB, "database", "user", "password" "localhost", 3306, maxConnections, timeout)
55+
# let rdb = dbOpen(MySQL, "database", "user", "password", "localhost", 3306, maxConnections, timeout)
56+
# let rdb = dbOpen(MariaDB, "database", "user", "password", "localhost", 3306, maxConnections, timeout)
57+
# let rdb = dbOpen(PostgreSQL, databaseUrl = asDatabaseUrl("postgresql://user:password@localhost:5432/database"), maxConnections=maxConnections, timeout=timeout)
5758
```
5859

5960
## SELECT

example/connections.nim

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ let
1717
let
1818
# rdb* = dbOpen(SQLite3, sqliteHost, maxConnections=maxConnections, shouldDisplayLog=false)
1919
rdb* = dbOpen(SQLite3, ":memory:", maxConnections=maxConnections, shouldDisplayLog=false)
20-
# rdb* = dbOpen(MySQL, database, user, password, mysqlHost, mysqlPort, maxConnections, timeout, shouldDisplayLog=true)
21-
# rdb* = dbOpen(MariaDB, database, user, password, mariadbHost, mysqlPort, maxConnections, timeout, shouldDisplayLog=true)
22-
# rdb* = dbOpen(PostgreSQL, database, user, password, pgHost, pgPort, maxConnections, timeout, shouldDisplayLog=true)
20+
# rdb* = dbOpen(MySQL, databaseUrl = asDatabaseUrl("mysql://user:password@" & mysqlHost & ":" & $mysqlPort & "/" & database), maxConnections, timeout, shouldDisplayLog=true)
21+
# rdb* = dbOpen(MariaDB, databaseUrl = asDatabaseUrl("mariadb://user:password@" & mariadbHost & ":" & $mysqlPort & "/" & database), maxConnections, timeout, shouldDisplayLog=true)
22+
# rdb* = dbOpen(PostgreSQL, databaseUrl = asDatabaseUrl("postgresql://user:password@" & pgHost & ":" & $pgPort & "/" & database), maxConnections, timeout, shouldDisplayLog=true)
2323

2424
template asyncBlock*(body:untyped) =
2525
(proc(){.async.}=

src/allographer/connection.nim

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import ./env
22

3+
import ./query_builder/libs/database_url
4+
export DatabaseUrl, DatabaseUrlQuery, ParsedDatabaseUrl, asDatabaseUrl, parseDatabaseUrl, databaseName, sqliteDatabasePath, portOrDefault, requireDatabaseUrlScheme
35

46
when isExistsSqlite:
57
import ./query_builder/models/sqlite/sqlite_types; export SQLite3, SqliteConnections
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import std/strutils
2+
import std/uri
3+
4+
5+
type DatabaseUrl* = distinct string
6+
7+
type DatabaseUrlQuery* = tuple[key, value: string]
8+
9+
type ParsedDatabaseUrl* = object
10+
raw*: string
11+
scheme*: string
12+
username*: string
13+
password*: string
14+
hostname*: string
15+
port*: int
16+
hasPort*: bool
17+
path*: string
18+
query*: seq[DatabaseUrlQuery]
19+
20+
21+
proc asDatabaseUrl*(value: string): DatabaseUrl =
22+
DatabaseUrl(value)
23+
24+
25+
proc `$`*(value: DatabaseUrl): string =
26+
string(value)
27+
28+
29+
proc parseQueryPairs(query: string): seq[DatabaseUrlQuery] =
30+
if query.len == 0:
31+
return
32+
33+
for item in query.split('&'):
34+
if item.len == 0:
35+
continue
36+
37+
let eqIndex = item.find('=')
38+
if eqIndex < 0:
39+
result.add((decodeUrl(item), ""))
40+
else:
41+
result.add((decodeUrl(item[0..<eqIndex]), decodeUrl(item[eqIndex + 1 .. ^1])))
42+
43+
44+
proc parseDatabaseUrl*(value: string): ParsedDatabaseUrl =
45+
let parsed = parseUri(value)
46+
47+
result.raw = value
48+
result.scheme = parsed.scheme.toLowerAscii()
49+
result.username = decodeUrl(parsed.username)
50+
result.password = decodeUrl(parsed.password)
51+
result.hostname = decodeUrl(parsed.hostname)
52+
result.path = decodeUrl(parsed.path)
53+
result.query = parseQueryPairs(parsed.query)
54+
55+
if parsed.port.len > 0:
56+
result.port = parsed.port.parseInt()
57+
result.hasPort = true
58+
59+
60+
proc parseDatabaseUrl*(value: DatabaseUrl): ParsedDatabaseUrl =
61+
parseDatabaseUrl(string(value))
62+
63+
64+
proc requireDatabaseUrlScheme*(value: ParsedDatabaseUrl; expectedSchemes: openArray[string];
65+
driverName: string) =
66+
if value.scheme.len == 0 or value.scheme notin expectedSchemes:
67+
raise newException(
68+
ValueError,
69+
"Invalid URL format. Expected a " & driverName & " URL starting with '" &
70+
expectedSchemes[0] & "://'."
71+
)
72+
73+
74+
proc stripLeadingSlash(value: string): string =
75+
if value.len > 0 and value[0] == '/':
76+
if value.len == 1:
77+
return ""
78+
return value[1..^1]
79+
value
80+
81+
82+
proc databaseName*(value: ParsedDatabaseUrl): string =
83+
stripLeadingSlash(value.path)
84+
85+
86+
proc sqliteDatabasePath*(value: ParsedDatabaseUrl): string =
87+
if value.hostname.len == 0:
88+
return value.path
89+
90+
let tail = stripLeadingSlash(value.path)
91+
if tail.len == 0:
92+
return value.hostname
93+
94+
result = value.hostname & "/" & tail
95+
96+
97+
proc portOrDefault*(value: ParsedDatabaseUrl; defaultPort: int): int =
98+
if value.hasPort:
99+
value.port
100+
else:
101+
defaultPort

src/allographer/query_builder/models/mariadb/mariadb_open.nim

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import std/times
2-
import std/strutils
2+
import ../../libs/database_url
33
import ../../libs/mariadb/mariadb_rdb
44
import ../../error
55
import ../../log
@@ -48,15 +48,26 @@ proc dbOpen*(_: type MariaDB, database: string, user: string, password: string,
4848

4949
proc dbOpen*(_:type MariaDB, url: string, maxConnections: int = 1, timeout=30,
5050
shouldDisplayLog=false, shouldOutputLogFile=false, logDir=""): MariadbConnections =
51-
## url: "mariadb://username:password@localhost:3306/DB_Name"
52-
let isMariadb = url.startsWith("mariadb://")
53-
if not isMariadb:
54-
raise newException(ValueError, "Invalid URL format. Expected a MariaDB URL starting with 'mariadb://'.")
51+
return dbOpen(MariaDB, asDatabaseUrl(url), maxConnections, timeout, shouldDisplayLog, shouldOutputLogFile, logDir)
5552

56-
let user = url.split("://")[1].split(":")[0]
57-
let password = url.split(":")[2].split("@")[0]
58-
let host = url.split("@")[1].split(":")[0]
59-
let port = url.split(":")[3].split("/")[0]
60-
let database = url.split("/")[^1]
6153

62-
return dbOpen(Mariadb, database, user, password, host, port.parseInt, maxConnections, timeout, shouldDisplayLog, shouldOutputLogFile, logDir)
54+
proc dbOpen*(_:type MariaDB, databaseUrl: DatabaseUrl, maxConnections=1, timeout=30,
55+
shouldDisplayLog=false, shouldOutputLogFile=false, logDir=""): MariadbConnections =
56+
let parsed = parseDatabaseUrl(databaseUrl)
57+
requireDatabaseUrlScheme(parsed, ["mariadb"], "MariaDB")
58+
59+
let database = databaseName(parsed)
60+
let port = portOrDefault(parsed, 3306)
61+
return dbOpen(
62+
MariaDB,
63+
database,
64+
parsed.username,
65+
parsed.password,
66+
parsed.hostname,
67+
port,
68+
maxConnections,
69+
timeout,
70+
shouldDisplayLog,
71+
shouldOutputLogFile,
72+
logDir
73+
)

0 commit comments

Comments
 (0)