├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ ├── test-external.yaml │ └── test.yaml ├── .gitignore ├── .rubocop.yml ├── benchmark ├── benchmark.rb └── compare │ ├── .bundle │ └── config │ ├── config.ru │ └── gems.rb ├── config └── sus.rb ├── db.gemspec ├── examples ├── indexes.rb └── model.rb ├── fixtures └── db │ ├── client_context.rb │ └── datatype_context.rb ├── gems.rb ├── guides ├── datatypes │ └── readme.md ├── example-queries │ └── readme.md ├── executing-queries │ └── readme.md ├── getting-started │ └── readme.md └── links.yaml ├── lib ├── db.rb └── db │ ├── adapters.rb │ ├── client.rb │ ├── context │ ├── session.rb │ └── transaction.rb │ ├── query.rb │ ├── records.rb │ └── version.rb ├── license.md ├── readme.md ├── release.cert └── test ├── db.rb └── db ├── client.rb └── datatype └── datetime.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 | postgres: 27 | image: postgres 28 | ports: 29 | - 5432:5432 30 | env: 31 | POSTGRES_USER: test 32 | POSTGRES_PASSWORD: test 33 | POSTGRES_DB: test 34 | POSTGRES_HOST_AUTH_METHOD: trust 35 | options: >- 36 | --health-cmd pg_isready 37 | --health-interval 10s 38 | --health-timeout 5s 39 | --health-retries 20 40 | mariadb: 41 | image: mariadb 42 | ports: 43 | - 3306:3306 44 | env: 45 | MARIADB_USER: test 46 | MARIADB_PASSWORD: test 47 | MARIADB_DATABASE: test 48 | MARIADB_ROOT_PASSWORD: test 49 | options: >- 50 | --health-cmd "healthcheck.sh --connect --innodb_initialized" 51 | --health-interval 10s 52 | --health-timeout 5s 53 | --health-retries 20 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | - uses: ruby/setup-ruby@v1 58 | with: 59 | ruby-version: ${{matrix.ruby}} 60 | bundler-cache: true 61 | 62 | - name: Installing dependencies (ubuntu) 63 | if: matrix.os == 'ubuntu' 64 | run: | 65 | sudo systemctl stop mysql 66 | sudo apt-get install libmariadb-dev postgresql postgresql-contrib 67 | 68 | - name: Run tests 69 | timeout-minutes: 5 70 | run: bundle exec bake test 71 | 72 | - uses: actions/upload-artifact@v4 73 | with: 74 | include-hidden-files: true 75 | if-no-files-found: error 76 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 77 | path: .covered.db 78 | 79 | validate: 80 | needs: test 81 | runs-on: ubuntu-latest 82 | 83 | steps: 84 | - uses: actions/checkout@v4 85 | - uses: ruby/setup-ruby@v1 86 | with: 87 | ruby-version: "3.3" 88 | bundler-cache: true 89 | 90 | - uses: actions/download-artifact@v4 91 | 92 | - name: Validate coverage 93 | timeout-minutes: 5 94 | run: bundle exec bake covered:validate --paths */.covered.db \; 95 | -------------------------------------------------------------------------------- /.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 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | 21 | ruby: 22 | - "3.1" 23 | - "3.2" 24 | - "3.3" 25 | 26 | services: 27 | postgres: 28 | image: postgres 29 | ports: 30 | - 5432:5432 31 | env: 32 | POSTGRES_USER: test 33 | POSTGRES_PASSWORD: test 34 | POSTGRES_DB: test 35 | POSTGRES_HOST_AUTH_METHOD: trust 36 | options: >- 37 | --health-cmd pg_isready 38 | --health-interval 10s 39 | --health-timeout 5s 40 | --health-retries 20 41 | mariadb: 42 | image: mariadb 43 | ports: 44 | - 3306:3306 45 | env: 46 | MARIADB_USER: test 47 | MARIADB_PASSWORD: test 48 | MARIADB_DATABASE: test 49 | MARIADB_ROOT_PASSWORD: test 50 | options: >- 51 | --health-cmd "healthcheck.sh --connect --innodb_initialized" 52 | --health-interval 10s 53 | --health-timeout 5s 54 | --health-retries 20 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 postgresql postgresql-contrib 68 | 69 | - name: Run tests 70 | timeout-minutes: 10 71 | run: bundle exec bake test:external 72 | -------------------------------------------------------------------------------- /.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 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | 22 | ruby: 23 | - "3.1" 24 | - "3.2" 25 | - "3.3" 26 | 27 | experimental: [false] 28 | 29 | include: 30 | - os: ubuntu 31 | ruby: truffleruby 32 | experimental: true 33 | - os: ubuntu 34 | ruby: jruby 35 | experimental: true 36 | - os: ubuntu 37 | ruby: head 38 | experimental: true 39 | 40 | services: 41 | postgres: 42 | image: postgres 43 | ports: 44 | - 5432:5432 45 | env: 46 | POSTGRES_USER: test 47 | POSTGRES_PASSWORD: test 48 | POSTGRES_DB: test 49 | POSTGRES_HOST_AUTH_METHOD: trust 50 | options: >- 51 | --health-cmd pg_isready 52 | --health-interval 10s 53 | --health-timeout 5s 54 | --health-retries 20 55 | mariadb: 56 | image: mariadb 57 | ports: 58 | - 3306:3306 59 | env: 60 | MARIADB_USER: test 61 | MARIADB_PASSWORD: test 62 | MARIADB_DATABASE: test 63 | MARIADB_ROOT_PASSWORD: test 64 | options: >- 65 | --health-cmd "healthcheck.sh --connect --innodb_initialized" 66 | --health-interval 10s 67 | --health-timeout 5s 68 | --health-retries 20 69 | 70 | steps: 71 | - uses: actions/checkout@v4 72 | - uses: ruby/setup-ruby@v1 73 | with: 74 | ruby-version: ${{matrix.ruby}} 75 | bundler-cache: true 76 | 77 | - name: Installing dependencies (ubuntu) 78 | if: matrix.os == 'ubuntu' 79 | run: | 80 | sudo systemctl stop mysql 81 | sudo apt-get install libmariadb-dev postgresql postgresql-contrib 82 | 83 | - name: Run tests 84 | timeout-minutes: 10 85 | run: bundle exec bake test 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.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/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/StringLiterals: 52 | Enabled: true 53 | EnforcedStyle: double_quotes 54 | -------------------------------------------------------------------------------- /benchmark/benchmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "benchmark/ips" 7 | require "async" 8 | 9 | require "db/client" 10 | require "db/adapters" 11 | 12 | require "mysql2" 13 | require "pg" 14 | 15 | describe DB::Client do 16 | it "should be fast to insert data" do 17 | Benchmark.ips do |x| 18 | DB::Adapters.each do |name, klass| 19 | adapter = klass.new(**CREDENTIALS) 20 | client = DB::Client.new(adapter) 21 | 22 | Sync do 23 | client.session do |session| 24 | session.call("DROP TABLE IF EXISTS benchmark") 25 | session.call("CREATE TABLE benchmark (#{session.connection.key_column}, i INTEGER)") 26 | end 27 | end 28 | 29 | x.report("db-#{name}") do |repeats| 30 | Sync do 31 | client.session do |session| 32 | session.call("TRUNCATE benchmark") 33 | 34 | repeats.times do |index| 35 | session.call("INSERT INTO benchmark (i) VALUES (#{index})") 36 | end 37 | end 38 | end 39 | end 40 | end 41 | 42 | x.report("mysql2") do |repeats| 43 | client = Mysql2::Client.new(**CREDENTIALS) 44 | client.query("TRUNCATE benchmark") 45 | 46 | repeats.times do |index| 47 | client.query("INSERT INTO benchmark (i) VALUES (#{index})") 48 | end 49 | end 50 | 51 | x.report("pg") do |repeats| 52 | client = PG.connect(**PG_CREDENTIALS) 53 | 54 | client.exec("TRUNCATE benchmark") 55 | 56 | repeats.times do |index| 57 | client.exec("INSERT INTO benchmark (i) VALUES (#{index})") 58 | end 59 | end 60 | 61 | x.compare! 62 | end 63 | end 64 | 65 | it "should be fast to select data" do 66 | row_count = 100 67 | insert_query = +"INSERT INTO benchmark (i) VALUES" 68 | 69 | row_count.times.map do |index| 70 | insert_query << " (#{index})," 71 | end 72 | 73 | # Remove last comma: 74 | insert_query.chop! 75 | 76 | Benchmark.ips do |x| 77 | DB::Adapters.each do |name, klass| 78 | adapter = klass.new(**CREDENTIALS) 79 | client = DB::Client.new(adapter) 80 | 81 | Sync do 82 | client.session do |session| 83 | session.call("DROP TABLE IF EXISTS benchmark") 84 | session.call("CREATE TABLE benchmark (#{session.connection.key_column}, i INTEGER)") 85 | 86 | session.call(insert_query) 87 | end 88 | end 89 | 90 | x.report("db-#{name}") do |repeats| 91 | Sync do 92 | client.session do |session| 93 | repeats.times do |index| 94 | session.call("SELECT * FROM benchmark") do |connection| 95 | result = connection.next_result 96 | expect(result.to_a).to have_attributes(size: row_count) 97 | end 98 | end 99 | end 100 | end 101 | end 102 | end 103 | 104 | x.report("mysql2") do |repeats| 105 | client = Mysql2::Client.new(**CREDENTIALS) 106 | 107 | repeats.times do |index| 108 | result = client.query("SELECT * FROM benchmark") 109 | expect(result.to_a).to have_attributes(size: row_count) 110 | end 111 | end 112 | 113 | x.report("pg") do |repeats| 114 | client = PG.connect(**PG_CREDENTIALS) 115 | 116 | repeats.times do |index| 117 | result = client.exec("SELECT * FROM benchmark") 118 | expect(result.to_a).to have_attributes(size: row_count) 119 | end 120 | end 121 | 122 | x.compare! 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /benchmark/compare/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_WITH: "maintenance" 3 | -------------------------------------------------------------------------------- /benchmark/compare/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | ActiveSupport::IsolatedExecutionState.isolation_level = :fiber 5 | 6 | require "active_record" 7 | ActiveRecord::Base.establish_connection(adapter: "postgresql", database: "test", pool: 64) 8 | 9 | require_relative "../../lib/db" 10 | require "db/postgres" 11 | 12 | # TracePoint.new(:fiber_switch) do |trace_point| 13 | # $stderr.puts "************* fiber switch (pid=#{Process.pid}) *************" 14 | # $stderr.puts caller.first(8).join("\n") 15 | # $stderr.puts 16 | # end.enable 17 | 18 | class Compare 19 | def initialize(app) 20 | @app = app 21 | @db = DB::Client.new(DB::Postgres::Adapter.new(database: "test")) 22 | end 23 | 24 | PATH_INFO = "PATH_INFO".freeze 25 | OK = [200, [], ["OK"]] 26 | 27 | def active_record_checkout(env) 28 | Console.logger.measure("active_record") do 29 | connection = ActiveRecord::Base.connection_pool.checkout 30 | connection.execute("SELECT pg_sleep(1)") 31 | ensure 32 | ActiveRecord::Base.connection_pool.checkin(connection) 33 | end 34 | 35 | OK 36 | end 37 | 38 | def active_record_with_connection(env) 39 | Console.logger.measure("active_record") do 40 | ActiveRecord::Base.connection_pool.with_connection do |connection| 41 | connection.execute("SELECT pg_sleep(1)") 42 | end 43 | end 44 | 45 | OK 46 | end 47 | 48 | def active_record(env) 49 | Console.logger.measure("active_record") do 50 | ActiveRecord::Base.connection.execute("SELECT pg_sleep(1)") 51 | end 52 | 53 | OK 54 | end 55 | 56 | def db(env) 57 | Console.logger.measure("db") do 58 | @db.session do |session| 59 | session.query("SELECT pg_sleep(1)").call 60 | end 61 | 62 | puts @db.instance_variable_get(:@pool).to_s 63 | end 64 | 65 | OK 66 | end 67 | 68 | def call(env) 69 | _, name, *path = env[PATH_INFO].split("/") 70 | 71 | method = name&.to_sym 72 | 73 | if method and self.respond_to?(method) 74 | self.send(method, env) 75 | else 76 | @app.call(env) 77 | end 78 | end 79 | end 80 | 81 | use Compare 82 | 83 | run lambda {|env| [404, {}, ["Not Found"]]} 84 | -------------------------------------------------------------------------------- /benchmark/compare/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gem "falcon" 9 | gem "rack", "~> 2.0" 10 | 11 | gem "rails", git: "https://github.com/rails/rails" 12 | gem "mysql2" 13 | gem "pg" 14 | 15 | gem "db" 16 | gem "db-postgres" 17 | gem "db-mariadb" 18 | -------------------------------------------------------------------------------- /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 | Bundler.require(:adapters) 10 | 11 | ::CREDENTIALS = { 12 | username: "test", 13 | password: "test", 14 | database: "test", 15 | host: "127.0.0.1" 16 | } 17 | 18 | # Used for PG.connect: 19 | ::PG_CREDENTIALS = CREDENTIALS.dup.tap do |credentials| 20 | credentials[:user] = credentials.delete(:username) 21 | credentials[:dbname] = credentials.delete(:database) 22 | end 23 | -------------------------------------------------------------------------------- /db.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/db/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "db" 7 | spec.version = DB::VERSION 8 | 9 | spec.summary = "A low level database access gem." 10 | spec.authors = ["Samuel Williams"] 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" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/db/", 20 | "funding_uri" => "https://github.com/sponsors/ioquatix", 21 | "source_code_uri" => "https://github.com/socketry/db.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 | end 30 | -------------------------------------------------------------------------------- /examples/indexes.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2021-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require_relative "../lib/db/client" 9 | require "db/postgres" 10 | 11 | # Create the client and connection pool: 12 | client = DB::Client.new(DB::Postgres::Adapter.new(database: "test")) 13 | 14 | def create_schema(session) 15 | session.clause("DROP TABLE IF EXISTS").identifier(:things).call 16 | 17 | session.clause("CREATE TABLE IF NOT EXISTS") 18 | .identifier(:things) 19 | .clause("(") 20 | # .identifier(:id).clause("BIGSERIAL PRIMARY KEY,") 21 | .identifier(:low).clause("INT NOT NULL,") 22 | .identifier(:high).clause("INT NOT NULL") 23 | .clause(")").call 24 | 25 | session.clause("SET default_statistics_target=10000;").call 26 | session.clause("CREATE STATISTICS low_high_statistics (dependencies) ON low, high FROM things;").call 27 | end 28 | 29 | # Depending on the structure of the index, the query can be handled in different ways. 30 | # A bottom heavy index has few nodes at the root, so that in theory, cutting a node at the root reduces the search space by the biggest amount possible. 31 | # 32 | # low high 33 | # Top -> 1 -> 1 million children 34 | # -> 2 -> 1 milllion children 35 | # 36 | # Query in `low` is O(2) then produces a subsequent O(1 million) lookup. 37 | # 38 | # A top heavy index has as many possible nodes at the root, so that a single lookup produces the smallest set of possible options. 39 | # 40 | # low high 41 | # Top -> 1 million -> 2 children 42 | # -> ... -> 2 children 43 | # 44 | # Query in `low` is O(1 million) then produces a subsequent O(2) lookup. 45 | # 46 | # Different internal index structure may yield different results. 47 | # 48 | # In the general case above, we have structured the data so that they are essentially equivlent, e.g. O(low) followed by O(high) or O(high) followed by O(low). However, data does not often follow this structure. Often you will have unbalanced trees. Our query performance will be determined by the tree structure and whether we can avoid comparisons by culling potential search space as early as possible. 49 | def create_data(session) 50 | million = (0...1_000_000).to_a 51 | 52 | rows = { 53 | 5 => million.dup, 54 | 10 => million.dup, 55 | 15 => million.dup, 56 | 20 => million.dup, 57 | 25 => million.dup, 58 | 30 => million.dup, 59 | 35 => million.dup, 60 | 40 => million.dup, 61 | 45 => million.dup, 62 | 50 => million.dup, 63 | } 64 | 65 | rows.each do |low, values| 66 | insert = session.clause("INSERT INTO").identifier(:things) 67 | .clause("(") 68 | .identifier(:low).clause(",") 69 | .identifier(:high) 70 | .clause(")") 71 | .clause("VALUES") 72 | 73 | while high = values.pop 74 | insert.clause("(") 75 | .literal(low).clause(",") 76 | .literal(high) 77 | 78 | if values.empty? 79 | insert.clause(")") 80 | else 81 | insert.clause("),") 82 | end 83 | end 84 | 85 | insert.call 86 | end 87 | end 88 | 89 | def create_index(session, low_high: true, high_low: true) 90 | session.clause("SET default_statistics_target=10000;").call 91 | 92 | if low_high 93 | session.clause("CREATE INDEX IF NOT EXISTS") 94 | .identifier(:low_high) 95 | .clause("ON").identifier(:things).clause("(") 96 | .identifier(:low).clause(",") 97 | .identifier(:high) 98 | .clause(")").call 99 | else 100 | session.clause("DROP INDEX IF EXISTS").identifier(:low_high) 101 | end 102 | 103 | if high_low 104 | session.clause("CREATE INDEX IF NOT EXISTS") 105 | .identifier(:high_low) 106 | .clause("ON").identifier(:things).clause("(") 107 | .identifier(:high).clause(",") 108 | .identifier(:low) 109 | .clause(")").call 110 | else 111 | session.clause("DROP INDEX IF EXISTS").identifier(:high_low) 112 | end 113 | end 114 | 115 | permutations = [ 116 | # {low_high: false, high_low: false}, 117 | {low_high: true, high_low: false}, 118 | {low_high: false, high_low: true}, 119 | {low_high: true, high_low: true}, 120 | ] 121 | 122 | # Create an event loop: 123 | Sync do 124 | # Connect to the database: 125 | session = client.session 126 | 127 | create_schema(session) 128 | create_data(session) 129 | 130 | permutations.each do |permutation| 131 | Console.logger.info(session, permutation) 132 | 133 | create_index(session, **permutation) 134 | 135 | # Warm up the table/index: 136 | (1..3).each do |i| 137 | result = session.query("SELECT low, high FROM things WHERE low = #{i} AND high = 5001").call.to_a 138 | end 139 | 140 | analysis = session.query("EXPLAIN ANALYZE SELECT low, high FROM things WHERE low = 25 AND high = 500000").call.to_a 141 | Console.logger.info(session, *analysis.flatten) 142 | 143 | Console.logger.measure("query") do 144 | 10_000.times do |i| 145 | result = session.query("SELECT low, high FROM things WHERE low = #{i % 50} AND high = #{(i * 12351237) % 1_000_000}").call.to_a 146 | # pp result 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /examples/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | module LoginSchema 7 | include DB::Schema 8 | 9 | class User 10 | property :name 11 | property :password, BCrypt 12 | end 13 | 14 | has_many :users, User 15 | 16 | def call(username, password) 17 | if user = users.find(username: username) 18 | return user.password == password 19 | end 20 | end 21 | end 22 | 23 | module TodoSchema 24 | include DB::Schema 25 | 26 | class Item 27 | property :description 28 | property :due, Optional[DateTime] 29 | 30 | belongs_to :user, LoginSchema::User 31 | end 32 | 33 | has_many :items, Item 34 | end 35 | 36 | module AppliationSchema 37 | include DB::Schema 38 | 39 | schema :login => LoginSchema 40 | schema :todo => TodoSchema 41 | end 42 | 43 | client = DB::Client.new(DB::Postgres::Adapter.new(database: "test")) 44 | schema = ApplicationSchema.new(client) 45 | 46 | schema.login.call(username, password) 47 | 48 | pp schema.todo.todos # => [TodoSchema::Todo, ...] 49 | -------------------------------------------------------------------------------- /fixtures/db/client_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "db/client" 7 | require "db/adapters" 8 | require "sus/fixtures/async" 9 | 10 | module DB 11 | ClientContext = Sus::Shared("client context") do |adapter| 12 | include Sus::Fixtures::Async::ReactorContext 13 | 14 | let(:client) {DB::Client.new(adapter)} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /fixtures/db/datatype_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require_relative "client_context" 7 | 8 | module DB 9 | DatatypeContext = Sus::Shared("datatype context") do |adapter, datatype| 10 | include_context DB::ClientContext, adapter 11 | 12 | let(:table_name) {"datatype_test_#{datatype}".to_sym} 13 | 14 | before do 15 | Sync do 16 | client.session do |session| 17 | type = session.connection.types[datatype] 18 | 19 | session.query("DROP TABLE IF EXISTS %{table_name}", table_name: table_name).call 20 | session.query("CREATE TABLE %{table_name} (value #{type.name})", table_name: table_name).call 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /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 | # Specify your gem's dependencies in db.gemspec 9 | gemspec 10 | 11 | group :maintenance, optional: true do 12 | gem "bake-modernize" 13 | gem "bake-gem" 14 | 15 | gem "utopia-project" 16 | end 17 | 18 | group :adapters do 19 | gem "db-postgres" 20 | gem "db-mariadb" 21 | end 22 | 23 | group :benchmark do 24 | gem "benchmark-ips", "~> 2.8.0" 25 | gem "mysql2" 26 | gem "pg" 27 | end 28 | 29 | group :test do 30 | gem "sus" 31 | gem "covered" 32 | gem "decode" 33 | gem "rubocop" 34 | 35 | gem "sus-fixtures-async" 36 | 37 | gem "bake-test" 38 | gem "bake-test-external" 39 | end 40 | -------------------------------------------------------------------------------- /guides/datatypes/readme.md: -------------------------------------------------------------------------------- 1 | # Data Types 2 | 3 | This guide explains about SQL data types, and how they are used by the DB gem. 4 | 5 | Structured Query Language (SQL) defines a set of data types that can be used to store data in a database. The data types are used to define a column in a table, and each column in a table must have a data type associated with it. The data type of a column typically defines the kind of data that the column can store, althought some database systems allow you to store any kind of data in any column. 6 | 7 | When you build a program with a database, you need to be aware of the data types that are available in the database system you are using. The DB gem tries to expose standard data types, so that you can use the same data types across different database systems. There are two main operations that are affected by datatypes: appending literal values to SQL queries, and reading values from the database. 8 | 9 | ## Appending Literal Data Types 10 | 11 | The DB gem converts Ruby objects to SQL literals when you append them to a query. This is generally taken care of by the {ruby DB::Query#literal} and {ruby DB::Query#interpolate} methods, which are used to append literal values to a query. Generally speaking, the following native data types are supported: 12 | 13 | - `Time`, `DateTime` and `Date` objects convert to an appropriate format for the database system you are using. Some systems don't natively support timezones, and so time zone information may be lost. 14 | - `String` objects are escaped and quoted. 15 | - `Numeric` (including `Integer` and `Float`) objects are appended as-is. 16 | - `TrueClass` and `FalseClass` objects are converted to the appropriate boolean value for the database system you are using. 17 | - `NilClass` objects are converted to `NULL`. 18 | 19 | ## Reading Data Types 20 | 21 | When you read data from the database, the DB gem tries to convert the data to the appropriate Ruby object. When a query yields rows of fields, and those fields have a well defined field type, known by the adapter, the adapter will cast those objects back into rich Ruby objects where possible. The following conversions are generally supported: 22 | 23 | - `TEXT` and `VARCHAR` fields are converted to `String` objects. 24 | - `INTEGER` and `FLOAT` fields are converted to `Integer` and `Float` objects respectively. 25 | - `BOOLEAN` fields are converted to `TrueClass` and `FalseClass` objects. 26 | - `TIMESTAMP` and `DATETIME` fields are converted to `Time` objects. 27 | - `DATE` fields are converted to `Date` objects. 28 | - `NULL` values are converted to `nil`. 29 | -------------------------------------------------------------------------------- /guides/example-queries/readme.md: -------------------------------------------------------------------------------- 1 | # Example Queries 2 | 3 | This guide shows a variety of example queries using the DB gem. 4 | 5 | ## Setup 6 | 7 | ~~~ ruby 8 | require 'async' 9 | require 'db/client' 10 | require 'db/postgres' 11 | 12 | client = DB::Client.new(DB::Postgres::Adapter.new( 13 | database: 'test', 14 | host: '172.17.0.3', 15 | password: 'test', 16 | username: 'postgres', 17 | )) 18 | ~~~ 19 | 20 | ## A simple CREATE, INSERT and SELECT, with raw SQL 21 | 22 | ~~~ ruby 23 | Sync do 24 | session = client.session 25 | 26 | create = "CREATE TABLE IF NOT EXISTS my_table (a_timestamp TIMESTAMP NOT NULL)" 27 | session.query(create).call 28 | 29 | insert = "INSERT INTO my_table VALUES (NOW()), ('2022-12-12 12:13:14')" 30 | session.query(insert).call 31 | 32 | result = session.query("SELECT * FROM my_table WHERE a_timestamp > NOW()").call 33 | 34 | Console.info result.field_types.to_s 35 | Console.info result.field_names.to_s 36 | Console.info result.to_a.to_s 37 | ensure 38 | session&.close 39 | end 40 | ~~~ 41 | 42 | ### Output 43 | 44 | ~~~ 45 | 0.01s info: [#] 46 | 0.01s info: ["a_timestamp"] 47 | 0.01s info: [[2022-12-12 12:13:14 UTC]] 48 | ~~~ 49 | 50 | ## Parameterized CREATE, INSERT and SELECT 51 | 52 | The same process as before, but parameterized. Always use the parameterized form when dealing with untrusted data. 53 | 54 | ~~~ ruby 55 | Sync do 56 | session = client.session 57 | 58 | session.clause("CREATE TABLE IF NOT EXISTS") 59 | .identifier(:my_table) 60 | .clause("(") 61 | .identifier(:a_timestamp).clause("TIMESTAMP NOT NULL") 62 | .clause(")") 63 | .call 64 | 65 | session.clause("INSERT INTO") 66 | .identifier(:my_table) 67 | .clause("VALUES (") 68 | .literal("NOW()") 69 | .clause("), (") 70 | .literal("2022-12-12 12:13:14") 71 | .clause(")") 72 | .call 73 | 74 | result = session.clause("SELECT * FROM") 75 | .identifier(:my_table) 76 | .clause("WHERE") 77 | .identifier(:a_timestamp).clause(">").literal("NOW()") 78 | .call 79 | 80 | Console.info result.field_types.to_s 81 | Console.info result.field_names.to_s 82 | Console.info result.to_a.to_s 83 | ensure 84 | session&.close 85 | end 86 | ~~~ 87 | 88 | ### Output 89 | 90 | ~~~ 91 | 0.01s info: [#] 92 | 0.01s info: ["a_timestamp"] 93 | 0.01s info: [[2022-12-12 12:13:14 UTC]] 94 | ~~~ 95 | 96 | ## A parameterized SELECT 97 | 98 | ~~~ ruby 99 | Sync do |task| 100 | session = client.session 101 | result = session 102 | .clause("SELECT") 103 | .identifier(:column_one) 104 | .clause(",") 105 | .identifier(:column_two) 106 | .clause("FROM") 107 | .identifier(:another_table) 108 | .clause("WHERE") 109 | .identifier(:id) 110 | .clause("=") 111 | .literal(42) 112 | .call 113 | 114 | Console.info "#{result.field_names}" 115 | Console.info "#{result.to_a}" 116 | end 117 | ~~~ 118 | 119 | ### Output 120 | 121 | ~~~ 122 | 0.01s info: ["column_one", "column_two"] 123 | 0.01s info: [["foo", "bar"], ["baz", "qux"]] 124 | ~~~ 125 | 126 | ## Concurrent queries 127 | 128 | (Simulating slow queries with `PG_SLEEP`) 129 | 130 | ~~~ ruby 131 | Sync do |task| 132 | start = Time.now 133 | tasks = 10.times.map do 134 | task.async do 135 | session = client.session 136 | result = session.query("SELECT PG_SLEEP(10)").call 137 | result.to_a 138 | ensure 139 | session&.close 140 | end 141 | end 142 | 143 | results = tasks.map(&:wait) 144 | 145 | Console.info "Elapsed time: #{Time.now - start}s" 146 | end 147 | ~~~ 148 | 149 | ### Output 150 | 151 | ~~~ 152 | 10.05s info: Elapsed time: 10.049756222s 153 | ~~~ 154 | 155 | ## Limited to 3 connections 156 | 157 | (Simulating slow queries with `PG_SLEEP`) 158 | 159 | ~~~ ruby 160 | require 'async/semaphore' 161 | 162 | Sync do 163 | semaphore = Async::Semaphore.new(3) 164 | tasks = 10.times.map do |i| 165 | semaphore.async do 166 | session = client.session 167 | Console.info "Starting task #{i}" 168 | result = session.query("SELECT PG_SLEEP(10)").call 169 | result.to_a 170 | ensure 171 | session&.close 172 | end 173 | end 174 | 175 | results = tasks.map(&:wait) 176 | Console.info "Done" 177 | end 178 | ~~~ 179 | 180 | ### Output 181 | 182 | ~~~ 183 | 0.0s info: Starting task 0 184 | 0.0s info: Starting task 1 185 | 0.0s info: Starting task 2 186 | 10.02s info: Completed task 0 after 10.017388464s 187 | 10.02s info: Starting task 3 188 | 10.02s info: Completed task 1 after 10.02111175s 189 | 10.02s info: Starting task 4 190 | 10.03s info: Completed task 2 after 10.027889587s 191 | 10.03s info: Starting task 5 192 | 20.03s info: Completed task 3 after 10.011089096s 193 | 20.03s info: Starting task 6 194 | 20.03s info: Completed task 4 after 10.008169111s 195 | 20.03s info: Starting task 7 196 | 20.04s info: Completed task 5 after 10.007644749s 197 | 20.04s info: Starting task 8 198 | 30.04s info: Completed task 6 after 10.011244562s 199 | 30.04s info: Starting task 9 200 | 30.04s info: Completed task 7 after 10.011565997s 201 | 30.04s info: Completed task 8 after 10.004611464s 202 | 40.05s info: Completed task 9 after 10.008239803s 203 | 40.05s info: Done 204 | ~~~ 205 | 206 | ## Sequential vs Concurrent INSERTs 207 | 208 | ~~~ ruby 209 | DATA = 1_000_000.times.map { SecureRandom.hex } 210 | 211 | def setup_tables(client) 212 | session = client.session 213 | 214 | create = "CREATE TABLE IF NOT EXISTS salts (salt CHAR(32))" 215 | session.query(create).call 216 | 217 | truncate = "TRUNCATE TABLE salts" 218 | session.query(truncate).call 219 | 220 | session.close 221 | end 222 | 223 | def chunked_insert(rows, client, task=Async::Task.current) 224 | task.async do 225 | session = client.session 226 | rows.each_slice(1000) do |slice| 227 | insert = "INSERT INTO salts VALUES " + slice.map { |x| "('#{x}')" }.join(",") 228 | session.query(insert).call 229 | end 230 | ensure 231 | session&.close 232 | end 233 | end 234 | 235 | Sync do 236 | Console.info "Setting up tables" 237 | setup_tables(client) 238 | Console.info "Done" 239 | 240 | start = Time.now 241 | Console.info "Starting sequential insert" 242 | chunked_insert(DATA, client).wait 243 | Console.info "Completed sequential insert in #{Time.now - start}s" 244 | 245 | start = Time.now 246 | Console.info "Starting concurrent insert" 247 | DATA.each_slice(10_000).map do |slice| 248 | chunked_insert(slice, client) 249 | end.each(&:wait) 250 | Console.info "Completed concurrent insert in #{Time.now - start}s" 251 | end 252 | ~~~ 253 | 254 | ### Output 255 | 256 | ~~~ 257 | 1.45s info: Setting up tables 258 | 1.49s info: Done 259 | 1.49s info: Starting sequential insert 260 | 8.49s info: Completed sequential insert in 7.006533933s 261 | 8.49s info: Starting concurrent insert 262 | 9.92s info: Completed concurrent insert in 1.431470847s 263 | ~~~ 264 | -------------------------------------------------------------------------------- /guides/executing-queries/readme.md: -------------------------------------------------------------------------------- 1 | # Executing Queries 2 | 3 | This guide explains how to escape and execute queries. 4 | 5 | In order to execute a query, you need a connection. Database connections are stateful, and this state is encapsulated by a context. 6 | 7 | ## Contexts 8 | 9 | Contexts represent a stateful connection to a remote server. The most generic kind, {ruby DB::Context::Session} provides methods for sending queries and processing results. {ruby DB::Context::Transaction} extends this implementation and adds methods for database transactions. 10 | 11 | ### Sessions 12 | 13 | A {ruby DB::Context::Session} represents a connection to the database server and can be used to send queries to the server and read rows of results. 14 | 15 | ~~~ ruby 16 | require 'async' 17 | require 'db/client' 18 | require 'db/postgres' 19 | 20 | client = DB::Client.new(DB::Postgres::Adapter.new(database: 'test')) 21 | 22 | Sync do 23 | session = client.session 24 | 25 | # Build a query, injecting the literal 42 and the identifier LIFE into the statement: 26 | result = session 27 | .clause("SELECT").literal(42) 28 | .clause("AS").identifier(:LIFE).call 29 | 30 | pp result.to_a 31 | # => [[42]] 32 | end 33 | ~~~ 34 | 35 | ### Transactions 36 | 37 | Transactions ensure consistency when selecting and inserting data. While the exact semantics are server specific, transactions normally ensure that all statements execute at a consistent point in time and that if any problem occurs during the transaction, the entire transaction is aborted. 38 | 39 | ~~~ ruby 40 | require 'async' 41 | require 'db/client' 42 | require 'db/postgres' 43 | 44 | client = DB::Client.new(DB::Postgres::Adapter.new(database: 'test')) 45 | 46 | Sync do 47 | session = client.transaction 48 | 49 | # Use the explicit DSL for generating queries: 50 | session.clause("CREATE TABLE") 51 | .identifier(:users) 52 | .clause("(") 53 | .identifier(:id).clause("BIGSERIAL PRIMARY KEY,") 54 | .identifier(:name).clause("VARCHAR NOT NULL") 55 | .clause(")").call 56 | 57 | # Use interpolation for generating queries: 58 | session.query(<<~SQL, table: :users, column: :name, value: "ioquatix").call 59 | INSERT INTO %{table} (%{column}) VALUES (%{value}) 60 | SQL 61 | 62 | result = session.clause("SELECT * FROM").identifier(:users).call 63 | 64 | pp result.to_a 65 | 66 | session.abort 67 | 68 | ensure 69 | session.close 70 | end 71 | ~~~ 72 | 73 | Because the session was aborted, the table and data are never committed: 74 | 75 | ~~~ ruby 76 | require 'async' 77 | require 'db/client' 78 | require 'db/postgres' 79 | 80 | client = DB::Client.new(DB::Postgres::Adapter.new(database: 'test')) 81 | 82 | Sync do 83 | session = client.session 84 | 85 | result = session.clause("SELECT * FROM").identifier(:users).call 86 | # => DB::Postgres::Error: Could not get next result: ERROR: relation "users" does not exist 87 | 88 | pp result.to_a 89 | 90 | ensure 91 | session.close 92 | end 93 | ~~~ 94 | 95 | ### Closing Sessions 96 | 97 | It is important that you close a session or commit/abort a transaction (implicit close). Closing a session returns it to the connection pool. If you don't do this, you will leak connections. Both {ruby DB::Client#session} and {ruby DB::Client#transaction} can accept blocks and will implicitly close/commit/abort as appropriate. 98 | 99 | ## Query Builder 100 | 101 | A {ruby DB::Query} builder is provided to help construct queries and avoid SQL injection attacks. This query builder is bound to a {ruby DB::Context::Session} instance and provides convenient methods for constructing a query efficiently. 102 | 103 | ### Low Level Methods 104 | 105 | There are several low level methods for constructing queries. 106 | 107 | - {ruby DB::Query#clause} appends an unescaped fragment of SQL text. 108 | - {ruby DB::Query#literal} appends an escaped literal value (e.g. {ruby String}, {ruby Integer}, {ruby true}, {ruby nil}, etc). 109 | - {ruby DB::Query#identifier} appends an escaped identifier ({ruby Symbol}, {ruby Array}, {ruby DB::Identifier}). 110 | 111 | ~~~ ruby 112 | require 'async' 113 | require 'db/client' 114 | require 'db/postgres' 115 | 116 | client = DB::Client.new(DB::Postgres::Adapter.new(database: 'test')) 117 | 118 | Sync do 119 | session = client.session 120 | 121 | # Build a query, injecting the literal 42 and the identifier LIFE into the statement: 122 | result = session 123 | .clause("SELECT").literal(42) 124 | .clause("AS").identifier(:LIFE) 125 | .call 126 | 127 | pp result.to_a 128 | # => [[42]] 129 | end 130 | ~~~ 131 | 132 | ### Interpolation Method 133 | 134 | You can also use string interpolation to safely construct queries. 135 | 136 | - {ruby DB::Query#interpolate} appends an interpolated query string with escaped parameters. 137 | 138 | ~~~ ruby 139 | require 'async' 140 | require 'db/client' 141 | require 'db/postgres' 142 | 143 | client = DB::Client.new(DB::Postgres::Adapter.new(database: 'test')) 144 | 145 | Sync do 146 | session = client.session 147 | 148 | # Build a query, injecting the literal 42 and the identifier LIFE into the statement: 149 | result = session.query(<<~SQL, value: 42, column: :LIFE).call 150 | SELECT %{value} AS %{column} 151 | SQL 152 | 153 | pp result.to_a 154 | # => [[42]] 155 | end 156 | ~~~ 157 | 158 | Named parameters are escaped and substituted into the given fragment. 159 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains how to use `db` for database queries. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add db 11 | ~~~ 12 | 13 | ## Core Concepts 14 | 15 | `db` has several core concepts: 16 | 17 | - A {ruby DB::Client} instance which is configured to connect to a specific database using an adapter, and manages a connection pool. 18 | - A {ruby DB::Context::Session} instance which is bound to a specific connection and allows you to execute queries and enumerate results. 19 | 20 | ## Connecting to Postgres 21 | 22 | Add the Postgres adaptor to your project: 23 | 24 | ~~~ bash 25 | $ bundle add db-postgres 26 | ~~~ 27 | 28 | Set up the client with the appropriate credentials: 29 | 30 | ~~~ ruby 31 | require 'async' 32 | require 'db/client' 33 | require 'db/postgres' 34 | 35 | # Create the client and connection pool: 36 | client = DB::Client.new(DB::Postgres::Adapter.new(database: 'test')) 37 | 38 | # Create an event loop: 39 | Sync do 40 | # Connect to the database: 41 | session = client.session 42 | 43 | # Execute the query and get a result set: 44 | result = session.call("SELECT VERSION()") 45 | 46 | # Convert the result set to an array and print it out: 47 | pp result.to_a 48 | # => [["PostgreSQL 16.3 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 14.1.1 20240522, 64-bit"]] 49 | ensure 50 | # Return the connection to the client connection pool: 51 | session.close 52 | end 53 | ~~~ 54 | 55 | ## Connection to MariaDB/MySQL 56 | 57 | Add the MariaDB adaptor to your project: 58 | 59 | ~~~ bash 60 | $ bundle add db-mariadb 61 | ~~~ 62 | 63 | Set up the client with the appropriate credentials: 64 | 65 | ~~~ ruby 66 | require 'async' 67 | require 'db/client' 68 | require 'db/mariadb' 69 | 70 | # Create the client and connection pool: 71 | client = DB::Client.new(DB::MariaDB::Adapter.new(database: 'test')) 72 | 73 | # Create an event loop: 74 | Sync do 75 | # Connect to the database: 76 | session = client.session 77 | 78 | # Execute the query and get a result set: 79 | result = session.call("SELECT VERSION()") 80 | 81 | # Convert the result set to an array and print it out: 82 | pp result.to_a 83 | # => [["10.4.13-MariaDB"]] 84 | ensure 85 | # Return the connection to the client connection pool: 86 | session.close 87 | end 88 | ~~~ 89 | -------------------------------------------------------------------------------- /guides/links.yaml: -------------------------------------------------------------------------------- 1 | getting-started: 2 | order: 1 3 | executing-queries: 4 | order: 2 5 | example-queries: 6 | order: 3 7 | datatypes: 8 | order: 4 9 | -------------------------------------------------------------------------------- /lib/db.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "db/version" 7 | require_relative "db/adapters" 8 | require_relative "db/client" 9 | -------------------------------------------------------------------------------- /lib/db/adapters.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 | # A global map of registered adapters. 8 | # e.g. `DB::Adapters.register(:mariadb, DB::MariaDB::Adapter)` 9 | module Adapters 10 | @adapters = {} 11 | 12 | # Register the adapter class to the specified name. 13 | # @parameter name [Symbol] The adapter name. 14 | # @parameter adapter [Class] The adapter class. 15 | def self.register(name, adapter) 16 | @adapters[name] = adapter 17 | end 18 | 19 | # Enumerate all registered adapters. 20 | # @yields {|name, adapter| ...} The adapters if a block is given. 21 | # @parameter name [Symbol] The adapter name. 22 | # @parameter adapter [Class] The adapter class 23 | # @returns [Enumerator(Symbol, Class)] If no block is given. 24 | def self.each(&block) 25 | @adapters.each(&block) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/db/client.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/controller" 7 | 8 | require_relative "context/session" 9 | require_relative "context/transaction" 10 | 11 | module DB 12 | # Binds a connection pool to the specified adapter. 13 | class Client 14 | # Initialize the client and internal connection pool using the specified adapter. 15 | # @parameter adapter [Object] The adapter instance. 16 | def initialize(adapter, **options) 17 | @adapter = adapter 18 | 19 | @pool = connect(**options) 20 | end 21 | 22 | # The adapter used for making connections. 23 | # @attribute [Object] 24 | attr :adapter 25 | 26 | # Close all open connections in the connection pool. 27 | def close 28 | @pool.close 29 | end 30 | 31 | # Acquires a connection and sends the specified statement if given. 32 | # @parameters statement [String | Nil] An optional statement to send. 33 | # @yields {|session| ...} A connected session if a block is given. Implicitly closed. 34 | # @parameter session [Context::Session] 35 | # @returns [Context::Session] A connected session if no block is given. 36 | def session(**options) 37 | session = Context::Session.new(@pool, **options) 38 | 39 | return session unless block_given? 40 | 41 | begin 42 | session.connect! 43 | 44 | yield session 45 | ensure 46 | session.close 47 | end 48 | end 49 | 50 | alias context session 51 | 52 | # Acquires a connection and starts a transaction. 53 | # @parameters statement [String | Nil] An optional statement to send. Defaults to `"BEGIN"`. 54 | # @yields {|session| ...} A connected session if a block is given. Implicitly commits, or aborts the connnection if an exception is raised. 55 | # @parameter session [Context::Transaction] 56 | # @returns [Context::Transaction] A connected and started transaction if no block is given. 57 | def transaction(**options) 58 | transaction = Context::Transaction.new(@pool, **options) 59 | 60 | transaction.begin 61 | 62 | return transaction unless block_given? 63 | 64 | begin 65 | yield transaction 66 | 67 | rescue 68 | transaction.abort 69 | raise 70 | ensure 71 | transaction.commit? 72 | end 73 | end 74 | 75 | protected 76 | 77 | def connect(**options) 78 | Async::Pool::Controller.new(@adapter, **options) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/db/context/session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "../query" 7 | require_relative "../records" 8 | 9 | module DB 10 | module Context 11 | # A connected context for sending queries and reading results. 12 | class Session 13 | # Initialize the query context attached to the given connection pool. 14 | def initialize(pool, **options) 15 | @pool = pool 16 | @connection = nil 17 | end 18 | 19 | attr :pool 20 | attr :connection 21 | 22 | # Pin a connection to the current session. 23 | def connect! 24 | @connection ||= @pool.acquire 25 | end 26 | 27 | # Flush the connection and then return it to the connection pool. 28 | def close 29 | if @connection 30 | @pool.release(@connection) 31 | @connection = nil 32 | end 33 | end 34 | 35 | def closed? 36 | @connection.nil? 37 | end 38 | 39 | def with_connection(&block) 40 | if @connection 41 | yield @connection 42 | else 43 | @pool.acquire do |connection| 44 | @connection = connection 45 | 46 | yield connection 47 | ensure 48 | @connection = nil 49 | end 50 | end 51 | end 52 | 53 | # Send a query to the server. 54 | # @parameter statement [String] The SQL query to send. 55 | def call(statement, **options) 56 | self.with_connection do |connection| 57 | connection.send_query(statement, **options) 58 | 59 | if block_given? 60 | yield connection 61 | elsif result = connection.next_result 62 | return Records.wrap(result) 63 | end 64 | end 65 | end 66 | 67 | def query(fragment = String.new, **parameters) 68 | with_connection do 69 | if parameters.empty? 70 | Query.new(self, fragment) 71 | else 72 | Query.new(self).interpolate(fragment, **parameters) 73 | end 74 | end 75 | end 76 | 77 | def clause(fragment = String.new) 78 | with_connection do 79 | Query.new(self, fragment) 80 | end 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/db/context/transaction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "session" 7 | 8 | module DB 9 | module Context 10 | class Transaction < Session 11 | # Begin a transaction. 12 | def begin 13 | self.connect! 14 | self.call("BEGIN") 15 | end 16 | 17 | # Commit the transaction and return the connection to the connection pool. 18 | def commit 19 | self.call("COMMIT") 20 | self.close 21 | end 22 | 23 | def commit? 24 | unless self.closed? 25 | self.commit 26 | end 27 | end 28 | 29 | # Abort the transaction and return the connection to the connection pool. 30 | def abort 31 | self.call("ROLLBACK") 32 | self.close 33 | end 34 | 35 | # Mark a savepoint in the transaction. 36 | def savepoint(name) 37 | self.call("SAVEPOINT #{name}") 38 | end 39 | 40 | # Return back to a previously registered savepoint. 41 | def rollback(name) 42 | self.call("ROLLBACK #{name}") 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/db/query.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 | # Represents one or more identifiers for databases, tables or columns. 8 | class Identifier < Array 9 | def self.coerce(name_or_identifier) 10 | case name_or_identifier 11 | when Identifier 12 | name_or_identifier 13 | when Array 14 | self.new(name_or_identifier) 15 | when Symbol 16 | self[name_or_identifier] 17 | else 18 | self[name_or_identifier.to_sym] 19 | end 20 | end 21 | 22 | def append_to(query) 23 | query.identifier(self) 24 | end 25 | end 26 | 27 | # A mutable query builder. 28 | class Query 29 | # Create a new query builder attached to the specified context. 30 | # @parameter context [Context::Generic] the context which is used for escaping arguments. 31 | def initialize(context, buffer = String.new) 32 | @context = context 33 | @connection = context.connection 34 | @buffer = +buffer 35 | end 36 | 37 | # Append a raw textual clause to the query buffer. 38 | # @parameter value [String] A raw SQL string, e.g. `WHERE x > 10`. 39 | # @returns [Query] The mutable query itself. 40 | def clause(value) 41 | @buffer << " " unless @buffer.end_with?(" ") || @buffer.empty? 42 | 43 | @buffer << value 44 | 45 | return self 46 | end 47 | 48 | # Append a literal value to the query buffer. 49 | # Escapes the field according to the requirements of the underlying connection. 50 | # @parameter value [Object] Any kind of object, passed to the underlying database connection for conversion to a string representation. 51 | # @returns [Query] The mutable query itself. 52 | def literal(value) 53 | @buffer << " " unless @buffer.end_with?(" ") 54 | 55 | @connection.append_literal(value, @buffer) 56 | 57 | return self 58 | end 59 | 60 | # Append an identifier value to the query buffer. 61 | # Escapes the field according to the requirements of the underlying connection. 62 | # @parameter value [String | Symbol | DB::Identifier] Passed to the underlying database connection for conversion to a string representation. 63 | # @returns [Query] The mutable query itself. 64 | def identifier(value) 65 | @buffer << " " unless @buffer.end_with?(" ") 66 | 67 | @connection.append_identifier(value, @buffer) 68 | 69 | return self 70 | end 71 | 72 | # Interpolate a query fragment with the specified parameters. 73 | # The parameters are escaped before being appended. 74 | # 75 | # @parameter fragment [String] A fragment of SQL including placeholders, e.g. `WHERE x > %{column}`. 76 | # @parameter parameters [Hash] The substitution parameters. 77 | # @returns [Query] The mutable query itself. 78 | def interpolate(fragment, **parameters) 79 | parameters.transform_values! do |value| 80 | case value 81 | when Symbol, Identifier 82 | @connection.append_identifier(value) 83 | else 84 | @connection.append_literal(value) 85 | end 86 | end 87 | 88 | @buffer << sprintf(fragment, parameters) 89 | 90 | return self 91 | end 92 | 93 | def key_column(*arguments, **options) 94 | @buffer << @connection.key_column(*arguments, **options) 95 | 96 | return self 97 | end 98 | 99 | # Send the query to the remote server to be executed. See {Context::Session#call} for more details. 100 | # @returns [Enumerable] The resulting records. 101 | def call(&block) 102 | # Console.debug(self, "Executing query...", buffer: @buffer) 103 | @context.call(@buffer, &block) 104 | end 105 | 106 | def to_s 107 | @buffer 108 | end 109 | 110 | def inspect 111 | "\#<#{self.class} #{@buffer.inspect}>" 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/db/records.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | module DB 7 | # A buffer of records. 8 | class Records 9 | def self.wrap(result) 10 | # We want to avoid extra memory allocations when there are no columns: 11 | if result.field_count == 0 12 | return nil 13 | end 14 | 15 | return self.new(result.field_names, result.to_a) 16 | end 17 | 18 | def initialize(columns, rows) 19 | @columns = columns 20 | @rows = rows 21 | end 22 | 23 | def freeze 24 | return self if frozen? 25 | 26 | @columns.freeze 27 | @rows.freeze 28 | 29 | super 30 | end 31 | 32 | attr :columns 33 | attr :rows 34 | 35 | def to_a 36 | @rows 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/db/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 | VERSION = "0.12.0" 8 | end 9 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2020-2024, by Samuel Williams. 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # DB 2 | 3 | Provides event-driven asynchronous drivers for various database adaptors, including [Postgres](https://github.com/socketry/db-postgres) and [MariaDB/MySQL](https://github.com/socketry/db-mariadb). 4 | 5 | [![Development Status](https://github.com/socketry/db/workflows/Test/badge.svg)](https://github.com/socketry/db/actions?workflow=Test) 6 | 7 | ## Features 8 | 9 | - Event driven I/O for streaming queries and results. 10 | - Standard interface for multiple database adapters. 11 | 12 | ## Usage 13 | 14 | Please see the [project documentation](https://socketry.github.io/db/) for more details. 15 | 16 | - [Getting Started](https://socketry.github.io/db/guides/getting-started/index) - This guide explains how to use `db` for database queries. 17 | 18 | - [Executing Queries](https://socketry.github.io/db/guides/executing-queries/index) - This guide explains how to escape and execute queries. 19 | 20 | - [Example Queries](https://socketry.github.io/db/guides/example-queries/index) - This guide shows a variety of example queries using the DB gem. 21 | 22 | - [Data Types](https://socketry.github.io/db/guides/datatypes/index) - This guide explains about SQL data types, and how they are used by the DB gem. 23 | 24 | ## See Also 25 | 26 | - [db-postgres](https://github.com/socketry/db-postgres) - Postgres adapter for the DB gem. 27 | - [db-mariadb](https://github.com/socketry/db-mariadb) - MariaDB/MySQL adapter for the DB gem. 28 | - [db-model](https://github.com/socketry/db-model) - A simple object relational mapper (ORM) for the DB gem. 29 | - [db-migrate](https://github.com/socketry/db-migrate) - Database migration tooling for the DB gem. 30 | - [db-active\_record](https://github.com/socketry/db-active_record) - An ActiveRecord adapter for the DB gem. 31 | 32 | ## Contributing 33 | 34 | We welcome contributions to this project. 35 | 36 | 1. Fork it. 37 | 2. Create your feature branch (`git checkout -b my-new-feature`). 38 | 3. Commit your changes (`git commit -am 'Add some feature'`). 39 | 4. Push to the branch (`git push origin my-new-feature`). 40 | 5. Create new Pull Request. 41 | 42 | ### Developer Certificate of Origin 43 | 44 | 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. 45 | 46 | ### Community Guidelines 47 | 48 | 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. 49 | -------------------------------------------------------------------------------- /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.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "db" 7 | 8 | describe DB do 9 | it "has a version number" do 10 | expect(DB::VERSION).to be =~ /\d+\.\d+\.\d+/ 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/db/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "db/client_context" 7 | 8 | describe DB::Client do 9 | DB::Adapters.each do |name, klass| 10 | describe klass, unique: name do 11 | include_context DB::ClientContext, klass.new(**CREDENTIALS) 12 | 13 | it "can select version" do 14 | client.session do |session| 15 | result = session.call("SELECT VERSION()") 16 | expect(result).to be_a(DB::Records) 17 | 18 | row = result.rows.first 19 | expect(row[0]).to be_a(String) 20 | end 21 | end 22 | 23 | it "can execute multiple queries" do 24 | client.session do |session| 25 | query = <<~SQL * 2 26 | SELECT 42 AS LIFE; 27 | SQL 28 | 29 | session.call(query) do |connection| 30 | 2.times do 31 | result = connection.next_result 32 | expect(result.to_a).to be == [[42]] 33 | end 34 | end 35 | end 36 | end 37 | 38 | it "can generate a query with literal values" do 39 | client.session do |session| 40 | session.clause("SELECT").literal(42).clause("AS").identifier(:LIFE).call do |connection| 41 | result = connection.next_result 42 | expect(result.to_a).to be == [[42]] 43 | end 44 | end 45 | end 46 | 47 | it "can generate a query using interpolations" do 48 | client.session do |session| 49 | session.query("SELECT %{value} AS %{column}", value: 42, column: :LIFE).call do |connection| 50 | result = connection.next_result 51 | expect(result.to_a).to be == [[42]] 52 | end 53 | end 54 | end 55 | 56 | it "can execute a query in a transaction" do 57 | client.transaction do |transaction| 58 | transaction.call("SELECT 42 AS LIFE") do |connection| 59 | result = connection.next_result 60 | expect(result.to_a).to be == [[42]] 61 | end 62 | end 63 | end 64 | 65 | with "events table" do 66 | before do 67 | client.transaction do |transaction| 68 | transaction.call("DROP TABLE IF EXISTS events") 69 | 70 | transaction.call("CREATE TABLE IF NOT EXISTS events (#{transaction.connection.key_column}, created_at TIMESTAMP NOT NULL, description TEXT NULL)") 71 | end 72 | end 73 | 74 | it "can insert rows with timestamps" do 75 | client.session do |session| 76 | session.call("INSERT INTO events (created_at, description) VALUES ('2020-05-04 03:02:01', 'Hello World')") 77 | 78 | rows = session.call("SELECT * FROM events") do |connection| 79 | connection.next_result.to_a 80 | end 81 | 82 | expect(rows).to be == [[1, Time.parse("2020-05-04 03:02:01 UTC"), "Hello World"]] 83 | end 84 | end 85 | 86 | it "can insert null fields" do 87 | client.session do |session| 88 | session.call("INSERT INTO events (created_at, description) VALUES ('2020-05-04 03:02:01', NULL)") 89 | 90 | rows = session.call("SELECT * FROM events") do |connection| 91 | connection.next_result.to_a 92 | end 93 | 94 | expect(rows).to be == [[1, Time.parse("2020-05-04 03:02:01 UTC"), nil]] 95 | end 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/db/datatype/datetime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require "db/datatype_context" 7 | 8 | describe "datetime datatype" do 9 | DB::Adapters.each do |name, klass| 10 | describe klass, unique: name do 11 | include_context DB::DatatypeContext, klass.new(**CREDENTIALS), :datetime 12 | 13 | it "can insert utc time" do 14 | time = Time.utc(2020, 07, 02, 10, 11, 12) 15 | client.session do |session| 16 | session.query("INSERT INTO %{table_name} (value) VALUES (%{value})", table_name: table_name, value: time).call 17 | 18 | row = session.query("SELECT * FROM %{table_name}", table_name: table_name).call.to_a.first 19 | 20 | expect(row.first).to be == time 21 | end 22 | end 23 | 24 | it "can insert local time" do 25 | time = Time.new(2020, 07, 02, 10, 11, 12, "+12:00") 26 | 27 | client.session do |session| 28 | session.query("INSERT INTO %{table_name} (value) VALUES (%{value})", table_name: table_name, value: time).call 29 | 30 | row = session.query("SELECT * FROM %{table_name}", table_name: table_name).call.to_a.first 31 | 32 | expect(row.first).to be == time 33 | end 34 | end 35 | end 36 | end 37 | end 38 | --------------------------------------------------------------------------------