Skip to content

Commit 00b055d

Browse files
authored
Add support for unix socket connection (#115)
* Add connection options specs * Add specs for unix socket transport * Add UnixSocket support to connection * Update specs to be able to run with socket * Use native mysql in CI * Drop mysql 5.6 in CI * Use URI for transport * Update README
1 parent 58357e3 commit 00b055d

File tree

6 files changed

+204
-30
lines changed

6 files changed

+204
-30
lines changed

.github/workflows/ci.yml

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,37 @@ jobs:
1414
matrix:
1515
os: [ubuntu-latest]
1616
crystal: [1.3.0, latest, nightly]
17-
mysql_docker_image: ["mysql:5.6", "mysql:5.7"]
17+
mysql_version: ["5.7"]
18+
database_host: ["default", "/tmp/mysql.sock"]
1819
runs-on: ${{ matrix.os }}
1920
steps:
2021
- name: Install Crystal
2122
uses: crystal-lang/install-crystal@v1
2223
with:
2324
crystal: ${{ matrix.crystal }}
2425

25-
- name: Shutdown Ubuntu MySQL (SUDO)
26-
run: sudo service mysql stop # Shutdown the Default MySQL, "sudo" is necessary, please not remove it
26+
- id: setup-mysql
27+
uses: shogo82148/actions-setup-mysql@v1
28+
with:
29+
mysql-version: ${{ matrix.mysql_version }}
2730

28-
- name: Setup MySQL
31+
- name: Wait for MySQL
2932
run: |
30-
docker run -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 3306:3306 -d ${{ matrix.mysql_docker_image }}
31-
docker ps -a # log docker image
3233
while ! echo exit | nc localhost 3306; do sleep 5; done # wait mysql to start accepting connections
3334
3435
- name: Download source
35-
uses: actions/checkout@v2
36+
uses: actions/checkout@v4
3637

3738
- name: Install shards
3839
run: shards install
3940

40-
- name: Run specs
41+
- name: Run specs (Socket)
42+
run: DATABASE_HOST=${{ steps.setup-mysql.outputs.base-dir }}/tmp/mysql.sock crystal spec
43+
if: matrix.database_host == '/tmp/mysql.sock'
44+
45+
- name: Run specs (Plain TCP)
4146
run: crystal spec
47+
if: matrix.database_host == 'default'
4248

4349
- name: Check formatting
4450
run: crystal tool format; git diff --exit-code

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,29 @@ The connection string has the following syntax:
8686
mysql://[user[:[password]]@]host[:port][/schema][?param1=value1&param2=value2]
8787
```
8888

89-
Connection query params:
89+
#### Transport
9090

91-
- encoding: The collation & charset (character set) to use during the connection.
91+
The driver supports tcp connection or unix sockets
92+
93+
- `mysql://localhost` will connect using tcp and the default MySQL port 3306.
94+
- `mysql://localhost:8088` will connect using tcp using port 8088.
95+
- `mysql:///path/to/other.sock` will connect using unix socket `/path/to/other.sock`.
96+
97+
Any of the above can be used with `user@` or `user:password@` to pass credentials.
98+
99+
#### Default database
100+
101+
A `database` query string will specify the default database.
102+
Connection strings with a host can also use the first path component to specify the default database.
103+
Query string takes precedence because it's more explicit.
104+
105+
- `mysql://localhost/mydb`
106+
- `mysql://localhost:3306/mydb`
107+
- `mysql://localhost:3306?database=mydb`
108+
- `mysql:///path/to/other.sock?database=mydb`
109+
110+
#### Other query params
111+
112+
- `encoding`: The collation & charset (character set) to use during the connection.
92113
If empty or not defined, it will be set to `utf8_general_ci`.
93114
The list of available collations is defined in [`MySql::Collations::COLLATIONS_IDS_BY_NAME`](src/mysql/collations.cr)

spec/connection_options_spec.cr

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
require "./spec_helper"
2+
3+
private def from_uri(uri)
4+
Connection::Options.from_uri(URI.parse(uri))
5+
end
6+
7+
private def tcp(host, port)
8+
URI.new("tcp", host, port)
9+
end
10+
11+
private def socket(path)
12+
URI.new("unix", nil, nil, path)
13+
end
14+
15+
describe Connection::Options do
16+
describe ".from_uri" do
17+
it "parses mysql://user@host/db" do
18+
from_uri("mysql://root@localhost/test").should eq(
19+
MySql::Connection::Options.new(
20+
transport: tcp("localhost", 3306),
21+
username: "root",
22+
password: nil,
23+
initial_catalog: "test",
24+
charset: Collations.default_collation
25+
)
26+
)
27+
end
28+
29+
it "parses mysql://host" do
30+
from_uri("mysql://localhost").should eq(
31+
MySql::Connection::Options.new(
32+
transport: tcp("localhost", 3306),
33+
username: nil,
34+
password: nil,
35+
initial_catalog: nil,
36+
charset: Collations.default_collation
37+
)
38+
)
39+
end
40+
41+
it "parses mysql://host:port" do
42+
from_uri("mysql://localhost:1234").should eq(
43+
MySql::Connection::Options.new(
44+
transport: tcp("localhost", 1234),
45+
username: nil,
46+
password: nil,
47+
initial_catalog: nil,
48+
charset: Collations.default_collation
49+
)
50+
)
51+
end
52+
53+
it "parses ?encoding=..." do
54+
from_uri("mysql://localhost:1234?encoding=utf8mb4_unicode_520_ci").should eq(
55+
MySql::Connection::Options.new(
56+
transport: tcp("localhost", 1234),
57+
username: nil,
58+
password: nil,
59+
initial_catalog: nil,
60+
charset: "utf8mb4_unicode_520_ci"
61+
)
62+
)
63+
end
64+
65+
it "parses mysql://user@host?database=db" do
66+
from_uri("mysql://root@localhost?database=test").should eq(
67+
MySql::Connection::Options.new(
68+
transport: tcp("localhost", 3306),
69+
username: "root",
70+
password: nil,
71+
initial_catalog: "test",
72+
charset: Collations.default_collation
73+
)
74+
)
75+
end
76+
77+
it "parses mysql:///path/to/socket" do
78+
from_uri("mysql:///path/to/socket").should eq(
79+
MySql::Connection::Options.new(
80+
transport: socket("/path/to/socket"),
81+
username: nil,
82+
password: nil,
83+
initial_catalog: nil,
84+
charset: Collations.default_collation
85+
)
86+
)
87+
end
88+
89+
it "parses mysql:///path/to/socket?database=test" do
90+
from_uri("mysql:///path/to/socket?database=test").should eq(
91+
MySql::Connection::Options.new(
92+
transport: socket("/path/to/socket"),
93+
username: nil,
94+
password: nil,
95+
initial_catalog: "test",
96+
charset: Collations.default_collation
97+
)
98+
)
99+
end
100+
101+
it "parses mysql:///path/to/socket?encoding=utf8mb4_unicode_520_ci" do
102+
from_uri("mysql:///path/to/socket?encoding=utf8mb4_unicode_520_ci").should eq(
103+
MySql::Connection::Options.new(
104+
transport: socket("/path/to/socket"),
105+
username: nil,
106+
password: nil,
107+
initial_catalog: nil,
108+
charset: "utf8mb4_unicode_520_ci"
109+
)
110+
)
111+
end
112+
113+
it "parses mysql://user:pass@/path/to/socket?database=test" do
114+
from_uri("mysql://root:password@/path/to/socket?database=test").should eq(
115+
MySql::Connection::Options.new(
116+
transport: socket("/path/to/socket"),
117+
username: "root",
118+
password: "password",
119+
initial_catalog: "test",
120+
charset: Collations.default_collation
121+
)
122+
)
123+
end
124+
end
125+
end

spec/driver_spec.cr

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe Driver do
3232
db.exec "FLUSH PRIVILEGES"
3333
end
3434

35-
DB.open "mysql://crystal_test:secret@#{database_host}/crystal_mysql_test" do |db|
35+
DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test" do |db|
3636
db.scalar("SELECT DATABASE()").should eq("crystal_mysql_test")
3737
db.scalar("SELECT CURRENT_USER()").should match(/^crystal_test@/)
3838
end
@@ -48,7 +48,7 @@ describe Driver do
4848
db.exec "CREATE DATABASE crystal_mysql_test"
4949

5050
# By default, the encoding for the DB connection is set to utf8_general_ci
51-
DB.open "mysql://crystal_test:secret@#{database_host}/crystal_mysql_test" do |db|
51+
DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test" do |db|
5252
db.scalar("SELECT @@collation_connection").should eq("utf8_general_ci")
5353
db.scalar("SELECT @@character_set_connection").should eq("utf8")
5454
end
@@ -61,7 +61,7 @@ describe Driver do
6161
db.exec "DROP DATABASE IF EXISTS crystal_mysql_test"
6262
db.exec "CREATE DATABASE crystal_mysql_test"
6363

64-
DB.open "mysql://crystal_test:secret@#{database_host}/crystal_mysql_test?encoding=utf8mb4_unicode_520_ci" do |db|
64+
DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test&encoding=utf8mb4_unicode_520_ci" do |db|
6565
db.scalar("SELECT @@collation_connection").should eq("utf8mb4_unicode_520_ci")
6666
db.scalar("SELECT @@character_set_connection").should eq("utf8mb4")
6767
end

spec/spec_helper.cr

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ require "semantic_version"
55
include MySql
66

77
def db_url(initial_db = nil)
8-
"mysql://root@#{database_host}/#{initial_db}"
8+
if initial_db
9+
"mysql://root@#{database_host}?database=#{initial_db}"
10+
else
11+
"mysql://root@#{database_host}"
12+
end
913
end
1014

1115
def database_host
@@ -18,7 +22,7 @@ def with_db(database_name, options = nil, &block : DB::Database ->)
1822
db.exec "CREATE DATABASE crystal_mysql_test"
1923
end
2024

21-
DB.open "#{db_url(database_name)}?#{options}", &block
25+
DB.open "#{db_url(database_name)}&#{options}", &block
2226
ensure
2327
DB.open db_url do |db|
2428
db.exec "DROP DATABASE IF EXISTS crystal_mysql_test"

src/mysql/connection.cr

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,59 @@ require "socket"
22

33
class MySql::Connection < DB::Connection
44
record Options,
5-
host : String,
6-
port : Int32,
5+
transport : URI,
76
username : String?,
87
password : String?,
98
initial_catalog : String?,
109
charset : String do
1110
def self.from_uri(uri : URI) : Options
12-
host = uri.hostname || raise "no host provided"
13-
port = uri.port || 3306
11+
params = uri.query_params
12+
initial_catalog = params["database"]?
13+
14+
if (host = uri.hostname) && !host.blank?
15+
port = uri.port || 3306
16+
transport = URI.new("tcp", host, port)
17+
18+
# for tcp socket we support the first component to be the database
19+
# but the query string takes precedence because it's more explicit
20+
if initial_catalog.nil? && (path = uri.path) && path.size > 1
21+
initial_catalog = path[1..-1]
22+
end
23+
else
24+
transport = URI.new("unix", nil, nil, uri.path)
25+
end
26+
1427
username = uri.user
1528
password = uri.password
1629

17-
charset = uri.query_params.fetch "encoding", Collations.default_collation
18-
19-
path = uri.path
20-
if path && path.size > 1
21-
initial_catalog = path[1..-1]
22-
else
23-
initial_catalog = nil
24-
end
30+
charset = params.fetch "encoding", Collations.default_collation
2531

2632
Options.new(
27-
host: host, port: port, username: username, password: password,
33+
transport: transport,
34+
username: username, password: password,
2835
initial_catalog: initial_catalog, charset: charset)
2936
end
3037
end
3138

3239
def initialize(options : ::DB::Connection::Options, mysql_options : ::MySql::Connection::Options)
3340
super(options)
34-
@socket = uninitialized TCPSocket
41+
@socket = uninitialized UNIXSocket | TCPSocket
3542

3643
begin
3744
charset_id = Collations.id_for_collation(mysql_options.charset).to_u8
3845

39-
@socket = TCPSocket.new(mysql_options.host, mysql_options.port)
46+
transport = mysql_options.transport
47+
@socket =
48+
case transport.scheme
49+
when "tcp"
50+
host = transport.host || raise "Missing host in transport #{transport}"
51+
TCPSocket.new(host, transport.port)
52+
when "unix"
53+
UNIXSocket.new(transport.path)
54+
else
55+
raise "Transport not supported #{transport}"
56+
end
57+
4058
handshake = read_packet(Protocol::HandshakeV10)
4159

4260
write_packet(1) do |packet|

0 commit comments

Comments
 (0)