diff --git a/.duel.rb b/.duel.rb new file mode 100644 index 000000000..2ddcf166c --- /dev/null +++ b/.duel.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md +# +# Duel follows conventions, so set only what differs: benchmarks are in +# duel/**/*_duel.rb, each version's lib/ is first on the load path, a +# change is compared when it touches lib/ or ext/ (or the benchmarks, or +# this file), and releases are tagged v*. +Duel.configure do |c| + # The library each version's workers require (from the gemspec, unless + # this says otherwise). + # c.library = "pg" + + # Builds each version's C extension, so that each is measured with its + # own build. + c.build = "bundle exec rake compile" + + # A PostgreSQL server of each worker's own, and the queries, rows and + # server time that each benchmark took of it. + c.use :postgresql + + # When the workflow comments on a pull request with the report: :always, + # :results (passed or failed), :failures (failed, or couldn't compare), + # or :never. + # c.comment = :failures + + # Which runs a regression fails: :every_run, or :pull_requests, for a + # project where people push to master directly (on GitHub Actions, another + # run warns and passes, and a pushed commit gets the comment instead, from + # a workflow job written only where this is set: see docs/ci.md). + # c.fail_on = :every_run + + # A label that accepts a regression in a pull request, for a change + # that's slower on purpose (nil for none). + c.accept_label = "duel: accept" + + # What the workflow compares a push with: :before (the commit before it), + # or :last_passed (the last commit Duel passed, which a branch, + # duel/passed/master, marks), so small slowdowns add up until they fail. + # (A workflow job moves the branch, written only where this is set: see + # docs/ci.md.) + # c.push_base = :before +end diff --git a/.github/workflows/perf-comment.yml b/.github/workflows/perf-comment.yml new file mode 100644 index 000000000..1b961eb21 --- /dev/null +++ b/.github/workflows/perf-comment.yml @@ -0,0 +1,80 @@ +name: Perf comment + +# Duel's report, as a new comment on the pull request, when Duel says to: +# always (post), or only when its last comment's status differs from the +# run's (change), so the latest comment is never stale. It never edits or +# deletes a comment. It runs once the Perf workflow has, with this +# repository's own workflow and a token that can write, so a pull request +# from a fork gets a comment too. It checks nothing out and runs none of +# the pull request's code, and of what the Duel job kept (where that code +# ran), it trusts only what that job could have said: the pull request's +# number, if it names the pull request the run compared, and the comment +# and the status, if they're ones Duel says. A fork's report, which the +# fork's code wrote, is shown as text. +# +# (The workflow must be on the default branch before it runs at all.) +on: + workflow_run: + workflows: ["Perf"] + types: [completed] + +permissions: {} + +jobs: + comment: + name: Comment + if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' }} + runs-on: ubuntu-latest + permissions: + actions: read # to get the report from the Perf run + pull-requests: write # to comment on the pull request with the report + steps: + - name: Get the report + id: report + continue-on-error: true # there's none when the Duel job failed before comparing + uses: actions/download-artifact@v8 + with: + name: duel-report + path: ${{ runner.temp }}/report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Comment on the pull request + if: steps.report.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + said() { grep -xE "$1=($2)" "$RUNNER_TEMP/report/comment.env" | tail -n 1 | cut -d= -f2; } + PR=$(said pr '[0-9]+') + COMMENT=$(said comment 'post|change') + STATUS=$(said status 'passed|failed|neutral') + [ -n "$PR" ] && [ -n "$COMMENT" ] && [ -n "$STATUS" ] || exit 0 + # (The pull request's head commit, and the repository it's from.) + head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '"\(.head.sha) \(.head.repo.full_name)"') + [ "${head%% *}" = "$HEAD_SHA" ] || exit 0 + # The report, as Markdown for a branch of this repository (whose + # author can push here anyway), but for a fork's, as text: its run + # ran the fork's code, which could have written anything in it. In + # a code block, fenced with more backticks than any run of them in + # the report, it can't mention anyone, link anywhere or show an + # image. It's cut short where a comment couldn't hold it, and loses + # any NUL bytes, which GitHub wouldn't take, and grep reads it as + # text whatever's in it, so it finds every run of backticks. + report() { + if [ "${head#* }" = "$GITHUB_REPOSITORY" ]; then cat "$RUNNER_TEMP/report/duel.md" && return; fi + text="$RUNNER_TEMP/report.txt" + LC_ALL=C tr -d '\000' < "$RUNNER_TEMP/report/duel.md" | head -c 60000 > "$text" + fence=$(LC_ALL=C grep -ao '``*' "$text" | awk '{ if (length > n) n = length } END { n = (n < 3) ? 3 : n + 1; while (n-- > 0) printf "`" }') + printf 'From a fork, so shown as text:\n\n%s\n' "$fence" + cat "$text" + printf '\n%s\n' "$fence" + } + marker="\")" | tail -n 1) + if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then + { echo "$marker status=$STATUS -->"; report; } > "$RUNNER_TEMP/comment.md" + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null + fi diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 000000000..984d4ecb4 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,114 @@ +name: Perf + +on: + pull_request: + merge_group: # (a merge queue's) + push: + branches: [master] + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + +# A pull request's newer push cancels its run, if that's still going, as +# only its newest commit's result counts. Every other run (a push's, say) +# runs to the end, so that each push is compared. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# The Duel job runs the branch's code (its benchmarks too), so it only +# reads. Commenting on a commit is the Comment on the commit job's, which +# runs none of it, and commenting on a pull request is the Perf comment +# workflow's. +permissions: + contents: read + pull-requests: read # for the pull request's labels (see c.accept_label) + +jobs: + duel: + name: Duel + runs-on: ubuntu-latest + # Whether to comment (post, change or none), the run's status (passed, + # failed or neutral), and whether to mark a push passed (true, or + # nothing), from Duel. + outputs: + comment: ${{ steps.compare.outputs.comment }} + status: ${{ steps.compare.outputs.status }} + passed: ${{ steps.compare.outputs.passed }} + env: + BUNDLE_WITH: duel # the Gemfile's optional group, with Duel and its PostgreSQL plugin + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the baseline comes from history, tags included + persist-credentials: false + # The labels of the pull request (or for a push, of the one that + # merged it), for c.accept_label in .duel.rb, as a JSON array. They're + # read as the job runs, and adding the label (or taking it off) runs + # the job again: see the Perf comment workflow. + - name: Find the pull request's labels + id: labels + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push' + continue-on-error: true # (without them, nothing's accepted) + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + QUEUED: ${{ github.event.merge_group.head_ref }} + run: | + # (In a merge queue, the last pull request queued, which the queue's + # branch names: gh-readonly-queue/master/pr-123-.) + if [ -n "$QUEUED" ]; then + PR=${QUEUED##*/pr-} + PR=${PR%%-*} + fi + if [ -n "$PR" ]; then + labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/labels" --jq '[.[].name]') + else + labels=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq '[.[0].labels[]?.name]') + fi + echo "labels=$labels" >> "$GITHUB_OUTPUT" + # PostgreSQL, for the server each Duel worker starts (its programs in + # /usr/lib/postgresql/*/bin, where duel-postgresql looks, with + # pg_stat_statements), and libpq's headers, to build each version's + # C extension (c.build in .duel.rb). + - run: sudo apt-get install -y postgresql libpq-dev + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0" # the newest Ruby, which those who care most about speed run + bundler-cache: true + # Against the branch it targets for a pull request, the queue's base + # for a merge queue, what was there before for a push (or as + # c.push_base in .duel.rb says, the last commit Duel passed), and the + # last release on a schedule (slow creep). Duel adds its report to + # the job's summary, writes it to duel.md for the comment, and sets + # the step's outputs to say whether to comment, as c.comment in + # .duel.rb says, whatever the outcome. + - name: Compare + id: compare + env: + DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }} + # (Run by hand, it compares anyway: with nothing changed, the code + # with itself, which checks the setup.) + DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }} + DUEL_LABELS: ${{ steps.labels.outputs.labels }} + run: bundle exec duel --markdown "$RUNNER_TEMP/duel/duel.md" + # For a pull request, what Duel said, for the Perf comment workflow, + # which can't see this job's outputs. + - name: Say whether to comment + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + PR: ${{ github.event.pull_request.number }} + COMMENT: ${{ steps.compare.outputs.comment }} + STATUS: ${{ steps.compare.outputs.status }} + run: | + mkdir -p "$RUNNER_TEMP/duel" + printf 'pr=%s\ncomment=%s\nstatus=%s\n' "$PR" "$COMMENT" "$STATUS" > "$RUNNER_TEMP/duel/comment.env" + # The report, for the comments. + - name: Keep the report + if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'push') }} + uses: actions/upload-artifact@v7 + with: + name: duel-report + path: ${{ runner.temp }}/duel/ + if-no-files-found: ignore # none when Duel didn't get to compare + retention-days: 1 diff --git a/Gemfile b/Gemfile index a0f069130..173903e7e 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,22 @@ group :development do gem "mini_portile2", "~> 2.1" end +# Duel, for the performance regression guards in duel/: install it with +# `bundle config set --local with duel`, and run them with `rake duel`. +# Its PostgreSQL plugin needs a released pg, which would load its own +# pg_ext in the specs and in Duel's workers, so the group is optional, +# and the released pg is the source gem, whose lib/ has no pg_ext of the +# Ruby's version (lib/3.4/pg_ext) for pg.rb to load ahead of this tree's. +group :duel, optional: true do + # Duel requires Ruby 3.4 or later + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4") + gem "duel", "~> 0.1.7" + gem "duel-postgresql", "~> 0.1.5" + gem "pg", force_ruby_platform: true + end + gem "stackprof", platforms: :mri +end + group :test do gem "bundler", ">= 1.16", "< 5.0" gem "rake-compiler", "~> 1.0" diff --git a/Rakefile b/Rakefile index b19472d08..7711df0e4 100644 --- a/Rakefile +++ b/Rakefile @@ -181,3 +181,10 @@ task :translate do sh "po4a po4a.cfg" end end + +begin + require "duel/rake_task" + Duel::RakeTask.new +rescue LoadError + # Duel requires Ruby 3.4 or later, and the Gemfile's optional duel group +end diff --git a/duel/coder_duel.rb b/duel/coder_duel.rb new file mode 100644 index 000000000..7c8dcc2ae --- /dev/null +++ b/duel/coder_duel.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# Encoders and decoders on their own, 100 fixed values a call, as a type +# map runs them on a column: the text format, which results come in by +# default, and the binary one. + +duel "text decoder: Integer" do + once { @decoder = PG::TextDecoder::Integer.new } + TEXT_VALUES[:integers].each { |value| @decoder.decode(value) } +end + +duel "text decoder: Float" do + once { @decoder = PG::TextDecoder::Float.new } + TEXT_VALUES[:floats].each { |value| @decoder.decode(value) } +end + +duel "text decoder: TimestampWithoutTimeZone" do + once { @decoder = PG::TextDecoder::TimestampWithoutTimeZone.new } + TEXT_VALUES[:timestamps].each { |value| @decoder.decode(value) } +end + +duel "text decoder: TimestampWithTimeZone" do + once { @decoder = PG::TextDecoder::TimestampWithTimeZone.new } + TEXT_VALUES[:timestamptzs].each { |value| @decoder.decode(value) } +end + +duel "text decoder: Date" do + once { @decoder = PG::TextDecoder::Date.new } + TEXT_VALUES[:dates].each { |value| @decoder.decode(value) } +end + +duel "text decoder: an Array of 100 integers" do + once do + @decoder = PG::TextDecoder::Array.new(elements_type: PG::TextDecoder::Integer.new) + @array = "{#{TEXT_VALUES[:integers].join(',')}}" + end + @decoder.decode(@array) +end + +duel "text decoder: an Array of quoted strings, nested" do + once do + @decoder = PG::TextDecoder::Array.new(elements_type: PG::TextDecoder::String.new) + row = Array.new(10) { |i| %("name \\"#{i}\\", with, commas") }.join(",") + @array = "{#{Array.new(10) { "{#{row}}" }.join(',')}}" + end + @decoder.decode(@array) +end + +duel "text decoder: Bytea of 4 KB" do + once do + @decoder = PG::TextDecoder::Bytea.new + @hex = "\\x#{(0...4096).map { |i| format('%02x', i % 256) }.join}" + end + @decoder.decode(@hex) +end + +duel "text encoder: Integer" do + once do + @encoder = PG::TextEncoder::Integer.new + @integers = TEXT_VALUES[:integers].map(&:to_i) + end + @integers.each { |value| @encoder.encode(value) } +end + +duel "text encoder: TimestampWithTimeZone" do + once { @encoder = PG::TextEncoder::TimestampWithTimeZone.new } + TIMES.each { |value| @encoder.encode(value) } +end + +duel "text encoder: an Array of strings that need quoting" do + once do + @encoder = PG::TextEncoder::Array.new(elements_type: PG::TextEncoder::String.new) + @array = Array.new(10) { |i| Array.new(10) { |j| %(name "#{i}-#{j}", with {braces}) } } + end + @encoder.encode(@array) +end + +duel "binary decoder: Integer, Float and TimestampUtc" do + once do + @integer = PG::BinaryDecoder::Integer.new + @float = PG::BinaryDecoder::Float.new + @timestamp = PG::BinaryDecoder::TimestampUtc.new + @int8s = TEXT_VALUES[:integers].map { |value| [value.to_i].pack("q>") } + @float8s = TEXT_VALUES[:floats].map { |value| [value.to_f].pack("G") } + @times = TIMES.map { |time| PG::BinaryEncoder::TimestampUtc.new.encode(time) } + end + @int8s.each { |value| @integer.decode(value) } + @float8s.each { |value| @float.decode(value) } + @times.each { |value| @timestamp.decode(value) } +end + +duel "binary encoder: Int8, Float8 and TimestampUtc" do + once do + @int8 = PG::BinaryEncoder::Int8.new + @float8 = PG::BinaryEncoder::Float8.new + @timestamp = PG::BinaryEncoder::TimestampUtc.new + @integers = TEXT_VALUES[:integers].map(&:to_i) + @floats = TEXT_VALUES[:floats].map(&:to_f) + end + @integers.each { |value| @int8.encode(value) } + @floats.each { |value| @float8.encode(value) } + TIMES.each { |value| @timestamp.encode(value) } +end diff --git a/duel/connect_duel.rb b/duel/connect_duel.rb new file mode 100644 index 000000000..0dbeb5fff --- /dev/null +++ b/duel/connect_duel.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# What happens to the connection parameters before libpq connects: every +# PG.connect parses them, and a pool or an ORM builds them from a URL or a +# Hash. No connection is made. + +duel "connect: parse_connect_args of a URL" do + PG::Connection.parse_connect_args( + "postgres://jane:secret@db.example.com:5433/app_production?sslmode=require&connect_timeout=5" + ) +end + +duel "connect: parse_connect_args of a keyword string" do + PG::Connection.parse_connect_args("host=db.example.com port=5433 dbname=app_production user=jane sslmode=require") +end + +duel "connect: parse_connect_args of a Hash" do + PG::Connection.parse_connect_args(host: "db.example.com", port: 5433, dbname: "app_production", user: "jane", + password: "it's secret", sslmode: "require", application_name: "web") +end + +duel "connect: conninfo_hash of a connection", setup: -> { @conn = pg_connection }, teardown: -> { @conn.close } do + @conn.conninfo_hash +end diff --git a/duel/copy_duel.rb b/duel/copy_duel.rb new file mode 100644 index 000000000..a2d665deb --- /dev/null +++ b/duel/copy_duel.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# COPY, the fast way in and out of a table, with the CopyRow coders: an +# encoder that turns each row (an Array) into a line, and a decoder that +# turns each line back. On their own first, then through the worker's +# server. + +ROWS = Array.new(100) do |i| + [i, "user #{i}", "line\twith a tab", i.even? ? nil : "O'Brien \\ #{i}", (i * 1.5).to_s] +end.freeze + +duel "copy: encode 100 rows with TextEncoder::CopyRow" do + once { @encoder = PG::TextEncoder::CopyRow.new } + ROWS.each { |row| @encoder.encode(row) } +end + +duel "copy: decode 100 lines with TextDecoder::CopyRow" do + once do + @decoder = PG::TextDecoder::CopyRow.new + encoder = PG::TextEncoder::CopyRow.new + @lines = ROWS.map { |row| encoder.encode(row) } + end + @lines.each { |line| @decoder.decode(line) } +end + +copy_table = lambda do + @conn = pg_connection + @conn.exec("CREATE TEMP TABLE copied (id int, name text, note text, other text, score text)") + columns = PG::TypeMapByColumn.new([PG::TextEncoder::Integer.new, nil, nil, nil, nil]) + @encoder = PG::TextEncoder::CopyRow.new(type_map: columns) +end + +# Each iteration's rows are rolled back, so the table never grows. +rolled_back = lambda do |iteration| + @conn.exec("BEGIN") + iteration.call + @conn.exec("ROLLBACK") +end + +duel "copy: COPY 100 rows in with an encoder", + setup: copy_table, teardown: -> { @conn.close }, around_iteration: rolled_back do + @conn.copy_data("COPY copied FROM STDIN", @encoder) do + ROWS.each { |row| @conn.put_copy_data(row) } + end +end + +copy_out = lambda do + @conn = pg_connection + columns = PG::TypeMapByColumn.new([PG::TextDecoder::Integer.new, nil, PG::TextDecoder::Float.new]) + @decoder = PG::TextDecoder::CopyRow.new(type_map: columns) +end + +duel "copy: COPY 100 rows out with a decoder", setup: copy_out, teardown: -> { @conn.close } do + @conn.copy_data("COPY (SELECT g, 'user ' || g, g * 1.5 FROM generate_series(1, 100) g) TO STDOUT", @decoder) do + while @conn.get_copy_data; end + end +end diff --git a/duel/duel_helper.rb b/duel/duel_helper.rb new file mode 100644 index 000000000..163de8fd6 --- /dev/null +++ b/duel/duel_helper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# What several files of benchmarks share: a connection to the worker's +# own PostgreSQL server (see .duel.rb), and results and values fetched +# once, so that what's timed is the driver's work on them. + +# A new connection to the worker's server. A benchmark keeps one for all +# its samples. +def pg_connection + PG.connect(Duel::PostgreSQL.url) +end + +# A typical result's columns: an id, a name, a score, a flag, a +# timestamp, a date, and a small integer array, in 100 rows. +ROWS_SQL = <<~SQL + SELECT g AS id, 'user' || g AS name, g * 1.5::float8 AS score, g % 2 = 0 AS active, + timestamp '2026-01-01 12:00:00' + g * interval '1 minute 1.5 seconds' AS created_at, + date '2026-01-01' + g AS day, ARRAY[g, g + 1, g + 2] AS tags + FROM generate_series(1, 100) g +SQL + +# ROWS_SQL's result, in text format (or with +format+ 1, binary), over +# +conn+. It stays in memory after +conn+ moves on, so reading it makes +# no round trip. +def rows_result(conn, format = 0) + conn.exec_params(ROWS_SQL, [], format) +end + +# The +i+th of the timestamps below, as the server sends it, with +# +suffix+ (a zone, say). +def timestamp_text(i, suffix = "") + format("2026-%02d-%02d %02d:%02d:%02d.%06d%s", + (i % 12) + 1, (i % 28) + 1, i % 24, i % 60, (i * 7) % 60, i * 1234, suffix) +end + +# 100 fixed values of each kind that a text decoder takes, as the server +# sends them. +TEXT_VALUES = { + integers: Array.new(100) { |i| (i * 7919).to_s }, + floats: Array.new(100) { |i| (i * 3.14159).to_s }, + timestamps: Array.new(100) { |i| timestamp_text(i) }, + timestamptzs: Array.new(100) { |i| timestamp_text(i, "+02") }, + dates: Array.new(100) { |i| format("2026-%02d-%02d", (i % 12) + 1, (i % 28) + 1) }, +}.freeze + +# Times to encode, fixed. +TIMES = Array.new(100) { |i| Time.utc(2026, (i % 12) + 1, (i % 28) + 1, i % 24, i % 60, (i * 7) % 60, i * 1234) }.freeze diff --git a/duel/escape_duel.rb b/duel/escape_duel.rb new file mode 100644 index 000000000..e9369a603 --- /dev/null +++ b/duel/escape_duel.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# Quoting and escaping for SQL, which libpq does by the connection's +# encoding, and pg wraps: no round trip. Each escapes 20 fixed strings, +# some with quotes and backslashes in them. + +setup do + @conn = pg_connection + @strings = Array.new(20) { |i| i.even? ? "jane#{i}@example.com" : "O'Brien \\ \"#{i}\" it's" } +end +teardown { @conn.close } + +duel "escape: escape_string" do + @strings.each { |string| @conn.escape_string(string) } +end + +duel "escape: escape_literal" do + @strings.each { |string| @conn.escape_literal(string) } +end + +duel "escape: quote_ident of a schema-qualified name" do + PG::Connection.quote_ident(%w[public users]) + PG::Connection.quote_ident("Order Items") +end + +duel "escape: escape_bytea and unescape_bytea of 4 KB" do + once { @bytes = (0...4096).map { |i| (i % 256).chr }.join.b } + PG::Connection.unescape_bytea(@conn.escape_bytea(@bytes)) +end diff --git a/duel/query_duel.rb b/duel/query_duel.rb new file mode 100644 index 000000000..747983c67 --- /dev/null +++ b/duel/query_duel.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# Queries, over a Unix socket to the worker's own server: sending each +# one, waiting for its result and reading it. What the server does for +# them is in its own process, not this one's CPU time, so these are +# judged on what the driver does, and on the plugin's counts of the +# queries and rows the server ran. + +setup { @conn = pg_connection } +teardown { @conn.close } + +duel "query: exec a small SELECT" do + @conn.exec("SELECT 1") +end + +duel "query: exec_params with three parameters" do + @conn.exec_params("SELECT $1::int, $2::text, $3::bool", [42, "jane@example.com", true]) +end + +duel "query: exec_prepared a statement" do + once { @conn.prepare("user_by_id", "SELECT $1::int AS id, 'user' || $1 AS name") } + @conn.exec_prepared("user_by_id", [42]) +end + +duel "query: exec 100 rows and read their values" do + @conn.exec(ROWS_SQL).values +end + +duel "query: exec_params with a BasicTypeMapForQueries" do + once do + @conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(@conn) + @params = [42, "jane@example.com", 3.5, true, Time.utc(2026, 1, 2, 3, 4, 5), [1, 2, 3]] + end + @conn.exec_params("SELECT $1, $2, $3, $4, $5, $6", @params) +end + +duel "query: 100 rows decoded by a BasicTypeMapForResults" do + once { @conn.type_map_for_results = PG::BasicTypeMapForResults.new(@conn) } + @conn.exec(ROWS_SQL).values +end diff --git a/duel/result_duel.rb b/duel/result_duel.rb new file mode 100644 index 000000000..268b1d00b --- /dev/null +++ b/duel/result_duel.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# Reading a result that's in memory already, 100 rows of 7 columns (see +# ROWS_SQL): what an application does with every result, once its query +# has come back. + +setup do + conn = pg_connection + @result = rows_result(conn) + @symbols = rows_result(conn).field_names_as(:symbol) + conn.close +end + +duel "result: values" do + @result.values +end + +duel "result: getvalue of every field" do + result = @result + 100.times do |row| + 7.times { |field| result.getvalue(row, field) } + end +end + +duel "result: each, a Hash a row" do + @result.each { |_row| nil } +end + +duel "result: each_row, an Array a row" do + @result.each_row { |_row| nil } +end + +duel "result: column_values of two columns" do + @result.column_values(0) + @result.column_values(1) +end + +duel "result: tuple, and fields by name" do + result = @result + 100.times do |row| + tuple = result.tuple(row) + tuple["id"] + tuple["name"] + tuple[6] + end +end + +duel "result: each, a Hash a row with Symbol keys" do + @symbols.each { |_row| nil } +end + +duel "result: field names, numbers and types" do + result = @result + result.fields + 7.times do |field| + result.fname(field) + result.ftype(field) + result.fmod(field) + end + result.fnumber("created_at") + result.fnumber("tags") +end diff --git a/duel/type_map_duel.rb b/duel/type_map_duel.rb new file mode 100644 index 000000000..ed8c7928d --- /dev/null +++ b/duel/type_map_duel.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "duel_helper" + +# The type maps that decode a result's values into Ruby objects: the +# basic ones, which pick a decoder by the column's type OID, and one of +# explicit decoders by column. Each decodes a result that's in memory +# already (see ROWS_SQL), so no round trip is timed. + +setup do + @conn = pg_connection + @text = rows_result(@conn) + @binary = rows_result(@conn, 1) +end +teardown { @conn.close } + +duel "type map: BasicTypeMapForResults, text format" do + once { @map = PG::BasicTypeMapForResults.new(@conn) } + @text.map_types!(@map).values +end + +duel "type map: BasicTypeMapForResults, binary format" do + once { @map = PG::BasicTypeMapForResults.new(@conn) } + @binary.map_types!(@map).values +end + +duel "type map: TypeMapByColumn of text decoders" do + once do + @map = PG::TypeMapByColumn.new([ + PG::TextDecoder::Integer.new, nil, PG::TextDecoder::Float.new, PG::TextDecoder::Boolean.new, + PG::TextDecoder::TimestampWithoutTimeZone.new, PG::TextDecoder::Date.new, + PG::TextDecoder::Array.new(elements_type: PG::TextDecoder::Integer.new), + ]) + end + @text.map_types!(@map).values +end + +duel "type map: BasicTypeMapForQueries picks encoders for parameters" do + once do + @map = PG::BasicTypeMapForQueries.new(@conn) + @params = [42, "jane@example.com", 3.5, true, nil, Time.utc(2026, 1, 2, 3, 4, 5), [1, 2, 3], %w[a b], 2**40] + end + @map.query_param_encoders(@params) +end + +duel "type map: embed_params with a BasicTypeMapForQueries" do + once do + @map = PG::BasicTypeMapForQueries.new(@conn) + @params = [42, "O'Brien", 3.5, true, nil, Time.utc(2026, 1, 2, 3, 4, 5), [1, 2, 3]] + end + @conn.embed_params("SELECT * FROM users WHERE id = $1 AND name = $2 AND score > $3 AND active = $4 " \ + "AND deleted_at IS $5 AND created_at < $6 AND group_id = ANY($7)", @params, type_map: @map) +end