diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd52a57..8265eda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,8 @@ jobs: matrix: os: [ubuntu-latest] crystal: [1.3.0, latest, nightly] - mysql_docker_image: ["mysql:5.6", "mysql:5.7"] + mysql_version: ["5.7"] + database_host: ["default", "/tmp/mysql.sock"] runs-on: ${{ matrix.os }} steps: - name: Install Crystal @@ -22,23 +23,28 @@ jobs: with: crystal: ${{ matrix.crystal }} - - name: Shutdown Ubuntu MySQL (SUDO) - run: sudo service mysql stop # Shutdown the Default MySQL, "sudo" is necessary, please not remove it + - id: setup-mysql + uses: shogo82148/actions-setup-mysql@v1 + with: + mysql-version: ${{ matrix.mysql_version }} - - name: Setup MySQL + - name: Wait for MySQL run: | - docker run -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 3306:3306 -d ${{ matrix.mysql_docker_image }} - docker ps -a # log docker image while ! echo exit | nc localhost 3306; do sleep 5; done # wait mysql to start accepting connections - name: Download source - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install shards run: shards install - - name: Run specs + - name: Run specs (Socket) + run: DATABASE_HOST=${{ steps.setup-mysql.outputs.base-dir }}/tmp/mysql.sock crystal spec + if: matrix.database_host == '/tmp/mysql.sock' + + - name: Run specs (Plain TCP) run: crystal spec + if: matrix.database_host == 'default' - name: Check formatting run: crystal tool format; git diff --exit-code diff --git a/spec/connection_options_spec.cr b/spec/connection_options_spec.cr new file mode 100644 index 0000000..baab0a1 --- /dev/null +++ b/spec/connection_options_spec.cr @@ -0,0 +1,125 @@ +require "./spec_helper" + +private def from_uri(uri) + Connection::Options.from_uri(URI.parse(uri)) +end + +private def tcp(host, port) + MySql::Connection::TCPSocketTransport.new(host: host, port: port) +end + +private def socket(path) + MySql::Connection::UnixSocketTransport.new(path: Path.new(path)) +end + +describe Connection::Options do + describe ".from_uri" do + it "parses mysql://user@host/db" do + from_uri("mysql://root@localhost/test").should eq( + MySql::Connection::Options.new( + transport: tcp("localhost", 3306), + username: "root", + password: nil, + initial_catalog: "test", + charset: Collations.default_collation + ) + ) + end + + it "parses mysql://host" do + from_uri("mysql://localhost").should eq( + MySql::Connection::Options.new( + transport: tcp("localhost", 3306), + username: nil, + password: nil, + initial_catalog: nil, + charset: Collations.default_collation + ) + ) + end + + it "parses mysql://host:port" do + from_uri("mysql://localhost:1234").should eq( + MySql::Connection::Options.new( + transport: tcp("localhost", 1234), + username: nil, + password: nil, + initial_catalog: nil, + charset: Collations.default_collation + ) + ) + end + + it "parses ?encoding=..." do + from_uri("mysql://localhost:1234?encoding=utf8mb4_unicode_520_ci").should eq( + MySql::Connection::Options.new( + transport: tcp("localhost", 1234), + username: nil, + password: nil, + initial_catalog: nil, + charset: "utf8mb4_unicode_520_ci" + ) + ) + end + + it "parses mysql://user@host?database=db" do + from_uri("mysql://root@localhost?database=test").should eq( + MySql::Connection::Options.new( + transport: tcp("localhost", 3306), + username: "root", + password: nil, + initial_catalog: "test", + charset: Collations.default_collation + ) + ) + end + + it "parses mysql:///path/to/socket" do + from_uri("mysql:///path/to/socket").should eq( + MySql::Connection::Options.new( + transport: socket("/path/to/socket"), + username: nil, + password: nil, + initial_catalog: nil, + charset: Collations.default_collation + ) + ) + end + + it "parses mysql:///path/to/socket?database=test" do + from_uri("mysql:///path/to/socket?database=test").should eq( + MySql::Connection::Options.new( + transport: socket("/path/to/socket"), + username: nil, + password: nil, + initial_catalog: "test", + charset: Collations.default_collation + ) + ) + end + + it "parses mysql:///path/to/socket?encoding=utf8mb4_unicode_520_ci" do + from_uri("mysql:///path/to/socket?encoding=utf8mb4_unicode_520_ci").should eq( + MySql::Connection::Options.new( + transport: socket("/path/to/socket"), + username: nil, + password: nil, + initial_catalog: nil, + charset: "utf8mb4_unicode_520_ci" + ) + ) + end + + it "parses mysql://user:pass@/path/to/socket?database=test" do + from_uri("mysql://root:password@/path/to/socket?database=test").should eq( + MySql::Connection::Options.new( + transport: socket("/path/to/socket"), + username: "root", + password: "password", + initial_catalog: "test", + charset: Collations.default_collation + ) + ) + end + end +end diff --git a/spec/driver_spec.cr b/spec/driver_spec.cr index 0a13ae3..a96ef7f 100644 --- a/spec/driver_spec.cr +++ b/spec/driver_spec.cr @@ -32,7 +32,7 @@ describe Driver do db.exec "FLUSH PRIVILEGES" end - DB.open "mysql://crystal_test:secret@#{database_host}/crystal_mysql_test" do |db| + DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test" do |db| db.scalar("SELECT DATABASE()").should eq("crystal_mysql_test") db.scalar("SELECT CURRENT_USER()").should match(/^crystal_test@/) end @@ -48,7 +48,7 @@ describe Driver do db.exec "CREATE DATABASE crystal_mysql_test" # By default, the encoding for the DB connection is set to utf8_general_ci - DB.open "mysql://crystal_test:secret@#{database_host}/crystal_mysql_test" do |db| + DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test" do |db| db.scalar("SELECT @@collation_connection").should eq("utf8_general_ci") db.scalar("SELECT @@character_set_connection").should eq("utf8") end @@ -61,7 +61,7 @@ describe Driver do db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" db.exec "CREATE DATABASE crystal_mysql_test" - DB.open "mysql://crystal_test:secret@#{database_host}/crystal_mysql_test?encoding=utf8mb4_unicode_520_ci" do |db| + DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test&encoding=utf8mb4_unicode_520_ci" do |db| db.scalar("SELECT @@collation_connection").should eq("utf8mb4_unicode_520_ci") db.scalar("SELECT @@character_set_connection").should eq("utf8mb4") end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 8493f18..62bb5ae 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -5,7 +5,11 @@ require "semantic_version" include MySql def db_url(initial_db = nil) - "mysql://root@#{database_host}/#{initial_db}" + if initial_db + "mysql://root@#{database_host}?database=#{initial_db}" + else + "mysql://root@#{database_host}" + end end def database_host @@ -18,7 +22,7 @@ def with_db(database_name, options = nil, &block : DB::Database ->) db.exec "CREATE DATABASE crystal_mysql_test" end - DB.open "#{db_url(database_name)}?#{options}", &block + DB.open "#{db_url(database_name)}&#{options}", &block ensure DB.open db_url do |db| db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" diff --git a/src/mysql/connection.cr b/src/mysql/connection.cr index bc648d2..c423499 100644 --- a/src/mysql/connection.cr +++ b/src/mysql/connection.cr @@ -1,42 +1,60 @@ require "socket" class MySql::Connection < DB::Connection + record UnixSocketTransport, path : Path + record TCPSocketTransport, host : String, port : Int32 + record Options, - host : String, - port : Int32, + transport : UnixSocketTransport | TCPSocketTransport, username : String?, password : String?, initial_catalog : String?, charset : String do def self.from_uri(uri : URI) : Options - host = uri.hostname || raise "no host provided" - port = uri.port || 3306 + params = uri.query_params + initial_catalog = params["database"]? + + if (host = uri.hostname) && !host.blank? + port = uri.port || 3306 + transport = TCPSocketTransport.new(host: host, port: port) + + # for tcp socket we support the first component to be the database + # but the query string takes presedence because it's more explicit + if initial_catalog.nil? && (path = uri.path) && path.size > 1 + initial_catalog = path[1..-1] + end + else + # uri.path has a final / we want to drop + transport = UnixSocketTransport.new(path: Path.new(uri.path.chomp('/'))) + end + username = uri.user password = uri.password - charset = uri.query_params.fetch "encoding", Collations.default_collation - - path = uri.path - if path && path.size > 1 - initial_catalog = path[1..-1] - else - initial_catalog = nil - end + charset = params.fetch "encoding", Collations.default_collation Options.new( - host: host, port: port, username: username, password: password, + transport: transport, + username: username, password: password, initial_catalog: initial_catalog, charset: charset) end end def initialize(options : ::DB::Connection::Options, mysql_options : ::MySql::Connection::Options) super(options) - @socket = uninitialized TCPSocket + @socket = uninitialized UNIXSocket | TCPSocket begin charset_id = Collations.id_for_collation(mysql_options.charset).to_u8 - @socket = TCPSocket.new(mysql_options.host, mysql_options.port) + @socket = + case transport = mysql_options.transport + in TCPSocketTransport + TCPSocket.new(transport.host, transport.port) + in UnixSocketTransport + UNIXSocket.new(transport.path.to_s) + end + handshake = read_packet(Protocol::HandshakeV10) write_packet(1) do |packet|