├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ ├── test-external.yaml │ └── test.yaml ├── .gitignore ├── .mailmap ├── .rubocop.yml ├── config └── sus.rb ├── db-mariadb.gemspec ├── gems.rb ├── guides ├── getting-started │ └── readme.md └── links.yaml ├── lib └── db │ ├── mariadb.rb │ └── mariadb │ ├── adapter.rb │ ├── connection.rb │ ├── error.rb │ ├── native.rb │ ├── native │ ├── connection.rb │ ├── field.rb │ ├── result.rb │ └── types.rb │ └── version.rb ├── license.md ├── readme.md ├── release.cert └── test └── db └── mariadb └── connection.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.3" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.3" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | 22 | ruby: 23 | - "3.3" 24 | 25 | services: 26 | mariadb: 27 | image: mariadb 28 | ports: 29 | - 3306:3306 30 | env: 31 | MARIADB_USER: test 32 | MARIADB_PASSWORD: test 33 | MARIADB_DATABASE: test 34 | MARIADB_ROOT_PASSWORD: test 35 | options: >- 36 | --health-cmd "healthcheck.sh --connect --innodb_initialized" 37 | --health-interval 10s 38 | --health-timeout 5s 39 | --health-retries 20 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{matrix.ruby}} 46 | bundler-cache: true 47 | 48 | - name: Installing dependencies (ubuntu) 49 | if: matrix.os == 'ubuntu' 50 | run: | 51 | sudo systemctl stop mysql 52 | sudo apt-get install libmariadb-dev 53 | 54 | - name: Run tests 55 | timeout-minutes: 5 56 | run: bundle exec bake test 57 | 58 | - uses: actions/upload-artifact@v3 59 | with: 60 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 61 | path: .covered.db 62 | 63 | validate: 64 | needs: test 65 | runs-on: ubuntu-latest 66 | 67 | steps: 68 | - uses: actions/checkout@v4 69 | - uses: ruby/setup-ruby@v1 70 | with: 71 | ruby-version: "3.3" 72 | bundler-cache: true 73 | 74 | - uses: actions/download-artifact@v3 75 | 76 | - name: Validate coverage 77 | timeout-minutes: 5 78 | run: bundle exec bake covered:validate --paths */.covered.db \; 79 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | services: 17 | mariadb: 18 | image: mariadb 19 | ports: 20 | - 3306:3306 21 | env: 22 | MARIADB_USER: test 23 | MARIADB_PASSWORD: test 24 | MARIADB_DATABASE: test 25 | MARIADB_ROOT_PASSWORD: test 26 | options: >- 27 | --health-cmd "healthcheck.sh --connect --innodb_initialized" 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 20 31 | 32 | strategy: 33 | matrix: 34 | os: 35 | - ubuntu 36 | 37 | ruby: 38 | - "3.1" 39 | - "3.2" 40 | - "3.3" 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{matrix.ruby}} 47 | bundler-cache: true 48 | 49 | - name: Installing dependencies (ubuntu) 50 | if: matrix.os == 'ubuntu' 51 | run: | 52 | sudo systemctl stop mysql 53 | sudo apt-get install libmariadb-dev 54 | 55 | - name: Run tests 56 | timeout-minutes: 10 57 | run: bundle exec bake test:external 58 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | services: 18 | mariadb: 19 | image: mariadb 20 | ports: 21 | - 3306:3306 22 | env: 23 | MARIADB_USER: test 24 | MARIADB_PASSWORD: test 25 | MARIADB_DATABASE: test 26 | MARIADB_ROOT_PASSWORD: test 27 | options: >- 28 | --health-cmd "healthcheck.sh --connect --innodb_initialized" 29 | --health-interval 10s 30 | --health-timeout 5s 31 | --health-retries 20 32 | 33 | strategy: 34 | matrix: 35 | os: 36 | - ubuntu 37 | 38 | ruby: 39 | - "3.1" 40 | - "3.2" 41 | - "3.3" 42 | 43 | experimental: [false] 44 | 45 | include: 46 | - os: ubuntu 47 | ruby: truffleruby 48 | experimental: true 49 | - os: ubuntu 50 | ruby: jruby 51 | experimental: true 52 | - os: ubuntu 53 | ruby: head 54 | experimental: true 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | - uses: ruby/setup-ruby@v1 59 | with: 60 | ruby-version: ${{matrix.ruby}} 61 | bundler-cache: true 62 | 63 | - name: Installing dependencies (ubuntu) 64 | if: matrix.os == 'ubuntu' 65 | run: | 66 | sudo systemctl stop mysql 67 | sudo apt-get install libmariadb-dev 68 | 69 | - name: Run tests 70 | timeout-minutes: 10 71 | run: bundle exec bake test 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Hal Brodigan 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/EndAlignment: 20 | Enabled: true 21 | EnforcedStyleAlignWith: start_of_line 22 | 23 | Layout/BeginEndAlignment: 24 | Enabled: true 25 | EnforcedStyleAlignWith: start_of_line 26 | 27 | Layout/ElseAlignment: 28 | Enabled: true 29 | 30 | Layout/DefEndAlignment: 31 | Enabled: true 32 | 33 | Layout/CaseIndentation: 34 | Enabled: true 35 | 36 | Layout/CommentIndentation: 37 | Enabled: true 38 | 39 | Layout/EmptyLinesAroundClassBody: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundModuleBody: 43 | Enabled: true 44 | 45 | Style/FrozenStringLiteralComment: 46 | Enabled: true 47 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require 'covered/sus' 7 | include Covered::Sus 8 | 9 | ::CREDENTIALS = { 10 | username: 'test', 11 | password: 'test', 12 | database: 'test', 13 | host: '127.0.0.1' 14 | } 15 | -------------------------------------------------------------------------------- /db-mariadb.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/db/mariadb/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "db-mariadb" 7 | spec.version = DB::MariaDB::VERSION 8 | 9 | spec.summary = "An event-driven interface for MariaDB and MySQL servers." 10 | spec.authors = ["Samuel Williams", "Hal Brodigan"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ['release.cert'] 14 | spec.signing_key = File.expand_path('~/.gem/release.pem') 15 | 16 | spec.homepage = "https://github.com/socketry/db-mariadb" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/db-mariadb/", 20 | "funding_uri" => "https://github.com/sponsors/ioquatix", 21 | "source_code_uri" => "https://github.com/socketry/db-mariadb.git", 22 | } 23 | 24 | spec.files = Dir.glob(['{lib}/**/*', '*.md'], File::FNM_DOTMATCH, base: __dir__) 25 | 26 | spec.required_ruby_version = ">= 3.1" 27 | 28 | spec.add_dependency "async-pool" 29 | spec.add_dependency "bigdecimal" 30 | spec.add_dependency "ffi-module", "~> 0.3.0" 31 | end 32 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | source 'https://rubygems.org' 7 | 8 | gemspec 9 | 10 | group :maintenance, optional: true do 11 | gem "bake-gem" 12 | gem "bake-modernize" 13 | 14 | gem "utopia-project" 15 | end 16 | 17 | group :test do 18 | gem "sus" 19 | gem "covered" 20 | gem "decode" 21 | gem "rubocop" 22 | 23 | gem "sus-fixtures-async" 24 | 25 | gem "bake-test" 26 | gem "bake-test-external" 27 | 28 | gem "db" 29 | end 30 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains how to get started with the `db-mariadb` gem. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add db-mariadb 11 | ~~~ 12 | 13 | ## Usage 14 | 15 | Here is an example of the basic usage of the adapter: 16 | 17 | ~~~ ruby 18 | require 'async' 19 | require 'db/mariadb' 20 | 21 | # Create an event loop: 22 | Sync do 23 | # Create the adapter and connect to the database: 24 | adapter = DB::MariaDB::Adapter.new(database: 'test') 25 | connection = adapter.call 26 | 27 | # Execute the query: 28 | result = connection.send_query("SELECT VERSION()") 29 | 30 | # Get the results: 31 | pp connection.next_result.to_a 32 | # => [["10.4.13-MariaDB"]] 33 | ensure 34 | # Return the connection to the client connection pool: 35 | connection.close 36 | end 37 | ~~~ 38 | -------------------------------------------------------------------------------- /guides/links.yaml: -------------------------------------------------------------------------------- 1 | getting-started: 2 | order: 0 3 | -------------------------------------------------------------------------------- /lib/db/mariadb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative 'mariadb/native' 7 | require_relative 'mariadb/connection' 8 | 9 | require_relative 'mariadb/adapter' 10 | 11 | require 'db/adapters' 12 | DB::Adapters.register(:mariadb, DB::MariaDB::Adapter) 13 | -------------------------------------------------------------------------------- /lib/db/mariadb/adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative 'connection' 7 | 8 | module DB 9 | module MariaDB 10 | class Adapter 11 | def initialize(**options) 12 | @options = options 13 | end 14 | 15 | attr :options 16 | 17 | def call 18 | Connection.new(**@options) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/db/mariadb/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require 'async/pool/resource' 7 | 8 | require_relative 'native/connection' 9 | 10 | module DB 11 | module MariaDB 12 | # This implements the interface between the underyling native interface interface and "standardised" connection interface. 13 | class Connection < Async::Pool::Resource 14 | def initialize(**options) 15 | @native = Native::Connection.connect(**options) 16 | 17 | super() 18 | end 19 | 20 | def close 21 | @native.close 22 | 23 | super 24 | end 25 | 26 | def types 27 | @native.types 28 | end 29 | 30 | def append_string(value, buffer = String.new) 31 | buffer << "'" << @native.escape(value) << "'" 32 | 33 | return buffer 34 | end 35 | 36 | def append_literal(value, buffer = String.new) 37 | case value 38 | when Time, DateTime 39 | append_string(value.utc.strftime('%Y-%m-%d %H:%M:%S'), buffer) 40 | when Date 41 | append_string(value.strftime('%Y-%m-%d'), buffer) 42 | when Numeric 43 | buffer << value.to_s 44 | when TrueClass 45 | buffer << 'TRUE' 46 | when FalseClass 47 | buffer << 'FALSE' 48 | when nil 49 | buffer << 'NULL' 50 | else 51 | append_string(value, buffer) 52 | end 53 | 54 | return buffer 55 | end 56 | 57 | def append_identifier(value, buffer = String.new) 58 | case value 59 | when Array 60 | first = true 61 | value.each do |part| 62 | buffer << '.' unless first 63 | first = false 64 | 65 | buffer << escape_identifier(part) 66 | end 67 | else 68 | buffer << escape_identifier(value) 69 | end 70 | 71 | return buffer 72 | end 73 | 74 | def key_column(name = 'id', primary: true, null: false) 75 | buffer = String.new 76 | 77 | append_identifier(name, buffer) 78 | 79 | buffer << " BIGINT" 80 | 81 | if primary 82 | buffer << " AUTO_INCREMENT PRIMARY KEY" 83 | elsif !null 84 | buffer << " NOT NULL" 85 | end 86 | 87 | return buffer 88 | end 89 | 90 | def status 91 | @native.status 92 | end 93 | 94 | def send_query(statement) 95 | @native.discard_results 96 | 97 | @native.send_query(statement) 98 | end 99 | 100 | def next_result 101 | @native.next_result 102 | end 103 | 104 | protected 105 | 106 | def escape_identifier(value) 107 | "`#{@native.escape(value)}`" 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/db/mariadb/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | module DB 7 | module MariaDB 8 | class Error < StandardError 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/db/mariadb/native.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require 'ffi/module' 7 | require 'ffi/module/config_tool' 8 | 9 | module DB 10 | module MariaDB 11 | module Native 12 | extend FFI::Module::Library 13 | extend FFI::Module::Loader 14 | extend FFI::Module::ConfigTool 15 | 16 | ffi_load('mariadb') || 17 | ffi_load_using_config_tool(%w{mariadb_config --libs}) || 18 | ffi_load_using_config_tool(%w{mysql_config --libs}) || 19 | ffi_load_failure(<<~EOF) 20 | Unable to load libmariadb! 21 | 22 | ## Ubuntu 23 | 24 | sudo apt-get install libmariadb-dev 25 | 26 | ## Arch Linux 27 | 28 | sudo pacman -S mariadb 29 | 30 | ## MacPorts 31 | 32 | sudo port install mariadb-10.5 33 | sudo port select --set mysql mariadb-10.5 34 | 35 | ## Homebrew 36 | 37 | brew install mariadb 38 | 39 | EOF 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/db/mariadb/native/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative 'result' 7 | require_relative '../error' 8 | 9 | module DB 10 | module MariaDB 11 | module Native 12 | MYSQL_PROTOCOL_TCP = 1 13 | 14 | MYSQL_OPT_PROTOCOL = 9 15 | MYSQL_OPT_NONBLOCK = 6000 16 | 17 | MYSQL_WAIT_READ = 1 18 | MYSQL_WAIT_WRITE = 2 19 | MYSQL_WAIT_EXCEPT = 4 20 | MYSQL_WAIT_TIMEOUT = 8 21 | 22 | CLIENT_COMPRESS = 0x00000020 23 | CLIENT_LOCAL_FILES = 0x00000080 24 | CLIENT_MULTI_STATEMENT = 0x00010000 25 | CLIENT_MULTI_RESULTS = 0x00020000 26 | 27 | ffi_attach_function :mysql_init, [:pointer], :pointer 28 | ffi_attach_function :mysql_options, [:pointer, :int, :pointer], :int 29 | ffi_attach_function :mysql_get_socket, [:pointer], :int 30 | 31 | ffi_attach_function :mysql_real_connect_start, [:pointer, :pointer, :string, :string, :string, :string, :int, :string, :long], :int 32 | ffi_attach_function :mysql_real_connect_cont, [:pointer, :pointer, :int], :int 33 | 34 | ffi_attach_function :mysql_real_query_start, [:pointer, :pointer, :string, :ulong], :int 35 | ffi_attach_function :mysql_real_query_cont, [:pointer, :pointer, :int], :int 36 | 37 | ffi_attach_function :mysql_use_result, [:pointer], :pointer 38 | ffi_attach_function :mysql_next_result, [:pointer], :int 39 | ffi_attach_function :mysql_more_results, [:pointer], :int 40 | ffi_attach_function :mysql_free_result, [:pointer], :void 41 | 42 | ffi_attach_function :mysql_affected_rows, [:pointer], :uint64 43 | ffi_attach_function :mysql_insert_id, [:pointer], :uint64 44 | ffi_attach_function :mysql_info, [:pointer], :string 45 | 46 | ffi_attach_function :mysql_close, [:pointer], :void 47 | ffi_attach_function :mysql_errno, [:pointer], :uint 48 | ffi_attach_function :mysql_error, [:pointer], :string 49 | 50 | ffi_attach_function :mysql_stat, [:pointer], :string 51 | 52 | ffi_attach_function :mysql_real_escape_string, [:pointer, :pointer, :string, :size_t], :size_t 53 | 54 | class Connection < FFI::Pointer 55 | def self.connect(host: 'localhost', username: nil, password: nil, database: nil, port: 0, unix_socket: nil, client_flags: 0, compression: false, types: DEFAULT_TYPES, **options) 56 | pointer = Native.mysql_init(nil) 57 | Native.mysql_options(pointer, MYSQL_OPT_NONBLOCK, nil) 58 | 59 | # if protocol 60 | # Native.mysql_options(pointer, MYSQL_OPT_PROTOCOL, FFI::MemoryPointer.new(:uint, protocol)) 61 | # end 62 | 63 | client_flags |= CLIENT_MULTI_STATEMENT | CLIENT_MULTI_RESULTS 64 | 65 | if compression 66 | client_flags |= CLIENT_COMPRESSION 67 | end 68 | 69 | result = FFI::MemoryPointer.new(:pointer) 70 | 71 | status = Native.mysql_real_connect_start(result, pointer, host, username, password, database, port, unix_socket, client_flags); 72 | 73 | io = ::IO.new(Native.mysql_get_socket(pointer), "r+", autoclose: false) 74 | 75 | if status > 0 76 | while status > 0 77 | if status & MYSQL_WAIT_READ 78 | io.wait_readable 79 | elsif status & MYSQL_WAIT_WRITE 80 | io.wait_writable 81 | else 82 | io.wait_any 83 | end 84 | 85 | status = Native.mysql_real_connect_cont(result, pointer, status) 86 | end 87 | end 88 | 89 | if result.read_pointer.null? 90 | raise Error, "Could not connect: #{Native.mysql_error(pointer)}!" 91 | end 92 | 93 | return self.new(pointer, io, types, **options) 94 | end 95 | 96 | def initialize(address, io, types, **options) 97 | super(address) 98 | 99 | @io = io 100 | @result = nil 101 | 102 | @types = types 103 | end 104 | 105 | attr :types 106 | 107 | def wait_for(status) 108 | if status & MYSQL_WAIT_READ 109 | @io.wait_readable 110 | elsif status & MYSQL_WAIT_WRITE 111 | @io.wait_writable 112 | end 113 | end 114 | 115 | def check_error!(message) 116 | if Native.mysql_errno(self) != 0 117 | raise Error, "#{message}: #{Native.mysql_error(self)}!" 118 | end 119 | end 120 | 121 | def status 122 | Native.mysql_stat(self) 123 | end 124 | 125 | def free_result 126 | if @result 127 | Native.mysql_free_result(@result) 128 | 129 | @result = nil 130 | end 131 | end 132 | 133 | def close 134 | self.free_result 135 | 136 | Native.mysql_close(self) 137 | 138 | @io.close 139 | end 140 | 141 | def escape(value) 142 | value = value.to_s 143 | 144 | maximum_length = value.bytesize * 2 + 1 145 | out = FFI::MemoryPointer.new(:char, maximum_length) 146 | 147 | Native.mysql_real_escape_string(self, out, value, value.bytesize) 148 | 149 | return out.read_string 150 | end 151 | 152 | def send_query(statement) 153 | self.free_result 154 | 155 | error = FFI::MemoryPointer.new(:int) 156 | 157 | status = Native.mysql_real_query_start(error, self, statement, statement.bytesize) 158 | 159 | while status != 0 160 | self.wait_for(status) 161 | 162 | status = Native.mysql_real_query_cont(error, self, status) 163 | end 164 | 165 | if error.read_int != 0 166 | raise Error, "Could not send query: #{Native.mysql_error(self)}!" 167 | end 168 | end 169 | 170 | # @returns [Boolean] If there are more results. 171 | def more_results? 172 | Native.mysql_more_results(self) == 1 173 | end 174 | 175 | def next_result(types: @types) 176 | if result = self.get_result 177 | return Result.new(self, types, result) 178 | end 179 | end 180 | 181 | # Silently discard any results that application didn't read. 182 | def discard_results 183 | while result = self.get_result 184 | end 185 | 186 | return nil 187 | end 188 | 189 | def affected_rows 190 | Native.mysql_affected_rows(self) 191 | end 192 | 193 | def insert_id 194 | Native.mysql_insert_id(self) 195 | end 196 | 197 | def info 198 | Native.mysql_info(self) 199 | end 200 | 201 | protected 202 | def get_result 203 | if @result 204 | self.free_result 205 | 206 | # Successful and there are no more results: 207 | return if Native.mysql_next_result(self) == -1 208 | 209 | check_error!("Get result") 210 | end 211 | 212 | @result = Native.mysql_use_result(self) 213 | 214 | if @result.null? 215 | check_error!("Get result") 216 | 217 | return nil 218 | else 219 | return @result 220 | end 221 | end 222 | end 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /lib/db/mariadb/native/field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative '../native' 7 | 8 | require_relative 'types' 9 | 10 | require 'date' 11 | require 'json' 12 | 13 | module DB 14 | module MariaDB 15 | module Native 16 | Type = ffi_define_enumeration(:field_type, [ 17 | :decimal, 18 | :tiny, 19 | :short, 20 | :long, 21 | :float, 22 | :double, 23 | :null, 24 | :timestamp, 25 | :longlong, 26 | :int24, 27 | :date, 28 | :time, 29 | :datetime, 30 | :year, 31 | :newdate, 32 | :varchar, 33 | :bit, 34 | :json, 245, 35 | :newdecimal, 36 | :enum, 37 | :set, 38 | :tiny_blob, 39 | :medium_blob, 40 | :long_blob, 41 | :blob, 42 | :var_string, 43 | :string, 44 | :geometry, 45 | ]) 46 | 47 | DEFAULT_TYPES = { 48 | # Pseudo types: 49 | primary_key: Types::Integer.new('BIGINT AUTO_INCREMENT PRIMARY KEY'), 50 | foreign_key: Types::Integer.new('BIGINT'), 51 | text: Types::Text.new("TEXT"), 52 | string: Types::Text.new("VARCHAR(255)"), 53 | 54 | # Aliases 55 | smallint: Types::Integer.new("SHORT"), 56 | integer: Types::Integer.new("INTEGER"), 57 | bigint: Types::Integer.new("LONG"), 58 | 59 | # Native types: 60 | decimal: Types::Decimal.new, 61 | boolean: Types::Boolean.new, 62 | tiny: Types::Integer.new("TINY"), 63 | short: Types::Integer.new("SHORT"), 64 | long: Types::Integer.new("LONG"), 65 | longlong: Types::Integer.new("LONGLONG"), 66 | float: Types::Float.new, 67 | double: Types::Float.new("DOUBLE"), 68 | timestamp: Types::DateTime.new("TIMESTAMP"), 69 | date: Types::Date.new, 70 | datetime: Types::DateTime.new("DATETIME"), 71 | year: Types::Integer.new("YEAR"), 72 | newdate: Types::DateTime.new("DATETIME"), 73 | bit: Types::Integer.new("BIT"), 74 | json: Types::JSON.new, 75 | newdecimal: Types::Decimal.new, 76 | enum: Types::Symbol.new, 77 | set: Types::Integer.new("SET"), 78 | } 79 | 80 | class Field < FFI::Struct 81 | layout( 82 | :name, :string, 83 | :org_name, :string, 84 | :table, :string, 85 | :org_table, :string, 86 | :db, :string, 87 | :catalog, :string, 88 | :def, :string, 89 | :length, :ulong, 90 | :max_length, :ulong, 91 | :name_length, :uint, 92 | :org_name_length, :uint, 93 | :table_length, :uint, 94 | :org_table_length, :uint, 95 | :db_length, :uint, 96 | :catalog_length, :uint, 97 | :def_length, :uint, 98 | :flags, :uint, 99 | :decimals, :uint, 100 | :charsetnr, :uint, 101 | :type, Type, 102 | :extension, :pointer, 103 | ) 104 | 105 | def boolean? 106 | self[:length] == 1 && (self[:type] == :tiny || self[:type] == :long) 107 | end 108 | 109 | def name 110 | self[:name] 111 | end 112 | 113 | def type 114 | if boolean? 115 | :boolean 116 | else 117 | self[:type] 118 | end 119 | end 120 | 121 | def inspect 122 | "\#<#{self.class} name=#{self.name} type=#{self.type} length=#{self[:length]}>" 123 | end 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/db/mariadb/native/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative 'field' 7 | 8 | module DB 9 | module MariaDB 10 | module Native 11 | ffi_attach_function :mysql_fetch_row_start, [:pointer, :pointer], :int 12 | ffi_attach_function :mysql_fetch_row_cont, [:pointer, :pointer, :int], :int 13 | 14 | ffi_attach_function :mysql_num_rows, [:pointer], :uint64 15 | ffi_attach_function :mysql_num_fields, [:pointer], :uint32 16 | 17 | ffi_attach_function :mysql_fetch_fields, [:pointer], :pointer 18 | 19 | class Result < FFI::Pointer 20 | def initialize(connection, types = {}, address) 21 | super(address) 22 | 23 | @connection = connection 24 | @fields = nil 25 | @types = types 26 | @casts = nil 27 | end 28 | 29 | def field_count 30 | Native.mysql_num_fields(self) 31 | end 32 | 33 | def fields 34 | unless @fields 35 | pointer = Native.mysql_fetch_fields(self) 36 | 37 | @fields = field_count.times.map do |index| 38 | Field.new(pointer + index * Field.size) 39 | end 40 | end 41 | 42 | return @fields 43 | end 44 | 45 | def field_names 46 | fields.map(&:name) 47 | end 48 | 49 | def field_types 50 | fields.map{|field| @types[field.type]} 51 | end 52 | 53 | # In the context of unbuffered queries, this is the number of rows that have been fetched so far. 54 | def row_count 55 | Native.mysql_num_rows(self) 56 | end 57 | 58 | alias count row_count 59 | alias keys field_names 60 | 61 | def cast!(row) 62 | @casts ||= self.field_types 63 | 64 | row.size.times do |index| 65 | if cast = @casts[index] 66 | row[index] = cast.parse(row[index]) 67 | end 68 | end 69 | 70 | return row 71 | end 72 | 73 | def each 74 | row = FFI::MemoryPointer.new(:pointer) 75 | field_count = self.field_count 76 | 77 | while true 78 | status = Native.mysql_fetch_row_start(row, self) 79 | 80 | while status != 0 81 | @connection.wait_for(status) 82 | 83 | status = Native.mysql_fetch_row_cont(row, self, status) 84 | end 85 | 86 | pointer = row.read_pointer 87 | 88 | if pointer.null? 89 | break 90 | else 91 | yield cast!(pointer.get_array_of_string(0, field_count)) 92 | end 93 | end 94 | 95 | @connection.check_error!("Reading recordset") 96 | end 97 | 98 | def map(&block) 99 | results = [] 100 | 101 | self.each do |row| 102 | results << yield(row) 103 | end 104 | 105 | return results 106 | end 107 | 108 | def to_a 109 | rows = [] 110 | 111 | self.each do |row| 112 | rows << row 113 | end 114 | 115 | return rows 116 | end 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/db/mariadb/native/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require 'json' 7 | require 'bigdecimal' 8 | 9 | module DB 10 | module MariaDB 11 | module Native 12 | module Types 13 | class Text 14 | def initialize(name = "TEXT") 15 | @name = name 16 | end 17 | 18 | attr :name 19 | 20 | def parse(string) 21 | string 22 | end 23 | end 24 | 25 | class Integer 26 | def initialize(name = "INTEGER") 27 | @name = name 28 | end 29 | 30 | attr :name 31 | 32 | def parse(string) 33 | Integer(string) if string 34 | end 35 | end 36 | 37 | class Boolean 38 | def name 39 | "BOOLEAN" 40 | end 41 | 42 | def parse(string) 43 | case string 44 | when '0' 45 | false 46 | when '1' 47 | true 48 | when nil 49 | nil 50 | else 51 | Integer(string) 52 | end 53 | end 54 | end 55 | 56 | class Decimal 57 | def name 58 | "DECIMAL" 59 | end 60 | 61 | def parse(string) 62 | BigDecimal(string) if string 63 | end 64 | end 65 | 66 | class Float 67 | def initialize(name = "FLOAT") 68 | @name = name 69 | end 70 | 71 | attr :name 72 | 73 | def parse(string) 74 | Float(string) if string 75 | end 76 | end 77 | 78 | class Symbol 79 | def name 80 | "ENUM" 81 | end 82 | 83 | def parse(string) 84 | string&.to_sym 85 | end 86 | end 87 | 88 | class DateTime 89 | def initialize(name = "DATETIME") 90 | @name = name 91 | end 92 | 93 | attr :name 94 | 95 | def parse(string) 96 | if string 97 | parts = string.split(/[\-\s:]/) 98 | 99 | return Time.utc(*parts) 100 | end 101 | end 102 | end 103 | 104 | class Date 105 | def name 106 | "DATE" 107 | end 108 | 109 | def parse(string) 110 | if string 111 | parts = string.split(/[\-\s:]/) 112 | 113 | return Time.utc(*parts) 114 | end 115 | end 116 | end 117 | 118 | class JSON 119 | def name 120 | "JSON" 121 | end 122 | 123 | def parse(string) 124 | ::JSON.parse(string, symbolize_names: true) if string 125 | end 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/db/mariadb/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | module DB 7 | module MariaDB 8 | VERSION = "0.11.2" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2020-2024, by Samuel Williams. 4 | Copyright, 2024, by Hal Brodigan. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # DB::MariaDB 2 | 3 | A light-weight wrapper for Ruby connecting to MariaDB and MySQL servers. This gem provides an adapter for the [`db` gem](https://github.com/socketry/db). You should use the `db` gem directly as it provides a unified interface for all database adapters and contains the majority of the documentation. 4 | 5 | [![Development Status](https://github.com/socketry/db-mariadb/workflows/Test/badge.svg)](https://github.com/socketry/db-mariadb/actions?workflow=Test) 6 | 7 | ## Usage 8 | 9 | Please see the [project documentation](https://socketry.github.io/db-mariadb/) for more details. 10 | 11 | - [Getting Started](https://socketry.github.io/db-mariadb/guides/getting-started/index) - This guide explains how to get started with the `db-mariadb` gem. 12 | 13 | ## Contributing 14 | 15 | We welcome contributions to this project. 16 | 17 | 1. Fork it. 18 | 2. Create your feature branch (`git checkout -b my-new-feature`). 19 | 3. Commit your changes (`git commit -am 'Add some feature'`). 20 | 4. Push to the branch (`git push origin my-new-feature`). 21 | 5. Create new Pull Request. 22 | 23 | ### Developer Certificate of Origin 24 | 25 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 26 | 27 | ### Community Guidelines 28 | 29 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 30 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /test/db/mariadb/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require 'db/mariadb/connection' 7 | require 'sus/fixtures/async' 8 | 9 | describe DB::MariaDB::Connection do 10 | include Sus::Fixtures::Async::ReactorContext 11 | 12 | let(:connection) {subject.new(**CREDENTIALS)} 13 | 14 | it "should connect to local server" do 15 | expect(connection.status).to be(:include?, "Uptime") 16 | ensure 17 | connection.close 18 | end 19 | 20 | it "should execute query" do 21 | connection.send_query("SELECT 42 AS LIFE") 22 | 23 | result = connection.next_result 24 | 25 | expect(result.to_a).to be == [[42]] 26 | ensure 27 | connection.close 28 | end 29 | 30 | it "can list tables" do 31 | connection.send_query("SELECT * FROM INFORMATION_SCHEMA.TABLES") 32 | 33 | result = connection.next_result 34 | 35 | expect(result.to_a).not.to be(:empty?) 36 | ensure 37 | connection.close 38 | end 39 | 40 | it "can get current time" do 41 | connection.send_query("SELECT UTC_TIMESTAMP() AS NOW") 42 | 43 | result = connection.next_result 44 | row = result.to_a.first 45 | 46 | expect(row.first).to be_within(1).of(Time.now.utc) 47 | ensure 48 | connection.close 49 | end 50 | 51 | with '#append_string' do 52 | it "should escape string" do 53 | expect(connection.append_string("Hello 'World'")).to be == "'Hello \\'World\\''" 54 | expect(connection.append_string('Hello "World"')).to be == "'Hello \\\"World\\\"'" 55 | ensure 56 | connection.close 57 | end 58 | end 59 | 60 | with '#append_literal' do 61 | it "should escape string" do 62 | expect(connection.append_literal("Hello World")).to be == "'Hello World'" 63 | ensure 64 | connection.close 65 | end 66 | 67 | it "should not escape integers" do 68 | expect(connection.append_literal(42)).to be == "42" 69 | ensure 70 | connection.close 71 | end 72 | end 73 | 74 | with '#append_identifier' do 75 | it "should escape identifier" do 76 | expect(connection.append_identifier("Hello World")).to be == "`Hello World`" 77 | ensure 78 | connection.close 79 | end 80 | 81 | it "can handle booleans" do 82 | buffer = String.new 83 | buffer << "SELECT " 84 | connection.append_literal(true, buffer) 85 | 86 | connection.send_query(buffer) 87 | 88 | result = connection.next_result 89 | row = result.to_a.first 90 | 91 | expect(row.first).to be == true 92 | ensure 93 | connection.close 94 | end 95 | end 96 | end 97 | --------------------------------------------------------------------------------