├── .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-postgres.gemspec ├── gems.rb ├── guides ├── getting-started │ └── readme.md └── links.yaml ├── lib └── db │ ├── postgres.rb │ └── postgres │ ├── 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 └── postgres ├── connection.rb └── connection └── date_time.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 | 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 apt-get install postgresql postgresql-contrib 52 | 53 | - name: Run tests 54 | timeout-minutes: 5 55 | run: bundle exec bake test 56 | 57 | - uses: actions/upload-artifact@v3 58 | with: 59 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 60 | path: .covered.db 61 | 62 | validate: 63 | needs: test 64 | runs-on: ubuntu-latest 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: ruby/setup-ruby@v1 69 | with: 70 | ruby-version: "3.3" 71 | bundler-cache: true 72 | 73 | - uses: actions/download-artifact@v3 74 | 75 | - name: Validate coverage 76 | timeout-minutes: 5 77 | run: bundle exec bake covered:validate --paths */.covered.db \; 78 | -------------------------------------------------------------------------------- /.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 | 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 apt-get install postgresql postgresql-contrib 53 | 54 | - name: Run tests 55 | timeout-minutes: 10 56 | run: bundle exec bake test:external 57 | -------------------------------------------------------------------------------- /.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 | 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 apt-get install postgresql postgresql-contrib 67 | 68 | - name: Run tests 69 | timeout-minutes: 10 70 | run: bundle exec bake test 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Aidan Samuel 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, 2018-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-postgres.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/db/postgres/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "db-postgres" 7 | spec.version = DB::Postgres::VERSION 8 | 9 | spec.summary = "Ruby FFI bindings for libpq C interface." 10 | spec.authors = ["Samuel Williams", "Aidan Samuel", "Tony Schneider"] 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-postgres" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/db-postgres/", 20 | "funding_uri" => "https://github.com/sponsors/ioquatix", 21 | "source_code_uri" => "https://github.com/socketry/db-postgres.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 "ffi-native", "~> 0.4" 30 | end 31 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-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-postgres` gem. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add db-postgres 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/postgres' 20 | 21 | # Create an event loop: 22 | Sync do 23 | # Create the adapter and connect to the database: 24 | adapter = DB::Postgres::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 | # => [["PostgreSQL 16.3 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 14.1.1 20240522, 64-bit"]] 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/postgres.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require_relative 'postgres/native' 7 | require_relative 'postgres/connection' 8 | 9 | require_relative 'postgres/adapter' 10 | 11 | require 'db/adapters' 12 | DB::Adapters.register(:postgres, DB::Postgres::Adapter) 13 | -------------------------------------------------------------------------------- /lib/db/postgres/adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require_relative 'connection' 7 | 8 | module DB 9 | module Postgres 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/postgres/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require 'async/pool/resource' 7 | require_relative 'native/connection' 8 | 9 | module DB 10 | module Postgres 11 | # This implements the interface between the underlying 12 | class Connection < Async::Pool::Resource 13 | def initialize(**options) 14 | @native = Native::Connection.connect(**options) 15 | 16 | super() 17 | end 18 | 19 | def close 20 | if @native 21 | @native&.close 22 | @native = nil 23 | end 24 | 25 | super 26 | end 27 | 28 | def types 29 | @native.types 30 | end 31 | 32 | def append_string(value, buffer = String.new) 33 | buffer << @native.escape_literal(value) 34 | 35 | return buffer 36 | end 37 | 38 | def append_literal(value, buffer = String.new) 39 | case value 40 | when Time, DateTime, Date 41 | append_string(value.iso8601, 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 << @native.escape_identifier(part) 66 | end 67 | else 68 | buffer << @native.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 | if primary 80 | buffer << " BIGSERIAL" 81 | else 82 | buffer << " BIGINT" 83 | end 84 | 85 | if primary 86 | buffer << " PRIMARY KEY" 87 | elsif !null 88 | buffer << " NOT NULL" 89 | end 90 | 91 | return buffer 92 | end 93 | 94 | def status 95 | @native.status 96 | end 97 | 98 | def send_query(statement) 99 | @native.discard_results 100 | 101 | @native.send_query(statement) 102 | end 103 | 104 | def next_result 105 | @native.next_result 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/db/postgres/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 Postgres 8 | class Error < StandardError 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/db/postgres/native.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require 'ffi/native' 7 | require 'ffi/native/config_tool' 8 | 9 | module DB 10 | module Postgres 11 | module Native 12 | extend FFI::Native::Library 13 | extend FFI::Native::Loader 14 | extend FFI::Native::ConfigTool 15 | 16 | ffi_load('pq') || 17 | ffi_load_using_config_tool(%w{pg_config --libdir}, names: ['pq']) || 18 | ffi_load_failure(<<~EOF) 19 | Unable to load libpq! 20 | 21 | ## Ubuntu 22 | 23 | sudo apt-get install libpq-dev 24 | 25 | ## Arch Linux 26 | 27 | sudo pacman -S postgresql 28 | 29 | ## MacPorts 30 | 31 | sudo port install postgresql11 32 | 33 | ## Homebrew 34 | 35 | brew install postgresql 36 | 37 | EOF 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/db/postgres/native/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require_relative 'result' 7 | require_relative 'field' 8 | require_relative '../error' 9 | 10 | module DB 11 | module Postgres 12 | module Native 13 | class Strings 14 | def initialize(values) 15 | @array = FFI::MemoryPointer.new(:pointer, values.size + 1) 16 | @pointers = values.map do |value| 17 | FFI::MemoryPointer.from_string(value.to_s) 18 | end 19 | @array.write_array_of_pointer(@pointers) 20 | end 21 | 22 | attr :array 23 | end 24 | 25 | ffi_attach_function :PQconnectStartParams, [:pointer, :pointer, :int], :pointer, as: :connect_start_params 26 | 27 | ffi_define_enumeration :polling_status, [ 28 | :failed, 29 | :wait_readable, 30 | :wait_writable, 31 | :ok, 32 | ] 33 | 34 | ffi_attach_function :PQconnectPoll, [:pointer], :polling_status, as: :connect_poll 35 | 36 | # Close the connection and release underlying resources. 37 | ffi_attach_function :PQfinish, [:pointer], :void, as: :finish 38 | 39 | ffi_attach_function :PQerrorMessage, [:pointer], :string, as: :error_message 40 | 41 | ffi_define_enumeration :status, [ 42 | # Normal mode: 43 | :ok, 44 | :bad, 45 | 46 | # Non-blocking mode: 47 | :started, # Waiting for connection to be made. 48 | :made, # Connection OK; waiting to send. 49 | :awaiting_response, #Waiting for a response from the postmaster. 50 | :auth_ok, # Received authentication; waiting for backend startup. 51 | :setenv, # Negotiating environment. 52 | :ssl_startup, # Negotiating SSL. 53 | :needed, # Internal state: connect() needed 54 | :check_writable, # Check if we could make a writable connection. 55 | :consume, # Wait for any pending message and consume them. 56 | ] 57 | 58 | ffi_attach_function :PQstatus, [:pointer], :status, as: :status 59 | 60 | ffi_attach_function :PQsocket, [:pointer], :int, as: :socket 61 | 62 | ffi_attach_function :PQsetnonblocking, [:pointer, :int], :int, as: :set_nonblocking 63 | ffi_attach_function :PQflush, [:pointer], :int, as: :flush 64 | 65 | # Submits a command to the server without waiting for the result(s). 1 is returned if the command was successfully dispatched and 0 if not (in which case, use PQerrorMessage to get more information about the failure). 66 | ffi_attach_function :PQsendQuery, [:pointer, :string], :int, as: :send_query 67 | 68 | # int PQsendQueryParams(PGconn *conn, const char *command, int nParams, const Oid *paramTypes, const char * const *paramValues, const int *paramLengths, const int *paramFormats, int resultFormat); 69 | ffi_attach_function :PQsendQueryParams, [:pointer, :string, :int, :pointer, :pointer, :pointer, :pointer, :int], :int, as: :send_query_params 70 | 71 | ffi_attach_function :PQsetSingleRowMode, [:pointer], :int, as: :set_single_row_mode 72 | 73 | ffi_attach_function :PQgetResult, [:pointer], :pointer, as: :get_result 74 | 75 | # If input is available from the server, consume it: 76 | ffi_attach_function :PQconsumeInput, [:pointer], :int, as: :consume_input 77 | 78 | # Returns 1 if a command is busy, that is, PQgetResult would block waiting for input. A 0 return indicates that PQgetResult can be called with assurance of not blocking. 79 | ffi_attach_function :PQisBusy, [:pointer], :int, as: :is_busy 80 | 81 | ffi_attach_function :PQescapeLiteral, [:pointer, :string, :size_t], :pointer, as: :escape_literal 82 | ffi_attach_function :PQescapeIdentifier, [:pointer, :string, :size_t], :pointer, as: :escape_identifier 83 | 84 | class Connection < FFI::Pointer 85 | def self.connect(types: DEFAULT_TYPES, **options) 86 | # Postgres expects "dbname" as the key name: 87 | if database = options.delete(:database) 88 | options[:dbname] = database 89 | end 90 | 91 | # Postgres expects "user" as the key name: 92 | if username = options.delete(:username) 93 | options[:user] = username 94 | end 95 | 96 | keys = Strings.new(options.keys) 97 | values = Strings.new(options.values) 98 | 99 | pointer = Native.connect_start_params(keys.array, values.array, 0) 100 | Native.set_nonblocking(pointer, 1) 101 | 102 | io = ::IO.new(Native.socket(pointer), "r+", autoclose: false) 103 | 104 | while status = Native.connect_poll(pointer) 105 | break if status == :ok || status == :failed 106 | 107 | # one of :wait_readable or :wait_writable 108 | io.send(status) 109 | end 110 | 111 | if status == :failed 112 | io.close 113 | 114 | error_message = Native.error_message(pointer) 115 | 116 | Native.finish(pointer) 117 | 118 | raise Error, "Could not connect: #{error_message}" 119 | end 120 | 121 | return self.new(pointer, io, types) 122 | end 123 | 124 | def initialize(address, io, types) 125 | super(address) 126 | 127 | @io = io 128 | @types = types 129 | end 130 | 131 | attr :types 132 | 133 | # Return the status of the connection. 134 | def status 135 | Native.status(self) 136 | end 137 | 138 | # Return the last error message. 139 | def error_message 140 | Native.error_message(self) 141 | end 142 | 143 | # Return the underlying socket used for IO. 144 | def socket 145 | Native.socket(self) 146 | end 147 | 148 | # Close the connection. 149 | def close 150 | Native.finish(self) 151 | 152 | @io.close 153 | end 154 | 155 | def escape_literal(value) 156 | value = value.to_s 157 | 158 | result = Native.escape_literal(self, value, value.bytesize) 159 | 160 | string = result.read_string 161 | 162 | Native.free_memory(result) 163 | 164 | return string 165 | end 166 | 167 | def escape_identifier(value) 168 | value = value.to_s 169 | 170 | result = Native.escape_identifier(self, value, value.bytesize) 171 | 172 | string = result.read_string 173 | 174 | Native.free_memory(result) 175 | 176 | return string 177 | end 178 | 179 | def single_row_mode! 180 | Native.set_single_row_mode(self) 181 | end 182 | 183 | def send_query(statement) 184 | check! Native.send_query(self, statement) 185 | 186 | flush 187 | end 188 | 189 | def next_result(types: @types) 190 | if result = self.get_result 191 | status = Native.result_status(result) 192 | 193 | if status == :fatal_error 194 | message = Native.result_error_message(result) 195 | 196 | Native.clear(result) 197 | 198 | raise Error, message 199 | end 200 | 201 | return Result.new(self, types, result) 202 | end 203 | end 204 | 205 | # Silently discard any results that application didn't read. 206 | def discard_results 207 | while result = self.get_result 208 | status = Native.result_status(result) 209 | Native.clear(result) 210 | 211 | case status 212 | when :copy_in 213 | self.put_copy_end("Discard results") 214 | when :copy_out 215 | self.flush_copy_out 216 | end 217 | end 218 | 219 | return nil 220 | end 221 | 222 | protected 223 | 224 | def get_result 225 | while true 226 | check! Native.consume_input(self) 227 | 228 | while Native.is_busy(self) == 0 229 | result = Native.get_result(self) 230 | 231 | # Did we finish reading all results? 232 | if result.null? 233 | return nil 234 | else 235 | return result 236 | end 237 | end 238 | 239 | @io.wait_readable 240 | end 241 | end 242 | 243 | def put_copy_end(message = nil) 244 | while true 245 | status = Native.put_copy_end(self, message) 246 | 247 | if status == -1 248 | message = Native.error_message(self) 249 | raise Error, message 250 | elsif status == 0 251 | @io.wait_writable 252 | else 253 | break 254 | end 255 | end 256 | end 257 | 258 | def flush_copy_out 259 | buffer = FFI::MemoryPointer.new(:pointer, 1) 260 | 261 | while true 262 | status = Native.get_copy_data(self, buffer, 1) 263 | 264 | if status == -2 265 | message = Native.error_message(self) 266 | raise Error, message 267 | elsif status == -1 268 | break 269 | elsif status == 0 270 | @io.wait_readable 271 | else 272 | Native.free_memory(buffer.read_pointer) 273 | end 274 | end 275 | end 276 | 277 | # After sending any command or data on a nonblocking connection, call PQflush. If it returns 1, wait for the socket to become read- or write-ready. If it becomes write-ready, call PQflush again. If it becomes read-ready, call PQconsumeInput, then call PQflush again. Repeat until PQflush returns 0. (It is necessary to check for read-ready and drain the input with PQconsumeInput, because the server can block trying to send us data, e.g. NOTICE messages, and won't read our data until we read its.) Once PQflush returns 0, wait for the socket to be read-ready and then read the response as described above. 278 | def flush 279 | while true 280 | case Native.flush(self) 281 | when 1 282 | @io.wait_any 283 | 284 | check! Native.consume_input(self) 285 | when 0 286 | return 287 | end 288 | end 289 | end 290 | 291 | def check!(result) 292 | if result == 0 293 | message = Native.error_message(self) 294 | raise Error, message 295 | end 296 | end 297 | end 298 | end 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /lib/db/postgres/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 'types' 7 | 8 | module DB 9 | module Postgres 10 | module Native 11 | DEFAULT_TYPES = { 12 | # Pseudo types: 13 | primary_key: Types::Integer.new('BIGSERIAL PRIMARY KEY'), 14 | foreign_key: Types::Integer.new('BIGINT'), 15 | text: Types::Text.new("TEXT"), 16 | string: Types::Text.new("VARCHAR(255)"), 17 | 18 | # Symbolic types: 19 | decimal: Types::Decimal.new, 20 | boolean: Types::Boolean.new, 21 | 22 | smallint: Types::Integer.new("SMALLINT"), 23 | integer: Types::Integer.new("INTEGER"), 24 | bigint: Types::Integer.new("BIGINT"), 25 | 26 | float: Types::Float.new, 27 | double: Types::Float.new("DOUBLE"), 28 | 29 | timestamp: Types::DateTime.new("TIMESTAMPTZ"), 30 | date: Types::Date.new, 31 | datetime: Types::DateTime.new("TIMESTAMPTZ"), 32 | year: Types::Integer.new("LONG"), 33 | 34 | json: Types::JSON.new, 35 | enum: Types::Symbol.new, 36 | 37 | # Native types: 38 | # This data is extracted by hand from: 39 | # . 40 | # These are hard coded OIDs. 41 | 16 => Types::Boolean.new, 42 | 43 | 20 => Types::Integer.new("int8"), 44 | 21 => Types::Integer.new("int2"), 45 | 23 => Types::Integer.new("int4"), 46 | 47 | 114 => Types::JSON.new, 48 | 49 | 700 => Types::Float.new('float4'), 50 | 701 => Types::Float.new('float8'), 51 | 52 | 1082 => Types::Date.new, 53 | 1083 => Types::DateTime.new("TIME"), 54 | 55 | 1114 => Types::DateTime.new("TIMESTAMP"), 56 | 1184 => Types::DateTime.new("TIMESTAMPTZ"), 57 | 58 | 1700 => Types::Decimal.new, 59 | 60 | # Not sure if this is ever used? 61 | 3500 => Types::Symbol.new, 62 | } 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/db/postgres/native/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require_relative '../native' 7 | 8 | module DB 9 | module Postgres 10 | module Native 11 | ffi_define_enumeration :query_status, [ 12 | :empty_query, # empty query string was executed 13 | :command_ok, # a query command that doesn't return anything was executed properly by the backend 14 | :tuples_ok, # a query command that returns tuples was executed properly by the backend, PGresult contains the result tuples 15 | :copy_out, # Copy Out data transfer in progress 16 | :copy_in, # Copy In data transfer in progress 17 | :bad_response, # an unexpected response was recv'd from the backend 18 | :nonfatal_error, # notice or warning message 19 | :fatal_error, # query failed 20 | :copy_both, # Copy In/Out data transfer in progress 21 | :single_tuple, # single tuple from larger resultset 22 | ] 23 | 24 | ffi_attach_function :PQresultStatus, [:pointer], :query_status, as: :result_status 25 | ffi_attach_function :PQresultErrorMessage, [:pointer], :string, as: :result_error_message 26 | 27 | ffi_attach_function :PQntuples, [:pointer], :int, as: :row_count 28 | ffi_attach_function :PQnfields, [:pointer], :int, as: :field_count 29 | ffi_attach_function :PQfname, [:pointer, :int], :string, as: :field_name 30 | ffi_attach_function :PQftype, [:pointer, :int], :int, as: :field_type 31 | 32 | ffi_attach_function :PQgetvalue, [:pointer, :int, :int], :string, as: :get_value 33 | ffi_attach_function :PQgetisnull, [:pointer, :int, :int], :int, as: :get_is_null 34 | 35 | ffi_attach_function :PQclear, [:pointer], :void, as: :clear 36 | 37 | ffi_attach_function :PQputCopyEnd, [:pointer, :string], :int, as: :put_copy_end 38 | ffi_attach_function :PQgetCopyData, [:pointer, :pointer, :int], :int, as: :get_copy_data 39 | ffi_attach_function :PQfreemem, [:pointer], :void, as: :free_memory 40 | 41 | class Result < FFI::Pointer 42 | def initialize(connection, types = {}, address) 43 | super(address) 44 | 45 | @connection = connection 46 | @fields = nil 47 | @types = types 48 | @casts = nil 49 | end 50 | 51 | def field_count 52 | Native.field_count(self) 53 | end 54 | 55 | def field_types 56 | field_count.times.collect{|i| @types[Native.field_type(self, i)]} 57 | end 58 | 59 | def field_names 60 | field_count.times.collect{|i| Native.field_name(self, i)} 61 | end 62 | 63 | def row_count 64 | Native.row_count(self) 65 | end 66 | 67 | def cast!(row) 68 | @casts ||= self.field_types 69 | 70 | row.size.times do |index| 71 | if cast = @casts[index] 72 | row[index] = cast.parse(row[index]) 73 | end 74 | end 75 | 76 | return row 77 | end 78 | 79 | def each 80 | row_count.times do |i| 81 | yield cast!(get_row(i)) 82 | end 83 | 84 | Native.clear(self) 85 | end 86 | 87 | def map(&block) 88 | results = [] 89 | 90 | self.each do |row| 91 | results << yield(row) 92 | end 93 | 94 | return results 95 | end 96 | 97 | def to_a 98 | rows = [] 99 | 100 | self.each do |row| 101 | rows << row 102 | end 103 | 104 | return rows 105 | end 106 | 107 | protected 108 | 109 | def get_value(row, field) 110 | if Native.get_is_null(self, row, field) == 0 111 | Native.get_value(self, row, field) 112 | end 113 | end 114 | 115 | def get_row(row) 116 | field_count.times.collect{|j| get_value(row, j)} 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/db/postgres/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 Postgres 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 | string == 't' 44 | end 45 | end 46 | 47 | class Decimal 48 | def name 49 | "DECIMAL" 50 | end 51 | 52 | def parse(string) 53 | BigDecimal(string) if string 54 | end 55 | end 56 | 57 | class Float 58 | def initialize(name = "FLOAT") 59 | @name = name 60 | end 61 | 62 | attr :name 63 | 64 | def parse(string) 65 | Float(string) if string 66 | end 67 | end 68 | 69 | class Symbol 70 | def name 71 | "ENUM" 72 | end 73 | 74 | def parse(string) 75 | string&.to_sym 76 | end 77 | end 78 | 79 | class DateTime 80 | def initialize(name = "TIMESTAMP") 81 | @name = name 82 | end 83 | 84 | attr :name 85 | 86 | def parse(string) 87 | if string == '-infinity' || string == 'infinity' || string.nil? 88 | return string 89 | end 90 | 91 | if match = string.match(/\A(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+(?:\.\d+)?)([-+]\d\d(?::\d\d)?)?\z/) 92 | parts = match.captures 93 | 94 | parts[5] = Rational(parts[5]) 95 | 96 | if parts[6].nil? 97 | parts[6] = '+00' 98 | end 99 | 100 | return Time.new(*parts) 101 | end 102 | end 103 | end 104 | 105 | class Date 106 | def name 107 | "DATE" 108 | end 109 | 110 | def parse(string) 111 | if string 112 | parts = string.split(/[\-\s:]/) 113 | 114 | return Time.utc(*parts) 115 | end 116 | end 117 | end 118 | 119 | class JSON 120 | def name 121 | "JSON" 122 | end 123 | 124 | def parse(string) 125 | ::JSON.parse(string, symbolize_names: true) if string 126 | end 127 | end 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/db/postgres/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | module DB 7 | module Postgres 8 | VERSION = "0.8.0" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2018-2024, by Samuel Williams. 4 | Copyright, 2021, by Tony Schneider. 5 | Copyright, 2022, by Aidan Samuel. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # DB::Postgres 2 | 3 | A light-weight wrapper for Ruby connecting to Postgres 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-postgres/workflows/Test/badge.svg)](https://github.com/socketry/db-postgres/actions?workflow=Test) 6 | 7 | ## Usage 8 | 9 | Please see the [project documentation](https://socketry.github.io/db-postgres/) for more details. 10 | 11 | - [Getting Started](https://socketry.github.io/db-postgres/guides/getting-started/index) - This guide explains how to get started with the `db-postgres` 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/postgres/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require 'db/postgres/connection' 7 | require 'sus/fixtures/async' 8 | 9 | describe DB::Postgres::Connection do 10 | include Sus::Fixtures::Async::ReactorContext 11 | 12 | let(:connection) {subject.new(**CREDENTIALS)} 13 | 14 | it "should connect to local postgres" do 15 | expect(connection.status).to be == :ok 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 "should execute multiple queries" do 31 | connection.send_query("SELECT 42 AS LIFE; SELECT 24 AS LIFE") 32 | 33 | result = connection.next_result 34 | expect(result.to_a).to be == [[42]] 35 | 36 | result = connection.next_result 37 | expect(result.to_a).to be == [[24]] 38 | ensure 39 | connection.close 40 | end 41 | 42 | it "can get current time" do 43 | connection.send_query("SELECT (NOW() AT TIME ZONE 'UTC') AS NOW") 44 | 45 | result = connection.next_result 46 | row = result.to_a.first 47 | 48 | expect(row.first).to be_within(1).of(Time.now.utc) 49 | ensure 50 | connection.close 51 | end 52 | 53 | with '#append_string' do 54 | it "should escape string" do 55 | expect(connection.append_string("Hello 'World'")).to be == "'Hello ''World'''" 56 | expect(connection.append_string('Hello "World"')).to be == "'Hello \"World\"'" 57 | ensure 58 | connection.close 59 | end 60 | end 61 | 62 | with '#append_literal' do 63 | it "should escape string" do 64 | expect(connection.append_literal("Hello World")).to be == "'Hello World'" 65 | ensure 66 | connection.close 67 | end 68 | 69 | it "should not escape integers" do 70 | expect(connection.append_literal(42)).to be == "42" 71 | ensure 72 | connection.close 73 | end 74 | end 75 | 76 | with '#append_identifier' do 77 | it "should escape identifier" do 78 | expect(connection.append_identifier("Hello World")).to be == '"Hello World"' 79 | ensure 80 | connection.close 81 | end 82 | 83 | it "can handle booleans" do 84 | buffer = String.new 85 | buffer << "SELECT " 86 | connection.append_literal(true, buffer) 87 | 88 | connection.send_query(buffer) 89 | 90 | result = connection.next_result 91 | row = result.to_a.first 92 | 93 | expect(row.first).to be == true 94 | ensure 95 | connection.close 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/db/postgres/connection/date_time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require 'db/postgres/connection' 7 | require 'sus/fixtures/async' 8 | 9 | ATimestamp = Sus::Shared("a timestamp") do |zone, time, expected| 10 | it "can get timestamps with microseconds and timezone" do 11 | connection.send_query("SET TIME ZONE '#{zone}'") 12 | 13 | buffer = String.new 14 | buffer << "SELECT " 15 | connection.append_literal(time, buffer) 16 | buffer << "::TIMESTAMPTZ" 17 | 18 | connection.send_query buffer 19 | 20 | result = connection.next_result 21 | row = result.to_a.first 22 | 23 | expect(row.first).to be == expected 24 | ensure 25 | connection.close 26 | end 27 | end 28 | 29 | describe DB::Postgres::Connection do 30 | include Sus::Fixtures::Async::ReactorContext 31 | 32 | let(:connection) {subject.new(**CREDENTIALS)} 33 | 34 | # PG produces: "2022-11-11 12:38:59.123456+00" 35 | it_behaves_like ATimestamp, 'UTC', '2022-11-11 23:38:59.123456+11', Time.new(2022, 11, 11, 23, 38, Rational('59.123456'), '+11:00') 36 | 37 | # PG produces: "2022-11-11 12:38:59+00" 38 | it_behaves_like ATimestamp, 'UTC', '2022-11-11 23:38:59+11', Time.new(2022, 11, 11, 23, 38, Rational('59'), '+11:00') 39 | 40 | # PG produces: "2022-11-11 23:38:59.123456+00" 41 | it_behaves_like ATimestamp, 'UTC', '2022-11-11 23:38:59.123456', Time.new(2022, 11, 11, 23, 38, Rational('59.123456'), '+00:00') 42 | 43 | # PG produces: "2022-11-11 23:38:59+11" 44 | it_behaves_like ATimestamp, 'Australia/Sydney', '2022-11-11 23:38:59', Time.new(2022, 11, 11, 23, 38, Rational('59'), '+11:00') 45 | 46 | # PG produces: "2022-11-12 06:08:59.123456+11" 47 | it_behaves_like ATimestamp, 'Australia/Sydney', '2022-11-11 23:38:59.123456+04:30', Time.new(2022, 11, 11, 23, 38, Rational('59.123456'), '+04:30') 48 | 49 | # PG produces: "2000-01-01 05:30:00+05:30" 50 | it_behaves_like ATimestamp, 'Asia/Kolkata', '2000-01-01 00:00:00+00', Time.new(2000, 1, 1, 5, 30, 0, '+05:30') 51 | 52 | # PG produces: "2022-11-11 23:38:59+01" 53 | it_behaves_like ATimestamp, 'Europe/Lisbon', '2022-11-11 23:38:59+01', Time.new(2022, 11, 11, 23, 38, Rational('59'), '+01:00') 54 | 55 | # PG produces: "infinity" 56 | it_behaves_like ATimestamp, 'UTC', 'infinity', 'infinity' 57 | 58 | # PG produces: "-infinity" 59 | it_behaves_like ATimestamp, 'UTC', '-infinity', '-infinity' 60 | 61 | # PG produces: null 62 | it_behaves_like ATimestamp, 'UTC', nil, nil 63 | end 64 | --------------------------------------------------------------------------------