├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── connection_options_spec.cr ├── db_spec.cr ├── driver_spec.cr ├── pool_spec.cr ├── protocol_spec.cr └── spec_helper.cr └── src ├── mysql.cr └── mysql ├── collations.cr ├── connection.cr ├── driver.cr ├── packets.cr ├── read_packet.cr ├── result_set.cr ├── statement.cr ├── text_result_set.cr ├── types.cr ├── unprepared_statement.cr ├── version.cr └── write_packet.cr /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [master] 7 | schedule: 8 | - cron: '0 6 * * 1' # Every monday 6 AM 9 | 10 | jobs: 11 | test: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ubuntu-latest] 16 | crystal: [1.9.0, latest, nightly] 17 | mysql_version: ["5.7"] 18 | database_host: ["default", "/tmp/mysql.sock"] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - name: Install Crystal 22 | uses: crystal-lang/install-crystal@v1 23 | with: 24 | crystal: ${{ matrix.crystal }} 25 | 26 | - id: setup-mysql 27 | uses: shogo82148/actions-setup-mysql@v1 28 | with: 29 | mysql-version: ${{ matrix.mysql_version }} 30 | 31 | - name: Wait for MySQL 32 | run: | 33 | while ! echo exit | nc localhost 3306; do sleep 5; done # wait mysql to start accepting connections 34 | 35 | - name: Download source 36 | uses: actions/checkout@v4 37 | 38 | - name: Install shards 39 | run: shards install 40 | 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) 46 | run: crystal spec 47 | if: matrix.database_host == 'default' 48 | 49 | - name: Check formatting 50 | run: crystal tool format; git diff --exit-code 51 | if: matrix.crystal == 'latest' && matrix.os == 'ubuntu-latest' 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /lib/ 3 | /.crystal/ 4 | /.shards/ 5 | 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.16.0 (2023-12-21) 2 | 3 | * Update to crystal-db ~> 0.13.1. ([#112](https://github.com/crystal-lang/crystal-mysql/pull/112)) 4 | 5 | ## v0.15.0 (2023-06-23) 6 | 7 | * Update to crystal-db ~> 0.12.0. ([#107](https://github.com/crystal-lang/crystal-mysql/pull/107)) 8 | * Configure collation/charset using connection query params. ([#100](https://github.com/crystal-lang/crystal-mysql/pull/100), thanks @Dakad) 9 | 10 | ## v0.14.0 (2021-01-27) 11 | 12 | * Update to crystal-db ~> 0.11.0. ([#101](https://github.com/crystal-lang/crystal-mysql/pull/101)) 13 | * Migrate CI to GitHub Actions. ([#102](https://github.com/crystal-lang/crystal-mysql/pull/102), thanks @bcardiff) 14 | 15 | This release requires Crystal 1.0.0 or later. 16 | 17 | ## v0.13.0 (2021-01-26) 18 | 19 | * Add `UUID` support. ([#76](https://github.com/crystal-lang/crystal-mysql/pull/76), thanks @kalinon) 20 | 21 | ## v0.12.0 (2020-09-30) 22 | 23 | * Update to crystal-db ~> 0.10.0. ([#95](https://github.com/crystal-lang/crystal-mysql/pull/95)) 24 | 25 | This release requires Crystal 0.35.0 or later. 26 | 27 | ## v0.11.2 (2020-06-19) 28 | 29 | * Fix compatibility issues for Crystal 0.35.1. ([#92](https://github.com/crystal-lang/crystal-mysql/pull/92), thanks @bcardiff) 30 | 31 | ## v0.11.1 (2020-06-09) 32 | 33 | * Fix compatibility issues for Crystal 0.35.0. ([#90](https://github.com/crystal-lang/crystal-mysql/pull/90), thanks @bcardiff) 34 | 35 | ## v0.11.0 (2020-04-06) 36 | 37 | * Update to crystal-db ~> 0.9.0. ([#88](https://github.com/crystal-lang/crystal-mysql/pull/88), thanks @bcardiff) 38 | * Fix readme sample. ([#87](https://github.com/crystal-lang/crystal-mysql/pull/87), thanks @drum445) 39 | 40 | ## v0.10.0 (2019-12-11) 41 | 42 | * Update to crystal-db ~> 0.8.0. ([#85](https://github.com/crystal-lang/crystal-mysql/pull/85)) 43 | 44 | ## v0.9.0 (2019-09-20) 45 | 46 | This release requires Crystal >= 0.30.0 47 | 48 | * Update to crystal-db ~> 0.7.0 49 | 50 | ## v0.8.0 (2019-08-02) 51 | 52 | * Fix compatibility issues for Crystal 0.30.0. ([#80](https://github.com/crystal-lang/crystal-mysql/pull/80), thanks @bcardiff) 53 | 54 | ## v0.7.0 (2019-07-03) 55 | 56 | * Support implicit conversion from integer types to Bool ([#78](https://github.com/crystal-lang/crystal-mysql/pull/78), thanks @Blacksmoke16) 57 | * Improve docs. ([#71](https://github.com/crystal-lang/crystal-mysql/pull/71), thanks @fernandes) 58 | 59 | ## v0.6.0 (2019-04-18) 60 | 61 | * Fix compatibility issues for crystal 0.28.0 ([#73](https://github.com/crystal-lang/crystal-mysql/pull/73)) 62 | * Fix connection to IPv6 hosts ([#69](https://github.com/crystal-lang/crystal-mysql/pull/69), thanks @j8r) 63 | 64 | ## v0.5.1 (2018-11-06) 65 | 66 | * Fix `read_lenenc_int` return `UInt64`. 67 | * Add missing `IO#read_fully` when reading slice. ([#45](https://github.com/crystal-lang/crystal-mysql/pull/45), thanks @pacuum). 68 | 69 | ## v0.5.0 (2018-06-15) 70 | 71 | * Fix compatibility issues for crystal 0.25.0 ([#60](https://github.com/crystal-lang/crystal-mysql/pull/60)) 72 | * All the time instances are translated to UTC before saving them in the db 73 | * Send quit packet before closing connection ([#61](https://github.com/crystal-lang/crystal-mysql/pull/61), thanks @liuyang1204) 74 | 75 | ## v0.4.0 (2017-12-29) 76 | 77 | * Update to crystal-db ~> 0.5.0 78 | * Fix compatibility issues for crystal 0.24.1 (thanks @lipanski) 79 | * Drop support for zero dates 80 | 81 | ## v0.3.3 (2017-11-08) 82 | 83 | * Fix release connection. (see [#35](https://github.com/crystal-lang/crystal-mysql/pull/35) and [#38](https://github.com/crystal-lang/crystal-mysql/pull/38), thanks @benoist) 84 | * Fix unprepared queries creation. ([#37](https://github.com/crystal-lang/crystal-mysql/pull/37), thanks @benoist) 85 | * Fix use read_fully when reading slice. (see [#25](https://github.com/crystal-lang/crystal-mysql/issues/25)) 86 | * Add support for Date, Time and Mediumint. (see [#31](https://github.com/crystal-lang/crystal-mysql/pull/31) and [#41](https://github.com/crystal-lang/crystal-mysql/pull/41), thanks @crisward) 87 | 88 | ## v0.3.2 (2017-03-21) 89 | 90 | * Update to crystal-db ~> 0.4.0 91 | 92 | ## v0.3.1 (2016-12-24) 93 | 94 | * Update to crystal-db ~> 0.3.3 95 | * Fix compatibility issues for crystal 0.20.3 96 | * Add support for Timestamp 97 | 98 | ## v0.3.0 (2016-12-15) 99 | 100 | * Update to crystal-db ~> 0.3.1 101 | * Add support for unprepared statements using TextProtocol. This means only argless commands/query can be executed in unprepared fashion. 102 | * Add support for Bool (stored as BOOL/TINYINT(1)) 103 | 104 | ## v0.2.2 (2016-12-07) 105 | 106 | * Remove restriction to use only DB::Any in some cases 107 | 108 | ## v0.2.1 (2016-12-07) 109 | 110 | * Add support for TinyInt as Int8 and SmallInt as Int16. (thanks @crisward) 111 | * Update to crystal 0.20.0 (thanks @tbrand) 112 | 113 | ## v0.2.0 (2016-10-20) 114 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Brian J. Cardiff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crystal-mysql [![Build Status](https://travis-ci.org/crystal-lang/crystal-mysql.svg?branch=master)](https://travis-ci.org/crystal-lang/crystal-mysql) 2 | 3 | 4 | MySQL driver implement natively in Crystal, without relying on external libraries. 5 | 6 | Check [crystal-db](https://github.com/crystal-lang/crystal-db) for general db driver documentation. crystal-mysql driver is registered under `mysql://` uri. 7 | 8 | ## Why 9 | 10 | Using a natively implemented library has a significant performance improvement over working with an external library, since there is no need to copy data to and from the Crystal space and the native code. Initial tests with the library have shown a 2x-3x performance boost, though additional testing is required. 11 | 12 | Also, going through the MySQL external library *blocks* the Crystal thread using it, thus imposing a significant penalty to concurrent database accesses, such as those in web servers. We aim to overcome this issue through a full Crystal implementation of the MySQL driver that plays nice with non-blocking IO. 13 | 14 | ## Status 15 | 16 | This driver is a work in progress. 17 | It implements mysql's binary protocol to create prepared statements. 18 | Contributions are most welcome. 19 | 20 | ## Installation 21 | 22 | Add this to your application's `shard.yml`: 23 | 24 | ```yml 25 | dependencies: 26 | mysql: 27 | github: crystal-lang/crystal-mysql 28 | ``` 29 | 30 | ## Usage 31 | 32 | ```crystal 33 | require "mysql" 34 | 35 | # connect to localhost mysql test db 36 | DB.open "mysql://root@localhost/test" do |db| 37 | db.exec "drop table if exists contacts" 38 | db.exec "create table contacts (name varchar(30), age int)" 39 | db.exec "insert into contacts values (?, ?)", "John Doe", 30 40 | 41 | args = [] of DB::Any 42 | args << "Sarah" 43 | args << 33 44 | db.exec "insert into contacts values (?, ?)", args: args 45 | 46 | puts "max age:" 47 | puts db.scalar "select max(age) from contacts" # => 33 48 | 49 | puts "contacts:" 50 | db.query "select name, age from contacts order by age desc" do |rs| 51 | puts "#{rs.column_name(0)} (#{rs.column_name(1)})" 52 | # => name (age) 53 | rs.each do 54 | puts "#{rs.read(String)} (#{rs.read(Int32)})" 55 | # => Sarah (33) 56 | # => John Doe (30) 57 | end 58 | end 59 | end 60 | ``` 61 | 62 | When running this example, if you get the following exception: 63 | 64 | > Unhandled exception: Client does not support authentication protocol requested by server; consider upgrading MySQL client (Exception) 65 | 66 | You have two options, set a password for root, or (most recommended option) create another user with access to `test` database. 67 | 68 | ```mysql 69 | CREATE USER 'test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'yourpassword'; 70 | GRANT ALL PRIVILEGES ON test.* TO 'test'@'localhost' WITH GRANT OPTION; 71 | FLUSH PRIVILEGES; 72 | quit 73 | ``` 74 | 75 | Then use the example above changing the `DB.open` line to 76 | 77 | ```crystal 78 | DB.open "mysql://test:yourpassword@localhost/test" do |db| 79 | ``` 80 | 81 | ### Connection URI 82 | 83 | The connection string has the following syntax: 84 | 85 | ``` 86 | mysql://[user[:[password]]@]host[:port][/schema][?param1=value1¶m2=value2] 87 | ``` 88 | 89 | #### Transport 90 | 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 | #### Secure connections (SSL/TLS) 111 | 112 | By default a tcp connection will establish a secure connection, whether a unix socket will not. 113 | 114 | You can tweak this default behaviour and require further validation of certificates using `ssl-mode` and the following query strings. 115 | 116 | - `ssl-mode`: Either `disabled`, `preferred` (default), `required`, `verify_ca`, `verify_identity`. 117 | - `ssl-key`: Path to the client key. 118 | - `ssl-cert`: Path to the client certificate. 119 | - `ssl-ca`: Path to the CA certificate. 120 | 121 | #### Other query params 122 | 123 | - `encoding`: The collation & charset (character set) to use during the connection. 124 | If empty or not defined, it will be set to `utf8_general_ci`. 125 | The list of available collations is defined in [`MySql::Collations::COLLATIONS_IDS_BY_NAME`](src/mysql/collations.cr) 126 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: mysql 2 | version: 0.16.0 3 | 4 | dependencies: 5 | db: 6 | github: crystal-lang/crystal-db 7 | version: ~> 0.13.1 8 | 9 | authors: 10 | - Juan Wajnerman 11 | - Brian J. Cardiff 12 | 13 | crystal: ">= 1.0.0, < 2.0.0" 14 | 15 | license: MIT 16 | -------------------------------------------------------------------------------- /spec/connection_options_spec.cr: -------------------------------------------------------------------------------- 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 | private def ssl_from_params(params) 16 | Connection::SSLOptions.from_params(URI::Params.parse(params)) 17 | end 18 | 19 | SSL_OPTION_PREFERRED = Connection::SSLOptions.new(mode: :preferred) 20 | 21 | describe Connection::Options do 22 | describe ".from_uri" do 23 | it "parses mysql://user@host/db" do 24 | from_uri("mysql://root@localhost/test").should eq( 25 | Connection::Options.new( 26 | transport: tcp("localhost", 3306), 27 | username: "root", 28 | password: nil, 29 | initial_catalog: "test", 30 | charset: Collations.default_collation, 31 | ssl_options: SSL_OPTION_PREFERRED 32 | ) 33 | ) 34 | end 35 | 36 | it "parses mysql://host" do 37 | from_uri("mysql://localhost").should eq( 38 | Connection::Options.new( 39 | transport: tcp("localhost", 3306), 40 | username: nil, 41 | password: nil, 42 | initial_catalog: nil, 43 | charset: Collations.default_collation, 44 | ssl_options: SSL_OPTION_PREFERRED 45 | ) 46 | ) 47 | end 48 | 49 | it "parses mysql://host:port" do 50 | from_uri("mysql://localhost:1234").should eq( 51 | Connection::Options.new( 52 | transport: tcp("localhost", 1234), 53 | username: nil, 54 | password: nil, 55 | initial_catalog: nil, 56 | charset: Collations.default_collation, 57 | ssl_options: SSL_OPTION_PREFERRED 58 | ) 59 | ) 60 | end 61 | 62 | it "parses ?encoding=..." do 63 | from_uri("mysql://localhost:1234?encoding=utf8mb4_unicode_520_ci").should eq( 64 | Connection::Options.new( 65 | transport: tcp("localhost", 1234), 66 | username: nil, 67 | password: nil, 68 | initial_catalog: nil, 69 | charset: "utf8mb4_unicode_520_ci", 70 | ssl_options: SSL_OPTION_PREFERRED 71 | ) 72 | ) 73 | end 74 | 75 | it "parses mysql://user@host?database=db" do 76 | from_uri("mysql://root@localhost?database=test").should eq( 77 | Connection::Options.new( 78 | transport: tcp("localhost", 3306), 79 | username: "root", 80 | password: nil, 81 | initial_catalog: "test", 82 | charset: Collations.default_collation, 83 | ssl_options: SSL_OPTION_PREFERRED 84 | ) 85 | ) 86 | end 87 | 88 | it "parses mysql:///path/to/socket" do 89 | from_uri("mysql:///path/to/socket").should eq( 90 | Connection::Options.new( 91 | transport: socket("/path/to/socket"), 92 | username: nil, 93 | password: nil, 94 | initial_catalog: nil, 95 | charset: Collations.default_collation, 96 | ssl_options: SSL_OPTION_PREFERRED 97 | ) 98 | ) 99 | end 100 | 101 | it "parses mysql:///path/to/socket?database=test" do 102 | from_uri("mysql:///path/to/socket?database=test").should eq( 103 | Connection::Options.new( 104 | transport: socket("/path/to/socket"), 105 | username: nil, 106 | password: nil, 107 | initial_catalog: "test", 108 | charset: Collations.default_collation, 109 | ssl_options: SSL_OPTION_PREFERRED 110 | ) 111 | ) 112 | end 113 | 114 | it "parses mysql:///path/to/socket?encoding=utf8mb4_unicode_520_ci" do 115 | from_uri("mysql:///path/to/socket?encoding=utf8mb4_unicode_520_ci").should eq( 116 | Connection::Options.new( 117 | transport: socket("/path/to/socket"), 118 | username: nil, 119 | password: nil, 120 | initial_catalog: nil, 121 | charset: "utf8mb4_unicode_520_ci", 122 | ssl_options: SSL_OPTION_PREFERRED 123 | ) 124 | ) 125 | end 126 | 127 | it "parses mysql://user:pass@/path/to/socket?database=test" do 128 | from_uri("mysql://root:password@/path/to/socket?database=test").should eq( 129 | Connection::Options.new( 130 | transport: socket("/path/to/socket"), 131 | username: "root", 132 | password: "password", 133 | initial_catalog: "test", 134 | charset: Collations.default_collation, 135 | ssl_options: SSL_OPTION_PREFERRED 136 | ) 137 | ) 138 | end 139 | end 140 | end 141 | 142 | describe Connection::SSLOptions do 143 | describe ".from_params" do 144 | it "default is ssl-mode=preferred" do 145 | ssl_from_params("").mode.should eq(Connection::SSLMode::Preferred) 146 | end 147 | 148 | it "parses ssl-mode=preferred" do 149 | ssl_from_params("ssl-mode=preferred").mode.should eq(Connection::SSLMode::Preferred) 150 | ssl_from_params("ssl-mode=Preferred").mode.should eq(Connection::SSLMode::Preferred) 151 | ssl_from_params("ssl-mode=PREFERRED").mode.should eq(Connection::SSLMode::Preferred) 152 | end 153 | 154 | it "parses ssl-mode=disabled" do 155 | ssl_from_params("ssl-mode=disabled").mode.should eq(Connection::SSLMode::Disabled) 156 | ssl_from_params("ssl-mode=Disabled").mode.should eq(Connection::SSLMode::Disabled) 157 | ssl_from_params("ssl-mode=DISABLED").mode.should eq(Connection::SSLMode::Disabled) 158 | end 159 | 160 | it "parses ssl-mode=verifyca" do 161 | ssl_from_params("ssl-mode=verifyca").mode.should eq(Connection::SSLMode::VerifyCA) 162 | ssl_from_params("ssl-mode=verify-ca").mode.should eq(Connection::SSLMode::VerifyCA) 163 | ssl_from_params("ssl-mode=verify_ca").mode.should eq(Connection::SSLMode::VerifyCA) 164 | ssl_from_params("ssl-mode=VERIFY_CA").mode.should eq(Connection::SSLMode::VerifyCA) 165 | end 166 | 167 | it "parses ssl-mode=verifyidentity" do 168 | ssl_from_params("ssl-mode=verifyidentity").mode.should eq(Connection::SSLMode::VerifyIdentity) 169 | ssl_from_params("ssl-mode=verify-identity").mode.should eq(Connection::SSLMode::VerifyIdentity) 170 | ssl_from_params("ssl-mode=verify_identity").mode.should eq(Connection::SSLMode::VerifyIdentity) 171 | ssl_from_params("ssl-mode=VERIFY_IDENTITY").mode.should eq(Connection::SSLMode::VerifyIdentity) 172 | end 173 | 174 | it "parses ssl-key, ssl-cert, ssl-ca" do 175 | ssl_from_params("ssl-key=path/to/key.pem&ssl-cert=path/to/cert.pem&ssl-ca=path/to/ca.pem").should eq( 176 | Connection::SSLOptions.new(mode: Connection::SSLMode::Preferred, 177 | key: Path["path/to/key.pem"].expand(home: true), 178 | cert: Path["path/to/cert.pem"].expand(home: true), 179 | ca: Path["path/to/ca.pem"].expand(home: true)) 180 | ) 181 | end 182 | 183 | it "missing ssl-key, ssl-cert, ssl-ca as nil" do 184 | ssl_from_params("").should eq( 185 | Connection::SSLOptions.new(mode: Connection::SSLMode::Preferred, 186 | key: nil, 187 | cert: nil, 188 | ca: nil) 189 | ) 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /spec/db_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "db/spec" 3 | require "semantic_version" 4 | 5 | private class NotSupportedType 6 | end 7 | 8 | DB::DriverSpecs(MySql::Any).run do |ctx| 9 | before do 10 | DB.open db_url do |db| 11 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 12 | db.exec "CREATE DATABASE crystal_mysql_test" 13 | end 14 | end 15 | after do 16 | DB.open db_url do |db| 17 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 18 | end 19 | end 20 | 21 | connection_string db_url("crystal_mysql_test") 22 | 23 | sample_value true, "bool", "true", type_safe_value: false 24 | sample_value false, "bool", "false", type_safe_value: false 25 | sample_value 5_i8, "tinyint(1)", "5", type_safe_value: false 26 | sample_value 54_i16, "smallint(2)", "54", type_safe_value: false 27 | sample_value 123, "mediumint(2)", "123", type_safe_value: false 28 | sample_value 1, "int", "1", type_safe_value: false 29 | sample_value 1_i64, "bigint", "1" 30 | sample_value -5_i8, "tinyint(1)", "-5", type_safe_value: false 31 | sample_value -54_i16, "smallint(2)", "-54", type_safe_value: false 32 | sample_value -123, "mediumint(2)", "-123", type_safe_value: false 33 | sample_value -1, "int", "-1", type_safe_value: false 34 | sample_value -1_i64, "bigint", "-1" 35 | sample_value "hello", "varchar(25)", "'hello'" 36 | sample_value 1.5_f32, "float", "1.5", type_safe_value: false 37 | sample_value 1.5, "double", "1.5" 38 | sample_value Time.utc(2016, 2, 15), "datetime", "TIMESTAMP '2016-02-15 00:00:00.000'" 39 | sample_value Time.utc(2016, 2, 15, 10, 15, 30), "datetime", "TIMESTAMP '2016-02-15 10:15:30.000'" 40 | sample_value Time.utc(2016, 2, 15, 10, 15, 30), "timestamp", "TIMESTAMP '2016-02-15 10:15:30.000'" 41 | sample_value Time.local(2016, 2, 15, 7, 15, 30, location: Time::Location.fixed("fixed", -3*3600)), "timestamp", "'2016-02-15 10:15:30.000'", type_safe_value: false 42 | sample_value Time.utc(2016, 2, 29), "date", "LAST_DAY('2016-02-15')", type_safe_value: false 43 | sample_value Time::Span.new(nanoseconds: 0), "Time", "TIME('00:00:00')" 44 | sample_value Time::Span.new(hours: 10, minutes: 25, seconds: 21), "Time", "TIME('10:25:21')" 45 | sample_value Time::Span.new(days: 0, hours: 0, minutes: 10, seconds: 5, nanoseconds: 0), "Time", "TIME('00:10:05.000')" 46 | sample_value UUID.new("87b3042b-9b9a-41b7-8b15-a93d3f17025e"), "BLOB", "X'87b3042b9b9a41b78b15a93d3f17025e'", type_safe_value: false 47 | sample_value UUID.new("87b3042b-9b9a-41b7-8b15-a93d3f17025e"), "binary(16)", %(UNHEX(REPLACE("87b3042b-9b9a-41b7-8b15-a93d3f17025e", "-",""))), type_safe_value: false 48 | 49 | DB.open db_url do |db| 50 | # needs to check version, microsecond support >= 5.7 51 | if mysql_version(db) >= SemanticVersion.new(5, 7, 0) 52 | sample_value Time.utc(2016, 2, 15, 10, 15, 30, nanosecond: 543_000_000), "datetime(3)", "TIMESTAMP '2016-02-15 10:15:30.543'" 53 | sample_value Time.utc(2016, 2, 15, 10, 15, 30, nanosecond: 543_012_000), "datetime(6)", "TIMESTAMP '2016-02-15 10:15:30.543012'" 54 | sample_value Time.utc(2016, 2, 15, 10, 15, 30, nanosecond: 543_000_000), "timestamp(3)", "TIMESTAMP '2016-02-15 10:15:30.543'" 55 | sample_value Time.utc(2016, 2, 15, 10, 15, 30, nanosecond: 543_012_000), "timestamp(6)", "TIMESTAMP '2016-02-15 10:15:30.543012'" 56 | sample_value Time::Span.new(days: 0, hours: 10, minutes: 15, seconds: 30, nanoseconds: 543_000_000), "Time(3)", "TIME '10:15:30.543'" 57 | sample_value Time::Span.new(days: 0, hours: 10, minutes: 15, seconds: 30, nanoseconds: 543_012_000), "Time(6)", "TIME '10:15:30.543012'" 58 | end 59 | end 60 | 61 | ary = UInt8[0x41, 0x5A, 0x61, 0x7A] 62 | sample_value Bytes.new(ary.to_unsafe, ary.size), "BLOB", "X'415A617A'", type_safe_value: false 63 | 64 | [ 65 | {"TINYBLOB", 10}, 66 | {"BLOB", 1000}, 67 | {"MEDIUMBLOB", 10000}, 68 | {"LONGBLOB", 100000}, 69 | ].each do |type, size| 70 | sample_value Bytes.new((ary * size).to_unsafe, ary.size * size), type, "X'#{"415A617A" * size}'", type_safe_value: false 71 | end 72 | 73 | [ 74 | {"TINYTEXT", 10}, 75 | {"TEXT", 1000}, 76 | {"MEDIUMTEXT", 10000}, 77 | {"LONGTEXT", 100000}, 78 | ].each do |type, size| 79 | value = "Ham Sandwich" * size 80 | sample_value value, type, "'#{value}'" 81 | end 82 | 83 | binding_syntax do |index| 84 | "?" 85 | end 86 | 87 | create_table_1column_syntax do |table_name, col1| 88 | "create table #{table_name} (#{col1.name} #{col1.sql_type} #{col1.null ? "NULL" : "NOT NULL"})" 89 | end 90 | 91 | create_table_2columns_syntax do |table_name, col1, col2| 92 | "create table #{table_name} (#{col1.name} #{col1.sql_type} #{col1.null ? "NULL" : "NOT NULL"}, #{col2.name} #{col2.sql_type} #{col2.null ? "NULL" : "NOT NULL"})" 93 | end 94 | 95 | select_1column_syntax do |table_name, col1| 96 | "select #{col1.name} from #{table_name}" 97 | end 98 | 99 | select_2columns_syntax do |table_name, col1, col2| 100 | "select #{col1.name}, #{col2.name} from #{table_name}" 101 | end 102 | 103 | select_count_syntax do |table_name| 104 | "select count(*) from #{table_name}" 105 | end 106 | 107 | select_scalar_syntax do |expression| 108 | "select #{expression}" 109 | end 110 | 111 | insert_1column_syntax do |table_name, col, expression| 112 | "insert into #{table_name} (#{col.name}) values (#{expression})" 113 | end 114 | 115 | insert_2columns_syntax do |table_name, col1, expr1, col2, expr2| 116 | "insert into #{table_name} (#{col1.name}, #{col2.name}) values (#{expr1}, #{expr2})" 117 | end 118 | 119 | drop_table_if_exists_syntax do |table_name| 120 | "drop table if exists #{table_name}" 121 | end 122 | 123 | it "gets last insert row id", prepared: :both do |db| 124 | db.exec "create table person (id int not null primary key auto_increment, name varchar(25), age int)" 125 | db.exec %(insert into person (name, age) values ("foo", 10)) 126 | res = db.exec %(insert into person (name, age) values ("foo", 10)) 127 | res.last_insert_id.should eq(2) 128 | res.rows_affected.should eq(1) 129 | end 130 | 131 | it "get timestamp from table" do |db| 132 | db.exec "create table table1 (m int, dt datetime, ts timestamp DEFAULT CURRENT_TIMESTAMP)" 133 | db.exec "insert into table1 (m, dt) values(?, NOW())", 1 134 | 135 | dt, ts = db.query_one "SELECT dt, ts from table1", as: {Time, Time} 136 | (ts - dt).total_seconds.should be_close(0.0, 0.5) 137 | end 138 | 139 | it "raises on unsupported param types" do |db| 140 | expect_raises Exception, "MySql::Type does not support NotSupportedType values" do 141 | db.query "select ?", NotSupportedType.new 142 | end 143 | # TODO raising exception does not close the connection and pool is exhausted 144 | end 145 | 146 | it "ensures statements are closed" do |db| 147 | db.exec %(create table if not exists a (i int not null, str text not null);) 148 | db.exec %(insert into a (i, str) values (23, "bai bai");) 149 | 150 | 2.times do |i| 151 | DB.open ctx.connection_string do |db| 152 | begin 153 | db.query("SELECT i, str FROM a WHERE i = ?", 23) do |rs| 154 | rs.move_next 155 | break 156 | end 157 | rescue e 158 | fail("Expected no exception, but got \"#{e.message}\"") 159 | end 160 | 161 | begin 162 | db.exec("UPDATE a SET i = ? WHERE i = ?", 23, 23) 163 | rescue e 164 | fail("Expected no exception, but got \"#{e.message}\"") 165 | end 166 | end 167 | end 168 | end 169 | 170 | it "does not close a connection before cleaning up the result set" do |db| 171 | begin 172 | DB.open ctx.connection_string do |db| 173 | db.query("select 'foo'") do |rs| 174 | rs.each do 175 | rs.read(String) 176 | end 177 | db.query("select 'bar'") do |rs| 178 | rs.each do 179 | rs.read(String) 180 | end 181 | end 182 | end 183 | end 184 | rescue e 185 | fail("Expected no exception, but got \"#{e.message}\"") 186 | end 187 | end 188 | 189 | it "does not close a connection before cleaning up the text result set" do |db| 190 | begin 191 | DB.open ctx.connection_string do |db| 192 | db.unprepared.query("select 'foo'") do |rs| 193 | rs.each do 194 | rs.read(String) 195 | end 196 | db.unprepared.query("select 'bar'") do |rs| 197 | rs.each do 198 | rs.read(String) 199 | end 200 | end 201 | end 202 | end 203 | rescue e 204 | fail("Expected no exception, but got \"#{e.message}\"") 205 | end 206 | end 207 | 208 | it "allows unprepared statement queries" do |db| 209 | db.exec %(create table if not exists a (i int not null, str text not null);) 210 | db.exec %(insert into a (i, str) values (23, "bai bai");) 211 | 212 | 2.times do |i| 213 | DB.open ctx.connection_string do |db| 214 | begin 215 | db.unprepared.query("SELECT i, str FROM a WHERE i = 23") do |rs| 216 | rs.each do 217 | rs.read(Int32).should eq 23 218 | rs.read(String).should eq "bai bai" 219 | end 220 | end 221 | rescue e 222 | fail("Expected no exception, but got \"#{e.message}\"") 223 | end 224 | end 225 | end 226 | end 227 | 228 | it "should convert an EXISTS result to a Bool" do |db| 229 | db.exec "create table data (id int not null primary key auto_increment, name varchar(25));" 230 | db.exec %(insert into data (name) values ("foo");) 231 | 232 | db.query_one("SELECT EXISTS(SELECT 1 FROM data WHERE id = ?);", 1, as: Bool).should be_true 233 | db.query_one("SELECT EXISTS(SELECT 1 FROM data WHERE id = ?);", 2, as: Bool).should be_false 234 | end 235 | 236 | it "should raise when reading UUID from text columns" do |db| 237 | db.exec "create table data (id int not null primary key auto_increment, uuid_text varchar(36));" 238 | db.exec %(insert into data (uuid_text) values ("87b3042b-9b9a-41b7-8b15-a93d3f17025e");) 239 | 240 | expect_raises(DB::Error, "The column uuid_text of type MySql::Type::VarString returns a String and can't be read as UUID") do 241 | db.prepared.query_one("SELECT uuid_text FROM data", as: UUID) 242 | end 243 | 244 | expect_raises(DB::Error, "The column uuid_text of type MySql::Type::VarString returns a String and can't be read as UUID") do 245 | db.unprepared.query_one("SELECT uuid_text FROM data", as: UUID) 246 | end 247 | end 248 | 249 | it "should raise when reading UUID from binary columns with invalid length" do |db| 250 | db.exec "create table data (id int not null primary key auto_increment, uuid_blob blob);" 251 | db.exec %(insert into data (uuid_blob) values (X'415A617A');) 252 | 253 | expect_raises(ArgumentError, "Invalid bytes length 4, expected 16") do 254 | db.prepared.query_one("SELECT uuid_blob FROM data", as: UUID) 255 | end 256 | 257 | expect_raises(ArgumentError, "Invalid bytes length 4, expected 16") do 258 | db.unprepared.query_one("SELECT uuid_blob FROM data", as: UUID) 259 | end 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /spec/driver_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "semantic_version" 3 | 4 | def with_db(&block : DB::Database ->) 5 | DB.open db_url, &block 6 | end 7 | 8 | describe Driver do 9 | it "should register mysql name" do 10 | DB.driver_class("mysql").should eq(MySql::Driver) 11 | end 12 | 13 | it "should connect with credentials" do 14 | with_db do |db| 15 | db.scalar("SELECT DATABASE()").should be_nil 16 | db.scalar("SELECT CURRENT_USER()").should match(/^root@/) 17 | 18 | # ensure user is deleted 19 | if mysql_version(db) >= SemanticVersion.new(5, 7, 0) 20 | db.exec "DROP USER IF EXISTS crystal_test" 21 | else 22 | db.exec "GRANT USAGE ON *.* TO crystal_test IDENTIFIED BY 'secret'" 23 | db.exec "DROP USER crystal_test" 24 | end 25 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 26 | db.exec "FLUSH PRIVILEGES" 27 | 28 | # create test db with user 29 | db.exec "CREATE DATABASE crystal_mysql_test" 30 | db.exec "CREATE USER crystal_test IDENTIFIED BY 'secret'" 31 | db.exec "GRANT ALL PRIVILEGES ON crystal_mysql_test.* TO crystal_test" 32 | db.exec "FLUSH PRIVILEGES" 33 | end 34 | 35 | DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test" do |db| 36 | db.scalar("SELECT DATABASE()").should eq("crystal_mysql_test") 37 | db.scalar("SELECT CURRENT_USER()").should match(/^crystal_test@/) 38 | end 39 | 40 | with_db do |db| 41 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 42 | end 43 | end 44 | 45 | it "should connect with default encoding & collation for the connection set to utf8" do 46 | with_db do |db| 47 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 48 | db.exec "CREATE DATABASE crystal_mysql_test" 49 | 50 | # By default, the encoding for the DB connection is set to utf8_general_ci 51 | DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test" do |db| 52 | db.scalar("SELECT @@collation_connection").should eq("utf8_general_ci") 53 | db.scalar("SELECT @@character_set_connection").should eq("utf8") 54 | end 55 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 56 | end 57 | end 58 | 59 | it "should connect with requested encoding" do 60 | with_db do |db| 61 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 62 | db.exec "CREATE DATABASE crystal_mysql_test" 63 | 64 | DB.open "mysql://crystal_test:secret@#{database_host}?database=crystal_mysql_test&encoding=utf8mb4_unicode_520_ci" do |db| 65 | db.scalar("SELECT @@collation_connection").should eq("utf8mb4_unicode_520_ci") 66 | db.scalar("SELECT @@character_set_connection").should eq("utf8mb4") 67 | end 68 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 69 | end 70 | end 71 | 72 | it "should be able to connect without ssl" do 73 | with_db nil, "ssl-mode=disabled" do |db| 74 | db.scalar("SELECT VARIABLE_VALUE FROM performance_schema.session_status WHERE VARIABLE_NAME = 'Ssl_cipher'").should eq("") 75 | end 76 | end 77 | 78 | it "should be able to connect with ssl" do 79 | with_db nil, "ssl-mode=required" do |db| 80 | db.scalar("SELECT VARIABLE_VALUE FROM performance_schema.session_status WHERE VARIABLE_NAME = 'Ssl_cipher'").should_not eq("") 81 | end 82 | end 83 | 84 | it "create and drop test database" do 85 | sql = "SELECT count(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = 'crystal_mysql_test'" 86 | 87 | with_db do |db| 88 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 89 | db.exec "CREATE DATABASE crystal_mysql_test" 90 | DB.open db_url("crystal_mysql_test") do |db| 91 | db.scalar(sql).should eq(1) 92 | db.scalar("SELECT DATABASE()").should eq("crystal_mysql_test") 93 | end 94 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 95 | end 96 | 97 | with_db do |db| 98 | db.scalar(sql).should eq(0) 99 | db.scalar("SELECT DATABASE()").should be_nil 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/pool_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe DB::Pool do 4 | it "should write from multiple connections" do 5 | channel = Channel(Nil).new 6 | fibers = 20 7 | max_pool_size = 5 8 | max_n = 50 9 | 10 | with_db "crystal_mysql_test", "max_pool_size=#{max_pool_size}" do |db| 11 | db.exec "create table numbers (n int, fiber int)" 12 | 13 | fibers.times do |f| 14 | spawn do 15 | (1..max_n).each do |n| 16 | db.exec "insert into numbers (n, fiber) values (?, ?)", n, f 17 | sleep 0.01.seconds 18 | end 19 | channel.send nil 20 | end 21 | end 22 | 23 | fibers.times { channel.receive } 24 | 25 | # all numbers were inserted 26 | s = fibers * max_n * (max_n + 1) // 2 27 | db.scalar("select sum(n) from numbers").should eq(s) 28 | 29 | # numbers were not inserted one fiber at a time 30 | rows = db.query_all "select n, fiber from numbers", as: {Int32, Int32} 31 | rows.map(&.[1]).should_not eq(rows.map(&.[1]).sort) 32 | end 33 | end 34 | 35 | it "starting multiple connections does not exceed max pool size" do 36 | channel = Channel(Nil).new 37 | fibers = 100 38 | max_pool_size = 5 39 | 40 | with_db "crystal_mysql_test", "max_pool_size=#{max_pool_size}" do |db| 41 | db.exec "create table numbers (n int, fiber int)" 42 | 43 | max_open_connections = Atomic.new(0) 44 | 45 | fibers.times do |f| 46 | spawn do 47 | cnn = db.checkout 48 | max_open_connections.max(db.pool.stats.open_connections) 49 | sleep 0.01.seconds 50 | cnn.release 51 | channel.send nil 52 | end 53 | end 54 | 55 | fibers.times { channel.receive } 56 | max_open_connections.get.should be <= max_pool_size 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/protocol_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe MySql::Protocol do 4 | {% for number in [42, 250, 251, 1042, 65_535, 65_536, 65_542, 16_777_215, 16_777_216, 16_777_242, UInt64::MAX] %} 5 | it "should write/read LengthEncodedInteger: {{number}}" do 6 | DB.open db_url do |db| 7 | db.using_connection do |connection| 8 | content = IO::Memory.new 9 | 10 | # fake header with a 255 packet size 11 | content.write_bytes(0xFF_u8, IO::ByteFormat::LittleEndian) 12 | content.write_bytes(0x00_u8, IO::ByteFormat::LittleEndian) 13 | content.write_bytes(0x00_u8, IO::ByteFormat::LittleEndian) 14 | content.write_bytes(0x00_u8, IO::ByteFormat::LittleEndian) 15 | 16 | writer = MySql::WritePacket.new(content, connection) 17 | writer.write_lenenc_int({{number}}) 18 | content.rewind 19 | 20 | reader = MySql::ReadPacket.new(content, connection) 21 | reader.read_lenenc_int.should eq({{number}}) 22 | end 23 | end 24 | end 25 | {% end %} 26 | end 27 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/mysql" 3 | require "semantic_version" 4 | 5 | include MySql 6 | 7 | def db_url(initial_db = nil) 8 | if initial_db 9 | "mysql://root@#{database_host}?database=#{initial_db}" 10 | else 11 | "mysql://root@#{database_host}?" 12 | end 13 | end 14 | 15 | def database_host 16 | ENV.fetch("DATABASE_HOST", "localhost") 17 | end 18 | 19 | def with_db(database_name, options = nil, &block : DB::Database ->) 20 | DB.open db_url do |db| 21 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 22 | db.exec "CREATE DATABASE crystal_mysql_test" 23 | end 24 | 25 | DB.open "#{db_url(database_name)}&#{options}", &block 26 | ensure 27 | DB.open db_url do |db| 28 | db.exec "DROP DATABASE IF EXISTS crystal_mysql_test" 29 | end 30 | end 31 | 32 | def mysql_version(db) : SemanticVersion 33 | # some docker images might report 5.7.30-0ubuntu0.18.04.1, so we split in "-" 34 | SemanticVersion.parse(db.scalar("SELECT VERSION();").as(String).split("-").first) 35 | end 36 | -------------------------------------------------------------------------------- /src/mysql.cr: -------------------------------------------------------------------------------- 1 | require "db" 2 | require "./mysql/*" 3 | 4 | module MySql 5 | record ColumnSpec, catalog : String, schema : String, table : String, org_table : String, name : String, org_name : String, character_set : UInt16, column_length : UInt32, column_type_code : UInt8, flags : UInt16, decimal : UInt8 6 | 7 | struct ColumnSpec 8 | def column_type 9 | MySql::Type.types_by_code[column_type_code] 10 | end 11 | end 12 | 13 | alias Any = DB::Any | Int16 | Int8 | Time::Span | UUID 14 | 15 | # :nodoc: 16 | TIME_ZONE = Time::Location::UTC 17 | end 18 | -------------------------------------------------------------------------------- /src/mysql/collations.cr: -------------------------------------------------------------------------------- 1 | module MySql::Collations 2 | # Available collations mapped to the internal ID. 3 | # Handshake packet have only 1 byte for collation_id. 4 | # Only collations with ID > 255 are used during the handshake 5 | # The list of collation is from this SQL query: 6 | # SELECT ID, COLLATION_NAME FROM information_schema.COLLATIONS WHERE ID <= 255 ORDER BY ID; 7 | # 8 | # ucs2, utf16, and utf32 are excluded since they cannot be set as connection charset. 9 | # https://dev.mysql.com/doc/refman/5.7/en/charset-connection.html#charset-connection-impermissible-client-charset 10 | COLLATIONS_IDS_BY_NAME = { 11 | "big5_chinese_ci": 1, 12 | "latin2_czech_cs": 2, 13 | "dec8_swedish_ci": 3, 14 | "cp850_general_ci": 4, 15 | "latin1_german1_ci": 5, 16 | "hp8_english_ci": 6, 17 | "koi8r_general_ci": 7, 18 | "latin1_swedish_ci": 8, 19 | "latin2_general_ci": 9, 20 | "swe7_swedish_ci": 10, 21 | "ascii_general_ci": 11, 22 | "ujis_japanese_ci": 12, 23 | "sjis_japanese_ci": 13, 24 | "cp1251_bulgarian_ci": 14, 25 | "latin1_danish_ci": 15, 26 | "hebrew_general_ci": 16, 27 | "tis620_thai_ci": 18, 28 | "euckr_korean_ci": 19, 29 | "latin7_estonian_cs": 20, 30 | "latin2_hungarian_ci": 21, 31 | "koi8u_general_ci": 22, 32 | "cp1251_ukrainian_ci": 23, 33 | "gb2312_chinese_ci": 24, 34 | "greek_general_ci": 25, 35 | "cp1250_general_ci": 26, 36 | "latin2_croatian_ci": 27, 37 | "gbk_chinese_ci": 28, 38 | "cp1257_lithuanian_ci": 29, 39 | "latin5_turkish_ci": 30, 40 | "latin1_german2_ci": 31, 41 | "armscii8_general_ci": 32, 42 | "utf8_general_ci": 33, 43 | "cp1250_czech_cs": 34, 44 | "cp866_general_ci": 36, 45 | "keybcs2_general_ci": 37, 46 | "macce_general_ci": 38, 47 | "macroman_general_ci": 39, 48 | "cp852_general_ci": 40, 49 | "latin7_general_ci": 41, 50 | "latin7_general_cs": 42, 51 | "macce_bin": 43, 52 | "cp1250_croatian_ci": 44, 53 | "utf8mb4_general_ci": 45, 54 | "utf8mb4_bin": 46, 55 | "latin1_bin": 47, 56 | "latin1_general_ci": 48, 57 | "latin1_general_cs": 49, 58 | "cp1251_bin": 50, 59 | "cp1251_general_ci": 51, 60 | "cp1251_general_cs": 52, 61 | "macroman_bin": 53, 62 | "cp1256_general_ci": 57, 63 | "cp1257_bin": 58, 64 | "cp1257_general_ci": 59, 65 | "binary": 63, 66 | "armscii8_bin": 64, 67 | "ascii_bin": 65, 68 | "cp1250_bin": 66, 69 | "cp1256_bin": 67, 70 | "cp866_bin": 68, 71 | "dec8_bin": 69, 72 | "greek_bin": 70, 73 | "hebrew_bin": 71, 74 | "hp8_bin": 72, 75 | "keybcs2_bin": 73, 76 | "koi8r_bin": 74, 77 | "koi8u_bin": 75, 78 | "utf8_tolower_ci": 76, 79 | "latin2_bin": 77, 80 | "latin5_bin": 78, 81 | "latin7_bin": 79, 82 | "cp850_bin": 80, 83 | "cp852_bin": 81, 84 | "swe7_bin": 82, 85 | "utf8_bin": 83, 86 | "big5_bin": 84, 87 | "euckr_bin": 85, 88 | "gb2312_bin": 86, 89 | "gbk_bin": 87, 90 | "sjis_bin": 88, 91 | "tis620_bin": 89, 92 | "ujis_bin": 91, 93 | "geostd8_general_ci": 92, 94 | "geostd8_bin": 93, 95 | "latin1_spanish_ci": 94, 96 | "cp932_japanese_ci": 95, 97 | "cp932_bin": 96, 98 | "eucjpms_japanese_ci": 97, 99 | "eucjpms_bin": 98, 100 | "cp1250_polish_ci": 99, 101 | "utf8_unicode_ci": 192, 102 | "utf8_icelandic_ci": 193, 103 | "utf8_latvian_ci": 194, 104 | "utf8_romanian_ci": 195, 105 | "utf8_slovenian_ci": 196, 106 | "utf8_polish_ci": 197, 107 | "utf8_estonian_ci": 198, 108 | "utf8_spanish_ci": 199, 109 | "utf8_swedish_ci": 200, 110 | "utf8_turkish_ci": 201, 111 | "utf8_czech_ci": 202, 112 | "utf8_danish_ci": 203, 113 | "utf8_lithuanian_ci": 204, 114 | "utf8_slovak_ci": 205, 115 | "utf8_spanish2_ci": 206, 116 | "utf8_roman_ci": 207, 117 | "utf8_persian_ci": 208, 118 | "utf8_esperanto_ci": 209, 119 | "utf8_hungarian_ci": 210, 120 | "utf8_sinhala_ci": 211, 121 | "utf8_german2_ci": 212, 122 | "utf8_croatian_ci": 213, 123 | "utf8_unicode_520_ci": 214, 124 | "utf8_vietnamese_ci": 215, 125 | "utf8_general_mysql500_ci": 223, 126 | "utf8mb4_unicode_ci": 224, 127 | "utf8mb4_icelandic_ci": 225, 128 | "utf8mb4_latvian_ci": 226, 129 | "utf8mb4_romanian_ci": 227, 130 | "utf8mb4_slovenian_ci": 228, 131 | "utf8mb4_polish_ci": 229, 132 | "utf8mb4_estonian_ci": 230, 133 | "utf8mb4_spanish_ci": 231, 134 | "utf8mb4_swedish_ci": 232, 135 | "utf8mb4_turkish_ci": 233, 136 | "utf8mb4_czech_ci": 234, 137 | "utf8mb4_danish_ci": 235, 138 | "utf8mb4_lithuanian_ci": 236, 139 | "utf8mb4_slovak_ci": 237, 140 | "utf8mb4_spanish2_ci": 238, 141 | "utf8mb4_roman_ci": 239, 142 | "utf8mb4_persian_ci": 240, 143 | "utf8mb4_esperanto_ci": 241, 144 | "utf8mb4_hungarian_ci": 242, 145 | "utf8mb4_sinhala_ci": 243, 146 | "utf8mb4_german2_ci": 244, 147 | "utf8mb4_croatian_ci": 245, 148 | "utf8mb4_unicode_520_ci": 246, 149 | "utf8mb4_vietnamese_ci": 247, 150 | } 151 | 152 | def self.default_collation 153 | "utf8_general_ci" 154 | end 155 | 156 | def self.id_for_collation(collation : String) 157 | return COLLATIONS_IDS_BY_NAME.fetch collation, 0 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /src/mysql/connection.cr: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require "openssl" 3 | 4 | class MySql::Connection < DB::Connection 5 | enum SSLMode 6 | Disabled 7 | Preferred 8 | Required 9 | VerifyCA 10 | VerifyIdentity 11 | end 12 | record SSLOptions, mode : SSLMode, key : Path? = nil, cert : Path? = nil, ca : Path? = nil do 13 | def self.from_params(params : URI::Params) 14 | mode = 15 | if mode_param = params["ssl-mode"]? 16 | SSLMode.parse(mode_param) 17 | else 18 | SSLMode::Preferred 19 | end 20 | 21 | # NOTE: Passing paths prefixed with ~/ or ./ seems to not work with OpenSSL 22 | # we we expand the provided path. 23 | key = (params["ssl-key"]?).try { |v| Path[v].expand(home: true) } 24 | cert = (params["ssl-cert"]?).try { |v| Path[v].expand(home: true) } 25 | ca = (params["ssl-ca"]?).try { |v| Path[v].expand(home: true) } 26 | 27 | SSLOptions.new(mode: mode, key: key, cert: cert, ca: ca) 28 | end 29 | 30 | def build_context : OpenSSL::SSL::Context::Client 31 | ctx = OpenSSL::SSL::Context::Client.new 32 | 33 | ctx.verify_mode = 34 | case mode 35 | when SSLMode::VerifyCA, SSLMode::VerifyIdentity 36 | OpenSSL::SSL::VerifyMode::PEER 37 | else 38 | OpenSSL::SSL::VerifyMode::NONE 39 | end 40 | 41 | ctx.certificate_chain = cert.to_s if cert = @cert 42 | ctx.private_key = key.to_s if key = @key 43 | ctx.ca_certificates = ca.to_s if ca = @ca 44 | 45 | ctx 46 | end 47 | end 48 | 49 | record Options, 50 | transport : URI, 51 | username : String?, 52 | password : String?, 53 | initial_catalog : String?, 54 | charset : String, 55 | ssl_options : SSLOptions do 56 | def self.from_uri(uri : URI) : Options 57 | params = uri.query_params 58 | initial_catalog = params["database"]? 59 | 60 | if (host = uri.hostname) && !host.blank? 61 | port = uri.port || 3306 62 | transport = URI.new("tcp", host, port) 63 | 64 | # for tcp socket we support the first component to be the database 65 | # but the query string takes precedence because it's more explicit 66 | if initial_catalog.nil? && (path = uri.path) && path.size > 1 67 | initial_catalog = path[1..-1] 68 | end 69 | else 70 | transport = URI.new("unix", nil, nil, uri.path) 71 | end 72 | 73 | username = uri.user 74 | password = uri.password 75 | 76 | charset = params.fetch "encoding", Collations.default_collation 77 | 78 | Options.new( 79 | transport: transport, 80 | username: username, password: password, 81 | initial_catalog: initial_catalog, charset: charset, 82 | ssl_options: SSLOptions.from_params(params) 83 | ) 84 | end 85 | end 86 | 87 | def initialize(options : ::DB::Connection::Options, mysql_options : ::MySql::Connection::Options) 88 | super(options) 89 | @socket = uninitialized UNIXSocket | TCPSocket | OpenSSL::SSL::Socket::Client 90 | 91 | begin 92 | charset_id = Collations.id_for_collation(mysql_options.charset).to_u8 93 | 94 | transport = mysql_options.transport 95 | hostname = nil 96 | @socket = 97 | case transport.scheme 98 | when "tcp" 99 | hostname = transport.host || raise "Missing host in transport #{transport}" 100 | TCPSocket.new(hostname, transport.port) 101 | when "unix" 102 | UNIXSocket.new(transport.path) 103 | else 104 | raise "Transport not supported #{transport}" 105 | end 106 | 107 | handshake = read_packet(Protocol::HandshakeV10) 108 | 109 | handshake_response = Protocol::HandshakeResponse41.new(mysql_options.username, mysql_options.password, mysql_options.initial_catalog, handshake.auth_plugin_data, charset_id) 110 | seq = 1 111 | 112 | if mysql_options.ssl_options.mode != SSLMode::Disabled && 113 | # socket connection will not use ssl for preferred 114 | !(transport.scheme == "unix" && mysql_options.ssl_options.mode == SSLMode::Preferred) 115 | write_packet(seq) do |packet| 116 | handshake_response.write_ssl_request(packet) 117 | end 118 | seq += 1 119 | ctx = mysql_options.ssl_options.build_context 120 | @socket = OpenSSL::SSL::Socket::Client.new(@socket, context: ctx, sync_close: true, hostname: hostname) 121 | # NOTE: If ssl_options.mode is Preferred we should fallback to non-ssl socket if the ssl setup failed 122 | # if we do so, we should warn at least. Making Preferred behave as Required is a safer option 123 | # so the user would need to explicitly choose Disabled to avoid the ssl setup. 124 | end 125 | 126 | write_packet(seq) do |packet| 127 | handshake_response.write(packet) 128 | end 129 | 130 | read_ok_or_err do |packet, status| 131 | raise "packet #{status} not implemented" 132 | end 133 | rescue IO::Error 134 | raise DB::ConnectionRefused.new 135 | end 136 | end 137 | 138 | def do_close 139 | super 140 | 141 | begin 142 | write_packet do |packet| 143 | Protocol::Quit.new.write(packet) 144 | end 145 | @socket.close 146 | rescue 147 | end 148 | end 149 | 150 | # :nodoc: 151 | def read_ok_or_err(&) 152 | read_packet do |packet| 153 | raise_if_err_packet(packet) do |status| 154 | yield packet, status 155 | end 156 | end 157 | end 158 | 159 | # :nodoc: 160 | def read_packet(&) 161 | packet = build_read_packet 162 | begin 163 | yield packet 164 | ensure 165 | packet.discard 166 | end 167 | end 168 | 169 | # :nodoc: 170 | def read_packet(protocol_packet_type) 171 | read_packet do |packet| 172 | return protocol_packet_type.read(packet) 173 | end 174 | raise "unable to read packet" 175 | end 176 | 177 | # :nodoc: 178 | def build_read_packet 179 | ReadPacket.new(@socket, self) 180 | end 181 | 182 | # :nodoc: 183 | def write_packet(seq = 0, &) 184 | content = IO::Memory.new 185 | yield WritePacket.new(content, self) 186 | bytesize = content.bytesize 187 | 188 | packet = IO::Memory.new 189 | 3.times do 190 | packet.write_byte (bytesize & 0xff_u8).to_u8 191 | bytesize >>= 8 192 | end 193 | packet.write_byte seq.to_u8 194 | 195 | packet << content 196 | 197 | @socket << packet 198 | @socket.flush 199 | end 200 | 201 | # :nodoc: 202 | def handle_err_packet(packet) 203 | 8.times { packet.read_byte! } 204 | raise packet.read_string 205 | end 206 | 207 | # :nodoc: 208 | def raise_if_err_packet(packet) 209 | raise_if_err_packet(packet) do |status| 210 | raise "unexpected packet #{status}" 211 | end 212 | end 213 | 214 | # :nodoc: 215 | def raise_if_err_packet(packet, &) 216 | status = packet.read_byte! 217 | if status == 255 218 | handle_err_packet packet 219 | end 220 | 221 | yield status if status != 0 222 | 223 | status 224 | end 225 | 226 | # :nodoc: 227 | def read_column_definitions(target, column_count) 228 | # Parse column definitions 229 | # http://dev.mysql.com/doc/internals/en/com-query-response.html#packet-Protocol::ColumnDefinition 230 | column_count.times do 231 | self.read_packet do |packet| 232 | catalog = packet.read_lenenc_string 233 | schema = packet.read_lenenc_string 234 | table = packet.read_lenenc_string 235 | org_table = packet.read_lenenc_string 236 | name = packet.read_lenenc_string 237 | org_name = packet.read_lenenc_string 238 | next_length = packet.read_lenenc_int # length of fixed-length fields, always 0x0c 239 | raise "Unexpected next_length value: #{next_length}." unless next_length == 0x0c 240 | character_set = packet.read_fixed_int(2).to_u16! 241 | column_length = packet.read_fixed_int(4).to_u32! 242 | column_type = packet.read_fixed_int(1).to_u8! 243 | flags = packet.read_fixed_int(2).to_u16! 244 | decimal = packet.read_fixed_int(1).to_u8! 245 | filler = packet.read_fixed_int(2).to_u16! # filler [00] [00] 246 | raise "Unexpected filler value #{filler}" unless filler == 0x0000 247 | 248 | target << ColumnSpec.new(catalog, schema, table, org_table, name, org_name, character_set, column_length, column_type, flags, decimal) 249 | end 250 | end 251 | 252 | if column_count > 0 253 | self.read_packet do |eof_packet| 254 | eof_packet.read_byte # TODO assert EOF Packet 255 | end 256 | end 257 | end 258 | 259 | def build_prepared_statement(query) : MySql::Statement 260 | MySql::Statement.new(self, query) 261 | end 262 | 263 | def build_unprepared_statement(query) : MySql::UnpreparedStatement 264 | MySql::UnpreparedStatement.new(self, query) 265 | end 266 | end 267 | -------------------------------------------------------------------------------- /src/mysql/driver.cr: -------------------------------------------------------------------------------- 1 | class MySql::Driver < DB::Driver 2 | class ConnectionBuilder < ::DB::ConnectionBuilder 3 | def initialize(@options : ::DB::Connection::Options, @mysql_options : MySql::Connection::Options) 4 | end 5 | 6 | def build : ::DB::Connection 7 | MySql::Connection.new(@options, @mysql_options) 8 | end 9 | end 10 | 11 | def connection_builder(uri : URI) : ::DB::ConnectionBuilder 12 | params = HTTP::Params.parse(uri.query || "") 13 | ConnectionBuilder.new(connection_options(params), MySql::Connection::Options.from_uri(uri)) 14 | end 15 | end 16 | 17 | DB.register_driver "mysql", MySql::Driver 18 | -------------------------------------------------------------------------------- /src/mysql/packets.cr: -------------------------------------------------------------------------------- 1 | require "openssl/sha1" 2 | 3 | module MySql::Protocol 4 | struct HandshakeV10 5 | getter auth_plugin_data : Bytes 6 | getter charset : UInt8 7 | 8 | def initialize(@auth_plugin_data, @charset) 9 | end 10 | 11 | def self.read(packet : MySql::ReadPacket) 12 | protocol_version = packet.read_byte! 13 | version = packet.read_string 14 | thread = packet.read_int 15 | 16 | auth_data = Bytes.new(20) 17 | packet.read_fully(auth_data[0, 8]) 18 | packet.read_byte! 19 | cap1 = packet.read_byte! 20 | cap2 = packet.read_byte! 21 | charset = packet.read_byte! 22 | packet.read_byte_array(2) 23 | cap3 = packet.read_byte! 24 | cap4 = packet.read_byte! 25 | 26 | auth_plugin_data_length = packet.read_byte! 27 | packet.read_byte_array(10) 28 | packet.read_fully(auth_data[8, {13, auth_plugin_data_length.to_i16 - 8}.max - 1]) 29 | packet.read_byte! 30 | packet.read_string 31 | 32 | HandshakeV10.new(auth_data, charset) 33 | end 34 | end 35 | 36 | # https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_handshake_response.html#sect_protocol_connection_phase_packets_protocol_handshake_response41 37 | struct HandshakeResponse41 38 | CLIENT_LONG_PASSWORD = 0x00000001 39 | CLIENT_FOUND_ROWS = 0x00000002 40 | CLIENT_LONG_FLAG = 0x00000004 41 | CLIENT_CONNECT_WITH_DB = 0x00000008 42 | CLIENT_NO_SCHEMA = 0x00000010 43 | CLIENT_COMPRESS = 0x00000020 44 | CLIENT_ODBC = 0x00000040 45 | CLIENT_LOCAL_FILES = 0x00000080 46 | CLIENT_IGNORE_SPACE = 0x00000100 47 | CLIENT_PROTOCOL_41 = 0x00000200 48 | CLIENT_INTERACTIVE = 0x00000400 49 | CLIENT_SSL = 0x00000800 50 | CLIENT_IGNORE_SIGPIPE = 0x00001000 51 | CLIENT_TRANSACTIONS = 0x00002000 52 | CLIENT_RESERVED = 0x00004000 53 | CLIENT_SECURE_CONNECTION = 0x00008000 54 | CLIENT_MULTI_STATEMENTS = 0x00010000 55 | CLIENT_MULTI_RESULTS = 0x00020000 56 | CLIENT_PS_MULTI_RESULTS = 0x00040000 57 | CLIENT_PLUGIN_AUTH = 0x00080000 58 | CLIENT_CONNECT_ATTRS = 0x00100000 59 | CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000 60 | CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS = 0x00400000 61 | CLIENT_SESSION_TRACK = 0x00800000 62 | CLIENT_DEPRECATE_EOF = 0x01000000 63 | 64 | def initialize(@username : String?, @password : String?, @initial_catalog : String?, @auth_plugin_data : Bytes, @charset : UInt8) 65 | end 66 | 67 | # https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_ssl_request.html 68 | def write_ssl_request(packet : MySql::WritePacket) 69 | caps = CLIENT_PROTOCOL_41 | CLIENT_SSL 70 | caps |= CLIENT_CONNECT_WITH_DB if @initial_catalog 71 | 72 | packet.write_bytes caps, IO::ByteFormat::LittleEndian 73 | 74 | packet.write_bytes 0x00000000u32, IO::ByteFormat::LittleEndian 75 | packet.write_byte @charset 76 | 23.times { packet.write_byte 0_u8 } 77 | end 78 | 79 | def write(packet : MySql::WritePacket) 80 | caps = CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION | CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA 81 | 82 | caps |= CLIENT_PLUGIN_AUTH if @password 83 | 84 | caps |= CLIENT_CONNECT_WITH_DB if @initial_catalog 85 | 86 | packet.write_bytes caps, IO::ByteFormat::LittleEndian 87 | 88 | packet.write_bytes 0x00000000u32, IO::ByteFormat::LittleEndian 89 | packet.write_byte @charset 90 | 23.times { packet.write_byte 0_u8 } 91 | 92 | packet << @username 93 | packet.write_byte 0_u8 94 | 95 | if password = @password 96 | sizet_20 = LibC::SizeT.new(20) 97 | sha1 = OpenSSL::SHA1.hash(password) 98 | sha1sha1 = OpenSSL::SHA1.hash(sha1.to_unsafe, sizet_20) 99 | 100 | buffer = uninitialized UInt8[40] 101 | buffer.to_unsafe.copy_from(@auth_plugin_data.to_unsafe, 20) 102 | (buffer.to_unsafe + 20).copy_from(sha1sha1.to_unsafe, 20) 103 | 104 | sizet_40 = LibC::SizeT.new(40) 105 | buffer_sha1 = OpenSSL::SHA1.hash(buffer.to_unsafe, sizet_40) 106 | 107 | # reuse buffer 108 | 20.times { |i| 109 | buffer[i] = sha1[i] ^ buffer_sha1[i] 110 | } 111 | 112 | auth_response = Bytes.new(buffer.to_unsafe, 20) 113 | 114 | # packet.write_byte 0_u8 115 | packet.write_lenenc_int 20 116 | packet.write(auth_response) 117 | else 118 | packet.write_byte 0_u8 119 | end 120 | 121 | if initial_catalog = @initial_catalog 122 | packet << initial_catalog 123 | packet.write_byte 0_u8 124 | end 125 | 126 | if @password 127 | packet << "mysql_native_password" 128 | packet.write_byte 0_u8 129 | end 130 | end 131 | end 132 | 133 | struct Quit 134 | def write(packet : MySql::WritePacket) 135 | packet.write_byte 1_u8 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /src/mysql/read_packet.cr: -------------------------------------------------------------------------------- 1 | class MySql::ReadPacket < IO 2 | @length : Int32 = 0 3 | @remaining : Int32 = 0 4 | @seq : UInt8 = 0u8 5 | 6 | def initialize(@io : IO, @connection : Connection) 7 | @length = 0 8 | @remaining = 0 9 | @seq = 0u8 10 | begin 11 | header = uninitialized UInt8[4] 12 | io.read_fully(header.to_slice) 13 | @length = @remaining = header[0].to_i + (header[1].to_i << 8) + (header[2].to_i << 16) 14 | @seq = header[3] 15 | rescue e : IO::EOFError 16 | raise DB::ConnectionLost.new(@connection, cause: e) 17 | end 18 | end 19 | 20 | def to_s(io) 21 | io << "MySql::IncomingPacket[length: " << @length << ", seq: " << @seq << ", remaining: " << @remaining << "]" 22 | end 23 | 24 | def read(slice : Bytes) 25 | return 0 unless @remaining > 0 26 | read_bytes = @io.read_fully(slice) 27 | @remaining -= read_bytes 28 | read_bytes 29 | rescue e : IO::EOFError 30 | raise DB::ConnectionLost.new(@connection, cause: e) 31 | end 32 | 33 | {% if compare_versions(Crystal::VERSION, "0.35.0") == 0 %} 34 | def write(slice) : Int64 35 | raise "not implemented" 36 | end 37 | {% else %} 38 | def write(slice) : Nil 39 | raise "not implemented" 40 | end 41 | {% end %} 42 | 43 | def read_byte! 44 | read_byte || raise "Unexpected EOF" 45 | end 46 | 47 | def read_string 48 | String.build do |buffer| 49 | while (b = read_byte) != 0 && b 50 | buffer.write_byte b if b 51 | end 52 | end 53 | end 54 | 55 | def read_string(length) 56 | String.build do |buffer| 57 | length.to_i64.times do 58 | buffer.write_byte read_byte! 59 | end 60 | end 61 | end 62 | 63 | def read_lenenc_string 64 | length = read_lenenc_int 65 | read_string(length) 66 | end 67 | 68 | def read_int 69 | read_byte!.to_i + (read_byte!.to_i << 8) + (read_byte!.to_i << 16) + (read_byte!.to_i << 24) 70 | end 71 | 72 | # TODO: should return different types of int depending on n value (note: update Connection#read_column_definitions to remote to_i16/to_i8) 73 | def read_fixed_int(n) 74 | int = 0 75 | n.times do |i| 76 | int += (read_byte!.to_i << (i * 8)) 77 | end 78 | int 79 | end 80 | 81 | def read_lenenc_int(h = read_byte!) 82 | res = if h < 251 83 | h.to_i 84 | elsif h == 0xfc 85 | read_byte!.to_i + (read_byte!.to_i << 8) 86 | elsif h == 0xfd 87 | read_byte!.to_i + (read_byte!.to_i << 8) + (read_byte!.to_i << 16) 88 | elsif h == 0xfe 89 | read_bytes(UInt64, IO::ByteFormat::LittleEndian) 90 | else 91 | raise "Unexpected int length" 92 | end 93 | 94 | res.to_u64 95 | end 96 | 97 | def read_byte_array(length) 98 | Array(UInt8).new(length) { |i| read_byte! } 99 | end 100 | 101 | def read_blob 102 | ary = read_byte_array(read_lenenc_int.to_i32) 103 | Bytes.new(ary.to_unsafe, ary.size) 104 | end 105 | 106 | def read_int_string(length) 107 | value = 0 108 | length.times do 109 | value = value * 10 + read_byte!.chr.to_i 110 | end 111 | value 112 | end 113 | 114 | def read_int64_string(length) 115 | value = 0i64 116 | length.times do 117 | value = value * 10i64 + read_byte!.chr.to_i.to_i64 118 | end 119 | value 120 | end 121 | 122 | def discard 123 | skip(@remaining) if @remaining > 0 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /src/mysql/result_set.cr: -------------------------------------------------------------------------------- 1 | require "bit_array" 2 | 3 | class MySql::ResultSet < DB::ResultSet 4 | getter columns 5 | 6 | @conn : MySql::Connection 7 | @row_packet : MySql::ReadPacket? 8 | @header : UInt8 9 | @null_bitmap_slice : Bytes 10 | 11 | def initialize(statement, column_count) 12 | super(statement) 13 | @conn = statement.connection.as(MySql::Connection) 14 | 15 | columns = @columns = [] of ColumnSpec 16 | @conn.read_column_definitions(columns, column_count) 17 | 18 | @column_index = 0 # next column index to return 19 | @null_bitmap = BitArray.new(columns.size + 2) 20 | @null_bitmap_slice = @null_bitmap.to_slice 21 | 22 | @header = 0u8 23 | @eof_reached = false 24 | end 25 | 26 | def do_close 27 | while move_next 28 | end 29 | 30 | if row_packet = @row_packet 31 | row_packet.discard 32 | end 33 | ensure 34 | super 35 | end 36 | 37 | def move_next : Bool 38 | return false if @eof_reached 39 | 40 | # skip previous row_packet 41 | if row_packet = @row_packet 42 | row_packet.discard 43 | end 44 | 45 | @row_packet = row_packet = @conn.build_read_packet 46 | 47 | @header = row_packet.read_byte! 48 | if @header == 0xfe # EOF 49 | @eof_reached = true 50 | return false 51 | end 52 | 53 | @column_index = 0 54 | row_packet.read_fully(@null_bitmap_slice) 55 | return true 56 | end 57 | 58 | def column_count : Int32 59 | @columns.size 60 | end 61 | 62 | def column_name(index : Int32) : String 63 | @columns[index].name 64 | end 65 | 66 | protected def mysql_read(&) 67 | row_packet = @row_packet.not_nil! 68 | 69 | is_nil = @null_bitmap[@column_index + 2] 70 | col = @column_index 71 | @column_index += 1 72 | if is_nil 73 | nil 74 | else 75 | column = @columns[col] 76 | yield row_packet, column 77 | end 78 | end 79 | 80 | def next_column_index : Int32 81 | @column_index 82 | end 83 | 84 | def read 85 | mysql_read do |row_packet, column| 86 | val = column.column_type.read(row_packet) 87 | 88 | # http://dev.mysql.com/doc/internals/en/character-set.html 89 | if val.is_a?(Slice(UInt8)) && column.character_set != 63 90 | ::String.new(val) 91 | else 92 | val 93 | end 94 | end 95 | end 96 | 97 | def read(t : UUID.class) 98 | read(UUID?).as(UUID) 99 | end 100 | 101 | def read(t : (UUID | Nil).class) 102 | mysql_read do |row_packet, column| 103 | if column.flags.bits_set?(128) 104 | data = row_packet.read_blob 105 | # Check if binary flag is set 106 | # https://dev.mysql.com/doc/dev/mysql-server/latest/group__group__cs__column__definition__flags.html#gaf74577f0e38eed5616a090965aeac323 107 | UUID.new data 108 | else 109 | data = column.column_type.read(row_packet) 110 | raise ::DB::Error.new("The column #{column.name} of type #{column.column_type} returns a #{data.class} and can't be read as UUID") 111 | end 112 | end 113 | end 114 | 115 | def read(t : Bool.class) 116 | MySql::Type.from_mysql(read.as(Int::Signed)) 117 | end 118 | 119 | def read(t : (Bool | Nil).class) 120 | if v = read.as(Int::Signed?) 121 | MySql::Type.from_mysql(v) 122 | else 123 | nil 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /src/mysql/statement.cr: -------------------------------------------------------------------------------- 1 | class MySql::Statement < DB::Statement 2 | @statement_id : Int32 3 | 4 | def initialize(connection, command : String) 5 | super(connection, command) 6 | @statement_id = 0 7 | params = @params = [] of ColumnSpec 8 | columns = @columns = [] of ColumnSpec 9 | 10 | conn = self.conn 11 | 12 | # http://dev.mysql.com/doc/internals/en/com-stmt-prepare.html#packet-COM_STMT_PREPARE 13 | conn.write_packet do |packet| 14 | packet.write_byte 0x16u8 15 | packet << command 16 | end 17 | 18 | # http://dev.mysql.com/doc/internals/en/com-stmt-prepare-response.html 19 | conn.read_packet do |packet| 20 | conn.raise_if_err_packet packet 21 | 22 | @statement_id = packet.read_int 23 | num_columns = packet.read_fixed_int(2) 24 | num_params = packet.read_fixed_int(2) 25 | packet.read_byte! # reserved_1 26 | warning_count = packet.read_fixed_int(2) 27 | 28 | conn.read_column_definitions(params, num_params) 29 | conn.read_column_definitions(columns, num_columns) 30 | end 31 | end 32 | 33 | protected def perform_query(args : Enumerable) : MySql::ResultSet 34 | perform_exec_or_query(args).as(DB::ResultSet) 35 | end 36 | 37 | protected def perform_exec(args : Enumerable) : DB::ExecResult 38 | perform_exec_or_query(args).as(DB::ExecResult) 39 | end 40 | 41 | private def perform_exec_or_query(args : Enumerable) 42 | conn = self.conn 43 | conn.write_packet do |packet| 44 | packet.write_byte 0x17u8 45 | packet.write_bytes @statement_id.not_nil!, IO::ByteFormat::LittleEndian 46 | packet.write_byte 0x00u8 # flags: CURSOR_TYPE_NO_CURSOR 47 | packet.write_bytes 1i32, IO::ByteFormat::LittleEndian 48 | 49 | params = @params.not_nil! 50 | if params.size > 0 51 | null_bitmap = BitArray.new(params.size) 52 | args.each_with_index do |arg, index| 53 | next unless arg.nil? 54 | null_bitmap[index] = true 55 | end 56 | null_bitmap_slice = null_bitmap.to_slice 57 | packet.write null_bitmap_slice 58 | 59 | packet.write_byte 0x01u8 60 | 61 | # TODO raise if args.size and params.size does not match 62 | # params types 63 | args.each do |arg| 64 | arg = MySql::Type.to_mysql(arg) 65 | t = MySql::Type.type_for(arg.class) 66 | packet.write_byte t.hex_value 67 | packet.write_byte 0x00u8 68 | end 69 | 70 | # params values 71 | args.each do |arg| 72 | next if arg.nil? 73 | arg = MySql::Type.to_mysql(arg) 74 | t = MySql::Type.type_for(arg.class) 75 | t.write(packet, arg) 76 | end 77 | end 78 | end 79 | 80 | conn.read_packet do |packet| 81 | case header = packet.read_byte.not_nil! 82 | when 255 # err packet 83 | conn.handle_err_packet(packet) 84 | when 0 # ok packet 85 | affected_rows = packet.read_lenenc_int.to_i64 86 | last_insert_id = packet.read_lenenc_int.to_i64 87 | DB::ExecResult.new affected_rows, last_insert_id 88 | else 89 | MySql::ResultSet.new(self, packet.read_lenenc_int(header)) 90 | end 91 | end 92 | end 93 | 94 | protected def conn 95 | @connection.as(Connection) 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /src/mysql/text_result_set.cr: -------------------------------------------------------------------------------- 1 | # Implementation of ProtocolText::Resultset. 2 | # Used for unprepared statements. 3 | class MySql::TextResultSet < DB::ResultSet 4 | getter columns 5 | 6 | @conn : MySql::Connection 7 | @row_packet : MySql::ReadPacket? 8 | @first_byte : UInt8 9 | 10 | def initialize(statement, column_count) 11 | super(statement) 12 | @conn = statement.connection.as(MySql::Connection) 13 | 14 | columns = @columns = [] of ColumnSpec 15 | @conn.read_column_definitions(columns, column_count) 16 | 17 | @column_index = 0 # next column index to return 18 | 19 | @first_byte = 0u8 20 | @eof_reached = false 21 | @first_row_packet = false 22 | end 23 | 24 | def do_close 25 | while move_next 26 | end 27 | 28 | if row_packet = @row_packet 29 | row_packet.discard 30 | end 31 | ensure 32 | super 33 | end 34 | 35 | def move_next : Bool 36 | return false if @eof_reached 37 | 38 | # skip previous row_packet 39 | if row_packet = @row_packet 40 | row_packet.discard 41 | end 42 | 43 | @row_packet = row_packet = @conn.build_read_packet 44 | 45 | @first_byte = row_packet.read_byte! 46 | if @first_byte == 0xfe # EOF 47 | @eof_reached = true 48 | return false 49 | end 50 | 51 | @column_index = 0 52 | @first_row_packet = true 53 | # TODO remove row_packet.read(@null_bitmap_slice) 54 | return true 55 | end 56 | 57 | def column_count : Int32 58 | @columns.size 59 | end 60 | 61 | def column_name(index : Int32) : String 62 | @columns[index].name 63 | end 64 | 65 | protected def mysql_read(&) 66 | row_packet = @row_packet.not_nil! 67 | 68 | if @first_row_packet 69 | current_byte = @first_byte 70 | @first_row_packet = false 71 | else 72 | current_byte = row_packet.read_byte! 73 | end 74 | 75 | is_nil = current_byte == 0xfb 76 | col = @column_index 77 | @column_index += 1 78 | if is_nil 79 | nil 80 | else 81 | column = @columns[col] 82 | length = row_packet.read_lenenc_int(current_byte) 83 | yield row_packet, column, length 84 | end 85 | end 86 | 87 | def next_column_index : Int32 88 | @column_index 89 | end 90 | 91 | def read 92 | mysql_read do |row_packet, column, length| 93 | val = row_packet.read_string(length) 94 | val = column.column_type.parse(val) 95 | 96 | # http://dev.mysql.com/doc/internals/en/character-set.html 97 | if val.is_a?(Slice(UInt8)) && column.character_set != 63 98 | ::String.new(val) 99 | else 100 | val 101 | end 102 | end 103 | end 104 | 105 | def read(t : UUID.class) 106 | read(UUID?).as(UUID) 107 | end 108 | 109 | def read(t : (UUID | Nil).class) 110 | mysql_read do |row_packet, column, length| 111 | if column.flags.bits_set?(128) 112 | # Check if binary flag is set 113 | # https://dev.mysql.com/doc/dev/mysql-server/latest/group__group__cs__column__definition__flags.html#gaf74577f0e38eed5616a090965aeac323 114 | ary = row_packet.read_byte_array(length) 115 | val = Bytes.new(ary.to_unsafe, ary.size) 116 | 117 | UUID.new val 118 | else 119 | val = row_packet.read_string(length) 120 | raise ::DB::Error.new("The column #{column.name} of type #{column.column_type} returns a #{val.class} and can't be read as UUID") 121 | end 122 | end 123 | end 124 | 125 | def read(t : Bool.class) 126 | MySql::Type.from_mysql(read.as(Int::Signed)) 127 | end 128 | 129 | def read(t : (Bool | Nil).class) 130 | if v = read.as(Int::Signed?) 131 | MySql::Type.from_mysql(v) 132 | else 133 | nil 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /src/mysql/types.cr: -------------------------------------------------------------------------------- 1 | require "uuid" 2 | 3 | # :nodoc: 4 | abstract struct MySql::Type 5 | # Column types 6 | # http://dev.mysql.com/doc/internals/en/com-query-response.html#column-type 7 | 8 | @@types_by_code = Hash(UInt8, MySql::Type.class).new 9 | @@hex_value : UInt8 = 0x00u8 10 | 11 | def self.hex_value 12 | @@hex_value 13 | end 14 | 15 | def self.types_by_code 16 | @@types_by_code 17 | end 18 | 19 | # Returns which MySql::Type should be used to encode values of type *t*. 20 | # Used when sending query params. 21 | def self.type_for(t : ::Int8.class) 22 | MySql::Type::Tiny 23 | end 24 | 25 | def self.type_for(t : ::Int16.class) 26 | MySql::Type::Short 27 | end 28 | 29 | def self.type_for(t : ::Int32.class) 30 | MySql::Type::Long 31 | end 32 | 33 | def self.type_for(t : ::Int64.class) 34 | MySql::Type::LongLong 35 | end 36 | 37 | def self.type_for(t : ::Float32.class) 38 | MySql::Type::Float 39 | end 40 | 41 | def self.type_for(t : ::Float64.class) 42 | MySql::Type::Double 43 | end 44 | 45 | def self.type_for(t : ::String.class) 46 | MySql::Type::String 47 | end 48 | 49 | def self.type_for(t : ::Bytes.class) 50 | MySql::Type::Blob 51 | end 52 | 53 | def self.type_for(t : ::StaticArray(T, N).class) forall T, N 54 | MySql::Type::Blob 55 | end 56 | 57 | def self.type_for(t : ::Time.class) 58 | MySql::Type::DateTime 59 | end 60 | 61 | def self.type_for(t : ::Time::Span.class) 62 | MySql::Type::Time 63 | end 64 | 65 | def self.type_for(t : ::Nil.class) 66 | MySql::Type::Null 67 | end 68 | 69 | def self.type_for(t) 70 | raise "MySql::Type does not support #{t} values" 71 | end 72 | 73 | def self.db_any_type 74 | raise "not implemented" 75 | end 76 | 77 | # Writes in packet the value in ProtocolBinary format. 78 | # Used when sending query params. 79 | def self.write(packet, v) 80 | raise "not supported write" 81 | end 82 | 83 | # Reads from packet a value in ProtocolBinary format of the type 84 | # specified by self. 85 | def self.read(packet) 86 | raise "not supported read" 87 | end 88 | 89 | # Parse from str a value in TextProtocol format of the type 90 | # specified by self. 91 | def self.parse(str : ::String) 92 | raise "not supported" 93 | end 94 | 95 | # :nodoc: 96 | def self.to_mysql(v) 97 | v 98 | end 99 | 100 | # :nodoc: 101 | def self.to_mysql(v : Bool) 102 | v ? 1i8 : 0i8 103 | end 104 | 105 | # :nodoc: 106 | def self.to_mysql(v : ::UUID) 107 | v.bytes 108 | end 109 | 110 | # :nodoc: 111 | def self.from_mysql(v : Int::Signed) : Bool 112 | v != 0 113 | end 114 | 115 | macro decl_type(name, value, db_any_type = nil) 116 | struct {{name}} < Type 117 | @@hex_value = {{value}} 118 | 119 | {% if db_any_type %} 120 | def self.db_any_type 121 | {{db_any_type}} 122 | end 123 | 124 | def self.write(packet, v : {{db_any_type}}) 125 | packet.write_bytes v, IO::ByteFormat::LittleEndian 126 | end 127 | 128 | def self.read(packet) 129 | packet.read_bytes {{db_any_type}}, IO::ByteFormat::LittleEndian 130 | end 131 | 132 | def self.parse(str : ::String) 133 | {{db_any_type}}.new(str) 134 | end 135 | {% end %} 136 | 137 | {{yield}} 138 | end 139 | 140 | Type.types_by_code[{{value}}] = {{name}} 141 | end 142 | 143 | decl_type Decimal, 0x00u8 144 | decl_type Tiny, 0x01u8, ::Int8 145 | decl_type Short, 0x02u8, ::Int16 146 | decl_type Long, 0x03u8, ::Int32 147 | decl_type Float, 0x04u8, ::Float32 148 | decl_type Double, 0x05u8, ::Float64 149 | decl_type Null, 0x06u8, ::Nil do 150 | def self.read(packet) 151 | nil 152 | end 153 | 154 | def self.parse(str : ::String) 155 | nil 156 | end 157 | end 158 | decl_type Timestamp, 0x07u8, ::Time do 159 | def self.write(packet, v : ::Time) 160 | MySql::Type::DateTime.write(packet, v) 161 | end 162 | 163 | def self.read(packet) 164 | MySql::Type::DateTime.read(packet) 165 | end 166 | 167 | def self.parse(str : ::String) 168 | MySql::Type::DateTime.parse(str) 169 | end 170 | end 171 | decl_type LongLong, 0x08u8, ::Int64 172 | decl_type Int24, 0x09u8, ::Int32 173 | 174 | def self.datetime_read(packet) 175 | MySql::Type::DateTime.read(packet) 176 | end 177 | 178 | def self.datetime_write(packet, v : ::Time) 179 | MySql::Type::DateTime.write(packet, v) 180 | end 181 | 182 | decl_type Date, 0x0au8, ::Time do 183 | def self.write(packet, v : ::Time) 184 | self.datetime_write(packet, v) 185 | end 186 | 187 | def self.read(packet) 188 | self.datetime_read(packet) 189 | end 190 | 191 | def self.parse(str : ::String) 192 | MySql::Type::DateTime.parse(str) 193 | end 194 | end 195 | decl_type Time, 0x0bu8, ::Time::Span do 196 | def self.write(packet, v : ::Time::Span) 197 | negative = v.to_i < 0 ? 1 : 0 198 | d = v.days 199 | raise ArgumentError.new("MYSQL TIME over 34 days cannot be saved - https://dev.mysql.com/doc/refman/5.7/en/time.html") if d > 34 200 | microsecond : Int32 201 | microsecond = (v.nanoseconds // 1000).to_i32 202 | packet.write_blob UInt8.slice( 203 | negative, d.to_i8, (d >> 8).to_i8, (d >> 16).to_i8, (d >> 24).to_i8, 204 | v.hours.to_i8, v.minutes.to_i8, v.seconds.to_i8, 205 | (microsecond & 0x000000FF).to_u8, 206 | ((microsecond & 0x0000FF00) >> 8).to_u8, 207 | ((microsecond & 0x00FF0000) >> 16).to_u8, 208 | ((microsecond & 0xFF000000) >> 24).to_u8 209 | ) 210 | end 211 | 212 | def self.read(packet) 213 | pkt = packet.read_byte! 214 | return ::Time::Span.new(nanoseconds: 0) if pkt < 1 215 | negative = packet.read_byte!.to_i32 216 | days = packet.read_fixed_int(4) 217 | hour = packet.read_byte!.to_i32 218 | minute = packet.read_byte!.to_i32 219 | second = packet.read_byte!.to_i32 220 | ns = pkt > 8 ? (packet.read_int.to_i32 * 1000) : nil 221 | time = ns ? ::Time::Span.new(days: days, hours: hour, minutes: minute, seconds: second, nanoseconds: ns) : ::Time::Span.new(days: days, hours: hour, minutes: minute, seconds: second) 222 | negative > 0 ? (::Time::Span.new(nanoseconds: 0) - time) : time 223 | end 224 | 225 | def self.parse(str : ::String) 226 | # TODO replace parsing without using Time parser 227 | begin 228 | time = ::Time.parse(str, "%H:%M:%S.%N", location: MySql::TIME_ZONE) 229 | rescue 230 | time = ::Time.parse(str, "%H:%M:%S", location: MySql::TIME_ZONE) 231 | end 232 | ::Time::Span.new(days: 0, hours: time.hour, minutes: time.minute, seconds: time.second, nanoseconds: time.nanosecond) 233 | end 234 | end 235 | decl_type DateTime, 0x0cu8, ::Time do 236 | def self.write(packet, v : ::Time) 237 | v = v.in(location: MySql::TIME_ZONE) 238 | microsecond : Int32 239 | microsecond = (v.nanosecond // 1000).to_i32 240 | packet.write_blob UInt8.slice( 241 | v.year.to_i16, 242 | v.year.to_i16 // 256, 243 | v.month.to_i8, v.day.to_i8, 244 | v.hour.to_i8, v.minute.to_i8, v.second.to_i8, 245 | (microsecond & 0x000000FF).to_u8, 246 | ((microsecond & 0x0000FF00) >> 8).to_u8, 247 | ((microsecond & 0x00FF0000) >> 16).to_u8, 248 | ((microsecond & 0xFF000000) >> 24).to_u8 249 | ) 250 | end 251 | 252 | def self.read(packet) 253 | pkt = packet.read_byte! 254 | return ::Time.local(0, 0, 0, location: MySql::TIME_ZONE) if pkt < 1 255 | year = packet.read_fixed_int(2).to_i32 256 | month = packet.read_byte!.to_i32 257 | day = packet.read_byte!.to_i32 258 | return ::Time.local(year, month, day, location: MySql::TIME_ZONE) if pkt < 6 259 | hour = packet.read_byte!.to_i32 260 | minute = packet.read_byte!.to_i32 261 | second = packet.read_byte!.to_i32 262 | return ::Time.local(year, month, day, hour, minute, second, location: MySql::TIME_ZONE) if pkt < 8 263 | ns = packet.read_int.to_i32 * 1000 264 | return ::Time.local(year, month, day, hour, minute, second, nanosecond: ns, location: MySql::TIME_ZONE) 265 | end 266 | 267 | def self.parse(str : ::String) 268 | return ::Time.local(0, 0, 0, location: MySql::TIME_ZONE) if str.starts_with?("0000-00-00") 269 | begin 270 | begin 271 | ::Time.parse(str, "%F %H:%M:%S.%N", location: MySql::TIME_ZONE) 272 | rescue 273 | ::Time.parse(str, "%F %H:%M:%S", location: MySql::TIME_ZONE) 274 | end 275 | rescue 276 | ::Time.parse(str, "%F", location: MySql::TIME_ZONE) 277 | end 278 | end 279 | end 280 | decl_type Year, 0x0du8 281 | decl_type VarChar, 0x0fu8 282 | decl_type Bit, 0x10u8 283 | decl_type NewDecimal, 0xf6u8, ::Float64 do 284 | def self.read(packet) 285 | packet.read_lenenc_string.to_f64 286 | end 287 | end 288 | decl_type Enum, 0xf7u8 289 | decl_type Set, 0xf8u8 290 | decl_type TinyBlob, 0xf9u8 291 | decl_type MediumBlob, 0xfau8 292 | decl_type LongBlob, 0xfbu8 293 | decl_type Blob, 0xfcu8, ::Bytes do 294 | def self.write(packet, v : ::Bytes) 295 | packet.write_blob v 296 | end 297 | 298 | def self.write(packet, v : ::StaticArray(T, N)) forall T, N 299 | packet.write_blob v.to_slice 300 | end 301 | 302 | def self.read(packet) 303 | packet.read_blob 304 | end 305 | 306 | def self.parse(str : ::String) 307 | str.to_slice 308 | end 309 | end 310 | decl_type VarString, 0xfdu8, ::String do 311 | def self.write(packet, v : ::String) 312 | packet.write_lenenc_string v 313 | end 314 | 315 | def self.read(packet) 316 | packet.read_lenenc_string 317 | end 318 | 319 | def self.parse(str : ::String) 320 | str 321 | end 322 | end 323 | decl_type String, 0xfeu8, ::String do 324 | def self.write(packet, v : ::String) 325 | packet.write_lenenc_string v 326 | end 327 | 328 | def self.read(packet) 329 | packet.read_lenenc_string 330 | end 331 | 332 | def self.parse(str : ::String) 333 | str 334 | end 335 | end 336 | decl_type Geometry, 0xffu8 337 | end 338 | -------------------------------------------------------------------------------- /src/mysql/unprepared_statement.cr: -------------------------------------------------------------------------------- 1 | class MySql::UnpreparedStatement < DB::Statement 2 | def initialize(connection, command : String) 3 | super(connection, command) 4 | end 5 | 6 | protected def conn 7 | @connection.as(Connection) 8 | end 9 | 10 | protected def perform_query(args : Enumerable) : DB::ResultSet 11 | perform_exec_or_query(args).as(DB::ResultSet) 12 | end 13 | 14 | protected def perform_exec(args : Enumerable) : DB::ExecResult 15 | perform_exec_or_query(args).as(DB::ExecResult) 16 | end 17 | 18 | private def perform_exec_or_query(args : Enumerable) 19 | raise "exec/query with args is not supported" if args.size > 0 20 | 21 | conn = self.conn 22 | conn.write_packet do |packet| 23 | packet.write_byte 0x03u8 24 | packet << command 25 | # TODO to support args an interpolation needs to be done 26 | end 27 | 28 | conn.read_packet do |packet| 29 | case header = packet.read_byte.not_nil! 30 | when 255 # err packet 31 | conn.handle_err_packet(packet) 32 | when 0 # ok packet 33 | affected_rows = packet.read_lenenc_int.to_i64 34 | last_insert_id = packet.read_lenenc_int.to_i64 35 | DB::ExecResult.new affected_rows, last_insert_id 36 | else 37 | MySql::TextResultSet.new(self, packet.read_lenenc_int(header)) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/mysql/version.cr: -------------------------------------------------------------------------------- 1 | module MySql 2 | VERSION = "0.16.0" 3 | end 4 | -------------------------------------------------------------------------------- /src/mysql/write_packet.cr: -------------------------------------------------------------------------------- 1 | class MySql::WritePacket < IO 2 | def initialize(@io : IO, @connection : Connection) 3 | end 4 | 5 | def read(slice) 6 | raise "not implemented" 7 | end 8 | 9 | def write(slice) : Nil 10 | @io.write(slice) 11 | rescue e : IO::EOFError 12 | raise DB::ConnectionLost.new(@connection, cause: e) 13 | end 14 | 15 | def write_lenenc_string(s : String) 16 | write_lenenc_int(s.bytesize) 17 | write_string(s) 18 | end 19 | 20 | def write_lenenc_int(v) 21 | if v < 251 22 | write_bytes(v.to_u8, IO::ByteFormat::LittleEndian) 23 | elsif v < 65_536 24 | write_bytes(0xfc_u8, IO::ByteFormat::LittleEndian) 25 | write_bytes(v.to_u16, IO::ByteFormat::LittleEndian) 26 | elsif v < 16_777_216 27 | write_bytes(0xfd_u8, IO::ByteFormat::LittleEndian) 28 | write_bytes((v & 0x000000FF).to_u8) 29 | write_bytes(((v & 0x0000FF00) >> 8).to_u8) 30 | write_bytes(((v & 0x00FF0000) >> 16).to_u8) 31 | else 32 | write_bytes(0xfe_u8, IO::ByteFormat::LittleEndian) 33 | write_bytes(v.to_u64, IO::ByteFormat::LittleEndian) 34 | end 35 | end 36 | 37 | def write_string(s : String) 38 | @io << s 39 | rescue e : IO::EOFError 40 | raise DB::ConnectionLost.new(@connection, cause: e) 41 | end 42 | 43 | def write_blob(v : Bytes) 44 | write_lenenc_int(v.size) 45 | write(v) 46 | end 47 | end 48 | --------------------------------------------------------------------------------